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:
+162
-12
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user