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
+55 -35
View File
@@ -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")