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)
|
||||
|
||||
|
||||
+26
-15
@@ -101,27 +101,38 @@ The worker segfaults, confirming the use-after-free.
|
||||
| OOM during RFC1867 session decode | Confirmed |
|
||||
| Apache prefork worker survival after OOM | Confirmed |
|
||||
| Stale `BG(unserialize)` state across requests | Confirmed (level=1, data=freed ptr) |
|
||||
| Page reclamation via filler field spray | Confirmed (100 x 8200B = 300 pages) |
|
||||
| UAF crash (SIGSEGV in `var_push`) | Confirmed (first attempt, reproducible) |
|
||||
| Page reclamation via filler field spray | Confirmed (27+ fields crashes worker) |
|
||||
| UAF crash (SIGSEGV in `var_tmp_var`) | Confirmed (reproducible) |
|
||||
| Type confusion via `var_access` | Confirmed (stale zval, type matches OOM session) |
|
||||
|
||||
**Not achieved: full RCE.**
|
||||
|
||||
The struct's critical pointer fields (offsets 0-23) overlap with the
|
||||
`zend_string` GC header and cannot be controlled by the attacker:
|
||||
Two UAF primitives are confirmed, but they are mutually exclusive:
|
||||
|
||||
| Struct field | Offset | Overlaps with | Value |
|
||||
|---|---|---|---|
|
||||
| `last` | 0 | `gc_refcount` + `gc_type_info` | ~0x00000306_00000001 (invalid pointer) |
|
||||
| `first_dtor` | 8 | `hash` | 0 (lazily computed, safe) |
|
||||
| `last_dtor` | 16 | `len` | 8200 (low address, crash) |
|
||||
**1. Crash (filler spray path):** Filler fields reclaim the stale
|
||||
struct pages. `var_tmp_var` reads `last_dtor` at struct offset 16
|
||||
(now overwritten by the `zend_string` `len` field, e.g. 0x2008).
|
||||
Dereferences unmapped address. SIGSEGV.
|
||||
|
||||
Controlled bytes start at offset 24 (`allowed_classes`, `ref_props`,
|
||||
`max_depth`, `entries.used_slots`, `entries.next`, `entries.data[]`),
|
||||
but `var_push` crashes at offset 0 before reaching them.
|
||||
**2. Type confusion (no spray path):** Session data with `R:N;`
|
||||
backreferences resolves through `var_access`, which reads inline
|
||||
entries at struct offset 56. Without spray, entries.data[0] holds
|
||||
a stale pointer to the attacker's own data from request 1. The
|
||||
cached ZendMM chunk retains page data (only the header is zeroed).
|
||||
PHP reads the stale zval without crashing: type matches whatever
|
||||
the OOM session's first entry was (string, integer, etc.).
|
||||
|
||||
Full RCE would require either an info leak to construct a valid pointer
|
||||
in the GC header position, or a different allocation type that provides
|
||||
control over the first 24 bytes of an 8216-byte ZendMM allocation.
|
||||
The two primitives cannot coexist: `var_tmp_var` reads offset 16
|
||||
BEFORE `var_access` reads offset 56. Filler spray corrupts offset
|
||||
16, crashing before the type confusion can execute.
|
||||
|
||||
Full RCE is blocked by:
|
||||
1. **No info leak**: type confusion reads own data, not heap metadata
|
||||
2. **Mutual exclusion**: spray crashes `var_tmp_var` before `var_access`
|
||||
3. **GC header overlap**: uncontrolled bytes at offset 0-23 crash var_push
|
||||
4. **No usable classes**: SplFileObject/PDO have function pointer calls
|
||||
in `free_obj`, but fake objects need libphp.so base (ASLR)
|
||||
5. **Session key validation**: php serializer rejects keys containing `|`
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
+55
-35
@@ -53,28 +53,46 @@ Requirements:
|
||||
|
||||
Exploitation notes:
|
||||
|
||||
This PoC demonstrates the UAF primitive (stale pointer dereference)
|
||||
and crash (SIGSEGV from corrupted 'last' pointer). Full RCE from
|
||||
this primitive is blocked by an overlap between the struct's critical
|
||||
pointer fields (offsets 0-23: last, first_dtor, last_dtor) and the
|
||||
zend_string GC header (refcount, type_info, hash, len). These
|
||||
fields cannot be directly controlled by the attacker.
|
||||
This PoC demonstrates two distinct UAF primitives:
|
||||
|
||||
Specifically:
|
||||
- 'last' = gc_refcount(4) + gc_type_info(4) -> invalid pointer
|
||||
- 'first_dtor' = hash(8) = 0 initially -> NULL (safe for var_destroy)
|
||||
- 'last_dtor' = len(8) = string length -> low address (crash)
|
||||
1. CRASH (var_push path): Filler fields reclaim the stale struct
|
||||
pages. The zend_string GC header at offset 0 overwrites the
|
||||
'last' pointer with refcount|type_info (not a valid address).
|
||||
var_push reads 'last' and dereferences it -> SIGSEGV.
|
||||
|
||||
Controlled bytes start at offset 24 (allowed_classes, ref_props,
|
||||
max_depth, entries.used_slots, entries.next, entries.data[]), but
|
||||
var_push crashes before these fields are reached because it reads
|
||||
'last' first.
|
||||
2. TYPE CONFUSION (var_access path): Session data using R:N
|
||||
backreferences resolves through var_access, which reads the
|
||||
inline 'entries' at struct offset 56 instead of 'last' at
|
||||
offset 0. Without the filler spray, the stale struct's 'last'
|
||||
pointer self-references &entries (valid), and entries.data[0]
|
||||
holds a dangling pointer to a zval from request 1's heap.
|
||||
PHP reads this stale zval without crashing. The value's type
|
||||
matches whatever the first OOM session entry contained:
|
||||
- String value -> stale IS_STRING (original content intact)
|
||||
- Integer value -> stale IS_LONG (original value)
|
||||
- Object value -> CRASH (freed zend_object dereference)
|
||||
|
||||
Full RCE would require either:
|
||||
a) An info leak to construct a valid 'last' pointer in the GC header
|
||||
position (which would require a separate vulnerability)
|
||||
b) A different allocation type that gives control over offset 0 of
|
||||
a 3-page (8216-byte) ZendMM allocation
|
||||
The two primitives are MUTUALLY EXCLUSIVE: filler spray corrupts
|
||||
struct offset 16 (last_dtor), and var_tmp_var reads last_dtor
|
||||
BEFORE var_access runs. With fillers, var_tmp_var crashes.
|
||||
Without fillers, the type confusion works but reads the attacker's
|
||||
own data from the stale heap (the cached ZendMM chunk retains page
|
||||
data across requests without zeroing). No info leak is possible.
|
||||
|
||||
Full RCE is blocked by:
|
||||
- ASLR: no info leak primitive. The type confusion reads back
|
||||
the attacker's own session data, not heap metadata or code
|
||||
pointers. The cached chunk retains original bytes.
|
||||
- Mutual exclusion: filler spray (needed for controlled content)
|
||||
crashes var_tmp_var before var_access can read the fillers.
|
||||
- GC header overlap at offset 0: prevents pointer control when
|
||||
spraying (var_push reads 'last' before reaching controlled
|
||||
bytes at offset 24+)
|
||||
- No loaded classes during sapi_activate: PHP built-in classes
|
||||
(SplFileObject, PDO) have exploitable handlers but constructing
|
||||
fake objects requires knowing libphp.so base address (ASLR)
|
||||
- Session key validation: PHP's php serializer skips keys
|
||||
containing '|', blocking injection via upload progress name
|
||||
|
||||
Usage:
|
||||
python3 exploit.py --target http://host:port/index.php
|
||||
@@ -544,27 +562,29 @@ def print_summary(patched=False, crash=False):
|
||||
print(" BG(unserialize).level=1, .data=freed heap pointer")
|
||||
print(" Apache prefork worker survives, serves new requests")
|
||||
if crash:
|
||||
print(" [+] Use-After-Free crash on next request")
|
||||
print(" [+] Use-After-Free crash (var_push path)")
|
||||
print(" Filler zend_strings reclaim stale struct pages")
|
||||
print(" var_push dereferences GC header as 'last' pointer -> SIGSEGV")
|
||||
print(" [+] Type confusion (var_access path)")
|
||||
print(" R:N backreferences bypass the corrupted 'last' pointer")
|
||||
print(" var_access reads inline entries at offset 56 (not offset 0)")
|
||||
print(" Stale entries.data[0] resolves to freed heap memory")
|
||||
print(" PHP interprets stale bytes as a live zval (observed: IS_ARRAY)")
|
||||
print()
|
||||
print(" Exploitation status:")
|
||||
print(" The UAF primitive (stale pointer reuse) is confirmed.")
|
||||
if crash:
|
||||
print(" Page reclamation via filler fields is confirmed.")
|
||||
print(" However, full RCE is blocked by an inherent constraint:")
|
||||
print(" the struct's critical pointer fields (offsets 0-23: last,")
|
||||
print(" first_dtor, last_dtor) overlap with the zend_string GC header")
|
||||
print(" (refcount, type_info, hash, len), which cannot be controlled")
|
||||
print(" by the attacker.")
|
||||
print(" Two UAF primitives confirmed: crash (DoS) and type confusion.")
|
||||
print(" Full RCE is blocked by five independent constraints:")
|
||||
print()
|
||||
print(" 'last' = gc_refcount + gc_type_info -> invalid pointer (crash)")
|
||||
print(" 'first_dtor' = hash = 0 initially (safe, dtor chain empty)")
|
||||
print(" 'last_dtor' = len = string length (low address, crash)")
|
||||
print()
|
||||
print(" Full RCE would require an info leak for valid pointer placement")
|
||||
print(" at offset 0, or a different allocation type that gives control")
|
||||
print(" over the first 24 bytes of an 8216-byte ZendMM allocation.")
|
||||
print(" 1. ASLR: type confusion reads attacker's own data, not heap")
|
||||
print(" metadata or code pointers (cached chunk retains original bytes)")
|
||||
print(" 2. Mutual exclusion: filler spray crashes var_tmp_var (offset 16)")
|
||||
print(" before var_access can read controlled data at offset 56+")
|
||||
print(" 3. GC header overlap: uncontrolled bytes at struct offset 0-23")
|
||||
print(" prevent var_push from reaching controlled data at offset 24+")
|
||||
print(" 4. No exploitable classes: SplFileObject/PDO have function pointer")
|
||||
print(" calls in free_obj, but fake objects need libphp.so base (ASLR)")
|
||||
print(" 5. Session key validation: php serializer skips keys with '|',")
|
||||
print(" blocking deserialization injection via upload progress name")
|
||||
print()
|
||||
print(" Affected: PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11")
|
||||
print(" Fixed in: PHP 8.3.36, 8.4.27, 8.5.12")
|
||||
|
||||
Reference in New Issue
Block a user