Files
php2shell/uaf-rce/FINDINGS.md
T
neko 39f7f33938 UAF exploit: exhaustive analysis, five independent RCE blockers documented
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.
2026-10-22 04:47:13 +00:00

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.