Type confusion via var_access confirmed: R:N; references resolve stale entries.data[0] without crashing, returning the attacker's own session data from the cached ZendMM chunk (pages retained, not zeroed). Key finding: filler spray and type confusion are mutually exclusive. var_tmp_var reads last_dtor (struct offset 16) BEFORE var_access reads entries (offset 56). Spray corrupts offset 16, crashing before the type confusion can execute. Built-in class audit: SplFileObject and PDO have function pointer calls in free_obj, but constructing fake objects requires libphp.so base (ASLR). Patch audit (8.5.12): all three defense layers are solid, no bypass found. Five RCE blockers: no info leak, filler/type-confusion mutual exclusion, GC header overlap, no usable classes without ASLR bypass, session key validation.
190 lines
7.3 KiB
Markdown
190 lines
7.3 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 (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 `|`
|
|
|
|
## 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)
|