//nefariousplan

CVE-2026-6643: ASUSTOR's vpnupload.cgi Has Eight Buffer Overflows on a Binary With Zero Mitigations

pattern

cve

proof of concept

ASUSTOR's NAS appliances run ADM, an embedded Linux distribution with a web administration panel. The home-office user who configured WireGuard for remote access on their ASUSTOR did it by uploading a .conf file through that panel. The CGI that parses the upload is vpnupload.cgi, and the function inside it that handles WireGuard configs reads each line into a 32,768-byte buffer with fgets, then copies eight fields into 300-byte stack buffers via sscanf("%s") with no length specifier. After parsing, it serializes the fields into JSON and passes the result directly to printf, no format string in front of it. The binary has no PIE, no stack canary, no FORTIFY_SOURCE, and partial RELRO with a writable GOT. CVE-2026-6643 calls this two vulnerabilities. The handler has eight buffer overflows and one format string, and the build profile ensures every one of them is RCE, available to any user with a valid Revive_Session cookie.

"High" is what the vendor calls a 9.9

The CVSS vector is AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H, score 9.9. PR:L is "Low," meaning any account on the NAS is a sufficient precondition: the contractor you gave a media user to last summer, the relative who logs in once a quarter to back up photos, the IoT device on the same VLAN running with a service account. UI:N means the attacker does not need anyone to do anything. S:C means the consequences cross out of the web server's process and into the NAS's filesystem and shell. The README's Severity row says High. CVSS 9.0 and above is Critical.

The vendor's own table downgraded the score by a band before anyone outside saw it.

The handler reads a config file like it's 1998

upload_wireguard is the action that runs when an ADM user uploads a .conf file through the WireGuard panel. The body is multipart. The handler's parser uses a boundary counter and only activates sscanf on the second section, which is why the public PoC sends a one-line dummy first part before the real payload. After the second section's Content-Disposition is consumed, the handler reads the body line by line with fgets, matches each line against a hardcoded list of WireGuard fields, and pulls the value out with sscanf:

__isoc23_sscanf(line, "PrivateKey = %s",           local_ac4);  // 300 byte buffer
__isoc23_sscanf(line, "Address = %s",              local_998);  // 300 byte buffer
__isoc23_sscanf(line, "PublicKey = %s",            local_86c);  // 300 byte buffer
__isoc23_sscanf(line, "ListenPort = %s",           local_740);  // 300 byte buffer
__isoc23_sscanf(line, "PresharedKey = %s",         local_4e8);  // 300 byte buffer
__isoc23_sscanf(line, "AllowedIPs = %s",           local_3bc);  // 300 byte buffer
__isoc23_sscanf(line, "PersistentKeepalive = %s",  local_290);  // 300 byte buffer
__isoc23_sscanf(line, "Endpoint = %s",             local_164);  // 300 byte buffer

%s with no length specifier copies until whitespace or null. fgets reads up to 32,768 bytes per line. The destination is 300. The arithmetic between those two numbers is the bug, and the bug is the same bug eight times over. There is one field, DNS, that the same handler bounds with a length-limited format. Whoever wrote this function bounded that one. The other eight, the same person, the same week, the same file, decided not to.

Eight identical bugs in one function is not a bug count. It is a parser style.

And then it hands the parsed JSON to printf

After the eight fields are copied into stack buffers, the handler assembles them into a JSON object representing the validated config. The string version of that object goes here:

pcVar2 = (char *)Json_To_String(uVar1);
printf(pcVar2);                          // user-controlled format string

printf's first argument is a format string. The argument here is a JSON document the attacker just authored. Any %x an attacker put into the PrivateKey field reads stack memory. Any %n writes to memory. With FORTIFY_SOURCE enabled, the compiler would have rewritten this printf to __printf_chk, which refuses %n writes when the format string is in writable memory. FORTIFY_SOURCE is not enabled on this binary. The PoC reaches the format string by sending:

[Interface]
PrivateKey = AAAA_%08x_%08x_%08x_%08x
Address = 10.0.0.2/24
DNS = 1.1.1.1

[Peer]
PublicKey = BBBB_normal
AllowedIPs = 0.0.0.0/0
Endpoint = vpn.test.com:51820

and reading back the JSON response field clientprivatekey, which now contains four hex words from the CGI's stack:

AAAA_feebd19f_0000012b_0000007d_00000002

That is enough to find the format-string argument index, and from there enough to dump 40 stack words and pull out a libc pointer. The format-string side is independent of the buffer-overflow side; the PoC's --stage offset and --stage leak run first because the libc base they leak is the address the buffer overflow will return into.

The binary itself is the second bug

The README's mitigations table is the part nobody else's coverage will reproduce, because the mitigations table is not what the CVE description names:

Protection Status
FORTIFY_SOURCE Disabled
Stack Canary Disabled
PIE Disabled
RELRO Partial (GOT writable)

These are four separate compiler flags. Each has been default in upstream toolchains for somewhere between fifteen and twenty years. Disabling all four is not the kind of thing that happens by accident. It is what the build profile looks like for embedded-Linux SDKs that target the smallest possible binary, the slowest possible CPU, and the quickest possible time-to-market. It is also what the build profile looks like for an attacker.

No PIE means GOT and gadget addresses are static and need no leak. No Canary means a saved-RIP overwrite does not have to defeat a stack cookie. No FORTIFY_SOURCE means printf is the original printf, with %n available against any writable target. Partial RELRO means the GOT itself is writable, so a %n write can replace printf with system and turn every subsequent call to printf(user_input) into system(user_input). The PoC's --stage shell mode is precisely this: it presumes a previous %n has rewritten GOT[printf] to point at system, and then sends the command as the PrivateKey field. The handler's printf(pcVar2) becomes system(pcVar2). The shell is whatever string the attacker put into the JSON.

This is the Unmitigated Binary shape. The eight sscanf bugs are exploitation primitives. The mitigations table is the substrate. The substrate decides which primitives are exploitable. The patch fixes the primitives. The substrate ships unchanged.

Endpoint is the buffer the PoC overwrites through

The PoC reaches saved RIP through the Endpoint field, not any of the seven other overflowable buffers. The reason is the stack layout:

local_ac4  PrivateKey           rbp-0xac4   300 bytes
local_998  Address              rbp-0x998   300 bytes
local_86c  PublicKey            rbp-0x86c   300 bytes
local_740  ListenPort           rbp-0x740   300 bytes
local_4e8  PresharedKey         rbp-0x4e8   300 bytes
local_3bc  AllowedIPs           rbp-0x3bc   300 bytes
local_290  PersistentKeepalive  rbp-0x290   300 bytes
local_164  Endpoint             rbp-0x164   300 bytes  <- closest to RIP
saved RBP                       rbp+0x000
saved RIP                       rbp+0x008                <- 364 bytes from Endpoint

The attacker has eight buffer-overflow entry points and picks the one closest to the return address, because picking a farther one means writing through every intervening buffer and corrupting the stack frame in ways the rest of the function might trip on before the epilogue runs. From Endpoint it is exactly 0x164 + 8 = 364 bytes to saved RIP. The PoC sends 364 bytes of padding plus an eight-byte target packed little-endian, and the handler's epilogue returns into a libc one-gadget.

The detail the PoC gets right that an academic walk-through would gloss over is the null-byte handling on the RIP overwrite. A libc address looks like 0x00007f1234567890. In little-endian that is \x90\x78\x56\x34\x12\x7f\x00\x00. sscanf("%s") stops copying at null bytes, so the last two bytes of the target address never make it into the buffer. Normally that breaks the overwrite, because saved RIP becomes \x90\x78\x56\x34\x12\x7f followed by whatever was already in the next two bytes. Here it does not break, because the upper two bytes of the saved RIP slot were already \x00\x00 from the original return address. sscanf stops short, and the bytes it stops short of were already correct.

This is an exploitation detail that survives because the binary has no stack canary. If vpnupload.cgi had been built with -fstack-protector-strong, the overflow would have to leak the canary first via the format string and rewrite it during the overflow. Without a canary, the format string and the overflow are independent capabilities, usable in any order. The PoC uses them sequentially, format string first for the libc base, overflow second for RIP control.

The patch the README describes is two lines

The README documents the fix at the bottom:

Vulnerability Fix
Format String Replace printf(pcVar2) with printf("%s", pcVar2) or fputs(pcVar2, stdout)
Buffer Overflow Add length limits to all sscanf format strings (e.g. %299s for 300-byte buffers)

That fix closes CVE-2026-6643. It does not turn FORTIFY_SOURCE on. It does not enable PIE. It does not enable the stack canary. It does not move RELRO from partial to full. The next memory bug in vpnupload.cgi, or in any of the other CGI binaries built from the same Makefile in this firmware, the next sscanf someone forgets to bound, the next strcpy, the next sprintf, lands the same way this one did. ASUSTOR's CGI binaries, this one and its siblings, are compiled to ensure that any future memory-safety bug in any of them is RCE.

ADM 5.1.2 is the version field on the README; the firmware build is X64_G3_5.1.2.REO1.img, dated 2026-02-25. The PoC was published on GitHub on 2026-04-28, by an author whose research blog credits the discovery on 2026-03-14. The vendor's mitigation guidance, if it follows the pattern of the last decade of NAS firmware advisories, will be an updated point release. The patch will replace one printf and add %299s in eight places.

PoC: mlgzackfly/CVE-2026-6643

The patch closes the bugs. It does not close the build.