Files
php2shell/uaf-rce/README.md
T
neko e02c9f3122 Bypass post_max_size via chunked Transfer-Encoding for stock PHP RCE
PHP checks Content-Length against post_max_size before parsing multipart
bodies, but Transfer-Encoding: chunked requests have no Content-Length.
The full chunked body is processed without any size check, allowing the
exploit to send 140MB+ to exhaust memory_limit=128M despite post_max_size=8M.

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

Changes:
- post() auto-switches to chunked TE for bodies over 8MB
- Default --huge-mb raised from 12 to 140 (exceeds stock 128M limit)
- README rewritten to document the working three-stage RCE chain
- docker-compose.yml adds stock variant for testing
2026-10-22 12:58:04 +00:00

200 lines
7.9 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: 4/4 successful RCE against stock `php:8.4.26-apache` Docker image
with zero configuration changes (`memory_limit=128M`, `post_max_size=8M`,
`display_errors=STDOUT`, `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 three independent defense
layers. Any one of them blocks the attack:
1. **`php_basic_early_rinit()`**: Called from `php_request_startup()`
AFTER `zend_activate()` but BEFORE `sapi_activate()`. Zeros
`BG(serialize_lock)`, `BG(serialize)`, and `BG(unserialize)` with
`memset`. Eliminates the stale state window entirely.
2. **Delayed OOM**: `zend_bailout()` on OOM is removed. The allocator
returns NULL (fallible mode) or succeeds beyond the soft limit
(non-fallible mode) and queues a delayed error. No longjmp, so all
cleanup code runs normally. A hard limit calls `_exit(137)`.
3. **Session handler hardening**: RFC1867 session decode runs in
`incomplete_classes_only` mode (no `__wakeup`/`__unserialize`).
`php_rinit_session_globals()` at `MULTIPART_EVENT_START`.
`$_SESSION` cleanup at `MULTIPART_EVENT_END`.
## 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, 4/4 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 (offsets in DB)
## 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)