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
244 lines
9.2 KiB
Markdown
244 lines
9.2 KiB
Markdown
# 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 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, 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 |
|
|
|
|
## Root cause
|
|
|
|
### CVE-2026-109618: OOM triggers longjmp past cleanup
|
|
|
|
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 state that survives the bailout (in `BG` module globals):
|
|
|
|
```c
|
|
// ext/standard/basic_functions.c
|
|
struct {
|
|
uint32_t level; // should be 0, stays at 1
|
|
php_unserialize_data_t data; // freed 8216-byte struct pointer
|
|
} unserialize;
|
|
```
|
|
|
|
`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.
|
|
|
|
### 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. BG globals persist because
|
|
they are module-level, not request-scoped.
|
|
|
|
**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.
|
|
|
|
## OOM trigger mechanism
|
|
|
|
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 OOM triggers during `sapi_activate()` -> RFC1867 callback ->
|
|
`php_session_initialize()` -> `php_session_decode()` -> unserialize ->
|
|
`HashTable` resize.
|
|
|
|
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
|
|
|
|
## The UAF mechanism
|
|
|
|
### php_unserialize_data struct layout (8216 bytes, 3 pages)
|
|
|
|
```
|
|
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)
|
|
```
|
|
|
|
### Why var_push crashes
|
|
|
|
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:
|
|
|
|
```
|
|
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)
|
|
```
|
|
|
|
`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
|
|
|
|
Tested on PHP 8.3.35 (source build), Apache 2.4.58 (prefork,
|
|
MaxRequestWorkers=1), Ubuntu 24.04, x86_64:
|
|
|
|
| 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 |
|
|
|
|
## Exploitation constraints
|
|
|
|
### Why full RCE is not achieved
|
|
|
|
The struct's three pointer fields at offsets 0-23 overlap with the
|
|
`zend_string` header, which is set by the allocator:
|
|
|
|
- `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.
|
|
|
|
- `first_dtor` (offset 8) = `hash`: lazily computed, initially 0 (NULL).
|
|
This is safe: `var_destroy` checks `first_dtor != NULL` before
|
|
iterating the dtor chain.
|
|
|
|
- `last_dtor` (offset 16) = `len`: the string length (e.g., 8200 =
|
|
`0x2008`). This is also a low, unmapped address.
|
|
|
|
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) {
|
|
BG(serialize_lock) = 0;
|
|
memset(&BG(serialize), 0, sizeof(BG(serialize)));
|
|
memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
|
|
}
|
|
```
|
|
|
|
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 forces `incomplete_classes_only` mode
|
|
during RFC1867 session decode (no `__wakeup`/`__unserialize`)
|
|
- `php_rinit_session_globals()` called at `MULTIPART_EVENT_START`
|
|
- `$_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 latest PHP
|
|
|
|
The latest stable PHP versions (8.3.36+, 8.4.27+, 8.5.12+) include all
|
|
three defense layers. The patch is comprehensive:
|
|
|
|
- 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)
|
|
|
|
No bypass path has been identified for any of the three layers.
|