[4] OMG tail call restores a stale wasm bounds-size register over the callee's
A cross-instance tail call installs the callee's bounds limit, then overwrites it.
High. The register that every explicit bounds check in the tail-callee compares against is overwritten — after the tail-call sequence had already installed the correct value — by a callee-save restore that had no business owning it. The limit that lands there belongs to a different instance's memory, which is what turns an ABI slip into out-of-bounds linear-memory access.
WebAssembly bounds checks are cheap because the limit they compare against lives in a register the ABI pins for the whole function, rather than being reloaded from the instance on every access. In JSC's top-tier wasm compiler — OMG, the B3/Air-based optimizing backend — GPRInfo::wasmBoundsCheckingSizeRegister (regCS4) holds that limit, and the compiler removes it from the register allocator's pool by calling m_proc.pinRegister(...), but only when the function being compiled is itself in MemoryMode::BoundsChecking. A tail call reuses the caller's stack frame and jumps without any caller-side fixup afterwards, so the sequence performing it is the last chance to install the callee's memory registers.
The angle: a wasm module that cross-instance tail-calls out of a fast-memory function leaves the callee checking every load and store against a bounds limit belonging to somebody else's linear memory.
Patch Details
The fix adds a continue to the callee-save restore loop in prepareForTailCallImpl, skipping wasmBoundsCheckingSizeRegister so that the value the tail-call sequence installed for the callee survives the shuffle. The added regression test pins the required tier configuration: the victim function must be compiled by BBQ, because — per the comment in the new test — IPInt's prologue reloads regCS4 on entry while BBQ's does not, so BBQ code trusts whatever the caller left in the register.
A pinned ABI register left in the allocator's pool for one compilation mode, then restored as an ordinary callee-save over the value the call sequence had just installed.
Background
Memory modes. A wasm function compiles in one of two memory modes. BoundsChecking emits an explicit compare against the current linear-memory limit for every access. Signaling uses fast memory, where guard pages catch out-of-range accesses and no explicit check is emitted at all.
Pinned registers. pinRegister tells B3 that a physical register carries live ABI state and must be removed from the allocator's pool for the whole compilation. OMG only pins wasmBoundsCheckingSizeRegister when the function is in BoundsChecking mode; in Signaling mode the register stays in Air's mutable set and B3 may allocate it as an ordinary general-purpose register.
clobberEarly and the callee-save list. Per the commit message, createTailCallPatchpoint declares the entire callee-save set as clobberEarly — so B3 does not park a tail-call input in a register the tail call's parallel move is about to overwrite. That declaration causes AirHandleCalleeSaves to add regCS4 to the function's calleeSaveRegisterAtOffsetList().
Tail-call frame reuse. return_call and return_call_indirect reuse the caller's frame rather than pushing a new one. prepareForTailCallImpl performs the shuffle: moving arguments into place and restoring the callee-save list before the jump.
Analysis
This is a logic and ABI error in generated code whose direct security effect is a bounds-check bypass.
Signaling-mode caller (OMG) regCS4 / wasmBoundsCheckingSizeRegister
--------------------------- ---------------------------------------
prologue
AirHandleCalleeSaves saw
regCS4 in the callee-save
list (via clobberEarly) spill caller-entry value into frame
return_call, cross-instance
install callee memory regs <- callee instance's bounds limit (OK)
prepareForTailCallImpl
restore callee-save list <- caller-entry value (STALE)
jump; callee executes (BBQ)
every explicit bounds check compares against the stale limit
The ordering is the whole bug. A wasm tail call must install the target's memory registers — base pointer and bounds size — before jumping, because the tail-callee reuses the caller's frame and never runs a caller-side setup afterwards. In the vulnerable ordering shown above, the cross-instance path sets wasmBoundsCheckingSizeRegister to the callee instance's memory size, and then the callee-save shuffle inside prepareForTailCallImpl immediately overwrites it with the value the OMG prologue had spilled from the caller's entry.
The precondition chain is specific and entirely script-arrangeable. The caller must be compiled by OMG in Signaling mode — that is what leaves regCS4 unpinned and therefore eligible to land in the callee-save list. The call must be a cross-instance return_call / return_call_indirect, so that the memory registers genuinely differ between caller and callee. And the callee must be in BBQ, whose prologue does not reload regCS4, so the stale value survives into the callee's body.
The tail-callee then begins executing with a bounds limit that belongs to a different context and has no relationship to its own linear memory. Every subsequent explicit bounds check in that callee compares against it. Whether that yields an out-of-bounds read or write depends on the direction of the mismatch — a stale limit larger than the callee's actual memory admits accesses past the end of the callee's buffer.
This vulnerability weakens the one invariant wasm's memory-safety story rests on: that an explicit bounds check compares an index against the limit of the memory being indexed.
Audit directions
- Conditionally pinned ABI registers. Any register that carries live ABI state in some compilation modes but not others is a candidate for this exact bug, because the allocator will happily use it in the modes where it is unpinned. Enumerate the
pinRegistercall sites in the OMG generator and check each for whether the guarding condition matches the register's actual live range. - Ordering between call-sequence setup and callee-save restore. The pattern is a value installed by one part of a call sequence and then clobbered by a later part of the same sequence. In
prepareForTailCallImpland its analogues, any register written by the memory-register setup should be checked against the callee-save list; the code-review tell is a restore loop overcalleeSaveRegisterAtOffsetList()with no exclusions, sitting downstream of code that writes pinned registers by hand. - Cross-tier prologue assumptions. BBQ trusts the incoming regCS4 while IPInt reloads it. Wherever one tier's generated code depends on what another tier's prologue does or does not do, that dependency is unwritten contract. Enumerate the pinned registers and record, per tier, which are reloaded on entry and which are inherited.