Eric Labrador


← e1abrador.com


PHP 8.4+ UAF via ZEND_FRAMELESS_FUNCTION: From Crash to disable_functions Bypass

2026-08-13  —  PHP 0-day Use-After-Free RCE

Background

PHP 8.4 introduced frameless functions, an optimization for performance-critical builtins like implode, preg_replace, str_replace, and strtr. In the traditional call path, the VM pushes arguments through SEND_VAR opcodes, which increment the refcount on each argument. The callee holds its own reference, so the caller cannot destroy the data mid-execution.

Frameless functions skip this. ZEND_FRAMELESS_FUNCTION dispatches directly to the C implementation with raw zval pointers from the opcode operand slots. No stack frame, no SEND_VAR, no refcount bump. If user code runs during the call, for example through a __toString() on a Stringable array element, it can free the data the function is still iterating. The refcount sits at 1, and nothing else is holding it.

The Vulnerability

Several PHP string functions walk array arguments with ZEND_HASH_FOREACH_VAL and never take a reference on the array itself. When the loop hits a Stringable object, the engine calls __toString(). That runs user code. If that code nulls the variable holding the array, refcount goes to zero, the HashTable and its arData are freed, and the loop keeps reading into deallocated memory.

Minimal trigger:

class Crash implements Stringable {
    public function __toString(): string {
        global $a;
        $a = null;   // refcount 1 → 0 → free
        return "X";
    }
}

$a = [new Crash, 2, 3, 4];
implode(",", $a);   // reads freed arData

ASAN output:

==641665==ERROR: AddressSanitizer: heap-use-after-free on address 0x7ab10ede4640
READ of size 1 at 0x7ab10ede4640 thread T0
    #0 in zval_get_type Zend/zend_types.h:685
    #1 in php_implode ext/standard/string.c:946
    #2 in zflf_implode_2 ext/standard/string.c:1088
    #3 in ZEND_FRAMELESS_ICALL_2_SPEC_HANDLER Zend/zend_vm_execute.h:3899

Frame #2 is zflf_implode_2, the frameless entrypoint. The PHP_FUNCTION(implode) path does not crash because SEND_VAR keeps refcount ≥ 2.

I reported this as GH-23204. The fix (8ce7f7f) wraps the iteration in GC_TRY_ADDREF / GC_TRY_DTOR_NO_REF for implode(), strtr(), and str_replace().

Before the fix, php_implode() looked like this:

PHPAPI void php_implode(const zend_string *glue, HashTable *pieces, zval *return_value)
{
    // ...
    ZEND_HASH_FOREACH_VAL(pieces, tmp) {
        // ...
        } else {
            ptr->str = zval_get_string_func(tmp);  // __toString()
            // pieces may be freed here
        }
    } ZEND_HASH_FOREACH_END();
    // ...
}

After:

    GC_TRY_ADDREF(pieces);

    ZEND_HASH_FOREACH_VAL(pieces, tmp) {
        // ...
    } ZEND_HASH_FOREACH_END();

    free_alloca(strings, use_heap);
    GC_TRY_DTOR_NO_REF(pieces);
    RETURN_NEW_STR(str);

The refcount bump before the loop means that if __toString() nulls the global, refcount drops to 1 instead of 0. The array survives.

Exploiting preg_replace

The fix covers three functions. It does not cover preg_replace, which has the same pattern in _preg_replace_common():

// ext/pcre/php_pcre.c
ZEND_HASH_FOREACH_KEY_VAL(subject_ht, num_key, string_key, subject_entry) {
    zend_string *subject_entry_str = zval_get_tmp_string(subject_entry, &tmp_subject_entry_str);
    //                               ↑ __toString() on Stringable elements
    result = php_replace_in_subject(..., subject_entry_str, ...);
} ZEND_HASH_FOREACH_END();

The frameless entrypoint extracts the HashTable* with no refcount protection:

ZEND_FRAMELESS_FUNCTION(preg_replace, 3)
{
    Z_FLF_PARAM_ARRAY_HT_OR_STR(3, subject_ht, subject_str, subject_tmp);
    // no GC_TRY_ADDREF, subject_ht refcount stays at 1
    _preg_replace_common(return_value, ..., subject_ht, subject_str, ...);
}

So the exploit targets preg_replace. The regex /^NEVERMATCH/ is anchored to prevent PCRE from scanning the sprayed fake strings. The match never fires, the replacement never runs. The only thing the call does is iterate the subject array and invoke __toString() on the first element.

The Exploit Chain

The UAF gives type confusion over freed arData. Four phases turn it into RCE.

Phase A: Heap Address Leak

PHP packed arrays (sequential integer keys) store zvals contiguously. Bucket arrays store both keys and values. The exploit builds a packed array [$evil_a, "AA", $evil_a], unsets index 2, and passes it to preg_replace. When $evil_a->__toString() fires, it frees the array and sprays 64 bin-6 (24-byte) null strings into the same size class.

The FOREACH loop continues over the freed and now-reused memory. It reads the spray data as bucket entries, interpreting the h field as an integer key. That field contains a heap address from the allocator metadata. The address shows up as a key in the result array:

$result = @preg_replace("/^NEVERMATCH/", "Z", $arr);
foreach ($result as $key => $val) {
    if (is_int($key) && $key > 0xFFFF) $evil_ptr = $key;
}

Phase B: PHP Binary Base Leak

With the heap address of an Evil object from Phase A, Phase B triggers a second UAF and sprays a crafted bin-12 (128-byte) string over the freed slot. The spray data fakes an IS_STRING zval (type byte 0x06) whose string pointer is set to $evil_ptr - 8, which lines up the "string content" with the Evil object's internal zend_object fields.

The FOREACH loop reads this fake string and returns the raw bytes. The first 8 bytes are the object's ce pointer (class entry). The next 8 are handlers, which points to std_object_handlers in the PHP binary's .data section. Subtracting the known offset of that symbol gives the PIE base:

$ce       = unpack("P", substr($raw_data, 0, 8))[1];
$handlers = unpack("P", substr($raw_data, 8, 8))[1];
$php_base = $handlers - $OFF_SOH;

The std_object_handlers offset is not hardcoded. It comes from parsing PHP_BINARY's ELF .dynsym table at runtime.

Phase A-2: Fake Object Address Leak

Same technique as Phase A. Leaks the heap address of $table_str, a 128-byte string used to hold the fake object and handlers table for the next phase.

Phase C: RCE via cast_object

The exploit builds a fake zend_object inside $table_str. A final UAF sprays a fake IS_OBJECT zval (type 0x08) pointing at this fake object. When the FOREACH loop hits it, the engine tries to stringify it:

// zend_operators.c
case IS_OBJECT:
    zval tmp;
    if (Z_OBJ_HT_P(op)->cast_object(Z_OBJ_P(op), &tmp, IS_STRING) == SUCCESS) {
        // ...

Z_OBJ_HT_P(op) reads the handlers pointer from the fake object, which points back into $table_str (address known from Phase A-2). At the cast_object offset in that table, the exploit writes the address of libc's system().

The cast_object slot sits at byte offset 136 in zend_object_handlers on PHP 8.4 (17 function pointers at 8 bytes each). PHP 8.5 added a clone_obj_with field before it, pushing the offset to 144:

$cast_offset = (PHP_MINOR_VERSION >= 5) ? 144 : 136;

When the engine calls cast_object(obj, &tmp, IS_STRING), it actually calls system(obj). The obj pointer is the start of the fake object data, where the exploit wrote "sh /tmp/.c\0". The call becomes system("sh /tmp/.c").

The script at /tmp/.c:

exec >/tmp/.o 2>&1
id
exit 1

The exit 1 matters. If system() returns 0, the engine interprets that as SUCCESS and tries to use the uninitialized tmp zval, which causes a SIGSEGV. A nonzero return means FAILURE, and the engine takes the error path, which calls zend_throw_error. That dereferences the fake object's ce to get the class name, so the exploit sets ce to the real class entry leaked in Phase B.

Resolving Offsets at Runtime

Nothing is hardcoded. The exploit includes an ELF parser written in pure PHP that reads .dynsym from any ELF binary. It parses the program headers to locate PT_DYNAMIC, extracts DT_SYMTAB and DT_STRTAB, translates virtual addresses to file offsets through PT_LOAD segments, and walks the symbol table.

Two binaries are parsed: PHP_BINARY (for std_object_handlers) and the libc shared object found in /proc/self/maps (for system). The libc base address is the load address from the maps entry. The final address is libc_base + system_offset.

function elf_parse_dynsym($path) {
    $elf = @file_get_contents($path);
    // ELF header → e_phoff, e_phentsize, e_phnum
    // PT_DYNAMIC → DT_SYMTAB, DT_STRTAB, DT_SYMENT
    // PT_LOAD segments → vaddr-to-file-offset table
    // walk .dynsym → {name: st_value}
    return ['symbols' => $symbols, 'e_machine' => $e_machine];
}

This covers x86_64 and aarch64, any distro, any libc version.

Heap Reliability

The exploit runs at 100% in testing. The heap setup is deterministic: 400 bin-6 and 400 bin-12 strings drain the freelists upfront so spray allocations land where the freed arData was. Each Evil object is kept alive in a separate variable ($ea_tmp, $eb_tmp, etc.) so destroying the array does not free them. 39 stdClass objects are pre-allocated to align bin-6 slabs. Each phase sprays into a different index range of the $keep_spray array (0–63, 64, 128–191, 192–256) to avoid cross-phase interference.

Tested 100/100 on a hardened PHP 8.4.10 container (43 disabled functions, OPcache, Full RELRO, non-root user) and 100/100 on PHP 8.6.0-dev on aarch64.

Prerequisites

All of these are the default on standard PHP-FPM, mod_php, and Docker deployments.

Impact

A single PHP file gives arbitrary command execution regardless of disable_functions. The exploit resolves libc system() through ELF symbol lookup and calls it via a corrupted vtable. PHP's function dispatch is never involved, so the disable_functions check never runs.

ASLR is defeated by the heap and binary base leaks. PIE is defeated by computing the base from std_object_handlers. Full RELRO does not help because the exploit reads from libc directly, not the GOT. No .so upload is needed. Earlier disable_functions bypasses required dlopen() with a malicious shared object. This one is a single PHP file.

PHP's security policy excludes disable_functions bypasses and __toString() abuse from CVE scope. There will be no CVE for this. It works against any default PHP 8.4+ deployment.

Mitigation

Update PHP. The fix for the ZEND_HASH_FOREACH refcount issue landed in 8ce7f7f for implode, strtr, and str_replace. The same GC_TRY_ADDREF / GC_TRY_DTOR_NO_REF pair needs to be added to _preg_replace_common() around its ZEND_HASH_FOREACH_KEY_VAL loop.

If patching is not possible immediately:

ZEND_FRAMELESS_FUNCTION removed the implicit safety of SEND_VAR's refcount bump. Every function that iterates an array and may call user code during iteration needs its own GC_TRY_ADDREF. The fix covered three functions. Others remain.

The PoC is available at github.com/e1abrador/php-uaf-frameless-rce.

Report Timeline

← e1abrador.com