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 // 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 ## Confirmed primitives
Tested on PHP 8.3.35 (source build), Apache 2.4.58 (prefork, 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 | | OOM during RFC1867 session decode | Confirmed | 33000-entry session, 4MB limit |
| Apache prefork worker survival | Confirmed | Same PID after OOM | | Apache prefork worker survival | Confirmed | Same PID after OOM |
| Stale BG(unserialize) state | Confirmed | level=1, data=freed ptr (GDB) | | 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 | | 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 ## Exploitation constraints
### Why full RCE is not achieved ### 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 The struct's three pointer fields at offsets 0-23 overlap with the
`zend_string` header, which is set by the allocator: `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) `entries.data[]`), but `var_push` crashes reading `last` (offset 0)
before any of these fields are reached. 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 ### What would be needed for full RCE
1. **Info leak**: A separate vulnerability to leak a valid heap address. 1. **An info leak from a separate vulnerability**: The type confusion
The leaked address would need to be placed at offset 0 of the reads the attacker's own data. A separate vulnerability providing
allocation, which is impossible with a `zend_string` overlay (GC either a heap pointer or a libphp.so code pointer would enable:
header is set by the allocator). - 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) 2. **Bypass of filler/type-confusion mutual exclusion**: A way to place
allocation where the attacker controls offset 0. No such allocation controlled data on stale pages without corrupting struct offset 16
type has been identified during multipart parsing. (`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 3. **Loaded classes with POP chains**: If a framework's classes were
fields WITHOUT overlapping a `zend_string` header. This would require loaded before session decode, deserialized objects could trigger
a non-contiguous write primitive. 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) ## The patch (PHP 8.3.36, 8.4.27, 8.5.12)
+26 -15
View File
@@ -101,27 +101,38 @@ The worker segfaults, confirming the use-after-free.
| OOM during RFC1867 session decode | Confirmed | | OOM during RFC1867 session decode | Confirmed |
| Apache prefork worker survival after OOM | Confirmed | | Apache prefork worker survival after OOM | Confirmed |
| Stale `BG(unserialize)` state across requests | Confirmed (level=1, data=freed ptr) | | Stale `BG(unserialize)` state across requests | Confirmed (level=1, data=freed ptr) |
| Page reclamation via filler field spray | Confirmed (100 x 8200B = 300 pages) | | Page reclamation via filler field spray | Confirmed (27+ fields crashes worker) |
| UAF crash (SIGSEGV in `var_push`) | Confirmed (first attempt, reproducible) | | 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.** **Not achieved: full RCE.**
The struct's critical pointer fields (offsets 0-23) overlap with the Two UAF primitives are confirmed, but they are mutually exclusive:
`zend_string` GC header and cannot be controlled by the attacker:
| Struct field | Offset | Overlaps with | Value | **1. Crash (filler spray path):** Filler fields reclaim the stale
|---|---|---|---| struct pages. `var_tmp_var` reads `last_dtor` at struct offset 16
| `last` | 0 | `gc_refcount` + `gc_type_info` | ~0x00000306_00000001 (invalid pointer) | (now overwritten by the `zend_string` `len` field, e.g. 0x2008).
| `first_dtor` | 8 | `hash` | 0 (lazily computed, safe) | Dereferences unmapped address. SIGSEGV.
| `last_dtor` | 16 | `len` | 8200 (low address, crash) |
Controlled bytes start at offset 24 (`allowed_classes`, `ref_props`, **2. Type confusion (no spray path):** Session data with `R:N;`
`max_depth`, `entries.used_slots`, `entries.next`, `entries.data[]`), backreferences resolves through `var_access`, which reads inline
but `var_push` crashes at offset 0 before reaching them. 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 The two primitives cannot coexist: `var_tmp_var` reads offset 16
in the GC header position, or a different allocation type that provides BEFORE `var_access` reads offset 56. Filler spray corrupts offset
control over the first 24 bytes of an 8216-byte ZendMM allocation. 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 ## Usage
+55 -35
View File
@@ -53,28 +53,46 @@ Requirements:
Exploitation notes: Exploitation notes:
This PoC demonstrates the UAF primitive (stale pointer dereference) This PoC demonstrates two distinct UAF primitives:
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.
Specifically: 1. CRASH (var_push path): Filler fields reclaim the stale struct
- 'last' = gc_refcount(4) + gc_type_info(4) -> invalid pointer pages. The zend_string GC header at offset 0 overwrites the
- 'first_dtor' = hash(8) = 0 initially -> NULL (safe for var_destroy) 'last' pointer with refcount|type_info (not a valid address).
- 'last_dtor' = len(8) = string length -> low address (crash) var_push reads 'last' and dereferences it -> SIGSEGV.
Controlled bytes start at offset 24 (allowed_classes, ref_props, 2. TYPE CONFUSION (var_access path): Session data using R:N
max_depth, entries.used_slots, entries.next, entries.data[]), but backreferences resolves through var_access, which reads the
var_push crashes before these fields are reached because it reads inline 'entries' at struct offset 56 instead of 'last' at
'last' first. 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: The two primitives are MUTUALLY EXCLUSIVE: filler spray corrupts
a) An info leak to construct a valid 'last' pointer in the GC header struct offset 16 (last_dtor), and var_tmp_var reads last_dtor
position (which would require a separate vulnerability) BEFORE var_access runs. With fillers, var_tmp_var crashes.
b) A different allocation type that gives control over offset 0 of Without fillers, the type confusion works but reads the attacker's
a 3-page (8216-byte) ZendMM allocation 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: Usage:
python3 exploit.py --target http://host:port/index.php 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(" BG(unserialize).level=1, .data=freed heap pointer")
print(" Apache prefork worker survives, serves new requests") print(" Apache prefork worker survives, serves new requests")
if crash: 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(" Filler zend_strings reclaim stale struct pages")
print(" var_push dereferences GC header as 'last' pointer -> SIGSEGV") 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()
print(" Exploitation status:") print(" Exploitation status:")
print(" The UAF primitive (stale pointer reuse) is confirmed.") print(" Two UAF primitives confirmed: crash (DoS) and type confusion.")
if crash: print(" Full RCE is blocked by five independent constraints:")
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() print()
print(" 'last' = gc_refcount + gc_type_info -> invalid pointer (crash)") print(" 1. ASLR: type confusion reads attacker's own data, not heap")
print(" 'first_dtor' = hash = 0 initially (safe, dtor chain empty)") print(" metadata or code pointers (cached chunk retains original bytes)")
print(" 'last_dtor' = len = string length (low address, crash)") print(" 2. Mutual exclusion: filler spray crashes var_tmp_var (offset 16)")
print() print(" before var_access can read controlled data at offset 56+")
print(" Full RCE would require an info leak for valid pointer placement") print(" 3. GC header overlap: uncontrolled bytes at struct offset 0-23")
print(" at offset 0, or a different allocation type that gives control") print(" prevent var_push from reaching controlled data at offset 24+")
print(" over the first 24 bytes of an 8216-byte ZendMM allocation.") 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()
print(" Affected: PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11") 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") print(" Fixed in: PHP 8.3.36, 8.4.27, 8.5.12")