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:
+74
-54
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user