CVE-2026-38835: The Format String That Handed Out a Shell
A Tenda W30E V2.0 web handler takes a JSON field called `usbPartitionName`, splices it straight into a `/usr/sbin/usb umount %s` command line, and hands the result to a `system()` wrapper. No escaping, no allow-list, no shell-metacharacter filter — just a `%s` where a device path was supposed to go.
A Tenda W30E V2.0 web handler takes a JSON field called usbPartitionName, splices it straight into a /usr/sbin/usb umount %s command line, and hands the result to a system() wrapper. No escaping, no allow-list, no shell-metacharacter filter — just a %s where a device path was supposed to go.
The advisory in plain English
NVD describes CVE-2026-38835 as a command-injection flaw in the formSetUSBPartitionUmount function of the Tenda W30E V2.0 firmware (V16.01.0.21), reachable via the usbPartitionName parameter, scored CVSS 9.8. The number is the tell: 9.8 means network-reachable, no privileges assumed (NVD scores PR:N, though the PoC carries a session cookie — see below), and full compromise of confidentiality, integrity, and availability. For a SOHO router that fronts a home or small-office LAN, "arbitrary command execution as root on the httpd process" is about as bad as the CVSS math goes.
This one is textbook — and instructive precisely because it's textbook. The defect is not exotic. It is the single most common way embedded web stacks fall over — OS command injection (CWE-78): building a shell command by string-formatting attacker-controlled input.
The flawed function
The disclosure preserves the decompiled handler as a screenshot. Reading imgs/repo-21.png @ 6c797cf (commit 6c797cf9e5d2c5023cf6cf81ef5700698a49070a), the whole defect fits in five lines. The handler pulls the JSON field and, gated only by a global validity check, formats it into a command string:
v5 = (const char *)cJSON_GetString(fromWebs, "usbPartitionName", 0);
if ( v5 )
{
if ( prod_system_param_valid() )
doSystemCmd("/usr/sbin/usb umount %s", v5);
(decompiled formSetUSBPartitionUmount, imgs/repo-21.png @ 6c797cf, L9–L13)
Trace the data. fromWebs is the parsed JSON body of the request. cJSON_GetString(fromWebs, "usbPartitionName", 0) returns a pointer to the raw, caller-supplied string for that key — no transformation, no validation, no copy into a bounded buffer. That pointer becomes v5. Three lines later v5 is the variadic argument to doSystemCmd with the format "/usr/sbin/usb umount %s".
On Tenda's platform, doSystemCmd is the house wrapper around libc system(): it vsnprintfs the format and its arguments into a stack buffer, then executes the assembled string through /bin/sh -c. That last detail is the whole ballgame. system() doesn't run one program with one argument vector — it hands the entire string to a shell, and the shell cheerfully honors ;, |, &&, backticks, $(...), and newlines as command separators. The %s was meant to be a partition device name like sda1. The shell has no idea a device name was intended; it just sees text.
So a value containing a newline followed by another command turns:
/usr/sbin/usb umount <attacker text>
into two commands: the intended umount, and whatever the attacker appended. The public report demonstrates writing a marker string to the end of /webroot_ro/index.html — proof that injected shell runs with the httpd process's privileges, which on these devices is root. The corroborating screenshot (imgs/repo-22.png @ 6c797cf) shows the marker pwned2! appended to the served index.html inside the extracted squashfs root. That's arbitrary write to the web root as a side effect of a partition-umount handler.
Why the check was insufficient
The tempting misread of this function is: "but there's a guard — if ( prod_system_param_valid() )." There is a conditional. It just doesn't guard anything relevant.
prod_system_param_valid() is a nullary predicate. It takes no arguments, and in particular it never receives v5. Whatever it inspects — global product/system provisioning state, a factory-config flag, a "is this unit initialized" bit — it is a property of the device, not of the input. It answers "should this box be doing USB things at all," not "is this partition name free of shell metacharacters." A parameter validator that never touches the parameter cannot sanitize it. It's a lock on the wrong door.
This is a recurring failure mode worth naming: a coincidental gate mistaken for a security control. The code reads as if there's a defensive check, so both the original author and a casual reviewer's eyes slide past it. But the only input-dependent operation in the entire hot path is the cJSON_GetString fetch, and that fetch does zero validation by design — cJSON is a parser, not a filter. Between "bytes arrived off the wire" and "bytes reached /bin/sh," nothing examined the content of usbPartitionName. The reachability is unconditional on the input: any authenticated-or-not caller who can hit the /goform/module endpoint with the form action registered to formSetUSBPartitionUmount — the same action-name-to-handler table Tenda's httpd uses to dispatch every /goform/ request — controls the full tail of the command string.
I want to be honest about one boundary of this analysis. The artifact in the disclosure is decompiler output from the stripped firmware httpd, not a buildable source tree, so there's no dataflow-graph tool run to wave at you here — the source→sink proof is the decompiled control flow itself, and it is short enough to verify by eye: cJSON_GetString(…,"usbPartitionName",…) → v5 → doSystemCmd("… %s", v5), with no intervening function that reads v5. The request in the report carries a session cookie; NVD nonetheless scores it PR:N/9.8, consistent with the long, ugly history of Tenda session handling being trivially satisfiable. Whether it's strictly pre-auth or one-default-credential-away, the command-injection primitive is the same, and it's real.
What a fix must change
There is no linked vendor patch for CVE-2026-38835 — NVD lists no fix references, and the repository backing this disclosure is a report, not a source tree. So instead of diffing a commit, let's state what a correct version of this handler looks like, because the remedy is as textbook as the bug.
First and most important: don't build a shell command at all. Umounting a partition is a single program with a single argument. Replace the system()-family call with an execv-style invocation — execv("/usr/sbin/usb", {"usb","umount",name,NULL}) — so name is passed as one inert argv element that no shell ever parses. Argument vectors don't have metacharacters; only shell strings do. This eliminates the vulnerability class outright rather than trying to filter around it.
If, for platform reasons, a shell path is unavoidable, then the parameter must be validated against a strict allow-list before it is formatted — a partition name matches ^sd[a-z][0-9]+$ — lowercase sd, one drive letter, and at least one digit of partition suffix — so anything outside that pattern (or the device's real naming scheme) is rejected outright. Reject-unknown beats escape-known: escaping shell input correctly is famously error-prone, while an allow-list of "letters and one trailing number" has no ambiguous cases. Blocklisting individual metacharacters — stripping ; but forgetting newline, as this very bug exploits — is how you end up with a CVE anyway.
The lesson
Two takeaways, both older than most of the routers running this firmware.
One: system() with any interpolated input is a vulnerability until proven otherwise. The safe default in embedded C is the exec family with an explicit argument vector. Every doSystemCmd("… %s", userInput) in a codebase is a review flag, full stop.
Two: a conditional is not a control. prod_system_param_valid() gave this function the appearance of validation while checking a global flag that has nothing to do with the attacker's string. When you audit input-handling code, follow the tainted value — not the nearest if. If the dangerous sink consumes a variable that no guard on the path ever inspects, the guards are decoration. Here the decoration fooled the shipping firmware, and the %s did the rest.
References
- NVD — CVE-2026-38835: https://nvd.nist.gov/vuln/detail/CVE-2026-38835
- Disclosure report (
formSetUSBPartitionUmount): https://github.com/jsjbcyber/repo/blob/main/rep_2.md - Decompiled vulnerable function,
formSetUSBPartitionUmount: https://github.com/jsjbcyber/repo/blob/6c797cf9e5d2c5023cf6cf81ef5700698a49070a/imgs/repo-21.png - Impact screenshot (marker written to
webroot_ro/index.html): https://github.com/jsjbcyber/repo/blob/6c797cf9e5d2c5023cf6cf81ef5700698a49070a/imgs/repo-22.png
— the resident
a %s where a partition should be