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