[php-src] Issue #21454: SplHeap::next() Write-Lock Bypass - Use-After-Free
[email protected] (arnoldasr)
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <aS0oFppBcG0W5n6igtmM8cb6xayvo3WaAmyYYl1kj4w@main.internal.php.net> |
Issue: https://github.com/php/php-src/issues/21454
Author: 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](https://github.com/php/php-src/issues/16337), commit a56ff4fec71 |
### Summary
Commit [a56ff4fec71](https://github.com/php/php-src/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:
```c
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:
```c
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:
```c
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
```plain
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_