Repository navigation
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
windshock commentedon Nov 4, 2026
Adding our independent measurements on top of the earlier comments by @srayuth089 (legacy SPL variants) and the maintainer's status notes — we were not aware of those when starting our runs, so there is overlap on the SPL entry points; the new pieces from our side are the session-decode entry point, the 8.2.35/8.3.36/8.5.12 rows, the oracle caveat in §4, and the read-site precision in §5.
One status note that may matter for the fix path: as of 2026-11-05, commit 08f4b64 is not present in the PHP-8.3, PHP-8.4 or PHP-8.5 branches (GitHub compare: diverged), so the Oct 21 emergency releases (8.2.35 / 8.3.36 / 8.4.27 / 8.5.12) remain affected on the entry points below until #24751 lands and is backported.
We independently reproduced and extended the entry-point map in this report, with ASAN-based confirmation (the original reproducers detect the invalid read via a
R:backreference marker — see the caveat in §4):1. Legacy
SplObjectStorage::unserialize()— ASAN confirmation + 8.5.12.Heap-use-after-free 3/3 on 8.4.27 (two builds) and 8.5.12 (not in the report's version list), via the legacy
x:i:N;wire format: an entry whose info-field parse fails (O:...:{},BAD) leaves the fully-parsed object unstored, so its destructor runs during thevar_destroy()teardown walk and re-entersunserialize("R:i;"). Read sitephp_var_unserialize(var_unserializer.re:912), freed byvar_destroy←zim_SplObjectStorage_unserializeteardown — same site as the report's trace.2. Magic
__unserialize()path — ASAN upgrade of the marker-based reproducers.The
SplDoublyLinkedList/ArrayObjectO:-payload reproducers also produce ASAN heap-use-after-free 3/3 per build (8.4.27 ×2, 8.5.12) plus SIGSEGV 3/3 under the stock allocator (wild pointer read through the freed chunk-chain entry —zval_get_typederef of the stale pointer returned by the R lookup). Master is clean in all configurations. One caveat on the original reproducers, see §4.3. New entry point not in the report: session deserialization.
session_decode()/ session data decoding (ext/session, php serializer) uses its ownPHP_VAR_UNSERIALIZE_INITcontext. A failing pair (declareda:Nwith fewer elements) leaves the parsed object unstored → destroyed during the teardown walk → same re-entry. Heap-use-after-free 3/3 on 8.4.27 (two builds), 8.5.12, and 8.3.36; master clean. This path is reachable from userland (session_decode()) and end-to-end from stored session data: with the session file pre-polluted (attacker-controlled storage premise) and the trigger class defined by the application,session_start()'s automatic decode fires the same UAF — measured 3/3 per emergency-release line (8.3.36 / 8.4.27 / 8.5.12), master clean, 12/12 expected outcomes. Note the contrast with the RFC1867 hook (§7): plainsession_start()decoding runs after user code has executed, so a defined trigger class is instantiated normally instead of being neutralized to__PHP_Incomplete_Class.4. Reproducer caveat: the
R:marker alone is not a reliable oracle.With an element-count mismatch (declared
a:1100vs. fewer/more actual elements), the parse bails out and the re-enteredunserialize()resolves live entries of the still-registered context — the "UAF_READ" marker fires even on fixed master. The same applies to__unserialize()reproducers with mismatched counts. We'd recommend exact element counts (asserted) plus ASAN (USE_ZEND_ALLOC=0) as the oracle; with mismatched counts the marker only demonstrates a logical violation (live reads), not this UAF.5. Trigger structure (for anyone extending the reproducers).
A successful parse does not fire — parsed objects stay referenced by the storage/list. The trigger requires parsed-but-unstored objects: (a) info/parse failure after the object parses (legacy path), or (b) duplicate-key drops. In a failing parse the failing call's own entries are NULL-marked (3584a98 mitigation), so only entries added by prior successful calls resolve. The boundary is VAR_ENTRIES_MAX (1018 in 8.4.27): with a single chunk everything read is live; once earlier successful calls have spilled into a second chunk, that chunk is freed before the destructors run, and the first re-entrant
unserialize()call from any destructor already reads freed memory — the prologue ofphp_var_unserializereads theused_slotsof the last (freed) chunk before anyR:id is even looked up (var_unserializer.re:912), so the exposure is not limited to resolving high ids; the subsequent R lookup through the chunk chain is a second exposure path.6. Master fix verification.
08f4b64 ("Fix GH-24373") — unregistering
BG(unserialize).databeforevar_destroy()— covers all three entry families we tested: legacy SPL, magic__unserialize, and session decode (clean on master in every configuration, including 8.5.12 which predates the commit). This supports the direction of PR #24751.7. Boundary notes. The RFC1867 upload-progress hook decode is not reachable (pre-RINIT, objects neutralized to
__PHP_Incomplete_Class— verified). Separately:msg_receive()'s failed-unserialize path leaks the partially-unserialized value (LeakSanitizer, 56 B) — likely worth its own report; happy to file separately if useful.windshock commentedon Nov 4, 2026
Correction to my status note above: I wrote that 08f4b64 is not present in the PHP-8.3, PHP-8.4 or PHP-8.5 branches — that was wrong for 8.4/8.5. GitHub compare confirms 08f4b64 is contained in PHP-8.4 and PHP-8.5 (branch...commit: behind by 141 / 4246 commits, ahead by 0) and is included in 8.4.28RC1 / 8.5.13RC1; only the 8.4.27 / 8.5.12 release tags (cut 2026-10-19, before the 2026-10-22 fix) and the PHP-8.3 branch (diverged) lack it. Sorry for the noise — the practical consequence stands but narrower: the two Oct 21 emergency lines still ship without it, while 8.4/8.5 get it in the next patch; PHP-8.3's status depends on whether the fix qualifies for the security-only branch.
Also a note on §6 of my comment: since the maintainer's status comments above describe 08f4b64 as superseded/incomplete on its own terms, please read that section as "master as of 2026-11-04 is clean on all three entry families in our runs" rather than as an endorsement of that specific commit — the open fix path remains #24751.