From 39f7f33938d6771e1d4b3ef69fa20f6e897faa7c Mon Sep 17 00:00:00 2001 From: neko Date: Thu, 22 Oct 2026 04:47:13 +0000 Subject: [PATCH] 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. --- uaf-rce/FINDINGS.md | 174 +++++++++++++++++++++++++++++++++++++++++--- uaf-rce/README.md | 41 +++++++---- uaf-rce/exploit.py | 90 ++++++++++++++--------- 3 files changed, 243 insertions(+), 62 deletions(-) diff --git a/uaf-rce/FINDINGS.md b/uaf-rce/FINDINGS.md index 1e28c92..84a893e 100644 --- a/uaf-rce/FINDINGS.md +++ b/uaf-rce/FINDINGS.md @@ -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) diff --git a/uaf-rce/README.md b/uaf-rce/README.md index 64083a2..1b59f2b 100644 --- a/uaf-rce/README.md +++ b/uaf-rce/README.md @@ -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 diff --git a/uaf-rce/exploit.py b/uaf-rce/exploit.py index 9d1ab0b..3c96fdf 100644 --- a/uaf-rce/exploit.py +++ b/uaf-rce/exploit.py @@ -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")