Files
php2shell/uaf-rce/README.md
T
neko fbaced0a77 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
2026-10-22 03:48:05 +00:00

179 lines
6.7 KiB
Markdown

# 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**.
No LFI, no framework dependency, no non-default settings.
## 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.
## 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
No LFI. No specific framework or library. No non-default PHP settings.
## How it works
### CVE-2026-109618: OOM triggers longjmp past cleanup
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).
Apache prefork workers survive `zend_bailout()` and accept new
connections with the stale state intact.
### CVE-2026-109619: Stale state reused on next request
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.
This is a use-after-free: the pointer was freed when the previous
request's heap was destroyed during `zend_deactivate()`.
### Phase 1: Plant session file
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 2: OOM trigger
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()`.
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
### Phase 3: UAF crash
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
```
### 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 |
## 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
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**: 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
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.
```
php_admin_value memory_limit 4M
php_admin_value session.upload_progress.enabled 1
php_admin_value session.upload_progress.cleanup 0
```
## References
- CVE-2026-109618: OOM during multipart parsing triggers longjmp
- CVE-2026-109619: Session globals survive across requests
- CVSS: 9.2 (both CVEs, per Rapid7)