Skip to content

SplHeap::next() Write-Lock Bypass - Use-After-Free #21454

@arnoldasr

Description

@arnoldasr

Description

Field Value
Component ext/spl/spl_heap.c
Affects PHP 8.4.x, 8.5.x (all versions since GH-16337 fix)
Type Use-After-Free / Heap Corruption
Requires PHP code execution, Fibers
Tested on PHP 8.5.2, x86_64 Linux, debug + release builds
Related GH-16337, commit a56ff4f

Summary

Commit a56ff4fec71 (Oct 2024) introduced SPL_HEAP_WRITE_LOCKED and spl_heap_consistency_validations() to prevent concurrent modification of SplHeap internals. The fix was applied to insert(), extract(), and top(), but two code paths that also call spl_ptr_heap_delete_top() were missed:

  1. PHP_METHOD(SplHeap, next) at line 962
  2. spl_heap_it_move_forward() at line 937 validates with write=false (should be write=true)

Both call spl_ptr_heap_delete_top(), which is a write operation that modifies the heap's internal array and calls the user-supplied compare() function during sift-down. When a Fiber suspends inside compare() during a write-locked operation (e.g., extract()), calling next() or iterating with foreach performs a concurrent delete_top() on the same heap, corrupting its internal state.

Root Cause

ext/spl/spl_heap.c lines 962-969:

PHP_METHOD(SplHeap, next)
{
    spl_heap_object *intern = Z_SPLHEAP_P(ZEND_THIS);
    ZEND_PARSE_PARAMETERS_NONE();

    // BUG: No call to spl_heap_consistency_validations(intern, true)
    //      extract() and insert() both perform this check.

    spl_ptr_heap_delete_top(intern->heap, NULL, ZEND_THIS);
}

Compare with extract() at line 635, which correctly checks:

PHP_METHOD(SplHeap, extract)
{
    // ...
    if (UNEXPECTED(spl_heap_consistency_validations(intern, true) != SUCCESS)) {
        RETURN_THROWS();
    }
    // ...
}

And spl_heap_it_move_forward() at line 937 has the wrong flag:

static void spl_heap_it_move_forward(zend_object_iterator *iter)
{
    spl_heap_object *object = Z_SPLHEAP_P(&iter->data);

    // BUG: write=false, but this calls delete_top (a write operation)
    if (UNEXPECTED(spl_heap_consistency_validations(object, false) != SUCCESS)) {
        return;
    }

    spl_ptr_heap_delete_top(object->heap, NULL, &iter->data);
    // ...
}

Technique

  1. Subclass SplMinHeap with a compare() that calls Fiber::suspend() on a specific comparison count
  2. Start extract() inside a Fiber - it suspends mid-sift-down while the heap is WRITE_LOCKED
  3. Call $heap->next() - bypasses the lock, performs a concurrent spl_ptr_heap_delete_top()
  4. Each next() call frees an element via zval_ptr_dtor(), decrements heap->count, runs its own sift-down, and clears WRITE_LOCKED - all while the original extract's sift-down is still suspended
  5. Resume the Fiber - the original sift-down continues with stale limit and bottom pointers, accessing positions beyond the now-reduced valid range

Confirmed impact

Debug build (ZTS DEBUG):

  • Assertion failure: ht=0x... is already destroyed at zend_hash.c:2692
  • Crash via SIGABRT (exit code 134)

Release build (NTS, default ZMM allocator):

  • extract() returns NULL
  • Subsequent extractions return corrupted data
  • Elements are duplicated
  • Elements are lost
  • Queue ordering is destroyed
  • zend_mm_heap corrupted - heap allocator metadata destroyed

Valgrind (release build, USE_ZEND_ALLOC=0):

  • 48 errors from 28 contexts
  • Multiple Invalid read of size 4 and Invalid write on freed blocks of size 56 (zend_array)

Memory-level issue path

The freed zend_array structures are 56 bytes (ZMM bin 6). On release builds with the default allocator:

  1. UAF frees zend_array (56 bytes) back to ZMM bin 6 free list
  2. Attacker allocates zend_string objects of length 24-31 (header 24 + data + null = 56 bytes, same bin)
  3. ZMM's LIFO free list guarantees the string lands in the freed slot
  4. The dangling zval (type=IS_ARRAY, value.arr pointing to the now-reallocated memory) interprets the string data as a zend_array struct
  5. String bytes at offset 24-31 overlap zend_array.pDestructor (a function pointer at offset 48)
  6. When the fake array is destroyed, pDestructor(zval*) is called with the attacker-controlled address

PHP Version

PHP 8.5.2 (cli) (built: Mar  9 2026 15:05:25) (ZTS DEBUG)
Copyright (c) The PHP Group
Zend Engine v4.5.2, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.2, Copyright (c), by Zend Technologies

Operating System

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions