Repository navigation
var_destroy() UAF also reachable through SplObjectStorage::__unserialize() — separate entry point from #24481 #24674
Description
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
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
unserialize($payload)parses the outerO:16:"SplObjectStorage"tag- Instantiates
SplObjectStorageand calls its__unserialize()handler - 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
PHP_VAR_UNSERIALIZE_DESTROY(var_hash)→ callsvar_destroy()on the SPL contextvar_destroy()frees the extension chunks- Then fires
__destructon entries in the dtor list — withoutBG(serialize_lock) - 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: Use-after-free in var_destroy() during unserialize() re-entrancy allows __destruct to read a freed var_entries chunk #24481 (original reporter + jogoossens cross-branch confirmation)
- SPL entry-point discovery and reproducer: srayuth089 (this report)
Activity
srayuth089 commentedon Oct 25, 2026
Update: working reproducers for the two remaining siblings
Both
SplDoublyLinkedList::__unserialize()andArrayObject::__unserialize()now have deterministic reproducers — same result as theSplObjectStorageone (R:1024resolves to a value read from the freed extension chunks), verified on stock CLI of 8.4.27, 8.3.36 and 8.2.35 (3/3 runs each):Reproducer (shared trigger + the two payload shapes, learned from
serialize()of real objects):The other SPL/internal classes listed in the report that use the same
PHP_VAR_UNSERIALIZE_INIT/var_push_dtor/PHP_VAR_UNSERIALIZE_DESTROYpattern in their__unserializehandlers should be considered reachable through the same construction.srayuth089 commentedon Oct 25, 2026
Additional entry point: legacy
SplObjectStorage::unserialize(string)I verified that the legacy method also reaches the same
var_destroy()UAF, separately from theSplObjectStorage::__unserialize()path in the report. The reproducer calls$storage->unserialize($wire)directly. In PHP 8.4.26 ASAN, the trace is a heap-use-after-free read inphp_var_unserialize()(ext/standard/var_unserializer.c:849); the chunk was freed earlier invar_destroy()(:245). The call stack goes throughzim_SplObjectStorage_unserialize()(ext/spl/spl_observer.c:1006). The stock CLI reproducer producedUAF_READ R:1024 => objectin 3/3 fresh runs on PHP 8.2.35, 8.3.36, and 8.4.27.Minimal reproducer:
This is an additional legacy-method entry point to the reported root cause, not a claim of a separate root cause or of RCE. The ASAN stack and complete run output are available if useful.
srayuth089 commentedon Oct 25, 2026
Additional ASAN confirmation: legacy
SplDoublyLinkedList::unserialize(string)I also reproduced the same UAF through the legacy custom-unserialization method, independently of the
SplDoublyLinkedList::__unserialize()payload above. The detector creates a list with 2,000 integer entries, then a tail object whose__wakeup()removes itself from the list; its destructor re-entersunserialize('R:1500;')while the customvar_hashteardown is active.AddressSanitizer reports
heap-use-after-freeinphp_var_unserialize()on both PHP 8.4.26 (ext/standard/var_unserializer.c:849) and 8.4.27 (:906). The call path goes throughSplDoublyLinkedList::unserialize(), and the freed region is thevar_entriesextension chunk released byvar_destroy()before the deferred destructor pass. This confirms the legacy SPL entry point still reaches the same root cause after the 8.4.27 release; it is not a claim of a separate root cause or CVE.The reproducer and full ASAN logs are available in my local lab if maintainers want them. The test only detects the invalid read; it does not attempt exploitation.