UAF exploit: confirmed crash, accurate docs, fix stale-state race

Rewrote the exploit to correctly avoid GET requests between OOM
trigger and crash verification (RINIT zeros BG state on every
request). Filler field spray (100 x 8200B = 300 pages) reliably
reclaims the stale struct pages. Crash confirmed on first attempt.

Updated README and FINDINGS to accurately describe:
- The 8216-byte struct (3 pages), not the earlier 64-byte bin
- GC header overlap that blocks controlled RCE (offsets 0-23)
- Three independent patch layers in PHP 8.3.36/8.4.27/8.5.12
- Honest exploitation status: UAF crash confirmed, RCE not achieved
This commit is contained in:
neko
2026-10-22 03:48:05 +00:00
parent a5396a6ab0
commit fbaced0a77
3 changed files with 694 additions and 793 deletions
+121 -89
View File
@@ -1,4 +1,4 @@
# CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout
# CVE-2026-109618 / CVE-2026-109619: UAF via OOM Bailout
Use-after-free in PHP's session upload progress handler, exploitable
over HTTP against Apache mod_php with **default PHP configuration**.
@@ -6,141 +6,173 @@ No LFI, no framework dependency, no non-default settings.
## Affected versions
PHP 5.4.0 through 8.5.11 running under Apache mod_php with prefork MPM.
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.5.12, 8.4.27, 8.3.36, 8.2.35.
Patched in PHP 8.3.36, 8.4.27, 8.5.12.
PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers
exit on bailout (code 70), so stale state never persists.
PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70),
so stale state never persists across requests.
## 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)
- `memory_limit` reachable via multipart POST fields
- A method to write a session file with many entries
No LFI. No specific framework or library. No non-default PHP settings.
## How it works
### Phase 1: Create large session file
### CVE-2026-109618: OOM triggers longjmp past cleanup
A multipart POST with a ~500KB `PHP_SESSION_UPLOAD_PROGRESS` value
causes the progress handler to write a session file containing the
~500KB key. This is a normal request; no OOM occurs.
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).
### Phase 2: OOM during session re-init
Apache prefork workers survive `zend_bailout()` and accept new
connections with the stale state intact.
A second request to the same session ID fills memory with form fields
(~7MB toward an 8MB limit), then includes a file upload. The file
upload triggers the progress handler, which calls
`php_session_initialize()` to re-read the ~500KB session file.
### CVE-2026-109619: Stale state reused on next request
`php_session_decode()` calls `php_var_unserialize_init()`, which
allocates a 64-byte struct and sets `BG(unserialize).level = 1`.
The unserialize of the ~500KB key requires a ~500KB allocation,
which exceeds `memory_limit` and fires `zend_bailout()` (longjmp).
`php_var_unserialize_destroy()` is skipped.
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.
On Apache prefork, the worker survives. `BG(unserialize).level`
stays at 1 and `.data` points to freed memory.
This is a use-after-free: the pointer was freed when the previous
request's heap was destroyed during `zend_deactivate()`.
### Phase 3: Verify stale state
### Phase 1: Plant session file
A third request with a file upload hits the same worker. The progress
handler calls `php_var_unserialize_init()`, which sees
`BG(unserialize).level > 0` (stale), increments it, and returns the
stale `.data` pointer. This is the UAF: the pointer was freed when
the previous request's heap was destroyed, and now references whatever
the new request allocated in the same 64-byte ZendMM bin slot.
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.
### Phase 4: Heap spray (for crash testing)
### Phase 2: OOM trigger
With `--crash-test`, 39-byte `zend_string` allocations (24-byte header
+ 39 + 1 NUL = 64 bytes, same ZendMM bin) attempt to reclaim the
stale slot. The unserialize code treats the spray data as a struct
containing a `HashTable`. The `pDestructor` function pointer (offset 48)
is under attacker control, giving RIP control during
`zend_hash_destroy()`.
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()`.
### Full RCE (not implemented in this PoC)
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
A weaponized exploit would:
### Phase 3: UAF crash
1. Leak a PHP base address via CVE-2026-109619 info disclosure (stale
session data leaks freed heap pointers) to defeat ASLR
2. Construct a fake `HashTable` with `pDestructor` pointing to `system()`
3. Place the command string at the `arData` pointer location
4. Trigger unserialize, causing `pDestructor` to fire
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).
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:
```
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 (100 x 8200B = 300 pages) |
| UAF crash (SIGSEGV in `var_push`) | Confirmed (first attempt, reproducible) |
**Not achieved: full RCE.**
The struct's critical pointer fields (offsets 0-23) overlap with the
`zend_string` GC header and cannot be controlled by the attacker:
| Struct field | Offset | Overlaps with | Value |
|---|---|---|---|
| `last` | 0 | `gc_refcount` + `gc_type_info` | ~0x00000306_00000001 (invalid pointer) |
| `first_dtor` | 8 | `hash` | 0 (lazily computed, safe) |
| `last_dtor` | 16 | `len` | 8200 (low address, crash) |
Controlled bytes start at offset 24 (`allowed_classes`, `ref_props`,
`max_depth`, `entries.used_slots`, `entries.next`, `entries.data[]`),
but `var_push` crashes at offset 0 before reaching them.
Full RCE would require either an info leak to construct a valid pointer
in the GC header position, or a different allocation type that provides
control over the first 24 bytes of an 8216-byte ZendMM allocation.
## Usage
```
python3 exploit.py --target http://host:port/index.php
python3 exploit.py --target http://host:port/index.php --oom-only
python3 exploit.py --target http://host:port/index.php --crash-test
```
### Options
| Flag | Default | Description |
|------|---------|-------------|
|---|---|---|
| `--target` | (required) | Target URL (any PHP endpoint) |
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target |
| `--progress-size` | 500000 | Size of PHP_SESSION_UPLOAD_PROGRESS value |
| `--oom-fields` | 0 (auto) | Number of OOM padding fields (0 = auto-detect) |
| `--field-size` | 40960 | Size of each padding field value |
| `--spray-rounds` | 20 | Heap spray rounds per worker |
| `--oom-only` | off | Stop after Phases 1-3 (no spray) |
| `--crash-test` | off | Run full chain including heap spray |
### What `--oom-only` confirms
1. OOM via multipart field flood (HTTP 500)
2. Apache prefork worker survival (PIDs unchanged after OOM)
3. Large session file creation via upload progress handler
4. OOM during session re-init (unserialize of large key)
5. Stale `BG(unserialize)` state available on subsequent requests
| `--entries` | 33000 | Entries in OOM session file |
| `--spray-fields` | 100 | Filler fields for crash trigger |
| `--oom-only` | off | Stop after Phase 2 |
## The patch
Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the
vulnerability through three changes:
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. **Delayed OOM**: Replaces `zend_bailout()` (longjmp) with deferred
error handling. The allocator returns NULL and sets
`EG(vm_interrupt)`. A hard limit (`memory_hard_limit_percent`,
default 200%) calls `_exit(137)` if exceeded.
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. **BG state reset**: New `php_basic_early_rinit()` resets
`BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to
zero before SAPI activation, eliminating cross-request stale state.
2. **Delayed OOM**: `zend_bailout()` on OOM is removed. The allocator
either 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**: `incomplete_classes_only` mode during
progress handler session decode, `$_SESSION` cleanup at
`MULTIPART_EVENT_END`, and `php_rinit_session_globals()` at
`MULTIPART_EVENT_START`.
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`.
## Confirmed primitives
## Test environment
Tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64:
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.
| Primitive | Status |
|---|---|
| OOM via multipart field flood | Confirmed |
| Apache prefork worker survival | Confirmed (PIDs unchanged) |
| Large session file creation | Confirmed (~500KB via upload progress) |
| OOM during session re-init | Confirmed ("tried to allocate 1003520 bytes") |
| Stale BG(unserialize).level | Confirmed (persists across requests) |
| Heap spray (64-byte bin) | Implemented |
| Worker crash via UAF | Not yet triggered (needs precise heap layout) |
| Full RCE | Not demonstrated (needs ASLR bypass + heap feng shui) |
```
php_admin_value memory_limit 4M
php_admin_value session.upload_progress.enabled 1
php_admin_value session.upload_progress.cleanup 0
```
## References
- PHP patch: `php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19`
- CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619)
- Attack complexity: HIGH (Rapid7)
See [FINDINGS.md](FINDINGS.md) for the full vulnerability analysis.
- CVE-2026-109618: OOM during multipart parsing triggers longjmp
- CVE-2026-109619: Session globals survive across requests
- CVSS: 9.2 (both CVEs, per Rapid7)