The original exploit put the file upload before the field flood,
so the progress handler's serialize completed before OOM fired.
This meant BG(serialize) was not left stale.
The corrected flow:
Phase 1: Create ~500KB session file via large upload progress value
Phase 2: Fill memory with fields, then file upload triggers session
re-init which OOMs during unserialize of the large session
Phase 3: Verify stale BG(unserialize) state on the surviving worker
Confirmed on PHP 8.3.6, Apache 2.4.58: error log shows
"tried to allocate 503808 bytes" during session handling,
followed by "Session ini settings cannot be changed when a
session is active" on subsequent requests.
147 lines
5.7 KiB
Markdown
147 lines
5.7 KiB
Markdown
# CVE-2026-109618 / CVE-2026-109619: UAF RCE 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.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.
|
|
|
|
PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers
|
|
exit on bailout (code 70), so stale state never persists.
|
|
|
|
## Prerequisites
|
|
|
|
- Apache mod_php with prefork MPM (default on Debian/Ubuntu)
|
|
- `session.upload_progress.enabled = 1` (default)
|
|
- Any PHP-handled URL (even a 404 page)
|
|
- `memory_limit` reachable via multipart POST fields
|
|
|
|
No LFI. No specific framework or library. No non-default PHP settings.
|
|
|
|
## How it works
|
|
|
|
### Phase 1: Create large session file
|
|
|
|
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.
|
|
|
|
### Phase 2: OOM during session re-init
|
|
|
|
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.
|
|
|
|
`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 Apache prefork, the worker survives. `BG(unserialize).level`
|
|
stays at 1 and `.data` points to freed memory.
|
|
|
|
### Phase 3: Verify stale state
|
|
|
|
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.
|
|
|
|
### Phase 4: Heap spray (for crash testing)
|
|
|
|
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()`.
|
|
|
|
### Full RCE (not implemented in this PoC)
|
|
|
|
A weaponized exploit would:
|
|
|
|
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
|
|
|
|
## 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
|
|
|
|
## The patch
|
|
|
|
Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the
|
|
vulnerability through three changes:
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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`.
|
|
|
|
## Confirmed primitives
|
|
|
|
Tested on PHP 8.3.6, Apache 2.4.58, 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) |
|
|
|
|
## 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.
|