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.
This commit is contained in:
neko
2026-10-22 04:47:13 +00:00
parent fbaced0a77
commit 39f7f33938
3 changed files with 243 additions and 62 deletions
+162 -12
View File
@@ -134,6 +134,69 @@ 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,
@@ -144,14 +207,23 @@ MaxRequestWorkers=1), Ubuntu 24.04, x86_64:
| 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 |
| Page reclamation via filler spray | Confirmed | 27+ fields crashes worker |
| UAF crash (SIGSEGV in var_push) | Confirmed | Reproducible, first attempt |
| Full RCE | Not achieved | GC header overlap blocks pointer control |
| 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:
@@ -172,20 +244,98 @@ The attacker controls bytes from offset 24 onward (`allowed_classes`,
`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. **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).
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. **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.
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. **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.
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)