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
|
## 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
@@ -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.
|
|
||||||
|
|||||||
+392
-518
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user