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.
260 lines
11 KiB
Markdown
260 lines
11 KiB
Markdown
# 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)
|