c18d1e3571f490235aa624c1593e7d1289ea7ba6 [Wasm] OMG tail call patchpoint needs to clobber late pinned registers.
Triage note: OMG indirect/return tail-call patchpoint did not mark late pinned registers as clobbered, so the register allocator could keep live values in them across the tail call, a JIT-soundness miscompile leading to memory corruption from crafted Wasm.
Contents
The bug at a glance
The vulnerability is a JIT miscompilation reachable from any WebAssembly module that a web page can compile and run, so it is web-content reachable without prior compromise; only the OMG tier and Wasm tail calls need be enabled, both of which normal execution reaches after warm-up. The register allocator can keep a live outgoing argument in a pinned register that the cross-instance context-switch reload overwrites, silently corrupting a value that flows into the callee — a JIT-soundness violation that produces attacker-influenced wrong values / memory corruption rather than a clean fault. Classified TypeConfusion per JIT-soundness convention (a miscompiled value violates the type/soundness model), with web reachability and corruption potential supporting 8.8 High.
This is a beautiful register-allocation soundness bug born from two well-intentioned refactors colliding. 312691@main rewrote OMG’s tail-call argument shuffle as a parallel-move and, to give B3 freedom, marked the boxed callee and stack-bound outgoing args as LateColdAny — meaning B3 may park them in callee-save (including pinned) registers. 312795@main then moved cross-instance pinned-register restoration into the tail-call patchpoint itself, so it reloads wasmBaseMemoryPointer and wasmBoundsCheckingSizeRegister from the callee instance before the shuffle runs. The catch: on a return_call_ref/return_call_indirect those late inputs are still live across that reload, and the patch that assembled all this dropped the line marking the pinned registers as late clobbers. So B3 happily allocates a live late input into a pinned register, the context-switch reload stomps it, and the shuffle consumes garbage — a miscompile you reach just by warming a Wasm tail call to OMG.
Root cause
OMGIRGenerator::emitIndirectCall in Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp builds the B3 patchpoint that implements an indirect/ref tail call. For a tail call the outgoing arguments must be shuffled into the exact ABI positions of the callee via a parallel-move algorithm, and (for a cross-instance callee) the pinned Wasm registers must be re-established for the target instance — wasmBaseMemoryPointer and wasmBoundsCheckingSizeRegister are reloaded from the callee instance so memory and bounds checks are correct after the switch.
The ordering is the crux. 312795@main moved that pinned-register restoration off the caller side and into the tail-call patchpoint, so emitRestoreInstanceFrameIfNeeded reloads the pinned registers before prepareForCall->run() executes the argument shuffle. Meanwhile 312691@main had constrained the boxed callee and stack-bound outgoing arguments as LateColdAny, deliberately letting B3 place them in callee-save registers — which include the pinned registers. On a return_call_ref / return_call_indirect, those boxed-callee / stack-bound argument values are late inputs that are still live at the moment of the reload: they have not yet been consumed by the shuffle.
Because the code no longer declared the pinned registers as late clobbers of the patchpoint, B3’s register allocator had no reason to keep those still-live late inputs out of the pinned registers. It could legally assign a live late input to, say, wasmBaseMemoryPointer or wasmBoundsCheckingSizeRegister, and then the in-patchpoint context-switch reload would overwrite that register with the callee instance’s memory base / bounds size before the shuffle read it. The shuffle would then move a corrupted value into the callee’s argument slot — a silent miscompile where an outgoing argument (or the boxed callee reference) is replaced by a raw memory-base/bounds pointer.
The fix adds a single call, patchpoint->clobberLate(RegisterSet::wasmPinnedRegisters());, right after appending calleeCode and calleeInstance to the patchpoint in emitIndirectCall. Declaring wasmPinnedRegisters() as a late clobber tells B3 that those registers are written late within the patchpoint, so the allocator must not keep any live late input in them across the call. That restores soundness: live late inputs (boxed callee, stack-bound args) are forced out of the pinned registers, the cross-instance reload can safely repopulate them, and the shuffle consumes the correct, unclobbered values. The accompanying comment records exactly why: ‘Cross-instance setup below reloads pinned registers before the tail-call shuffle consumes late inputs.’
Key code
The missing late-clobber that let B3 keep live tail-call inputs in pinned registers
// WasmOMGIRGenerator.cpp, OMGIRGenerator::emitIndirectCall
unsigned patchArgsIndex = patchpoint->reps().size();
patchpoint->append(calleeCode, ValueRep(GPRInfo::nonPreservedNonArgumentGPR0));
patchpoint->append(calleeInstance, ValueRep::SomeRegister);
+// Cross-instance setup below reloads pinned registers before the tail-call shuffle consumes late inputs.
+patchpoint->clobberLate(RegisterSet::wasmPinnedRegisters());
// emitRestoreInstanceFrameIfNeeded needs two scratches. wasmBaseMemoryPointer is
// always pinned so B3 won't allocate it. wasmBoundsCheckingSizeRegister
// is only pinned in BoundsChecking mode, so in Signaling mode we need B3 to give us a
// ...
Patch walkthrough
Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp— In OMGIRGenerator::emitIndirectCall, afterpatchpoint->append(calleeCode, ...)andpatchpoint->append(calleeInstance, ValueRep::SomeRegister), the patch insertspatchpoint->clobberLate(RegisterSet::wasmPinnedRegisters());with a comment explaining that the cross-instance setup reloads the pinned registers before the shuffle consumes its late inputs. This is the whole fix: it forbids B3 from allocating any live late input (boxed callee / stack-bound outgoing args, marked LateColdAny) into a pinned register, so the in-patchpoint context-switch reload of wasmBaseMemoryPointer/wasmBoundsCheckingSizeRegister can no longer clobber a still-live value.JSTests/wasm/stress/omg-indirect-tail-call-late-input-clobber.js— A stress regression test tuned to force the miscompile. Its run flags enable Wasm tail calls and push the relay function quickly to OMG (low OMG thresholds, disabled inlining/concurrent JIT). It builds a 24-argument target reached via return_call_ref from a 12-argument relay, with an outer function that return_calls the relay, and asserts the observed argument at index 22 (a stack-bound, late-input argument) survives the tail-call shuffle equal to argumentBase + 22 rather than being corrupted by the pinned-register reload.
Background
B3 patchpoint and clobberLate — A B3 StackmapValue/patchpoint is a hole into which the JIT emits custom machine code; it declares which registers it reads (its value reps) and which it writes. clobberLate(set) marks registers as written late in the patchpoint, so B3’s register allocator must not keep any live late input value in those registers across the patchpoint — precisely the guarantee this bug needed.
Wasm pinned registers — RegisterSet::wasmPinnedRegisters() are registers reserved by the Wasm ABI for engine state, notably wasmBaseMemoryPointer (linear-memory base, always pinned) and wasmBoundsCheckingSizeRegister (memory size, pinned in BoundsChecking mode). On a cross-instance call they must be reloaded from the target instance so memory accesses and bounds checks are correct for the callee.
Tail-call parallel-move shuffle — return_call / return_call_ref / return_call_indirect reuse the caller’s frame, so outgoing arguments must be moved directly into the callee’s ABI slots by a parallel-move algorithm. Values feeding this shuffle (the boxed callee and stack-bound args, marked LateColdAny) are ’late inputs’ that must stay intact until the shuffle consumes them.
LateColdAny constraint — A B3 value-rep hint that lets the allocator place a value anywhere, including callee-save (and thus pinned) registers, and treats it as late. It was introduced to give the tail-call shuffle freedom, but without a matching late clobber on the pinned set it permits the unsafe allocation this bug exploits.
Vulnerability window
- Refactor 1 (312691@main) — OMG’s tail-call shuffle becomes a parallel-move; boxed callee and stack-bound outgoing args are marked LateColdAny, letting B3 use callee-save/pinned registers for them.
- Refactor 2 (312795@main) — Cross-instance pinned-register restoration moves into the tail-call patchpoint, reloading wasmBaseMemoryPointer/wasmBoundsCheckingSizeRegister from the callee instance before prepareForCall->run() runs the shuffle; the line marking pinned registers as late clobbers is dropped.
- Trigger (warm-up) — A return_call_ref/return_call_indirect function is executed enough to tier up to OMG; B3 allocates a still-live late input into a pinned register.
- Corruption — The in-patchpoint context-switch reload overwrites that pinned register with the callee instance’s memory base / bounds size before the shuffle reads it.
- Use — The parallel-move shuffle copies the corrupted register into the callee’s argument slot, so the callee runs with an attacker-influenced wrong value (a raw engine pointer) where a program value should be.
Proof of concept
This is the shipped stress test. It arranges a return_call_ref from a 12-arg relay into a 24-arg target so that argument index 22 is stack-bound and late — exactly the kind of value B3 could park in a pinned register. The relay’s own frame is entered via return_call from outer, forcing the cross-instance tail-call path; after OMG compilation the observed argument must equal argumentBase + 22 (0x110016). On a vulnerable build the pinned-register reload clobbers that value before the shuffle, so the target reads back a corrupted (engine-pointer) value and the assert fails / behavior is unsound; the flags disable inlining and concurrent JIT and lower OMG thresholds so the miscompiled OMG code is reached deterministically.
//@ runDefaultWasm("--useWasmTailCalls=1", "--useBBQJIT=1", "--useConcurrentJIT=0",
// "--thresholdForOMGOptimizeAfterWarmUp=50", "--thresholdForOMGOptimizeSoon=50",
// "--wasmInliningMaximumWasmCalleeSize=0")
// (shipped test: JSTests/wasm/stress/omg-indirect-tail-call-late-input-clobber.js)
//
// target: 24 i32 args -> returns local[22]
// relay: 12 i32 args + a funcref; fills args 12..23 with constants (argumentBase+i)
// then return_call_ref target with local[callerArgumentCount] as callee
// outer: return_call relay, passing argumentBase+i and ref.func dump
//
// Warm the relay so it tiers to OMG, then call outer once and check arg 22 survived.
for (let i = 0; i < wasmTestLoopCount; ++i)
assertEqual(relay.exports.entry(...warmupArguments), 31337);
assertEqual(outer.exports.entry(), argumentBase + observedIndex); // 0x110000 + 22
Exploitation
- Deterministic tier-up — Drive a return_call_ref/return_call_indirect function to OMG (loop it past the OMG thresholds) so the miscompiled code path executes reliably; this is easy from web content since Wasm tiering is automatic.
- Choose the clobbered argument — Shape the call so a value the attacker cares about lands as a stack-bound late input that B3 assigns to a pinned register; the callee then receives wasmBaseMemoryPointer / wasmBoundsCheckingSizeRegister (a raw engine pointer) in place of a controlled i32/ref argument.
- Convert to a corruption primitive — Use the injected raw pointer where the callee treats the argument as an index/reference — e.g. feeding it into a memory computation, table/reference operation, or a value later stored — to build an OOB access or a pointer-disclosure/confusion primitive. Difficulty is high: the exact clobbered value is the engine’s memory base/size, so turning it into a controlled read/write requires careful callee construction, but it is a genuine soundness break with corruption reach.
Detection & hunting
For defenders and SOC / detection engineers:
- OMG tail-call crashes — Crashes or bounds-check faults inside OMG-compiled Wasm on functions using return_call_ref/return_call_indirect across instances, especially where an argument value equals a memory-base-like pointer, are indicative.
- Differential execution — Run the same Wasm module in the interpreter/BBQ vs OMG and diff results (as the test does with assertEqual): a tail-call argument that changes value between tiers flags this class of miscompile for fuzzers/CI.
- Wasm tail-call feature usage telemetry — Monitor for pages compiling modules that combine tail calls (–useWasmTailCalls) with cross-instance indirect/ref calls and large argument counts that spill to the stack; this is the narrow shape needed to hit the bug.
Audit directions
- Other in-patchpoint register reloads — Audit every OMG/B3 patchpoint that reloads or writes pinned/callee-save registers inside the patchpoint (context switches, instance restores) to confirm each declares a matching clobberLate for registers written before its late inputs are consumed.
- LateColdAny / late-input value reps — Review all uses of LateColdAny and late ValueReps in Wasm code generation; each grants the allocator freedom that must be paired with correct clobber declarations, and the tail-call and non-tail indirect paths should be compared for parity.
- BBQ / other tiers tail-call shuffles — Check whether the BBQ tail-call implementation and the non-OMG indirect-call paths have equivalent ordering of pinned-register restore vs argument shuffle, in case the same missing-clobber pattern exists outside OMG.