← WebKit — 2026-W40

33ca66ce3ffefd121b93cc2dd8db81d16eb791aa  [JSC] ValueNumbering-based WasmGC optimization in B3::ReduceStrength

severity high class TypeConfusion confidence 0.65 signal score 10.56 Source/JavaScriptCore/b3 patch-now AI review PLAUSIBLE
Primitive: B3 JIT miscompile: stale/incorrect store-to-load forwarding of Wasm GC struct/array fields
Triage note: Removes the ad-hoc WasmStruct/WasmArray CSE value maps from B3EliminateCommonSubexpressions and replaces them with an epoch-keyed availability scheme (wasmGCEpochs, catch-all slots, dominator-seeded epochs) whose comments explicitly describe cases where the old code left a stale store value findable across writes/dominators, plus proc.resetValueOwners() to avoid stale Value::owner lookups in CanonicalizePrePostIncrements. A JIT optimizer forwarding a wrong/stale value for Wasm GC accesses is a correctness bug reachable from WebAssembly that leads to type confusion / memory corruption.
Structural signals: security_assert (+6.47), null_guard (+2.59), msg_soft (+1.0), area:Source/JavaScriptCore/b3 (+2.5), large_churn (-2.0)

The bug at a glance

A correctness bug in JavaScriptCore’s B3 optimizer: store-to-load forwarding / redundancy elimination for WebAssembly GC struct-field and array-element accesses was unsound. The pre-patch WasmStructValueMap / WasmArrayValueMap in B3EliminateCommonSubexpressions keyed availability on the base SSA Value, but distinct SSA Values can refer to the same GC object, and the dominator/predecessor bookkeeping could leave a store’s value findable after an intervening write – so a WasmStructGet/WasmArrayGet could be rewritten to a stale or incorrect value. The proven defect is a JIT miscompile that forwards a wrong value for a typed wasm-GC slot, reachable from any web page that runs a WebAssembly GC module. Because the field/element slot is typed (including reference types), forwarding an attacker-chosen wrong value into it is a textbook route to type confusion and thus memory corruption (inferred escalation, standard for value-forwarding JIT bugs). High severity is warranted for a web-reachable Wasm-GC miscompile even though the end-to-end corruption step is not literally in the diff.

The patch deletes an entire ad-hoc CSE subsystem for wasm-GC and rebuilds forwarding as an epoch-keyed availability map inside ReduceStrength’s dominator-preorder walk; the giveaway is the new comment ‘different Values may be the same object, so a write bumps its slot’s epoch rather than retiring entries by base’ – precisely naming the aliasing/staleness the old base-keyed map got wrong.

Root cause

Invariant: a redundant wasm-GC load may be replaced by a previously stored/loaded value only if no write between the two could have changed the slot’s contents, for any aliasing of the base. The old scheme lived in B3EliminateCommonSubexpressions as WasmStructValueMap (keyed on WasmStructFieldKey = (child(0), fieldHeapKey)) and WasmArrayValueMap (keyed on (child(0), child(1))), with removeIf()/clobber() invalidation driven by HeapRange overlap and a predecessor worklist walk (findWasmStructValue/findWasmArrayValue), store-to-load forwarding in replaceWasmStructValue/replaceWasmArrayValue, and an m_structKeyFilters optimization. Because entries were retired by matching base Value, a store through one SSA Value did not retire a cached access to the same object reached through a different SSA Value, and the backward/dominator handling could still surface an entry across a clobbering write – the stale store-to-load-forwarding miscompile the triage describes. The patch removes all of that (including the B3WasmStruct*/B3WasmArray* includes, the *AtHead/*AtTail maps, and m_structKeyFilters) and reimplements forwarding in B3ReduceStrength with an epoch/availability scheme. Each access is reduced to a WasmGCLocation (base, index, abstract heap, a retiring ‘slot’, and a per-kind catch-all ‘allSlot’); wasmGCKeyFor() builds a WasmGCAccessKey {base, index, heap, slotEpoch, allEpoch} capturing the current epochs of its own slot and its kind’s catch-all from m_wasmGCEpochs. A load calls m_wasmGCMap.findOrAdd(key, {m_value,0}) and forwards an identical-key entry (narrowing via packedMask for i8/i16 fields); a store, in recordWasmGCStore(), nops a same-value re-store, otherwise does ++m_wasmGCEpochs[slot] so its key changes and every prior entry at the old epoch becomes unfindable. Any other value whose writes overlap the wasm-GC struct/array HeapRange span but cannot be pinned to a slot bumps the catch-all epoch (wasmGCStructAllSlot/wasmGCArrayAllSlot), and since each key carries the allEpoch, that retires every access of that kind at once. Immutable fields get key epochs {0,0} (validation guarantees a single init-time store), so they stay available across everything. Lookups need no dominance test because reduceAllBlocksStrengthInDominatorPreOrder() walks the dominator tree in preorder (children in CFG reverse-post-order) with layered maps (m_wasmGCMap/m_layeredCSE startLayer/dropLastLayer), so the map holds exactly the dominating values; beginWasmGCBlock() seeds m_wasmGCEpochs from the immediate dominator’s tail epochs (m_wasmGCEpochsAtTail) as a floor on what every reaching path has written, and conservatively bumps all epochs for blocks that lost predecessors to folded branches or sit on irreducible retreating edges. Supporting changes: WasmStructGet/Set trap-bit clearing is done in place (setKindUnsafely) rather than by inserting a replacement, so the tracking still sees the same Value; Procedure gains usesWasmGCAccesses(); and CanonicalizePrePostIncrements adds proc.resetValueOwners() because it now finds candidates through Value::owner.

Key code

New epoch-keyed availability: a write bumps the slot epoch instead of retiring by base

// ... a write bumps its slot's epoch rather than retiring entries by base; `slotEpoch`
// and `allEpoch` record the epochs of the access's slot and its kind's catch-all slot.
struct WasmGCAccessKey {
    Value* base { nullptr };
    Value* index { nullptr };
    uint64_t heap { 0 };
    uint32_t slotEpoch { 0 };
    uint32_t allEpoch { 0 };
    friend bool operator==(const WasmGCAccessKey&, const WasmGCAccessKey&) = default;
};

void recordWasmGCStore(const WasmGCLocation& location, Value* stored, uint32_t packedMask) {
    WasmGCAccessKey key = wasmGCKeyFor(location);
    std::optional<WasmGCAvailableValue> available = m_wasmGCMap.find(key);
    if (location.mutability == Mutability::Immutable) {
        RELEASE_ASSERT(!available);
    } else {
        if (available && available->value == stored) { m_value->replaceWithNop(); m_changed = true; return; }
        // The bumped epoch gives this store a key that's not present, so the add below inserts.
        ++m_wasmGCEpochs[location.slot];
        key = wasmGCKeyFor(location);
    }
    bool added = m_wasmGCMap.add(key, WasmGCAvailableValue { stored, packedMask });
    ASSERT_UNUSED(added, added);
}

Patch walkthrough

  • Source/JavaScriptCore/b3/B3EliminateCommonSubexpressions.cpp — Removes the entire ad-hoc wasm-GC CSE machinery: the B3WasmStruct*/B3WasmArray* includes, WasmStructMatches/WasmArrayMatches, WasmStructValueMap/WasmArrayValueMap, the wasmStruct/ArrayStoresAtHead/ValuesAtTail fields in ImpureBlockData, the clobber() removeIf blocks, processWasmStruct/ArrayGet/SetBeforeClobber/AfterClobber, find/replace helpers, and the m_structKeyFilters optimization. CSE no longer touches wasm-GC accesses – the unsound base-keyed forwarding is gone.
  • Source/JavaScriptCore/b3/B3ReduceStrength.cpp — Adds the replacement: WasmGCAccessKey/WasmGCAvailableValue/WasmGCLocation, the m_wasmGCEpochs slot model (struct catch-all slot 0, 8 array-element-type slots, array catch-all, one slot per mutable struct field), wasmGCKeyFor/forwardWasmGCLoad/recordWasmGCStore/processWasmGCAccess, and the dominator-preorder walk reduceAllBlocksStrengthInDominatorPreOrder() with layered m_wasmGCMap/m_layeredCSE and beginWasmGCBlock/endWasmGCBlock epoch seeding from the immediate dominator’s tail. replaceIfRedundant() and the Check-elimination path move from m_pureCSE to m_layeredCSE. WasmStructGet/WasmStructSet trap-bit removal is rewritten to clear the trap in place via setKindUnsafely so the Value identity is preserved for tracking.
  • Source/JavaScriptCore/b3/B3EliminateWasmGCAllocations.cpp — Comment update reflecting that forwarding now happens in ReduceStrength and only from a store/access that dominates the load: a WasmStructGet left behind after a diamond whose arms both store the field is a surviving use that keeps the allocation alive, so it is no longer removed.
  • Source/JavaScriptCore/b3/B3Procedure.h — Adds m_usesWasmGCAccesses with setUsesWasmGCAccesses()/usesWasmGCAccesses(), the flag ReduceStrength checks (only on the Initial pass, since lowerMacros later expands wasm-GC opcodes) to decide whether to run the new tracking.
  • Source/JavaScriptCore/b3/B3CanonicalizePrePostIncrements.cpp — Adds proc.resetValueOwners() before computing dominators, with the comment ‘Every candidate below is found and checked through Value::owner’ – the phase now relies on accurate Value::owner, which could otherwise be stale.

Background

B3 (Bare Bones Backend) — JavaScriptCore’s low-level SSA optimizing IR and backend, used (via WasmOMGIRGenerator for the OMG tier) to compile WebAssembly. ReduceStrength and EliminateCommonSubexpressions are B3 optimization phases; a miscompilation here produces native code that computes wrong values.

WebAssembly GC (struct/array) — The Wasm GC proposal adds heap types: struct.new/struct.get/struct.set and array.new/array.get/array.set operate on typed, garbage-collected objects whose fields/elements have declared types including reference types and packed i8/i16. B3 models these as WasmStructGet/Set and WasmArrayGet/Set values.

Store-to-load forwarding / redundancy elimination — An optimization that replaces a load with the value a dominating store wrote (or an earlier identical load), when no intervening write can have changed that memory. Correctness hinges on exactly the aliasing and reachability analysis this patch rewrites; forwarding a value past a write that actually changed the slot yields a wrong result.

Abstract heaps, HeapRange, and epochs — B3 tracks memory effects as HeapRanges over abstract heaps. The new scheme assigns each wasm-GC struct field and array-element type a ‘slot’ with an integer epoch in m_wasmGCEpochs; a write increments the relevant slot (or the per-kind catch-all) epoch, and an availability key embeds the epoch so any post-write lookup fails to match – replacing the old overlap/removeIf invalidation.

Type confusion via JIT miscompile — If a typed slot (especially a reference field) is read as a value that was never stored there, the engine treats attacker-controlled bits as a pointer/typed object. For GC reference fields this is a direct type confusion, the usual stepping stone to arbitrary read/write and code execution.

Vulnerability window

  1. Setup — A web page instantiates a WebAssembly GC module containing struct/array types; execution is hot enough to reach the OMG (B3) tier.
  2. Craft IR — The module is shaped so B3 sees a wasm-GC access pattern the old base-keyed map mishandles: e.g. two SSA Values that alias the same struct/array, or a load whose availability survives a clobbering store across a dominator/predecessor boundary.
  3. Trigger miscompile — EliminateCommonSubexpressions forwards the stale/earlier store value to the WasmStructGet/WasmArrayGet; B3 emits native code that loads a value that is no longer the slot’s actual contents.
  4. Wrong-typed value — The forwarded value occupies a typed slot (reference or packed field) it never legitimately held, so the running program observes a field/element of the wrong type or contents (inferred: attacker-chosen).
  5. Corruption (inferred) — Using the confused reference as a fake object, the attacker builds an addrof/fakeobj-style read/write primitive and proceeds toward arbitrary memory access in the WebContent process.

Proof of concept

No regression test is included in this bundle (the diff is a phase rewrite and is truncated inside beginWasmGCBlock). Conceptual trigger (inferred): build a WebAssembly GC module with a mutable struct (or array) type; obtain two refs that the OMG compiler cannot prove distinct yet alias the same object, store value A through the first ref, store value B through the second, then struct.get the field through the first ref inside a loop hot enough to tier up to B3. Pre-patch, CSE’s base-keyed map could forward the stale pre-write value A (or an entry that survived the clobber across blocks) instead of B. Instantiate it from JS via WebAssembly.instantiate and call the exported function in a warm-up loop; a mismatch between the interpreter/BBQ result and the OMG result demonstrates the miscompile. Turning the wrong value into a memory-corruption primitive is the type-confusion escalation and is not part of the diff.

Exploitation

  1. Reach — WebAssembly GC is reachable from any script; forcing OMG tier-up is a matter of running the hot function enough times. No special permissions.
  2. Trigger — Shape the module so a wasm-GC load is forwarded a stale/incorrect store value by the pre-patch base-keyed CSE across aliasing bases or clobbering control flow.
  3. Primitive — Obtain a field/element holding a value of the wrong type – for a reference field, a controllable fake reference (type confusion).
  4. Escalate (inferred) — Compose the confusion into addrof/fakeobj and then a stable arbitrary read/write within the WebContent process heap.
  5. Impact (inferred) — Memory corruption / likely RCE in the sandboxed WebContent process; the diff proves only the miscompile, not the full chain.

Detection & hunting

For defenders and detection engineers:

  • OMG vs lower-tier result divergence — Differential-test wasm-GC modules across the interpreter/BBQ and OMG tiers; any struct.get/array.get whose OMG result differs is a forwarding miscompile.
  • Forwarding across a store to an aliasing base — With B3EliminateCommonSubexpressionsInternal::verbose (pre-patch) or IR dumps, flag a WasmStructGet/WasmArrayGet replaced by a value whose defining store is separated from it by a write that overlaps the field/element HeapRange.
  • Wrong epoch/dominance in forwarding (post-patch regressions) — Assert that forwarded loads carry a WasmGCAccessKey whose slotEpoch/allEpoch match the current m_wasmGCEpochs and whose source dominates the load; a mismatch indicates a seeding/epoch bug.

Audit directions

  • Other B3 store-to-load forwarding — Review MemoryValue forwarding (processMemoryBeforeClobber/AfterClobber) and PureCSE/LayeredCSE for the same base-keyed-vs-aliasing assumption, especially anywhere two SSA Values may name one object.
  • Wasm-GC access modeling — Verify the abstract-heap assignment (WasmOMGIRGenerator arrayElementHeap / struct field heaps, subtype/supertype agreement) matches wasmGCArrayTypeIndex/wasmGCStructSlot so slot/epoch coverage is complete and no mutable field maps to a catch-all slot.
  • Epoch-seeding edge cases — Scrutinize beginWasmGCBlock seeding for blocks with folded-away predecessors, irreducible/retreating edges, and natural-loop back edges – the conservative all-epoch bumps are the soundness backstop and a missed case re-opens stale forwarding.
  • Value identity preservation — Check other phases that rewrite wasm-GC values (trap-bit handling, ReduceStrength insertions) preserve Value identity where tracking depends on it, and that resetValueOwners() is called wherever Value::owner is consulted.

Before / after

Loading diff…