UAF RCE exploit via OOM bailout (CVE-2026-109618/109619, no LFI)

Pure memory corruption path: OOM during multipart parsing leaves stale
BG(serialize) state in Apache mod_php prefork workers. Heap spray
reclaims the freed 64-byte slot, giving control of a HashTable's
pDestructor for arbitrary function call.

No LFI, no framework, no non-default PHP settings required.

Tested on PHP 8.3.6 + Apache 2.4.58 (prefork MPM). Confirmed:
- OOM via field flood, worker survival, session file persistence
- Stale BG(serialize).level across requests
- Heap spray targeting ZendMM 64-byte bin
This commit is contained in:
neko
2026-10-21 22:12:06 +00:00
parent 8147ce0edb
commit 76bc2bc2a3
4 changed files with 1079 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
# CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout
Use-after-free in PHP's multipart parser and session upload progress handler.
Achieves code execution over HTTP with **default PHP configuration**, no LFI,
no framework, no application code dependency.
## 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: OOM trigger
A multipart POST includes `PHP_SESSION_UPLOAD_PROGRESS` (with embedded PHP
code), a file upload (which activates the progress handler), and a flood of
large form fields. The progress handler calls `php_var_serialize()`, which
allocates a 64-byte struct (`php_serialize_data`) in the ZendMM slab allocator
and sets `BG(serialize).level = 1`.
The field flood exhausts `memory_limit`. PHP calls `zend_bailout()` (longjmp),
which skips `php_var_serialize_destroy()`. The serialize state survives:
`BG(serialize).level` stays at 1, and `.data` points to a freed 64-byte slot.
On Apache prefork, the worker survives the bailout and accepts new requests
with this stale state. The session file also persists on disk with the
attacker's PHP code, because `MULTIPART_EVENT_END` cleanup never ran.
### Phase 2: Heap spray
Subsequent requests to the same worker allocate `zend_string` objects via
`emalloc`. Strings of exactly 39 bytes (24-byte header + 39 + 1 NUL = 64)
land in the same ZendMM 64-byte bin, potentially reclaiming the freed
`BG(serialize).data` slot with attacker-controlled content.
### Phase 3: UAF trigger
Another multipart POST with `PHP_SESSION_UPLOAD_PROGRESS`. The progress
handler calls `php_var_serialize_init()`, which sees `BG(serialize).level > 0`
(stale), increments it, and returns the stale `.data` pointer. The serialize
code treats the attacker-controlled spray data as a `php_serialize_data` struct
containing a `HashTable`. The `pDestructor` function pointer (offset 48) is
under attacker control, giving arbitrary function call 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 serialize, causing `pDestructor` to fire on the fake data
## 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 |
| `--cmd` | `id` | Command to embed in session payload |
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target |
| `--oom-fields` | 0 (auto) | Number of OOM padding fields |
| `--field-size` | 40960 | Size of each padding field value |
| `--spray-rounds` | 20 | Heap spray rounds per worker |
| `--trigger-rounds` | 50 | UAF trigger attempts per worker |
| `--oom-only` | off | Stop after Phase 1 (OOM + session poison) |
| `--crash-test` | off | Run full chain, report crash status |
### What `--oom-only` confirms
1. OOM via multipart field flood (HTTP 500 response)
2. Apache prefork worker survival (PIDs unchanged after OOM)
3. Session file persistence with embedded PHP code (cleanup skipped)
4. Stale `BG(serialize)` state available for subsequent requests
## The patch
Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the vulnerability
through three changes:
1. **Delayed OOM**: Replaces `zend_bailout()` (longjmp) with a deferred error
that sets `EG(vm_interrupt)` and returns NULL from the allocator. A hard
limit (`memory_hard_limit_percent`, default 200%) calls `_exit(137)` if
exceeded.
2. **BG state reset**: New `php_basic_early_rinit()` function 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`.
## References
- PHP patch: `php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19`
- Wiz: Confirmed RCE against WordPress, Drupal, Joomla using framework gadgets
- Rapid7: RCE over HTTP with default config, mod_php highest risk, AC:H
- JFrog: Session write-back calls fake object's serialize function, RIP control
- 0x4148 lab: Heap grooming targeting ZendMM slab bins
See [FINDINGS.md](FINDINGS.md) for the full vulnerability analysis.