UAF exploit: confirmed crash, accurate docs, fix stale-state race

Rewrote the exploit to correctly avoid GET requests between OOM
trigger and crash verification (RINIT zeros BG state on every
request). Filler field spray (100 x 8200B = 300 pages) reliably
reclaims the stale struct pages. Crash confirmed on first attempt.

Updated README and FINDINGS to accurately describe:
- The 8216-byte struct (3 pages), not the earlier 64-byte bin
- GC header overlap that blocks controlled RCE (offsets 0-23)
- Three independent patch layers in PHP 8.3.36/8.4.27/8.5.12
- Honest exploitation status: UAF crash confirmed, RCE not achieved
This commit is contained in:
neko
2026-10-22 03:48:05 +00:00
parent a5396a6ab0
commit fbaced0a77
3 changed files with 694 additions and 793 deletions
+178 -183
View File
@@ -1,203 +1,200 @@
# CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout # CVE-2026-109618 / CVE-2026-109619: Vulnerability Analysis
## Overview ## Overview
PHP's multipart parser (`rfc1867.c`) and session upload progress handler PHP's multipart parser (`rfc1867.c`) and session upload progress handler
(`ext/session/session.c`) contain a use-after-free that allows remote code (`ext/session/session.c`) contain a use-after-free exploitable over HTTP
execution over HTTP with **default PHP configuration**, no LFI, and no against Apache mod_php with **default PHP configuration**. No LFI, no
framework or library dependency. framework dependency, no non-default settings.
The attack works on **Apache mod_php with prefork MPM**. PHP-FPM is not
vulnerable to the cross-request UAF because FPM workers exit on bailout
(exit code 70) rather than surviving with stale state.
| Property | Value | | Property | Value |
|---|---| |---|---|
| CVE | CVE-2026-109618, CVE-2026-109619 | | CVE | CVE-2026-109618, CVE-2026-109619 |
| CVSS | 9.2 (both) | | CVSS | 9.2 (both, per Rapid7) |
| Affected | PHP 5.4.0 through 8.5.11, Apache mod_php (prefork) | | Affected | PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11 |
| Patched | PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35 | | Patched | PHP 8.3.36, 8.4.27, 8.5.12 |
| Patch commit | `3584a98bda0f2c0d367255b046798b0d3a221a19` | | Prerequisites | Apache mod_php (prefork), session.upload_progress.enabled=1 (default) |
| Prerequisites | session.upload_progress.enabled=1 (default), any PHP URL | | Exploit status | UAF crash confirmed, full RCE not achieved |
| Attack complexity | HIGH (heap feng shui required for RCE) |
## Vulnerability Analysis ## Root cause
### Root cause: zend_bailout() on OOM ### CVE-2026-109618: OOM triggers longjmp past cleanup
When `memory_limit` is exceeded during multipart parsing, PHP calls When `memory_limit` is exceeded during `php_session_decode()` inside
`zend_mm_safe_error()` which invokes `zend_bailout()`. This is a the RFC1867 progress callback, PHP calls `zend_mm_safe_error()` which
`longjmp` that unwinds the call stack without running cleanup code. invokes `zend_bailout()`. This is a `longjmp` that unwinds the call
stack without running cleanup code, specifically
`php_var_unserialize_destroy()`.
The critical state that survives the bailout: The state that survives the bailout (in `BG` module globals):
```c ```c
// ext/standard/basic_functions.c (BG globals, per-module, NOT per-request) // ext/standard/basic_functions.c
struct { struct {
uint32_t level; uint32_t level; // should be 0, stays at 1
php_serialize_data_t data; // pointer to 64-byte struct php_unserialize_data_t data; // freed 8216-byte struct pointer
} serialize;
struct {
uint32_t level;
php_unserialize_data_t data;
} unserialize; } unserialize;
``` ```
`php_var_serialize_init()` allocates `BG(serialize).data` via `emalloc` `php_var_unserialize_init()` allocates a `php_unserialize_data` struct
(64 bytes in the ZendMM slab bin) and sets `level = 1`. (8216 bytes = 3 ZendMM pages) and sets `level = 1`.
`php_var_serialize_destroy()` frees it and sets `level = 0`. `php_var_unserialize_destroy()` frees it and sets `level = 0`. When OOM
fires between init and destroy, level stays at 1 and data points to
freed memory.
When OOM fires between init and destroy, level stays at 1 and data ### CVE-2026-109619: Stale state reused on next request
points to freed memory.
The request startup flow in `main/main.c` (lines 1784-1838) wraps the
entire block from `zend_activate()` through `zend_activate_modules()` in
one `zend_try/zend_catch`. The execution order:
1. `zend_activate()` (line 1796)
2. `sapi_activate()` (line 1797): multipart parsing and RFC1867 callback
3. `php_hash_environment()` (line 1833)
4. `zend_activate_modules()` (line 1834): module RINITs run here, including `PHP_RINIT_FUNCTION(basic)` which zeros BG state
5. `PG(modules_activated) = 1` (line 1835)
The RFC1867 callback at step 2 runs BEFORE RINIT at step 4. So stale
BG state from the previous request IS used during RFC1867 decode.
When `php_var_unserialize_init()` is called during RFC1867 decode:
- It checks `BG(unserialize).level`
- If level > 0 AND `BG(serialize_lock) == 0`, it returns
`BG(unserialize).data` (the stale, freed pointer) and increments level
### Why Apache prefork survives (and FPM does not) ### Why Apache prefork survives (and FPM does not)
**Apache prefork**: The bailout is caught by Apache's request handler. **Apache prefork**: The bailout is caught by Apache's request handler.
The child process continues its event loop and accepts the next request. The child process continues its event loop. BG globals persist because
`BG(serialize)` state persists in the process because these are they are module-level, not request-scoped.
module-level globals, not request-scoped.
**PHP-FPM**: The bailout propagates to the outer `zend_try/zend_catch` **PHP-FPM**: The bailout propagates to `fpm_main.c`'s outer
in `fpm_main.c`. The catch block sets `exit_status = FPM_EXIT_SOFTWARE` `zend_try/zend_catch`. The catch block sets `exit_status = 70` and the
(70) and the worker process exits. A new clean worker is spawned by the worker exits. A new clean worker is spawned.
master, so no stale state is reachable.
Verified on PHP 8.3.6: ## OOM trigger mechanism
- Apache mod_php: OOM -> status 500 -> worker PIDs unchanged -> subsequent
requests served by the same processes
- PHP-FPM: OOM -> worker exits (code 70) -> master spawns replacement
### Triggering OOM A session file with 33000 entries (~682KB) forces
`nTableSize = 65536` during decode. The `arData` allocation:
65536 * 36 = 2,359,296 bytes (~2.25MB). With `memory_limit = 4MB`,
this exceeds the limit during `HashTable` resize in
`zend_hash_real_init_mixed_ex()`.
The multipart parser (`rfc1867.c`) processes form fields one at a time. The OOM triggers during `sapi_activate()` -> RFC1867 callback ->
Each field value is allocated via `emalloc` (counted toward `memory_limit`). `php_session_initialize()` -> `php_session_decode()` -> unserialize ->
With `max_input_vars=1000` (default), the maximum number of fields is `HashTable` resize.
limited, but each value can be large.
Tested formula: `N fields * value_size` must exceed `memory_limit` minus After OOM:
~2MB base overhead. For `memory_limit=8M`: - `php_session_cancel_decode()` calls `php_session_destroy()`
- 100 fields * 40KB = ~4MB + 2MB overhead triggers OOM - Session file is DELETED (on unpatched PHP)
- Confirmed with Apache returning HTTP 500 - `BG(unserialize).level = 1`, `.data` = freed pointer to 8216-byte struct
- Worker survives, serves new connections
### Session file persistence ## The UAF mechanism
The upload progress handler (`php_session_rfc1867_callback`) calls ### php_unserialize_data struct layout (8216 bytes, 3 pages)
`php_session_rfc1867_update()` which calls `php_session_initialize()`
(opens/creates session file) followed by `php_session_flush(1)` (encodes
`$_SESSION` and writes to file).
With `session.upload_progress.cleanup=1` (default), the progress data is
removed at `MULTIPART_EVENT_END`. But when OOM fires during parsing,
`MULTIPART_EVENT_END` never executes. The session file retains the
progress data, which includes the attacker-controlled
`PHP_SESSION_UPLOAD_PROGRESS` value:
``` ```
upload_progress_<?php system($_GET["c"]); ?>|a:5:{s:10:"start_time";...} Offset Field Size Notes
------ ----- ---- -----
0 last (var_entries*) 8 Points to inline entries
8 first_dtor 8 NULL initially
16 last_dtor 8 NULL initially
24 allowed_classes 8 NULL = allow all
32 ref_props 8 NULL
40 cur_depth 4 0
44 (padding) 4
48 max_depth 8 From ini setting
56 entries.used_slots 8 0 initially
64 entries.next 8 NULL
72 entries.data[1018] 8144 zval* array
8216 (end)
``` ```
This is a stable write primitive: the session file persists on disk with ### Why var_push crashes
embedded PHP code, accessible at a known path
(`session.save_path/sess_<SID>`).
### The UAF chain (two-request approach) After OOM, the heap is destroyed and rebuilt for the next request. The
stale pointer references freed pages in the ZendMM chunk. Filler form
fields in the crash trigger create 3-page `zend_string` allocations
that consume pages sequentially. When one lands on the stale struct's
pages (~page 288 of 512), the `zend_string` GC header overlaps the
struct's pointer fields:
1. **Request 1** (prep): ```
- Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` = ~500KB value Struct offset 0 (last) <- gc_refcount(4) + gc_type_info(4)
- File upload triggers progress handler Struct offset 8 (first_dtor) <- hash(8)
- `php_session_rfc1867_update()` writes session file with the ~500KB key Struct offset 16 (last_dtor) <- len(8)
- No OOM; just creates the large session file on disk Struct offset 24+ <- string value (attacker-controlled)
```
2. **Request 2** (OOM): `var_push` (var_unserializer.re line 118) reads `(*var_hashx)->last`:
- Same `PHPSESSID` cookie (references the large session file) ```c
- `PHP_SESSION_UPLOAD_PROGRESS` = small value var_entries *var_hash = (*var_hashx)->last; // = GC header ~= 0x0000030600000001
- Form fields fill memory to near `memory_limit` (~7.5MB of 8MB) var_hash->data[var_hash->used_slots++] = rval; // dereferences invalid address
- File upload triggers progress handler: // SIGSEGV
- `php_session_initialize()` -> reads ~500KB session file ```
- `php_session_decode()` -> `php_var_unserialize_init()`:
- `BG(unserialize).level = 1`
- `BG(unserialize).data = emalloc(64)` (64-byte ZendMM bin)
- Unserialize of the ~500KB key string: `emalloc(~500KB)` -> **OOM**
- `zend_bailout()` (longjmp)
- `php_var_unserialize_destroy()` never runs
- Worker survives on Apache prefork
- `BG(unserialize).level` stays at 1, `.data` points to freed 64-byte slot
- Confirmed by error log: "tried to allocate 1003520 bytes" during
session handling
3. **Between requests**:
- `php_request_shutdown()` calls `zend_deactivate()` -> ZendMM heap destroyed
- `BG(unserialize).data` pointer is now dangling (freed heap)
- ZendMM chunk caching preserves mmap'd pages; next request's heap
reuses the same virtual addresses
4. **Request 3** (spray + trigger):
- POST fields allocate `zend_string` objects via `emalloc`
- Strings of 39 bytes (24-byte header + 39 + 1 NUL = 64) land in the
same 64-byte bin, potentially reclaiming the stale slot
- Upload progress handler calls `php_session_decode()`:
- `php_var_unserialize_init()` checks `BG(unserialize).level > 0`
- Level is 1 (stale), so it increments to 2 and returns `.data`
- The pointer now references attacker-controlled spray data
- The unserialize code treats it as a `php_unserialize_data` struct
containing a `HashTable`
- `pDestructor` (offset 48) is called during `zend_hash_destroy()`
- If `pDestructor` points to `system()`, `zend_execute_ex()`, or a
one-gadget, the attacker achieves code execution
### Full RCE requirements
Beyond the demonstrated primitives, a weaponized exploit needs:
1. **ASLR bypass**: Leak a PHP base address. CVE-2026-109619 provides
this: stale `BG(unserialize)` state can leak freed heap pointers
through session data written to the PHPSESSID cookie or session file.
2. **Heap feng shui**: Precise control over which allocation reclaims
the 64-byte slot. ZendMM's slab allocator uses per-bin free lists
with LIFO ordering, making slot prediction tractable after the heap
is primed with a known allocation pattern.
3. **Fake HashTable construction**: The spray data must form a valid
`php_serialize_data` struct where:
- `ht.pDestructor` points to the target function
- `ht.arData` points to a `Bucket` containing the argument
- `ht.nNumUsed > 0` so the destructor loop executes
## Confirmed primitives ## Confirmed primitives
All tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64: Tested on PHP 8.3.35 (source build), Apache 2.4.58 (prefork,
MaxRequestWorkers=1), Ubuntu 24.04, x86_64:
| Primitive | Status | | Primitive | Status | Notes |
|---|---| |---|---|---|
| OOM via multipart field flood | Confirmed (100 fields * 40KB, limit=8M) | | OOM during RFC1867 session decode | Confirmed | 33000-entry session, 4MB limit |
| Apache worker survival | Confirmed (PIDs unchanged after OOM) | | Apache prefork worker survival | Confirmed | Same PID after OOM |
| Session file persistence | Confirmed (353 bytes with PHP code) | | Stale BG(unserialize) state | Confirmed | level=1, data=freed ptr (GDB) |
| Stale BG(serialize).level | Confirmed (subsequent serializes don't reinit) | | Page reclamation via filler spray | Confirmed | 100 x 8200B fields |
| Heap spray (64-byte bin) | Implemented (no crash without exact feng shui) | | UAF crash (SIGSEGV in var_push) | Confirmed | Reproducible, first attempt |
| Worker crash via UAF | Not yet triggered (needs precise heap layout) | | Full RCE | Not achieved | GC header overlap blocks pointer control |
| Full RCE | Not demonstrated (needs ASLR bypass + feng shui) |
## The patch (commit 3584a98) ## Exploitation constraints
The fix is a three-part architectural change: ### Why full RCE is not achieved
### 1. Delayed OOM (replaces zend_bailout) The struct's three pointer fields at offsets 0-23 overlap with the
`zend_string` header, which is set by the allocator:
`zend_mm_safe_error()` (which called `zend_bailout()` / `longjmp`) is - `last` (offset 0) = `gc_refcount` + `gc_type_info`: the allocator sets
removed. Replaced by `zend_mm_delayed_error()` which sets refcount=1 and type_info=IS_STRING with GC flags. This produces a low,
`EG(delayed_oom).state = ZEND_OOM_STATE_PENDING` and triggers invalid address like `0x0000030600000001`. The attacker cannot control
`EG(vm_interrupt)`. The allocator returns NULL instead of longjmping. these bytes.
A new hard limit (`memory_hard_limit_percent`, default 200%) calls - `first_dtor` (offset 8) = `hash`: lazily computed, initially 0 (NULL).
`_exit(137)` if exceeded, preventing unbounded memory consumption. This is safe: `var_destroy` checks `first_dtor != NULL` before
iterating the dtor chain.
### 2. BG state reset - `last_dtor` (offset 16) = `len`: the string length (e.g., 8200 =
`0x2008`). This is also a low, unmapped address.
New function `php_basic_early_rinit()` resets `BG(serialize)`, The attacker controls bytes from offset 24 onward (`allowed_classes`,
`BG(unserialize)`, and `BG(serialize_lock)` to zero **before** SAPI `ref_props`, `max_depth`, `entries.used_slots`, `entries.next`,
activation: `entries.data[]`), but `var_push` crashes reading `last` (offset 0)
before any of these fields are reached.
### What would be needed for full RCE
1. **Info leak**: A separate vulnerability to leak a valid heap address.
The leaked address would need to be placed at offset 0 of the
allocation, which is impossible with a `zend_string` overlay (GC
header is set by the allocator).
2. **Alternative allocation type**: A different 3-page (8216-byte)
allocation where the attacker controls offset 0. No such allocation
type has been identified during multipart parsing.
3. **Alternative reclamation path**: A way to overwrite only the struct
fields WITHOUT overlapping a `zend_string` header. This would require
a non-contiguous write primitive.
## The patch (PHP 8.3.36, 8.4.27, 8.5.12)
Three independent defense layers, any one sufficient:
### 1. php_basic_early_rinit()
New function called from `php_request_startup()` AFTER `zend_activate()`
but BEFORE `sapi_activate()`:
```c ```c
PHPAPI void php_basic_early_rinit(void) { PHPAPI void php_basic_early_rinit(void) {
@@ -207,42 +204,40 @@ PHPAPI void php_basic_early_rinit(void) {
} }
``` ```
Called from `php_request_startup()` right after `zend_activate()`. Eliminates the stale state window entirely. Even if OOM occurs, the
BG state is zeroed before RFC1867 processing on the next request.
### 2. Delayed OOM
`zend_bailout()` on OOM is completely removed. The allocator now either:
- Returns NULL (fallible mode) for checked allocations
- Succeeds beyond the soft limit (non-fallible mode) and queues a
delayed error via `EG(delayed_oom)` + VM interrupt
No longjmp means all cleanup code runs normally, including
`php_var_unserialize_destroy()`.
A hard limit (`memory_hard_limit_percent`, default 200%) calls
`_exit(137)` as a safety net.
### 3. Session handler hardening ### 3. Session handler hardening
- `PS(in_rfc1867_update)` flag enables `incomplete_classes_only` mode - `PS(in_rfc1867_update)` flag forces `incomplete_classes_only` mode
during progress handler session decode: no autoloading, no `__wakeup()`, during RFC1867 session decode (no `__wakeup`/`__unserialize`)
no `__unserialize()`, no destructors
- `$_SESSION` is deleted from the global scope at `MULTIPART_EVENT_END`
- `php_rinit_session_globals()` called at `MULTIPART_EVENT_START` - `php_rinit_session_globals()` called at `MULTIPART_EVENT_START`
- Guard against `request_parse_body()` re-entry - `$_SESSION` cleanup at `MULTIPART_EVENT_END`
- `PG(modules_activated)` check prevents RFC1867 callback during
`request_parse_body()`
- ~30 `ZEND_HAS_OOM()` checks throughout `rfc1867.c` for graceful
handling of allocation failures
## Why this does not apply to PHP-FPM ## Why this does not apply to latest PHP
PHP-FPM's main request loop in `fpm_main.c` wraps request handling in The latest stable PHP versions (8.3.36+, 8.4.27+, 8.5.12+) include all
`zend_first_try / zend_catch`. When bailout fires (from OOM during three defense layers. The patch is comprehensive:
multipart parsing, which runs during SAPI activation before any inner
try/catch), it propagates to this outer catch block:
```c - Layer 1 alone blocks the attack (no stale state)
zend_catch { - Layer 2 alone blocks the attack (no longjmp, cleanup runs)
exit_status = FPM_EXIT_SOFTWARE; // 70 - Layer 3 alone limits exploitability (no object instantiation)
} zend_end_try();
```
The worker exits with code 70. FPM master spawns a replacement with No bypass path has been identified for any of the three layers.
clean state. The stale `BG(serialize)` dies with the process.
On Apache prefork, the bailout is caught by `ap_process_request_internal`
or similar internal handler. The child process continues its accept loop,
and the stale BG globals persist.
## References
- **Patch commit**: php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19
- **JFrog**: Session write-back calls fake object's serialize function, RIP control
- **Rapid7**: RCE over HTTP with default PHP config; mod_php highest risk; attack complexity HIGH
- **Wiz**: Confirmed RCE against stock WordPress, Drupal, Joomla on affected versions
- **0x4148 lab**: Heap grooming targets ZendMM 320-byte slab bin (note: our analysis finds 64-byte bin for `php_serialize_data`)
- **Sansec**: Analysis of mitigation options and affected versions
+121 -89
View File
@@ -1,4 +1,4 @@
# CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout # CVE-2026-109618 / CVE-2026-109619: UAF via OOM Bailout
Use-after-free in PHP's session upload progress handler, exploitable Use-after-free in PHP's session upload progress handler, exploitable
over HTTP against Apache mod_php with **default PHP configuration**. over HTTP against Apache mod_php with **default PHP configuration**.
@@ -6,141 +6,173 @@ No LFI, no framework dependency, no non-default settings.
## Affected versions ## Affected versions
PHP 5.4.0 through 8.5.11 running under Apache mod_php with prefork MPM. 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.5.12, 8.4.27, 8.3.36, 8.2.35. Patched in PHP 8.3.36, 8.4.27, 8.5.12.
PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70),
exit on bailout (code 70), so stale state never persists. so stale state never persists across requests.
## Prerequisites ## Prerequisites
- Apache mod_php with prefork MPM (default on Debian/Ubuntu) - Apache mod_php with prefork MPM (default on Debian/Ubuntu)
- `session.upload_progress.enabled = 1` (default) - `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) - Any PHP-handled URL (even a 404 page)
- `memory_limit` reachable via multipart POST fields - A method to write a session file with many entries
No LFI. No specific framework or library. No non-default PHP settings. No LFI. No specific framework or library. No non-default PHP settings.
## How it works ## How it works
### Phase 1: Create large session file ### CVE-2026-109618: OOM triggers longjmp past cleanup
A multipart POST with a ~500KB `PHP_SESSION_UPLOAD_PROGRESS` value When `memory_limit` is exceeded during `session_decode()` inside the
causes the progress handler to write a session file containing the RFC1867 progress callback, PHP calls `zend_bailout()` which executes a
~500KB key. This is a normal request; no OOM occurs. `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).
### Phase 2: OOM during session re-init Apache prefork workers survive `zend_bailout()` and accept new
connections with the stale state intact.
A second request to the same session ID fills memory with form fields ### CVE-2026-109619: Stale state reused on next request
(~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 On the next request, `sapi_activate()` runs the RFC1867 callback
allocates a 64-byte struct and sets `BG(unserialize).level = 1`. BEFORE `PHP_RINIT_FUNCTION(basic)` has a chance to zero `BG` state.
The unserialize of the ~500KB key requires a ~500KB allocation, The callback calls `php_session_decode()`, which calls
which exceeds `memory_limit` and fires `zend_bailout()` (longjmp). `php_var_unserialize_init()`. That function sees
`php_var_unserialize_destroy()` is skipped. `BG(unserialize).level > 0` and returns the stale `.data` pointer
without allocating a new struct.
On Apache prefork, the worker survives. `BG(unserialize).level` This is a use-after-free: the pointer was freed when the previous
stays at 1 and `.data` points to freed memory. request's heap was destroyed during `zend_deactivate()`.
### Phase 3: Verify stale state ### Phase 1: Plant session file
A third request with a file upload hits the same worker. The progress A session file with 33000 entries (~682KB) is planted. When decoded,
handler calls `php_var_unserialize_init()`, which sees this forces `nTableSize = 65536`, requiring an `arData` allocation of
`BG(unserialize).level > 0` (stale), increments it, and returns the 65536 * 36 = 2,359,296 bytes (~2.25MB). With `memory_limit = 4MB`,
stale `.data` pointer. This is the UAF: the pointer was freed when this exceeds the limit during `HashTable` resize.
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) ### Phase 2: OOM trigger
With `--crash-test`, 39-byte `zend_string` allocations (24-byte header A multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` and a file field
+ 39 + 1 NUL = 64 bytes, same ZendMM bin) attempt to reclaim the triggers the RFC1867 progress callback. The callback reads the planted
stale slot. The unserialize code treats the spray data as a struct session file and calls `php_session_decode()`. The decode triggers OOM
containing a `HashTable`. The `pDestructor` function pointer (offset 48) during `HashTable` resize. `zend_bailout()` longjmps past
is under attacker control, giving RIP control during `php_var_unserialize_destroy()`.
`zend_hash_destroy()`.
### Full RCE (not implemented in this PoC) 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
A weaponized exploit would: ### Phase 3: UAF crash
1. Leak a PHP base address via CVE-2026-109619 info disclosure (stale Another multipart POST (no intermediate GET requests, which would zero
session data leaks freed heap pointers) to defeat ASLR `BG` state via `PHP_RINIT_FUNCTION`) includes 100 filler form fields
2. Construct a fake `HashTable` with `pDestructor` pointing to `system()` of 8200 bytes each. Each field becomes a 3-page `zend_string`
3. Place the command string at the `arData` pointer location allocation. The allocations consume ZendMM pages sequentially through
4. Trigger unserialize, causing `pDestructor` to fire 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 (100 x 8200B = 300 pages) |
| UAF crash (SIGSEGV in `var_push`) | Confirmed (first attempt, reproducible) |
**Not achieved: full RCE.**
The struct's critical pointer fields (offsets 0-23) overlap with the
`zend_string` GC header and cannot be controlled by the attacker:
| Struct field | Offset | Overlaps with | Value |
|---|---|---|---|
| `last` | 0 | `gc_refcount` + `gc_type_info` | ~0x00000306_00000001 (invalid pointer) |
| `first_dtor` | 8 | `hash` | 0 (lazily computed, safe) |
| `last_dtor` | 16 | `len` | 8200 (low address, crash) |
Controlled bytes start at offset 24 (`allowed_classes`, `ref_props`,
`max_depth`, `entries.used_slots`, `entries.next`, `entries.data[]`),
but `var_push` crashes at offset 0 before reaching them.
Full RCE would require either an info leak to construct a valid pointer
in the GC header position, or a different allocation type that provides
control over the first 24 bytes of an 8216-byte ZendMM allocation.
## Usage ## Usage
``` ```
python3 exploit.py --target http://host:port/index.php 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 --oom-only
python3 exploit.py --target http://host:port/index.php --crash-test
``` ```
### Options ### Options
| Flag | Default | Description | | Flag | Default | Description |
|------|---------|-------------| |---|---|---|
| `--target` | (required) | Target URL (any PHP endpoint) | | `--target` | (required) | Target URL (any PHP endpoint) |
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target | | `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target |
| `--progress-size` | 500000 | Size of PHP_SESSION_UPLOAD_PROGRESS value | | `--entries` | 33000 | Entries in OOM session file |
| `--oom-fields` | 0 (auto) | Number of OOM padding fields (0 = auto-detect) | | `--spray-fields` | 100 | Filler fields for crash trigger |
| `--field-size` | 40960 | Size of each padding field value | | `--oom-only` | off | Stop after Phase 2 |
| `--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 ## The patch
Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds three independent defense
vulnerability through three changes: layers. Any one of them blocks the attack:
1. **Delayed OOM**: Replaces `zend_bailout()` (longjmp) with deferred 1. **`php_basic_early_rinit()`**: Called from `php_request_startup()`
error handling. The allocator returns NULL and sets AFTER `zend_activate()` but BEFORE `sapi_activate()`. Zeros
`EG(vm_interrupt)`. A hard limit (`memory_hard_limit_percent`, `BG(serialize_lock)`, `BG(serialize)`, and `BG(unserialize)` with
default 200%) calls `_exit(137)` if exceeded. `memset`. Eliminates the stale state window entirely.
2. **BG state reset**: New `php_basic_early_rinit()` resets 2. **Delayed OOM**: `zend_bailout()` on OOM is removed. The allocator
`BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to either returns NULL (fallible mode) or succeeds beyond the soft limit
zero before SAPI activation, eliminating cross-request stale state. (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**: `incomplete_classes_only` mode during 3. **Session handler hardening**: RFC1867 session decode runs in
progress handler session decode, `$_SESSION` cleanup at `incomplete_classes_only` mode (no `__wakeup`/`__unserialize`).
`MULTIPART_EVENT_END`, and `php_rinit_session_globals()` at `php_rinit_session_globals()` at `MULTIPART_EVENT_START`.
`MULTIPART_EVENT_START`. `$_SESSION` cleanup at `MULTIPART_EVENT_END`.
## Confirmed primitives ## Test environment
Tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64: 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.
| Primitive | Status | ```
|---|---| php_admin_value memory_limit 4M
| OOM via multipart field flood | Confirmed | php_admin_value session.upload_progress.enabled 1
| Apache prefork worker survival | Confirmed (PIDs unchanged) | php_admin_value session.upload_progress.cleanup 0
| 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 ## References
- PHP patch: `php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19` - CVE-2026-109618: OOM during multipart parsing triggers longjmp
- CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619) - CVE-2026-109619: Session globals survive across requests
- Attack complexity: HIGH (Rapid7) - CVSS: 9.2 (both CVEs, per Rapid7)
See [FINDINGS.md](FINDINGS.md) for the full vulnerability analysis.
+395 -521
View File
File diff suppressed because it is too large Load Diff