[JSC] Wasm::InstanceAnchor should be unregistered at the prologue of JSWebAssemblyInstance destructor
CVE: CVE-2026-28947 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: A use-after-free issue was addressed with improved memory management. Credit: dr3dd
High. Nothing in the destructor changed except where five lines sit — and those five lines are the difference between "no other thread can name this object" and "a compiler thread can still find it after its tables have been destructed." Ordinary script drives both sides of the race; escalation past a background-thread crash depends on what the compiler does with the stale slots.
JavaScriptCore compiles WebAssembly in tiers, which means hot functions get recompiled on background threads while the main thread keeps executing script — and those background threads need a way to reach the live instance whose data they're compiling against. That lookup path is Wasm::InstanceAnchor, a handle the instance registers on its owning module the moment it finishes construction, with a source comment that says exactly what it's for: "Now, JSWebAssemblyInstance is fully initialized. Expose it to the concurrent compiler." Publication-last is a deliberate discipline — the object becomes reachable only after every field is valid. The mirror-image half of that contract is that destruction must un-publish first, before any field stops being valid.
The angle: A page can make a garbage collection tear down a Wasm instance's tables and cached data while a background compiler thread is still able to look that same instance up and read it.
Source/JavaScriptCore/wasm/js/JSWebAssemblyInstance.cpp
JSTests/wasm/stress/instance-anchor.js
Patch Details
The functional change is five lines moved, not rewritten. The if (m_anchor) { m_anchor->tearDown(); m_anchor = nullptr; } block is byte-identical before and after; it simply relocates from the bottom of ~JSWebAssemblyInstance() to the top. Everything that previously ran ahead of it now runs behind it: m_vm->traps().unregisterMirror(m_stackMirror), clearJSCallICs(*m_vm), and the three std::destroy_at loops that walk importFunctionInfos(), tables(), and baselineDatas().
Before: After:
~JSWebAssemblyInstance() ~JSWebAssemblyInstance()
unregisterMirror anchor->tearDown() ◄── window closed here
clearJSCallICs unregisterMirror
destroy_at importFunctionInfos clearJSCallICs
destroy_at tables destroy_at importFunctionInfos
destroy_at baselineDatas destroy_at tables
anchor->tearDown() ◄── too late destroy_at baselineDatas
The second half of the commit is JSTests/wasm/stress/instance-anchor.js, a new regression test. It runs under --jitPolicyScale=0.1 so tier-up thresholds drop and functions get queued for optimizing compilation after far fewer calls than normal. The test instantiates one module twice: instanceB is created and warmed inside a 500-frame-deep recursion via bury(), then instanceA is created and warmed 500 times at the top level, then gc() is called synchronously. The final line reads print("done (should have crashed above)") — the test's pass condition before the fix was a crash.
Background
Wasm instances in JSC. new WebAssembly.Instance(module) produces a JSWebAssemblyInstance, a garbage-collected JSC cell that owns the instantiation's memories, tables, globals, import-function call link info, JS call inline caches, and per-instance baseline data. Because it is GC-managed, its destructor does not run at a point the script chooses; it runs from JSWebAssemblyInstance::destroy during a sweep, on the main thread.
Concurrent tier-up. JSC does not compile Wasm once. Functions start in a lower tier and, once their execution counts cross a threshold, are queued for recompilation by an optimizing compiler running on background compiler threads while JavaScript continues on the main thread. Those background threads consult per-instance state — baseline data, tables, call link info — as part of that work.
Wasm::InstanceAnchor. The compiler threads need a way to name a live instance, and the anchor is it: a handle registered on the owning Wasm::Module through m_module->registerAnchor(this) as the final act of finishCreation. It is the lookup path by which a thread that is not the owner reaches the instance. tearDown() is the corresponding unregistration.
std::destroy_at. This invokes an object's destructor in place without deallocating its storage. For the instance's slot arrays that distinction matters: the slot bytes are inline in the cell and survive until the GC sweep finishes, but anything a slot object owned through a heap pointer — a table's backing store, for instance — is released the moment its destructor runs.
Publish/unpublish symmetry. The discipline is that initialization publishes an object to shared visibility as its last step, and destruction unpublishes as its first, so that the interval during which an object is visible is a strict superset of the interval during which it is usable.
Conservative stack scanning. JSC's collector treats the live portion of the machine stack as a set of GC roots. A value that was live in a frame which has since returned is not automatically kept alive — the collector no longer scans that region. Calling a function at great recursion depth and then unwinding leaves any reference it held in stack memory that is out of scanning range, which is how a test forces a specific object to become collectable at a chosen moment. The companion knobs the test uses are --jitPolicyScale=0.1, which scales down tier-up thresholds, and the jsc shell's gc() intrinsic, which requests a collection synchronously.
Analysis
This is an unpublish-after-partial-destroy bug: the object stays findable by other threads through a registry while its own destructor has already begun dismantling it.
Main thread (GC sweep) Wasm compiler thread
──────────────────────── ─────────────────────────────
~JSWebAssemblyInstance()
unregisterMirror
clearJSCallICs module->anchor lookup ──► this
destroy_at importFunctionInfos │ still resolves
destroy_at tables │
destroy_at baselineDatas ▼
read baselineDatas()/tables()
── destructors already ran ──
anchor->tearDown() ◄── window ends only here
The window in the diagram is the whole body of the destructor above tearDown(). finishCreation gets the publication side right — the anchor goes up only after every field is valid, and the source comment says so — but the destructor was written in the order its author thought about the members rather than in reverse order of visibility, so the unpublish landed last. Between the destructor's first statement and its last, the anchor still resolves to this, and a compiler thread that acquires the instance through it is operating on a half-destructed object.
What "half-destructed" concretely means depends on how far the sweep has gotten. After clearJSCallICs(*m_vm) the embedded JS call inline caches have been reset. After the std::destroy_at loops, each slot in importFunctionInfos(), tables(), and baselineDatas() has had its destructor run — the slot storage itself is inline in the cell and is not yet freed, but whatever those objects owned on the heap has been released. So a compiler thread reading a Wasm::Table slot at that point walks a structure that no longer owns its backing store; a thread reading baselineDatas() reads an entry whose destructor has already completed.
The added test is a compact recipe for producing exactly that interleaving from script:
- Compile one module, so both instances share the same
Wasm::Moduleand therefore the same anchor registry and compilation machinery. - Create
instanceBinsidebury(warmUpInstanceB, 500)and call its export — the instance is warmed under--jitPolicyScale=0.1, so it crosses tier-up thresholds almost immediately. - Let the recursion unwind.
instanceB's only reference is now in stack memory below the live frame, which conservative scanning no longer covers, so it is genuinely collectable. - Create
instanceAand call its export 500 times, keeping the module's compilation pipeline busy on a background thread. - Call
gc(). The sweep runs~JSWebAssemblyInstance()forinstanceBon the main thread while tier-up for the same module is in flight on the compiler thread.
The reason bury() is there at all is step 3: without it, instanceB would still be reachable from a live frame and the collection would not destroy it, so the race could never be staged deterministically. This is a race, not a deterministic dereference — the compiler thread has to acquire through the anchor inside the window — but both sides of it are driven by ordinary web content, and the test demonstrates that plain JavaScript is enough to line them up.
The fix hoists tearDown() to the prologue, so the anchor is gone from the module's registry before unregisterMirror runs, before the call ICs are cleared, and before any std::destroy_at. Visible lifetime once again strictly contains usable lifetime: once destruction begins, no lookup can hand the instance to anyone. The immediate observable effect this prevents is a crash on a compiler thread touching state whose destructors have already run; a cross-thread use-after-free read of reclaimed heap contents could follow, and a write primitive would depend on the compiler thread mutating that stale state, which the surrounding context does not establish. The whole thing lives inside the WebContent process — JSC and its Wasm compiler threads are all renderer threads — so no sandbox boundary is crossed here; an attacker would still need a separate escape.
Publication was correctly ordered last in construction but unpublication was ordered last in destruction too, leaving the entire destructor body as a window in which compiler threads could still resolve the instance.
Insight
The correct discipline was already written down in this file — finishCreation publishes the anchor last, with a comment explaining why publication must follow full initialization. Publish-last is only half the contract; unpublish-first is the other half, and it is the half that gets forgotten, because destructors tend to be written in the order the author thinks about members rather than in reverse order of visibility. As JSC's Wasm implementation moves more work onto concurrent compiler threads, every new cross-thread handle — anchors, callee groups, shared baseline data — adds another registry whose teardown ordering has to be audited against GC sweep timing. The test is worth stealing independently of the bug: bury(f, 500) is a portable trick for defeating conservative stack scanning so a specific object is guaranteed collectable at a chosen gc(), useful in any JSC lifetime test or PoC.
Audit directions
-
Unpublish-after-partial-destroy. The invariant is that destruction order must be the exact reverse of publication order — visible lifetime a strict superset of usable lifetime. Narrow: grep JSC destructors for
unregister,tearDown,remove, or table-deref calls that are not the first statement; start with~JSWebAssemblyInstance's siblings inSource/JavaScriptCore/wasm/js/and with everyregister*/unregister*pair onVM::traps()andWasm::Module. Wider: the same class appears anywhere early publication pairs with late unpublication —this-escaping constructors, objects inserted into a global cache before their fields are set, weak-handle tables, observer registries where the removal call sits below member cleanup. Widest: this is the general publish/unpublish-ordering class and holds in any language with shared registries and deterministic teardown — Rust types whoseDropreleases owned state before removing themselves from anArc<Mutex<HashMap>>registry, Java objects that unregister from aConcurrentHashMapafter nulling fields, Go objects removed from a map after their channels are closed. Match tell per rung: narrow — the deregistration call is textually below any member cleanup; wider — a constructor's last line and a destructor's last line touch the same registry; widest — ask "between the first line of teardown and the deregistration, can any other thread still name this object?" -
GC-sweep destruction racing background compiler threads. The invariant is that anything a compiler thread can reach must either be kept alive by a reference it holds, or be severed from it before the owner's teardown begins. Narrow: audit every JSC object a Wasm compiler thread can acquire without holding a reference — trace the users of
Wasm::Module::registerAnchor/Wasm::InstanceAnchorand ofWasm::CalleeGroup, and for each check whether the sweep-time destructor of the owning cell severs the handle before touching owned state. Wider: the class covers any object acquired through a registry lookup rather than through refcounting — inline-cache stubs consulted off-thread, profiling and baseline data shared between execution and compilation, structure and watchpoint state read by the concurrent DFG/FTL compilers; the shape to notice in search results is a background thread that resolves a raw pointer from a table and then dereferences it after the table lock is released. Widest: the classic weak-handle-across-threads reclamation class, present wherever one thread reclaims what another looks up — Chromium's cross-threadWeakPtrusage, Java caches drained viaReferenceQueue, any RCU or hazard-pointer scheme. Carry this into other codebases: unpublishing is not quiescence, and a lookup table that can hand out a pointer must be closed before, not after, the pointed-to state is dismantled. -
Whether severing the anchor is sufficient for in-flight users, not just future lookups. The fix closes the acquisition window, but a thread that resolved the anchor one instruction earlier could still hold the pointer — teardown that blocks new acquisitions is not the same as draining existing ones. Narrow: read
Wasm::InstanceAnchor::tearDown()andWasm::Module::registerAnchorand determine whethertearDowntakes the same lock the compiler thread holds across its whole use of the instance, or merely nulls a slot; if it merely nulls, enumerate the compiler-side call sites and check how long the resolved pointer is held. Wider: ask the same of every JSC handle whose teardown is a single nulling store observed by another thread — watchpoint invalidation, callee-group replacement, IC repatching; the shape to notice is atearDown/invalidatethat returns immediately rather than draining readers. Widest: the reusable principle is that safe reclamation requires a quiescence guarantee, not merely an unpublish step — the invariant behind RCU grace periods, hazard pointers, epoch-based reclamation, andArc-versus-Weak::upgradein Rust. Match tell: any reclamation path where the writer's "remove from table" and the reader's "use the pointer" are not covered by a common lock or a documented grace period. Verification here is nontrivial and generally needs TSan or targeted stress testing rather than reading alone.