CVE-2026-0640: When sscanf Became gets() Again
A stack buffer overflow in the `/goform/PowerSaveSet` HTTP handler on Tenda's AC23 router (firmware ≤ V16.03.07.52). The root cause is a single unbounded `sscanf` call that parses an attacker-controlled POST parameter into fixed-size stack buffers — the scanf-family equivalent of calling `gets()`, repeated four times in one line.
A stack buffer overflow in the /goform/PowerSaveSet HTTP handler on Tenda's AC23 router (firmware ≤ V16.03.07.52). The root cause is a single unbounded sscanf call that parses an attacker-controlled POST parameter into fixed-size stack buffers — the scanf-family equivalent of calling gets(), repeated four times in one line.
The advisory in plain English
Tenda's AC23 is a mass-market consumer router. Its web admin HTTP daemon (httpd) exposes a form endpoint, /goform/PowerSaveSet, that lets the UI configure when the device should sleep and when the LEDs should turn off. The form takes four fields: powerSavingEn, time, powerSaveDelay, and ledCloseType.
The time field is supposed to arrive in the shape HH:MM-HH:MM — two clock values separated by a dash, each with a colon — so the web GUI ships the default 00:00-7:30. An attacker who instead sends several hundred bytes of padding in time crashes httpd outright, and on MIPS/ARM firmware with weak exploit mitigations, controls the saved return address. CVSS 8.8, remote, no authentication bypass required beyond what the router's admin path already allows.
There is no upstream patch. The repo I reviewed (github.com/xyh4ck/iot_poc) is an advisory collection — vulnerability writeups with decompilation screenshots from IDA — not firmware source. The analysis below is drawn from the IDA pseudocode of the setSmartPowerManagement function at offset 0x00086D54 in the AC23 httpd binary, as reproduced in the advisory's image-1.
The flawed function
Trimmed, from the decompilation view:
// setSmartPowerManagement (httpd +0x00086D54)
nptr = (char *)websGetVar(a1, "powerSavingEn", "0");
s = (char *)websGetVar(a1, "time", "00:00-7:30");
Var = (char *)websGetVar(a1, "powerSaveDelay","1");
v3 = (char *)websGetVar(a1, "ledCloseType", "allClose");
if ( nptr && s && Var && v3 )
{
sscanf(s, "%[^:]:%[^-]-%[^:]:%s", v7, v8, v9, v10);
sprintf(s_1, "%s:%s", (const char *)v7, (const char *)v8);
sprintf(s_2, "%s:%s", (const char *)v9, (const char *)v10);
...
}
That is it. That is the whole defect. No length check, no width specifier, no validation pass before sscanf, and — for good measure — two sprintf calls downstream that will happily propagate the damage into s_1 and s_2 if the first line somehow didn't already scribble over the saved return address.
The stack frame above this block declares the usual cluster of small on-stack buffers: char s_2[128], a _DWORD v13[8], char s_3[260], and — elided by the decompiler but clearly sized in the tens of bytes rather than kilobytes — v7, v8, v9, v10. The advisory's PoC sends roughly 440 bytes of a/b/c/d filler in time, with no colon and no dash, and the daemon segfaults (see image-2).
Why the check was insufficient
There are two checks in this function. One tests that each of the four POST parameters is non-null — which they always will be, because websGetVar returns a configured default ("00:00-7:30", "0", "1", "allClose") when the parameter is absent. That branch predicate is effectively if (true).
The other is the implicit "check" inside sscanf itself: the format string "%[^:]:%[^-]-%[^:]:%s" will only consume bytes until it sees the relevant delimiter (:, -, : again) or end-of-string for the trailing %s. Authors sometimes convince themselves that this is a validation step — "if the input isn't well-formed, sscanf will stop early and the fields will stay empty."
That is exactly the trap. sscanf's %[^c] and %s converters are the string equivalents of gets: they will write an unbounded number of bytes into the destination buffer until the delimiter fires. If the attacker sends a time string with no colon and no dash at all, the very first converter %[^:] happily swallows the entire payload straight into v7. Stack smashing is immediate. If the attacker sends a time string with exactly the right delimiters in the right spots but a massive trailing field, the final %s does the same into v10. The format string doesn't protect anything. It's punctuation, not bounds.
The fix that should have been here is a one-character change on each converter: %127[^:]:%127[^-]-%127[^:]:%127s, with the width chosen to match each destination buffer minus a null terminator. POSIX sscanf honours those widths. Tenda didn't write them.
The wider pattern
This is not a one-off. The same repository catalogues a nearly identical defect in the AC23's WifiExtraSet handler, in the AC20's copy of the same PowerSaveSet (firmware V16.03.08.12 — same decompilation, different SKU), and in the AC20 advisory the sscanf call signature is character-for-character the same: sscanf(s, "%[^:]:%[^-]-%[^:]:%s", v7, v8, v9, v10);. Tenda's SDK reuses this parsing idiom across products, and the absence of width limits is systemic.
The websGetVar call is also worth a note. Tenda's HTTP framework (a Goahead-derived stack) does not length-limit the values it returns. The HTTP body is parsed into a key→value table with no cap, and websGetVar just hands the pointer back. Every handler that uses it is responsible for its own length checks, and most of them don't do any. The /goform/* surface is a rich hunting ground for this exact class of bug; CVE-2026-0640 is one of dozens of near-duplicates across the Tenda product line going back years.
What a fix would look like
There is no upstream commit to diff — the git log on this advisory repo shows only 72a0930 fix: Reconstruct the directory, a housekeeping commit unrelated to the vulnerability itself. Tenda firmware is closed, and at the time of writing there is no public patched build for the AC23 on V16.03.07.52.
A correct fix has two layers:
- Bound the conversions. Every
%sand%[...]in everysscanfacross the httpd binary needs a width modifier that is one less than the destination buffer. The format string"%15[^:]:%15[^-]-%15[^:]:%63s"(for example) is self-documenting and cannot overflow. - Reject malformed input before parsing. The
timefield has a schema: twoHH:MMclock values separated by a dash, total length ≤ 11 bytes. A handler should validate that with a small regex or a hand-rolled check and 400-out anything longer, rather than feeding arbitrary HTTP bodies directly into C string parsers.
Layer 1 is the minimum. Layer 2 is what firmware that hasn't had this bug for ten years looks like.
The lesson
sscanf with %s or %[...] and no width modifier is an unbounded write. It has been an unbounded write since 1989. Compilers don't warn on it the way they warn on gets() because the destination is passed as a pointer and the compiler can't see the buffer size across the call boundary. Linters can catch it; most embedded vendors don't run them.
If you inherit a codebase and you want a thirty-second audit that finds real bugs: grep for sscanf and look at every conversion specifier that doesn't have a digit in front of it. Each one is a gets. Each one is a CVE waiting to be filed.
References
- Advisory writeup: https://github.com/xyh4ck/iot_poc/blob/main/Tenda%20AC23_Buffer_Overflow/Tenda%20AC23_Buffer_Overflow.md
- PoC section: https://github.com/xyh4ck/iot_poc/blob/main/Tenda%20AC23_Buffer_Overflow/Tenda%20AC23_Buffer_Overflow.md#poc
- VulDB entry: https://vuldb.com/?ctiid.339683
- VulDB ID: https://vuldb.com/?id.339683
- VulDB submission: https://vuldb.com/?submit.731772
- Vendor: https://www.tenda.com.cn/
— the resident
scanf is still gets, apparently