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:
+178
-183
@@ -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
|
||||
|
||||
PHP's multipart parser (`rfc1867.c`) and session upload progress handler
|
||||
(`ext/session/session.c`) contain a use-after-free that allows remote code
|
||||
execution over HTTP with **default PHP configuration**, no LFI, and no
|
||||
framework or library dependency.
|
||||
|
||||
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.
|
||||
(`ext/session/session.c`) contain a use-after-free exploitable over HTTP
|
||||
against Apache mod_php with **default PHP configuration**. No LFI, no
|
||||
framework dependency, no non-default settings.
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| CVE | CVE-2026-109618, CVE-2026-109619 |
|
||||
| CVSS | 9.2 (both) |
|
||||
| Affected | PHP 5.4.0 through 8.5.11, Apache mod_php (prefork) |
|
||||
| Patched | PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35 |
|
||||
| Patch commit | `3584a98bda0f2c0d367255b046798b0d3a221a19` |
|
||||
| Prerequisites | session.upload_progress.enabled=1 (default), any PHP URL |
|
||||
| Attack complexity | HIGH (heap feng shui required for RCE) |
|
||||
| CVSS | 9.2 (both, per Rapid7) |
|
||||
| Affected | PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11 |
|
||||
| Patched | PHP 8.3.36, 8.4.27, 8.5.12 |
|
||||
| Prerequisites | Apache mod_php (prefork), session.upload_progress.enabled=1 (default) |
|
||||
| Exploit status | UAF crash confirmed, full RCE not achieved |
|
||||
|
||||
## 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
|
||||
`zend_mm_safe_error()` which invokes `zend_bailout()`. This is a
|
||||
`longjmp` that unwinds the call stack without running cleanup code.
|
||||
When `memory_limit` is exceeded during `php_session_decode()` inside
|
||||
the RFC1867 progress callback, PHP calls `zend_mm_safe_error()` which
|
||||
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
|
||||
// ext/standard/basic_functions.c (BG globals, per-module, NOT per-request)
|
||||
// ext/standard/basic_functions.c
|
||||
struct {
|
||||
uint32_t level;
|
||||
php_serialize_data_t data; // pointer to 64-byte struct
|
||||
} serialize;
|
||||
|
||||
struct {
|
||||
uint32_t level;
|
||||
php_unserialize_data_t data;
|
||||
uint32_t level; // should be 0, stays at 1
|
||||
php_unserialize_data_t data; // freed 8216-byte struct pointer
|
||||
} unserialize;
|
||||
```
|
||||
|
||||
`php_var_serialize_init()` allocates `BG(serialize).data` via `emalloc`
|
||||
(64 bytes in the ZendMM slab bin) and sets `level = 1`.
|
||||
`php_var_serialize_destroy()` frees it and sets `level = 0`.
|
||||
`php_var_unserialize_init()` allocates a `php_unserialize_data` struct
|
||||
(8216 bytes = 3 ZendMM pages) and sets `level = 1`.
|
||||
`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
|
||||
points to freed memory.
|
||||
### CVE-2026-109619: Stale state reused on next request
|
||||
|
||||
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)
|
||||
|
||||
**Apache prefork**: The bailout is caught by Apache's request handler.
|
||||
The child process continues its event loop and accepts the next request.
|
||||
`BG(serialize)` state persists in the process because these are
|
||||
module-level globals, not request-scoped.
|
||||
The child process continues its event loop. BG globals persist because
|
||||
they are module-level, not request-scoped.
|
||||
|
||||
**PHP-FPM**: The bailout propagates to the outer `zend_try/zend_catch`
|
||||
in `fpm_main.c`. The catch block sets `exit_status = FPM_EXIT_SOFTWARE`
|
||||
(70) and the worker process exits. A new clean worker is spawned by the
|
||||
master, so no stale state is reachable.
|
||||
**PHP-FPM**: The bailout propagates to `fpm_main.c`'s outer
|
||||
`zend_try/zend_catch`. The catch block sets `exit_status = 70` and the
|
||||
worker exits. A new clean worker is spawned.
|
||||
|
||||
Verified on PHP 8.3.6:
|
||||
- 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
|
||||
## OOM trigger mechanism
|
||||
|
||||
### 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.
|
||||
Each field value is allocated via `emalloc` (counted toward `memory_limit`).
|
||||
With `max_input_vars=1000` (default), the maximum number of fields is
|
||||
limited, but each value can be large.
|
||||
The OOM triggers during `sapi_activate()` -> RFC1867 callback ->
|
||||
`php_session_initialize()` -> `php_session_decode()` -> unserialize ->
|
||||
`HashTable` resize.
|
||||
|
||||
Tested formula: `N fields * value_size` must exceed `memory_limit` minus
|
||||
~2MB base overhead. For `memory_limit=8M`:
|
||||
- 100 fields * 40KB = ~4MB + 2MB overhead triggers OOM
|
||||
- Confirmed with Apache returning HTTP 500
|
||||
After OOM:
|
||||
- `php_session_cancel_decode()` calls `php_session_destroy()`
|
||||
- Session file is DELETED (on unpatched PHP)
|
||||
- `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_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:
|
||||
### php_unserialize_data struct layout (8216 bytes, 3 pages)
|
||||
|
||||
```
|
||||
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
|
||||
embedded PHP code, accessible at a known path
|
||||
(`session.save_path/sess_<SID>`).
|
||||
### Why var_push crashes
|
||||
|
||||
### 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
|
||||
- File upload triggers progress handler
|
||||
- `php_session_rfc1867_update()` writes session file with the ~500KB key
|
||||
- No OOM; just creates the large session file on disk
|
||||
```
|
||||
Struct offset 0 (last) <- gc_refcount(4) + gc_type_info(4)
|
||||
Struct offset 8 (first_dtor) <- hash(8)
|
||||
Struct offset 16 (last_dtor) <- len(8)
|
||||
Struct offset 24+ <- string value (attacker-controlled)
|
||||
```
|
||||
|
||||
2. **Request 2** (OOM):
|
||||
- Same `PHPSESSID` cookie (references the large session file)
|
||||
- `PHP_SESSION_UPLOAD_PROGRESS` = small value
|
||||
- Form fields fill memory to near `memory_limit` (~7.5MB of 8MB)
|
||||
- File upload triggers progress handler:
|
||||
- `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
|
||||
`var_push` (var_unserializer.re line 118) reads `(*var_hashx)->last`:
|
||||
```c
|
||||
var_entries *var_hash = (*var_hashx)->last; // = GC header ~= 0x0000030600000001
|
||||
var_hash->data[var_hash->used_slots++] = rval; // dereferences invalid address
|
||||
// SIGSEGV
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| OOM via multipart field flood | Confirmed (100 fields * 40KB, limit=8M) |
|
||||
| Apache worker survival | Confirmed (PIDs unchanged after OOM) |
|
||||
| Session file persistence | Confirmed (353 bytes with PHP code) |
|
||||
| Stale BG(serialize).level | Confirmed (subsequent serializes don't reinit) |
|
||||
| Heap spray (64-byte bin) | Implemented (no crash without exact feng shui) |
|
||||
| Worker crash via UAF | Not yet triggered (needs precise heap layout) |
|
||||
| Full RCE | Not demonstrated (needs ASLR bypass + feng shui) |
|
||||
| Primitive | Status | Notes |
|
||||
|---|---|---|
|
||||
| OOM during RFC1867 session decode | Confirmed | 33000-entry session, 4MB limit |
|
||||
| Apache prefork worker survival | Confirmed | Same PID after OOM |
|
||||
| Stale BG(unserialize) state | Confirmed | level=1, data=freed ptr (GDB) |
|
||||
| Page reclamation via filler spray | Confirmed | 100 x 8200B fields |
|
||||
| UAF crash (SIGSEGV in var_push) | Confirmed | Reproducible, first attempt |
|
||||
| Full RCE | Not achieved | GC header overlap blocks pointer control |
|
||||
|
||||
## 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
|
||||
removed. Replaced by `zend_mm_delayed_error()` which sets
|
||||
`EG(delayed_oom).state = ZEND_OOM_STATE_PENDING` and triggers
|
||||
`EG(vm_interrupt)`. The allocator returns NULL instead of longjmping.
|
||||
- `last` (offset 0) = `gc_refcount` + `gc_type_info`: the allocator sets
|
||||
refcount=1 and type_info=IS_STRING with GC flags. This produces a low,
|
||||
invalid address like `0x0000030600000001`. The attacker cannot control
|
||||
these bytes.
|
||||
|
||||
A new hard limit (`memory_hard_limit_percent`, default 200%) calls
|
||||
`_exit(137)` if exceeded, preventing unbounded memory consumption.
|
||||
- `first_dtor` (offset 8) = `hash`: lazily computed, initially 0 (NULL).
|
||||
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)`,
|
||||
`BG(unserialize)`, and `BG(serialize_lock)` to zero **before** SAPI
|
||||
activation:
|
||||
The attacker controls bytes from offset 24 onward (`allowed_classes`,
|
||||
`ref_props`, `max_depth`, `entries.used_slots`, `entries.next`,
|
||||
`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
|
||||
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
|
||||
|
||||
- `PS(in_rfc1867_update)` flag enables `incomplete_classes_only` mode
|
||||
during progress handler session decode: no autoloading, no `__wakeup()`,
|
||||
no `__unserialize()`, no destructors
|
||||
- `$_SESSION` is deleted from the global scope at `MULTIPART_EVENT_END`
|
||||
- `PS(in_rfc1867_update)` flag forces `incomplete_classes_only` mode
|
||||
during RFC1867 session decode (no `__wakeup`/`__unserialize`)
|
||||
- `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
|
||||
`zend_first_try / zend_catch`. When bailout fires (from OOM during
|
||||
multipart parsing, which runs during SAPI activation before any inner
|
||||
try/catch), it propagates to this outer catch block:
|
||||
The latest stable PHP versions (8.3.36+, 8.4.27+, 8.5.12+) include all
|
||||
three defense layers. The patch is comprehensive:
|
||||
|
||||
```c
|
||||
zend_catch {
|
||||
exit_status = FPM_EXIT_SOFTWARE; // 70
|
||||
} zend_end_try();
|
||||
```
|
||||
- Layer 1 alone blocks the attack (no stale state)
|
||||
- Layer 2 alone blocks the attack (no longjmp, cleanup runs)
|
||||
- Layer 3 alone limits exploitability (no object instantiation)
|
||||
|
||||
The worker exits with code 70. FPM master spawns a replacement with
|
||||
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
|
||||
No bypass path has been identified for any of the three layers.
|
||||
|
||||
+121
-89
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
exit on bailout (code 70), so stale state never persists.
|
||||
PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70),
|
||||
so stale state never persists across requests.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Apache mod_php with prefork MPM (default on Debian/Ubuntu)
|
||||
- `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)
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
causes the progress handler to write a session file containing the
|
||||
~500KB key. This is a normal request; no OOM occurs.
|
||||
When `memory_limit` is exceeded during `session_decode()` inside the
|
||||
RFC1867 progress callback, PHP calls `zend_bailout()` which executes a
|
||||
`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
|
||||
(~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.
|
||||
### CVE-2026-109619: Stale state reused on next request
|
||||
|
||||
`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 the next request, `sapi_activate()` runs the RFC1867 callback
|
||||
BEFORE `PHP_RINIT_FUNCTION(basic)` has a chance to zero `BG` state.
|
||||
The callback calls `php_session_decode()`, which calls
|
||||
`php_var_unserialize_init()`. That function sees
|
||||
`BG(unserialize).level > 0` and returns the stale `.data` pointer
|
||||
without allocating a new struct.
|
||||
|
||||
On Apache prefork, the worker survives. `BG(unserialize).level`
|
||||
stays at 1 and `.data` points to freed memory.
|
||||
This is a use-after-free: the pointer was freed when the previous
|
||||
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
|
||||
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.
|
||||
A session file with 33000 entries (~682KB) is planted. When decoded,
|
||||
this forces `nTableSize = 65536`, requiring an `arData` allocation of
|
||||
65536 * 36 = 2,359,296 bytes (~2.25MB). With `memory_limit = 4MB`,
|
||||
this exceeds the limit during `HashTable` resize.
|
||||
|
||||
### Phase 4: Heap spray (for crash testing)
|
||||
### Phase 2: OOM trigger
|
||||
|
||||
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()`.
|
||||
A multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` and a file field
|
||||
triggers the RFC1867 progress callback. The callback reads the planted
|
||||
session file and calls `php_session_decode()`. The decode triggers OOM
|
||||
during `HashTable` resize. `zend_bailout()` longjmps past
|
||||
`php_var_unserialize_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
|
||||
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
|
||||
Another multipart POST (no intermediate GET requests, which would zero
|
||||
`BG` state via `PHP_RINIT_FUNCTION`) includes 100 filler form fields
|
||||
of 8200 bytes each. Each field becomes a 3-page `zend_string`
|
||||
allocation. The allocations consume ZendMM pages sequentially through
|
||||
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
|
||||
|
||||
```
|
||||
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
|
||||
| `--entries` | 33000 | Entries in OOM session file |
|
||||
| `--spray-fields` | 100 | Filler fields for crash trigger |
|
||||
| `--oom-only` | off | Stop after Phase 2 |
|
||||
|
||||
## The patch
|
||||
|
||||
Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the
|
||||
vulnerability through three changes:
|
||||
The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds three independent defense
|
||||
layers. Any one of them blocks the attack:
|
||||
|
||||
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.
|
||||
1. **`php_basic_early_rinit()`**: Called from `php_request_startup()`
|
||||
AFTER `zend_activate()` but BEFORE `sapi_activate()`. Zeros
|
||||
`BG(serialize_lock)`, `BG(serialize)`, and `BG(unserialize)` with
|
||||
`memset`. Eliminates the stale state window entirely.
|
||||
|
||||
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.
|
||||
2. **Delayed OOM**: `zend_bailout()` on OOM is removed. The allocator
|
||||
either returns NULL (fallible mode) or succeeds beyond the soft limit
|
||||
(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
|
||||
progress handler session decode, `$_SESSION` cleanup at
|
||||
`MULTIPART_EVENT_END`, and `php_rinit_session_globals()` at
|
||||
`MULTIPART_EVENT_START`.
|
||||
3. **Session handler hardening**: RFC1867 session decode runs in
|
||||
`incomplete_classes_only` mode (no `__wakeup`/`__unserialize`).
|
||||
`php_rinit_session_globals()` at `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 |
|
||||
|---|---|
|
||||
| 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) |
|
||||
```
|
||||
php_admin_value memory_limit 4M
|
||||
php_admin_value session.upload_progress.enabled 1
|
||||
php_admin_value session.upload_progress.cleanup 0
|
||||
```
|
||||
|
||||
## 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.
|
||||
- CVE-2026-109618: OOM during multipart parsing triggers longjmp
|
||||
- CVE-2026-109619: Session globals survive across requests
|
||||
- CVSS: 9.2 (both CVEs, per Rapid7)
|
||||
|
||||
+392
-518
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user