Type confusion via var_access confirmed: R:N; references resolve stale entries.data[0] without crashing, returning the attacker's own session data from the cached ZendMM chunk (pages retained, not zeroed). Key finding: filler spray and type confusion are mutually exclusive. var_tmp_var reads last_dtor (struct offset 16) BEFORE var_access reads entries (offset 56). Spray corrupts offset 16, crashing before the type confusion can execute. Built-in class audit: SplFileObject and PDO have function pointer calls in free_obj, but constructing fake objects requires libphp.so base (ASLR). Patch audit (8.5.12): all three defense layers are solid, no bypass found. Five RCE blockers: no info leak, filler/type-confusion mutual exclusion, GC header overlap, no usable classes without ASLR bypass, session key validation.
394 lines
16 KiB
Markdown
394 lines
16 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
|
|
```
|
|
|
|
### Why var_access does NOT crash (type confusion primitive)
|
|
|
|
`var_access` (var_unserializer.re line 213) is used by `R:N;` and
|
|
`r:N;` backreferences. Unlike `var_push`, it reads the INLINE entries
|
|
at struct offset 56, NOT the `last` pointer at offset 0:
|
|
|
|
```c
|
|
var_entries *var_hash = &(*var_hashx)->entries; // offset 56, not 'last'
|
|
if (id >= var_hash->used_slots) return NULL;
|
|
return var_hash->data[id]; // offset 72+
|
|
```
|
|
|
|
Without the filler spray, the stale struct retains its request 1 layout
|
|
at the critical fields:
|
|
|
|
```
|
|
last = &entries (self-referencing, valid)
|
|
entries.used_slots = 1 (partially overwritten by heap metadata)
|
|
entries.data[0] = 0x780c8a923010 (stale zval* from request 1)
|
|
```
|
|
|
|
When the session data contains `x|R:1;`, the unserializer calls
|
|
`var_access(0, stale_struct)`, which reads `entries.data[0]` (the stale
|
|
pointer) and returns it. PHP then does `ZVAL_COPY(rval, stale_zval)`.
|
|
|
|
The stale zval's type matches whatever the first OOM session entry was:
|
|
|
|
| OOM session first value | Stale zval type | Result |
|
|
|---|---|---|
|
|
| String (e.g. `s:8000:"BBB..."`) | IS_STRING | Original string content intact |
|
|
| Integer (e.g. `i:42;`) | IS_LONG | Original integer value |
|
|
| Boolean (e.g. `b:1;`) | IS_TRUE | Original boolean value |
|
|
| Object (e.g. `O:8:"stdClass":0:{}`) | IS_OBJECT | CRASH (freed zend_object dereference) |
|
|
| Array (e.g. `a:0:{}`) | IS_ARRAY | Self-referencing `$_SESSION` (infinite recursion) |
|
|
|
|
The cached ZendMM chunk retains page data across requests (only the
|
|
chunk header metadata is zeroed, not the data pages). This means the
|
|
stale zval at `entries.data[0]` contains the **attacker's own data**
|
|
from request 1, not leaked heap memory or code pointers.
|
|
|
|
This is a confirmed type confusion, but it does NOT provide an info
|
|
leak. The value appears in `$_SESSION['x']` and is accessible to the
|
|
main PHP script, but only contains data the attacker already knows.
|
|
|
|
### Why filler spray and type confusion are mutually exclusive
|
|
|
|
The session decode calls `var_tmp_var(&var_hash)` (var_unserializer.re
|
|
line 187) BEFORE `php_var_unserialize()` for each session key-value
|
|
pair. `var_tmp_var` reads `(*var_hashx)->last_dtor` at struct offset 16.
|
|
|
|
Without filler spray: `last_dtor` retains its request 1 value (NULL for
|
|
simple string values). `var_tmp_var` allocates a new `var_dtor_entries`
|
|
struct and writes the pointer to offset 16. No crash.
|
|
|
|
With filler spray: offset 16 is overwritten by the `zend_string` `len`
|
|
field (e.g., 8200 = 0x2008). `var_tmp_var` dereferences 0x2008 as a
|
|
`var_dtor_entries*`, which is an unmapped address. SIGSEGV before
|
|
`var_access` runs.
|
|
|
|
This means there is no exploitation window where controlled data
|
|
coexists with a readable stale reference. Filler spray corrupts the
|
|
struct before `var_access` can read the controlled pages.
|
|
|
|
## 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 | 27+ fields crashes worker |
|
|
| UAF crash (SIGSEGV in var_push) | Confirmed | Reproducible, first attempt |
|
|
| Type confusion via var_access | Confirmed | R:1 resolves stale zval, type matches OOM session |
|
|
| Stale value = attacker's own data | Confirmed | Cached chunk retains pages, no info leak |
|
|
| Filler/type-confusion mutual exclusion | Confirmed | var_tmp_var crashes at offset 16 before var_access |
|
|
| Session key injection via `\|` | Blocked | php serializer skips keys with `\|` |
|
|
| Info leak via type confusion | Not possible | Reads own data, not heap metadata |
|
|
| Full RCE | Not achieved | ASLR + mutual exclusion + no info leak |
|
|
|
|
## Exploitation constraints
|
|
|
|
### Why full RCE is not achieved
|
|
|
|
Five independent constraints block RCE from this primitive.
|
|
|
|
#### 1. GC header overlap (var_push path)
|
|
|
|
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.
|
|
|
|
#### 2. No info leak (var_access path)
|
|
|
|
The type confusion via `var_access` avoids the GC header crash, but
|
|
`entries.data[0]` points to a Bucket in request 1's session HashTable
|
|
arData. The ZendMM cached chunk retains data pages across requests
|
|
(only the chunk header is zeroed during `zend_mm_shutdown` with
|
|
`memset(p, 0, sizeof(zend_mm_chunk))`). The stale zval at
|
|
`entries.data[0]` contains the **attacker's own session data**, not
|
|
leaked heap metadata or code pointers.
|
|
|
|
Testing confirms the stale value's type matches the OOM session's first
|
|
entry (string -> IS_STRING with original content, integer -> IS_LONG
|
|
with original value, etc.). This is not an info leak.
|
|
|
|
To exploit the type confusion for RCE, the attacker would need to:
|
|
1. Leak a heap pointer to determine the ZendMM chunk base, then
|
|
2. Spray controlled data to overlapping pages, then
|
|
3. Use the type confusion to read crafted objects
|
|
|
|
Step 1 is impossible through this vulnerability: no path reads heap
|
|
metadata or code pointers through the type confusion.
|
|
|
|
#### 2b. Mutual exclusion of filler spray and type confusion
|
|
|
|
The filler spray (needed to place controlled data on stale pages) and
|
|
the type confusion (needed to read stale data) cannot coexist:
|
|
|
|
- `var_tmp_var()` reads `(*var_hashx)->last_dtor` at struct offset 16
|
|
BEFORE `php_var_unserialize()` calls `var_access` at offset 56.
|
|
- Without spray: offset 16 = NULL (from request 1), `var_tmp_var`
|
|
allocates normally, type confusion works but reads own data.
|
|
- With spray: offset 16 = zend_string `len` (e.g., 0x2008), `var_tmp_var`
|
|
dereferences 0x2008 as a pointer -> SIGSEGV before var_access runs.
|
|
|
|
There is no exploitation window where controlled content on the stale
|
|
pages is readable via the type confusion.
|
|
|
|
#### 3. Built-in classes lack usable exploitation path
|
|
|
|
The RFC1867 session decode runs during `sapi_activate()`, before module
|
|
RINIT and main script execution. Only PHP's built-in/internal classes
|
|
are registered. An audit of all statically compiled extensions found:
|
|
|
|
**Classes with exploitable `free_obj` handlers:**
|
|
- `SplFileObject/SplFileInfo/DirectoryIterator`: `free_obj` calls
|
|
`intern->oth_handler->dtor(intern)`, a function pointer call through
|
|
object data. But `oth_handler` is only set by Phar.
|
|
- `PDO`: `free_obj` calls `dbh->methods->closer(dbh)`, another function
|
|
pointer through object data.
|
|
|
|
**Why these are unusable:** Constructing a fake `zend_object` that
|
|
redirects `handlers` to a real handlers table requires knowing
|
|
`libphp.so`'s base address. The heap chunk address is stable within a
|
|
process, but code segment addresses are independent. No code address
|
|
leak was found through this vulnerability.
|
|
|
|
#### 4. Session key validation
|
|
|
|
The `PHP_SESSION_UPLOAD_PROGRESS` form field value becomes part of the
|
|
session key (prefixed with `upload_progress_`). A `|` character in this
|
|
value could inject serialized data into the session file, since the
|
|
`php` session serializer uses `|` as the key-value delimiter.
|
|
|
|
However, `PS_SERIALIZER_ENCODE_FUNC(php)` explicitly checks for `|` in
|
|
session keys with `memchr()` and skips keys containing it. The injected
|
|
key is stored in `$_SESSION` but never written to the session file,
|
|
preventing deserialization injection on subsequent requests.
|
|
|
|
### What would be needed for full RCE
|
|
|
|
1. **An info leak from a separate vulnerability**: The type confusion
|
|
reads the attacker's own data. A separate vulnerability providing
|
|
either a heap pointer or a libphp.so code pointer would enable:
|
|
- Heap pointer: spray fake objects at known addresses
|
|
- Code pointer: construct fake `zend_object.handlers` pointing to
|
|
real handler tables for SplFileObject/PDO/Closure
|
|
|
|
2. **Bypass of filler/type-confusion mutual exclusion**: A way to place
|
|
controlled data on stale pages without corrupting struct offset 16
|
|
(`last_dtor`). No such technique was found. The struct occupies 3
|
|
pages (8216 bytes), and large allocations cover full pages including
|
|
offset 16.
|
|
|
|
3. **Loaded classes with POP chains**: If a framework's classes were
|
|
loaded before session decode, deserialized objects could trigger
|
|
exploitable magic methods. Standard PHP and WordPress do not load
|
|
user classes before `sapi_activate()`.
|
|
|
|
4. **Alternative allocation type**: An 8216-byte allocation where the
|
|
attacker controls offset 0 and 16 (blocking the GC header overlap
|
|
and `last_dtor` corruption). No such type was identified in the
|
|
multipart parsing code path.
|
|
|
|
## 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.
|