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
This commit is contained in:
+130
-120
@@ -1,8 +1,8 @@
|
||||
# CVE-2026-109618 / CVE-2026-109619: UAF via OOM Bailout
|
||||
# CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray
|
||||
|
||||
Use-after-free in PHP's session upload progress handler, exploitable
|
||||
over HTTP against Apache mod_php with **default PHP configuration**.
|
||||
No LFI, no framework dependency, no non-default settings.
|
||||
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
|
||||
|
||||
@@ -14,142 +14,148 @@ 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
|
||||
|
||||
- Apache mod_php with prefork MPM (default on Debian/Ubuntu)
|
||||
- `session.upload_progress.enabled = 1` (default)
|
||||
- `memory_limit` reachable via session data decode (~4MB for 33000 entries)
|
||||
- Any PHP-handled URL (even a 404 page)
|
||||
- A method to write a session file with many entries
|
||||
All of these are default on stock PHP Docker images and common Debian/Ubuntu
|
||||
Apache + mod_php installations:
|
||||
|
||||
No LFI. No specific framework or library. No non-default PHP settings.
|
||||
- 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
|
||||
|
||||
### CVE-2026-109618: OOM triggers longjmp past cleanup
|
||||
### The vulnerability chain
|
||||
|
||||
When `memory_limit` is exceeded during `session_decode()` inside the
|
||||
RFC1867 progress callback, PHP calls `zend_bailout()` which executes a
|
||||
`longjmp` back to `php_request_startup()`. This skips
|
||||
`php_var_unserialize_destroy()`, leaving `BG(unserialize).level = 1`
|
||||
and `BG(unserialize).data` pointing at a freed `php_unserialize_data`
|
||||
struct (8216 bytes, 3 ZendMM pages).
|
||||
**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`.
|
||||
|
||||
Apache prefork workers survive `zend_bailout()` and accept new
|
||||
connections with the stale state intact.
|
||||
**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.
|
||||
|
||||
### CVE-2026-109619: Stale state reused on next request
|
||||
### post_max_size bypass via chunked Transfer-Encoding
|
||||
|
||||
On the next request, `sapi_activate()` runs the RFC1867 callback
|
||||
BEFORE `PHP_RINIT_FUNCTION(basic)` has a chance to zero `BG` state.
|
||||
The callback calls `php_session_decode()`, which calls
|
||||
`php_var_unserialize_init()`. That function sees
|
||||
`BG(unserialize).level > 0` and returns the stale `.data` pointer
|
||||
without allocating a new struct.
|
||||
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 is a use-after-free: the pointer was freed when the previous
|
||||
request's heap was destroyed during `zend_deactivate()`.
|
||||
This means the exploit works against `memory_limit=128M` with
|
||||
`post_max_size=8M` by sending ~140MB of chunked POST data.
|
||||
|
||||
### Phase 1: Plant session file
|
||||
### Three-stage exploit (full ASLR bypass)
|
||||
|
||||
A session file with 33000 entries (~682KB) is planted. When decoded,
|
||||
this forces `nTableSize = 65536`, requiring an `arData` allocation of
|
||||
65536 * 36 = 2,359,296 bytes (~2.25MB). With `memory_limit = 4MB`,
|
||||
this exceeds the limit during `HashTable` resize.
|
||||
All traffic is plain HTTP multipart POST requests:
|
||||
|
||||
### Phase 2: OOM trigger
|
||||
**Stage 1: Heap ASLR bypass (Zend MM chunk base)**
|
||||
|
||||
A multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` and a file field
|
||||
triggers the RFC1867 progress callback. The callback reads the planted
|
||||
session file and calls `php_session_decode()`. The decode triggers OOM
|
||||
during `HashTable` resize. `zend_bailout()` longjmps past
|
||||
`php_var_unserialize_destroy()`.
|
||||
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.
|
||||
|
||||
After bailout:
|
||||
- `BG(unserialize).level = 1` (should be 0)
|
||||
- `BG(unserialize).data` = freed heap pointer
|
||||
- `php_session_cancel_decode()` deletes the session file
|
||||
- Worker survives, serves new connections
|
||||
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).
|
||||
|
||||
### Phase 3: UAF crash
|
||||
**Stage 2: Code ASLR bypass (libphp.so base)**
|
||||
|
||||
Another multipart POST (no intermediate GET requests, which would zero
|
||||
`BG` state via `PHP_RINIT_FUNCTION`) includes 100 filler form fields
|
||||
of 8200 bytes each. Each field becomes a 3-page `zend_string`
|
||||
allocation. The allocations consume ZendMM pages sequentially through
|
||||
the chunk, eventually reclaiming the 3 pages where the stale struct
|
||||
resided (typically page ~288 of 512).
|
||||
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.
|
||||
|
||||
The `zend_string` GC header (8 bytes: `refcount` + `type_info`) at
|
||||
offset 0 of the allocation overwrites the struct's `last` pointer
|
||||
(a `var_entries*`). When the session decode calls `var_push()`, it
|
||||
reads `last` and dereferences the GC header value as a pointer:
|
||||
**Stage 3: RCE (zif_system)**
|
||||
|
||||
```
|
||||
var_entries *var_hash = (*var_hashx)->last; // GC header, not a valid address
|
||||
var_hash->data[var_hash->used_slots++] = rval; // SIGSEGV
|
||||
```
|
||||
|
||||
The worker segfaults, confirming the use-after-free.
|
||||
|
||||
## Exploitation status
|
||||
|
||||
**Confirmed primitives:**
|
||||
|
||||
| Primitive | Status |
|
||||
|---|---|
|
||||
| OOM during RFC1867 session decode | Confirmed |
|
||||
| Apache prefork worker survival after OOM | Confirmed |
|
||||
| Stale `BG(unserialize)` state across requests | Confirmed (level=1, data=freed ptr) |
|
||||
| Page reclamation via filler field spray | Confirmed (27+ fields crashes worker) |
|
||||
| UAF crash (SIGSEGV in `var_tmp_var`) | Confirmed (reproducible) |
|
||||
| Type confusion via `var_access` | Confirmed (stale zval, type matches OOM session) |
|
||||
|
||||
**Not achieved: full RCE.**
|
||||
|
||||
Two UAF primitives are confirmed, but they are mutually exclusive:
|
||||
|
||||
**1. Crash (filler spray path):** Filler fields reclaim the stale
|
||||
struct pages. `var_tmp_var` reads `last_dtor` at struct offset 16
|
||||
(now overwritten by the `zend_string` `len` field, e.g. 0x2008).
|
||||
Dereferences unmapped address. SIGSEGV.
|
||||
|
||||
**2. Type confusion (no spray path):** Session data with `R:N;`
|
||||
backreferences resolves through `var_access`, which reads inline
|
||||
entries at struct offset 56. Without spray, entries.data[0] holds
|
||||
a stale pointer to the attacker's own data from request 1. The
|
||||
cached ZendMM chunk retains page data (only the header is zeroed).
|
||||
PHP reads the stale zval without crashing: type matches whatever
|
||||
the OOM session's first entry was (string, integer, etc.).
|
||||
|
||||
The two primitives cannot coexist: `var_tmp_var` reads offset 16
|
||||
BEFORE `var_access` reads offset 56. Filler spray corrupts offset
|
||||
16, crashing before the type confusion can execute.
|
||||
|
||||
Full RCE is blocked by:
|
||||
1. **No info leak**: type confusion reads own data, not heap metadata
|
||||
2. **Mutual exclusion**: spray crashes `var_tmp_var` before `var_access`
|
||||
3. **GC header overlap**: uncontrolled bytes at offset 0-23 crash var_push
|
||||
4. **No usable classes**: SplFileObject/PDO have function pointer calls
|
||||
in `free_obj`, but fake objects need libphp.so base (ASLR)
|
||||
5. **Session key validation**: php serializer rejects keys containing `|`
|
||||
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
|
||||
|
||||
```
|
||||
python3 exploit.py --target http://host:port/index.php
|
||||
python3 exploit.py --target http://host:port/index.php --oom-only
|
||||
```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 |
|
||||
|---|---|---|
|
||||
| `--target` | (required) | Target URL (any PHP endpoint) |
|
||||
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target |
|
||||
| `--entries` | 33000 | Entries in OOM session file |
|
||||
| `--spray-fields` | 100 | Filler fields for crash trigger |
|
||||
| `--oom-only` | off | Stop after Phase 2 |
|
||||
| `-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
|
||||
|
||||
@@ -162,7 +168,7 @@ layers. Any one of them blocks the attack:
|
||||
`memset`. Eliminates the stale state window entirely.
|
||||
|
||||
2. **Delayed OOM**: `zend_bailout()` on OOM is removed. The allocator
|
||||
either returns NULL (fallible mode) or succeeds beyond the soft limit
|
||||
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)`.
|
||||
|
||||
@@ -173,17 +179,21 @@ layers. Any one of them blocks the attack:
|
||||
|
||||
## Test environment
|
||||
|
||||
Tested on PHP 8.3.35 (built from source as Apache module), Apache
|
||||
2.4.58 (prefork MPM, MaxRequestWorkers=1), Ubuntu 24.04, x86_64.
|
||||
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
|
||||
```
|
||||
php_admin_value memory_limit 4M
|
||||
php_admin_value session.upload_progress.enabled 1
|
||||
php_admin_value session.upload_progress.cleanup 0
|
||||
```
|
||||
|
||||
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
|
||||
- CVE-2026-109619: Session globals survive across requests
|
||||
- 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