← All reports

[JSC] Wasm::InstanceAnchor should be unregistered at the prologue of JSWebAssemblyInstance destructor

HighJSC WebAssembly runtimeUAF

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

76b3468 | Bugzilla 310234

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

JSWebAssemblyInstance::~JSWebAssemblyInstance()
{
+ if (m_anchor) {
+ m_anchor->tearDown();
+ m_anchor = nullptr;
+ }
+
m_vm->traps().unregisterMirror(m_stackMirror);
clearJSCallICs(*m_vm);
 
for (auto& slot : importFunctionInfos())
std::destroy_at(&slot);
 
for (auto& slot : tables())
std::destroy_at(&slot);
 
for (auto& slot : baselineDatas())
std::destroy_at(&slot);
-
- if (m_anchor) {
- m_anchor->tearDown();
- m_anchor = nullptr;
- }
}

JSTests/wasm/stress/instance-anchor.js

+//@ runDefault("--jitPolicyScale=0.1")
+function bury(f, n) {
+ if (n === 0) {
+ return f();
+ }
+ return bury(f, n - 1);
+}
+function main() {
+ const mod = new WebAssembly.Module(WASM_CODE);
+ function warmUpInstanceB() {
+ const instanceB = new WebAssembly.Instance(mod);
+ instanceB.exports.foo();
+ }
+ bury(warmUpInstanceB, 500);
+ const instanceA = new WebAssembly.Instance(mod);
+ for (let i = 0; i < 500; i++)
+ instanceA.exports.foo();
+ gc();
+ print("done (should have crashed above)");
+}
+main();

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.

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.

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:

  1. Compile one module, so both instances share the same Wasm::Module and therefore the same anchor registry and compilation machinery.
  2. Create instanceB inside bury(warmUpInstanceB, 500) and call its export — the instance is warmed under --jitPolicyScale=0.1, so it crosses tier-up thresholds almost immediately.
  3. 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.
  4. Create instanceA and call its export 500 times, keeping the module's compilation pipeline busy on a background thread.
  5. Call gc(). The sweep runs ~JSWebAssemblyInstance() for instanceB on 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.

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.