Fix UAF exploit to use confirmed two-request OOM approach

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.
This commit is contained in:
neko
2026-10-21 22:17:29 +00:00
parent 76bc2bc2a3
commit a5396a6ab0
3 changed files with 490 additions and 443 deletions
+74 -54
View File
@@ -1,8 +1,8 @@
# 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.
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
@@ -10,8 +10,8 @@ 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.
PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers
exit on bailout (code 70), so stale state never persists.
## Prerequisites
@@ -24,48 +24,55 @@ No LFI. No specific framework or library. No non-default PHP settings.
## How it works
### Phase 1: OOM trigger
### Phase 1: Create large session file
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`.
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.
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.
### Phase 2: OOM during session re-init
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.
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.
### Phase 2: Heap spray
`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.
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.
On Apache prefork, the worker survives. `BG(unserialize).level`
stays at 1 and `.data` points to freed memory.
### Phase 3: UAF trigger
### Phase 3: Verify stale state
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
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
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
4. Trigger unserialize, causing `pDestructor` to fire
## Usage
@@ -79,48 +86,61 @@ python3 exploit.py --target http://host:port/index.php --crash-test
| Flag | Default | Description |
|------|---------|-------------|
| `--target` | (required) | Target URL |
| `--cmd` | `id` | Command to embed in session payload |
| `--target` | (required) | Target URL (any PHP endpoint) |
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target |
| `--oom-fields` | 0 (auto) | Number of OOM padding fields |
| `--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 |
| `--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 |
| `--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 response)
1. OOM via multipart field flood (HTTP 500)
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
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:
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.
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()` function resets
`BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to zero
before SAPI activation, eliminating cross-request stale state.
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`
- 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
- 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.