ce60d5d618367a814de46344cb0e45696bf27786 [JSC] Emit mutatorFence in BBQ JIT for WasmGC
Triage note: needsMutatorFence is now set from structType.hasRefFieldTypes() and emitMutatorFence is issued; without the fence the concurrent collector can observe uninitialized/partially-written ref fields, a GC ordering memory-safety bug.
Contents
The bug at a glance
The bug is reachable from any WebAssembly module that uses GC struct types with reference fields (struct.new / struct.new_default) once BBQ tier-up compiles the allocation, which is trivial to force from untrusted web content. Because the fence is omitted entirely, the concurrent marker can observe a freshly-allocated struct whose ref-field slots and its mark-state metadata are visible in an inconsistent order, causing the collector to mis-scan or skip live references and ultimately free objects that are still reachable, giving a use-after-free primitive. It is a race, so exploitation is probabilistic rather than deterministic, which is why it lands at 8.1 rather than higher.
WebKit’s concurrent GC depends on a store-store fence (the “mutator fence”) between the point where an object’s fields are written and the point where that object becomes visible to the collector, so that a marker thread never sees a half-constructed object. Every JSC tier that allocates a WasmGC struct emits this fence — except BBQ, which had hard-coded the assumption that a freshly-defaulted struct “needs no barrier because all fields are constants.” That reasoning conflates the write barrier (needed for old-to-new pointers) with the mutator fence (needed for ordering against the concurrent marker); the null/zero ref-field stores are constants, but they still must be ordered before the object is published. The fix restores parity with the other tiers by keying needsMutatorFence off structType.hasRefFieldTypes() and actually calling emitMutatorFence().
Root cause
In BBQJIT::emitAllocateGCStructUninitialized (used by both addStructNewDefault and addStructNew in Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp) the JIT allocates the GC struct cell, then loops over the fields issuing emitStructSet for each. For the default case the ref fields are initialized to Value::fromRef(TypeKind::RefNull, JSValue::encode(jsNull())) and numeric fields to zero; for addStructNew each field is set from the operand args[i]. The prior code initialized bool needsMutatorFence = false and then asserted ASSERT_UNUSED(needsMutatorFence, !needsMutatorFence) on the theory that constant stores never require a barrier.
The reaching path is entirely attacker-controlled: a WebAssembly module declares a GC struct type containing at least one reference-typed field, and the wasm function body executes struct.new or struct.new_default. After the function is invoked enough times to trigger BBQ tier-up, the allocation is compiled down this path and the resulting machine code publishes the new object pointer without a preceding fence.
This is unsafe because JSC’s concurrent collector scans the heap on a separate thread. Object allocation sets up the cell header (including the structure/type metadata the marker uses to find ref fields) and then the mutator writes the field slots. Without a storeFence between the field stores and the moment the collector can observe the cell, the marker thread — which only needs a data-dependent load of the object pointer — may observe the header as initialized while the ref-field slots still hold stale/garbage memory from the allocator’s free list, or observe the stores out of order. The marker then either treats garbage as a live pointer or, more dangerously, fails to mark a genuinely live referent, so the collector reclaims an object the struct still points to, yielding a dangling reference and a subsequent use-after-free.
The fix sets needsMutatorFence = structType.hasRefFieldTypes() up front and replaces the assertion with if (needsMutatorFence) emitMutatorFence();. Because hasRefFieldTypes() is true exactly when the struct contains reference fields — the only fields whose ordering the concurrent marker cares about — the fence is now emitted precisely when needed, matching the behavior already present in the OMG/Air and interpreter tiers. Numeric-only structs still skip the fence, preserving the original optimization where it was actually valid.
Key code
WasmBBQJIT64.cpp: arm the fence from the struct type and actually emit it
JIT_COMMENT(m_jit, "Struct allocation done, do initialization");
- bool needsMutatorFence = false;
+ bool needsMutatorFence = structType.hasRefFieldTypes();
for (StructFieldCount i = 0; i < structType.fieldCount(); ++i) {
if (Wasm::isRefType(structType.field(i).type))
needsMutatorFence |= emitStructSet(resultGPR, structType, i, Value::fromRef(TypeKind::RefNull, JSValue::encode(jsNull())));
else
needsMutatorFence |= emitStructSet(resultGPR, structType, i, Value::fromI64(0));
}
- // No write barrier needed here as all fields are set to constants.
- ASSERT_UNUSED(needsMutatorFence, !needsMutatorFence);
+ if (needsMutatorFence)
+ emitMutatorFence();
Patch walkthrough
Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp— In thestruct.new_defaultpath the initializer forneedsMutatorFencechanges from a hard-codedfalsetostructType.hasRefFieldTypes(), so the flag is armed whenever the struct type carries reference fields. The|=accumulation from each ref-fieldemitStructSetis preserved but is now redundant with the type-level check, giving a conservative correct result.Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp— The incorrectASSERT_UNUSED(needsMutatorFence, !needsMutatorFence)— which encoded the false belief that constant field stores never need ordering — is replaced withif (needsMutatorFence) emitMutatorFence();, actually emitting the store-store fence before the freshly built object becomes observable to the concurrent collector.Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp— The sameneedsMutatorFence = structType.hasRefFieldTypes()initialization is applied to theaddStructNewpath where fields come fromargs[i], so operand-initialized ref fields are also fenced before publication, restoring parity with the default-init path.
Background
mutatorFence (emitMutatorFence) — A store-store fence the mutator emits between initializing an object’s fields and making the object observable, so JSC’s concurrent marker never sees a half-initialized cell. It is distinct from the generational write barrier: the write barrier tracks old-to-new pointers for the remembered set, while the mutator fence enforces ordering against the marker thread.
WasmGC struct allocation in BBQ — BBQ is JSC’s baseline WebAssembly JIT. emitAllocateGCStructUninitialized bump-allocates a GC struct cell and the caller (addStructNewDefault / addStructNew) fills its fields via emitStructSet. structType.hasRefFieldTypes() reports whether any field is a reference type that the GC must scan.
Concurrent collector visibility — JSC marks the heap on a background thread concurrently with the mutator. A newly allocated object can become reachable to the marker as soon as its pointer is stored somewhere the marker can reach, so publication ordering of the header and ref-field slots relative to that store must be enforced with a fence.
Vulnerability window
- Introduction — The BBQ WasmGC struct allocation path shipped with
needsMutatorFencehard-coded to false and an assertion enshrining the belief that constant-only field stores need no barrier, overlooking the marker-ordering requirement for ref fields. - Latent exposure — The other tiers (OMG/interpreter) emitted the fence, masking the divergence; only modules that tiered up to BBQ and allocated ref-bearing GC structs under concurrent GC pressure were affected.
- Discovery — Analysis of tier parity for WasmGC allocation (bug 322246 / rdar://185480157) found BBQ omitting the fence that its sibling tiers emit.
- Fix — Commit ce60d5d618 (Yusuke Suzuki, 2026-08-20) keys
needsMutatorFenceoffstructType.hasRefFieldTypes()and callsemitMutatorFence(), restoring correct publication ordering.
Triggering
The patch ships no test and the defect is a probabilistic memory-ordering race, not a deterministic crash, so no faithful PoC can be reconstructed from the diff alone. A conceptual trigger would: (1) build a WebAssembly module declaring a GC struct type with one or more reference fields; (2) repeatedly call a function that executes struct.new_default / struct.new to force BBQ tier-up; (3) run allocation in a tight loop concurrently with heavy GC pressure so the concurrent marker frequently scans just-published, not-yet-fenced structs. Observing the UAF requires the marker to interleave between the cell’s publication and its field stores, which is timing-dependent; no reliable primitive is presentable without fabrication.
Exploitation
- Trigger the vulnerable code — Ship a wasm module with a ref-field GC struct and hammer its allocation to reach BBQ, allocating in a loop while forcing concurrent collection so the marker races the unfenced publication.
- Win the race — On the interleaving where the marker observes the cell before the ref-field stores retire, it either scans a stale slot as a pointer or skips marking a live referent; the latter lets the collector free an object the struct still references.
- Convert to UAF — Reallocate the freed slot with attacker-controlled contents and dereference through the still-live struct reference to obtain a type-confused / dangling object, the usual bridge to an addrof/fakeobj-style primitive — but reliability is gated by the race probability.
Detection & hunting
For defenders and SOC / detection engineers:
- Concurrent-GC crashes originating from WasmGC — Sporadic marker-thread crashes or ASan use-after-free reports whose allocation stack passes through BBQ struct.new/struct.new_default with reference fields are a strong indicator; the intermittency and marker-thread frame are the tell.
- Tier-specific reproduction — A crash that reproduces only after BBQ tier-up (and not in the interpreter or OMG) for the same ref-field struct allocation points at a tier-parity fence bug of exactly this shape.
- Generated code audit — Disassemble BBQ output for struct.new on a ref-field type and confirm a store-store fence sits between the last ref-field store and any subsequent publication of the object pointer.
Audit directions
- All BBQ GC allocation sites — Audit array.new / array.new_default and any other WasmGC allocation in BBQ for the same conflation of write-barrier vs mutator-fence, ensuring
emitMutatorFence()is emitted whenever a ref-bearing object is published. - Cross-tier fence parity — Diff the fence-emission logic across BBQ, OMG, and the interpreter for every GC object constructor to catch remaining divergences where one tier assumes constant stores are safe.
- hasRefFieldTypes callers — Review every consumer of
StructType::hasRefFieldTypes()to confirm it is used to gate fences/barriers consistently and is not shadowed by a stalefalseinitializer elsewhere.