[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_
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.