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:
PHP_METHOD(SplHeap, next) at line 962
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
- Subclass
SplMinHeap with a compare() that calls Fiber::suspend() on a specific comparison count
- Start
extract() inside a Fiber - it suspends mid-sift-down while the heap is WRITE_LOCKED
- Call
$heap->next() - bypasses the lock, performs a concurrent spl_ptr_heap_delete_top()
- 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
- 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:
- UAF frees
zend_array (56 bytes) back to ZMM bin 6 free list
- Attacker allocates
zend_string objects of length 24-31 (header 24 + data + null = 56 bytes, same bin)
- ZMM's LIFO free list guarantees the string lands in the freed slot
- The dangling zval (
type=IS_ARRAY, value.arr pointing to the now-reallocated memory) interprets the string data as a zend_array struct
- String bytes at offset 24-31 overlap
zend_array.pDestructor (a function pointer at offset 48)
- 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
Description
Summary
Commit a56ff4fec71 (Oct 2024) introduced
SPL_HEAP_WRITE_LOCKEDandspl_heap_consistency_validations()to prevent concurrent modification of SplHeap internals. The fix was applied toinsert(),extract(), andtop(), but two code paths that also callspl_ptr_heap_delete_top()were missed:PHP_METHOD(SplHeap, next)at line 962spl_heap_it_move_forward()at line 937 validates withwrite=false(should bewrite=true)Both call
spl_ptr_heap_delete_top(), which is a write operation that modifies the heap's internal array and calls the user-suppliedcompare()function during sift-down. When a Fiber suspends insidecompare()during a write-locked operation (e.g.,extract()), callingnext()or iterating withforeachperforms a concurrentdelete_top()on the same heap, corrupting its internal state.Root Cause
ext/spl/spl_heap.clines 962-969:Compare with
extract()at line 635, which correctly checks:And
spl_heap_it_move_forward()at line 937 has the wrong flag:Technique
SplMinHeapwith acompare()that callsFiber::suspend()on a specific comparison countextract()inside a Fiber - it suspends mid-sift-down while the heap isWRITE_LOCKED$heap->next()- bypasses the lock, performs a concurrentspl_ptr_heap_delete_top()next()call frees an element viazval_ptr_dtor(), decrementsheap->count, runs its own sift-down, and clearsWRITE_LOCKED- all while the original extract's sift-down is still suspendedlimitandbottompointers, accessing positions beyond the now-reduced valid rangeConfirmed impact
Debug build (ZTS DEBUG):
ht=0x... is already destroyedatzend_hash.c:2692Release build (NTS, default ZMM allocator):
extract()returns NULLzend_mm_heap corrupted- heap allocator metadata destroyedValgrind (release build, USE_ZEND_ALLOC=0):
Invalid read of size 4andInvalid writeon freed blocks of size 56 (zend_array)Memory-level issue path
The freed
zend_arraystructures are 56 bytes (ZMM bin 6). On release builds with the default allocator:zend_array(56 bytes) back to ZMM bin 6 free listzend_stringobjects of length 24-31 (header 24 + data + null = 56 bytes, same bin)type=IS_ARRAY,value.arrpointing to the now-reallocated memory) interprets the string data as azend_arraystructzend_array.pDestructor(a function pointer at offset 48)pDestructor(zval*)is called with the attacker-controlled addressPHP Version
Operating System
No response