The PHP 8.5.12 release was supposed to end a twenty-year memory-corruption class. A one-string unserialize() payload still trips AddressSanitizer on that exact build, and the reason is a missing pair of braces that has nothing to do with the bugs the patch fixed.
The finding was published on October 21, 2026 by Andrew Dorman (@acd421) in the php2shell-zeros package as its fourth item. It is a deterministic heap use-after-free in php_var_unserialize, reachable from any code path that passes attacker-controlled data to unserialize(). No configuration, no extension, no ini setting. The package reports it as unfiled, and the Wiz research team had already named the same gap in a companion repository as the "var_destroy re-entry UAF at n=1015" without demonstrating it.
Where the bug lives
unserialize() is not a rare surface. Laravel session payloads, signed cookies, cache backends, queue jobs, job-serialisation wrappers, and countless unserialize(base64_decode($_POST['blob'])) handlers all call it. Whatever the surrounding framework claims about authentication, the decoder itself is a parser for a binary format, and it is expected to survive hostile input.
The decoder keeps its working state in php_unserialize_data, which holds the values decoded so far in a chain of var_entries blocks. Each block carries 1018 slots. When the payload declares more values than the current block can hold, the decoder allocates another block and links it to the previous one.
Objects that declare a __wakeup() or __unserialize() method do not get called during the walk. The decoder records them in a delayed-call slot with VAR_WAKEUP_FLAG set and defers the callback to the end, because calling user code mid-parse would allow the object graph to be mutated while the decoder is still building it.
That deferral is correct. The teardown path is where it breaks.
The lock that only covers half the teardown
The decoder protects itself against re-entry with BG(serialize_lock). The rule is simple: while user code is running on behalf of the decoder, a nested serialize() or unserialize() call must not reuse the cached context. The brackets look like this:
BG(serialize_lock)++;
zend_call_known_instance_method(...); /* __wakeup, __unserialize, callback */
BG(serialize_lock)--;
When the delayed calls run in php_var_unserialize_destroy, the callback invocation is bracketed. The loop that walks the slot chain and destroys each value is not. Line 319 of ext/standard/var_unserializer.c calls i_zval_ptr_dtor on every slot without touching the lock.
That asymmetry is the whole bug. Destroying a slot can invoke a destructor, because the slot may hold the last reference to an object. If that destructor calls unserialize() again, the nested call sees BG(unserialize).level == 1 and BG(serialize_lock) == 0 — the state of an ordinary top-level call — and happily reuses the context that is currently being torn down.
Triggering it in three moves
The package's PoC is a single PHP file. It needs three conditions to line up.
First, cross the block boundary. The payload declares 1018 array elements, which forces the decoder into the boundary region of the var_entries chain. This is the part the public gap name refers to as n=1015, and it is the part that makes the freed chain reachable rather than merely adjacent to live memory.
Second, plant an object that both wakes and destructs. One class implements __wakeup() and __destruct(). The __wakeup() earns the object a delayed-call slot marked VAR_WAKEUP_FLAG. The __destruct() is the re-entry primitive:
class Boom {
public function __wakeup() {}
public function __destruct() {
unserialize('i:7;');
}
}
Third, make the partial tree get freed before the destroy walk. The payload ends with a decode error — a missing closing brace. That forces the decoder to free the partially built tree on the error path, which leaves the flagged slot holding the object's last reference. Now the destroy walk's i_zval_ptr_dtor is the call that actually drops the refcount to zero, so __destruct fires inside the walk, through the unbracketed dtor, while the chain is being iterated.
The nested unserialize('i:7;') then walks a context whose entry chain has already been released. The read happens at var_unserializer.c:906.
What the sanitizer shows
The receipt is short and unambiguous:
ERROR: AddressSanitizer: heap-use-after-free on address 0x52500000a100
SUMMARY: heap-use-after-free ext/standard/var_unserializer.c:906 in php_var_unserialize
READ of size 8 ... called from var_destroy (:319) <- i_zval_ptr_dtor <-
zend_objects_store_del <- __destruct <- zend_call_function <-
php_var_unserialize (nested) <- zif_unserialize
Read the stack from the bottom up and the control flow is the entire story: a userland unserialize() call enters the decoder, the decoder's own destroy path drops a reference, that reference runs an attacker destructor, the destructor calls unserialize() again, and the second call reads a var_entries chain that the first call already freed. The frame names line up with the source: var_destroy at line 319, the read at line 906.
The package reports the trace as deterministic, reproduced twice from the same file — once by the author's agent run and once by a coordinator re-run. An independent researcher later reproduced the invalid reads under valgrind on 8.5.12, 8.4.27, 8.3.36, 8.2.35 and 7.4.33, which is consistent with the package's own version notes: the var layer has not changed across those branches.
Why the stock build stays quiet
The identical PoC produces no visible failure on a stock, non-ASAN PHP 8.5.12 build. That is not a contradiction, and it is not a reason to downgrade the finding.
ASAN catches a read of freed heap by poisoning the shadow memory around the allocation. The stock build has no shadow memory. Whether the read faults depends on whether the page backing the freed block has been returned to the kernel, which in a short-lived CLI run it usually has not. The decoder reads stale but still-mapped bytes and continues with whatever they contain. The package is explicit that this is a silent corruption state in that shape, not a crash.
That distinction matters for how the finding should be read. It is a genuine CWE-416: attacker-controlled input, attacker-influenced timing of a free, and a read of freed memory whose contents the attacker also partly controls. What it is not, today, is a published exploit. No working remote code execution has been demonstrated against a stock build in either the package or the public discussion around it.
What escalation would actually require
Turning this into control flow needs three things in sequence: the freed var_entries block to be reallocated with attacker-influenced contents, a field inside that block to be read and used as a pointer or a length, and the resulting write to land somewhere useful. The nested decode gives the attacker a decoder running over the stale chain, which is a start. It does not, by itself, give a write primitive.
Proposals that circulate for this class of bug usually lean on /proc/self/mem, php://filter or a local file write to place controlled bytes at a known address. Those are local primitives. An attacker who can already write files to the target and read /proc/self/mem in the PHP process does not need a use-after-free; they have code execution or something equivalent. Treat such chains as proof that the bug is dangerous, not as evidence of a remote exploit.
The honest summary is the one the package gives: a memory-safety violation that should be fixed, a remote-triggerable parser bug, and no public demonstration of remote code execution.
The fix is one bracket
The minimal fix follows directly from the diagnosis. Bracket the slot destruction in var_destroy with the same serialize_lock protection that already guards the delayed-call invocations, so a destructor that re-enters unserialize() finds the lock held and cannot reuse the mid-destroy context:
BG(serialize_lock)++;
i_zval_ptr_dtor(entry->data);
BG(serialize_lock)--;
A stronger variant is to treat re-entry during destroy as an error outright: if BG(unserialize).level != 0 when the destroy walk begins, reset the context chain rather than continuing to iterate it. Either approach closes the specific hole. Neither is a redesign, and the second one also protects any future caller that reaches the same walk through a different path.
Until a fixed release ships, the operational advice is the advice that has been correct for a decade and is worth repeating because it keeps being ignored:
- Do not pass untrusted input to
unserialize(). Usejson_decode()for data exchange. - Where a serialised format is unavoidable, pass
['allowed_classes' => false]or an explicit allowlist, and do not depend on__wakeup()for validation. - Do not treat a framework's signed cookie or encrypted session blob as a trust boundary for the decoder. Signature verification happens before the decode only if the code is written that way.
- If you run your own builds, an ASAN build in CI with a small corpus of malformed serialised inputs will catch this class of regression before a release does.
The larger point is the same one the GC regression makes. The October patch reworked allocation-failure handling and reset request state at the right moment; it did not audit the decoder's teardown contract. The lock that exists to prevent re-entry was applied to the callbacks and not to the walks that can invoke user code indirectly. That is a two-line gap in a twenty-year-old file, and it survived a security release that was specifically about memory safety in the same subsystem.