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.
bukka commentedon Oct 30, 2026
Closing because this is not a separate SPL var_destroy() UAF. Legacy SPL teardown is covered by 08f4b64; the original live-context reproducers are tracked by #24751.
bukka commentedon Oct 30, 2026
Oh so it doesn't actually completely fixes this case so re-opening (just the fix was superseded but it was also wrong).
bukka commentedon Oct 30, 2026
So think 24751 actually addresses the rest completely so it's fine to close this.
bukka commentedon Oct 30, 2026
Although we don't actually have an issue for this so will re-open again :)
Fix phpGH-24674: Share the unserialize context only with Serializable
Fix phpGH-24674: Share the unserialize context only with Serializable