← All reports

[4] OMG tail call restores a stale wasm bounds-size register over the callee's

HighJSC WebAssembly OMG tierOOB

A cross-instance tail call installs the callee's bounds limit, then overwrites it.

929f6e8

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.

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.

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.

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.