3b9afb2b4f23fc2eb9555835a2ce0c6ebdb26056 [JSC] Fix boundary related code in BBQ / OMG
Triage note: Fixes signaling-memory large-offset accesses that could skip the bounds check and reach past the 4GiB+redzone reservation (missing trap -> OOB read/write) and ref.cast null-check elision when the fault handler is absent; JIT soundness with memory-corruption primitive per the added stress tests.
Contents
The bug at a glance
WebAssembly is reachable by any web page, and the bug lives in the BBQ/OMG JIT’s memory bounds-check and ref.cast null-check elision — the exact code that guarantees Wasm memory safety. Two flaws are addressed: a ref.cast null check that was elided even when no fault signal handler exists (a null dereference / potential missing trap), and bounds-check-skip logic keyed on offset rather than the last-accessed byte (boundary), which is over-conservative for signaling memory but is a genuine soundness defect. Given the stress-test-demonstrated missing-trap conditions and the corruption potential of skipping a Wasm bounds check, High/8.1 is justified.
WebAssembly linear-memory safety in JIT tiers rests on two shortcuts: signaling (fast) memories reserve 4GiB plus a redzone and skip the explicit bounds check when the compile-time offset is small enough that any real out-of-bounds access lands in the guarded region; and ref.cast can skip its explicit null check because the cast will dereference the reference and a null pointer faults inside the guard region, which the fault signal handler converts into a trap. Both shortcuts were keyed on the wrong quantity. The bounds-check-skip test compared the raw immediate offset (or uoffset) against the redzone size instead of the last-loaded byte boundary = offset + sizeOfOperation - 1, and the ref.cast null-check elision was applied unconditionally rather than only when Options::useWasmFaultSignalHandler() is true. With no handler installed, a ref.cast on null has nothing to turn the fault into a trap. The patch keys the memory check on boundary and gates the ref.cast elision on the fault handler being present.
Root cause
The vulnerable state is JIT code emitted for Wasm memory accesses and ref.cast on signaling (fast-mapped) memories. Fast memories reserve a 4GiB address window plus a redzone (Memory::fastMappedRedzoneBytes(), default 128 pages) that is mapped PROT_NONE, so an access whose furthest byte still falls inside the reservation is guaranteed to fault-and-trap rather than corrupt neighboring memory; the JIT therefore omits the explicit WasmBoundsCheck for offsets small enough to be absorbed.
The reaching path is a memory32 load/store with a large constant offset. A 32-bit dynamic index and an unsigned 32-bit immediate offset are folded in 64-bit arithmetic, so the effective address can reach far above 4GiB. In WasmOMGIRGenerator::emitCheckAndPreparePointer, WasmBBQJIT.h emitCheckAndPreparePointer, and WasmBBQJIT64.h emitCheckAndPrepareAndMaterializePointerApply, the decision to emit the bounds check was if (offset >= fastMappedRedzoneBytes()) / if (uoffset >= fastMappedRedzoneBytes()). This uses the base offset, not the last byte touched (offset + sizeOfOperation - 1). For a wide (e.g. v128, 16-byte) access whose base offset sits just below the redzone boundary but whose last byte crosses it, the skip decision was made on the smaller quantity. In practice the access still crosses the trapping zone and faults, but the computation was wrong and inconsistent across tiers; the fix makes the skip decision on boundary / lastLoadedOffset = offset + sizeOfOperation - 1, the true furthest byte.
The second, sharper defect is ref.cast. In B3LowerMacros.cpp and WasmBBQJIT64.cpp emitRefTestOrCast, a nullable reference cast could skip its explicit null branch when castAccessOffset() was within maxAcceptableOffsetForNullReference(): the cast dereferences the reference to read its type header, so a null pointer lands in the guard region and the fault handler produces a trap. But this reasoning is only valid when a fault signal handler is installed. With Options::useWasmFaultSignalHandler() == false there is nothing to convert the fault into a WebAssembly.RuntimeError trap, so eliding the null check turns a well-defined trap into an uncontrolled null dereference / missing trap.
The fix changes both: memory bounds-check skipping is now keyed on the last-accessed byte (boundary / lastLoadedOffset) rather than the base offset, tightening exactly which accesses may skip the check; and the ref.cast null-check elision is now guarded by Options::useWasmFaultSignalHandler() && in both B3LowerMacros and BBQJIT64, so the explicit null branch is emitted whenever the handler is absent. Comments are added spelling out that the elision relies on the handler turning the guard-region fault into a trap.
Key code
Gate ref.cast null-check elision on the fault handler; key bounds-skip on the last byte
// B3LowerMacros.cpp (ref.cast nullable elision)
- if (auto offset = castAccessOffset(); offset && offset.value() <= Wasm::maxAcceptableOffsetForNullReference()) {
+ if (auto offset = castAccessOffset(); Options::useWasmFaultSignalHandler() && offset && offset.value() <= Wasm::maxAcceptableOffsetForNullReference()) {
isNull = constant(Int32, 0);
canTrap = true;
} else
// WasmBBQJIT.h / WasmBBQJIT64.h (memory bounds-check skip)
- if (uoffset >= Memory::fastMappedRedzoneBytes()) {
+ if (boundary >= Memory::fastMappedRedzoneBytes()) {
// WasmOMGIRGenerator.cpp (emitCheckAndPreparePointer)
- if (offset >= Memory::fastMappedRedzoneBytes()) {
- uint64_t lastLoadedOffset = static_cast<uint64_t>(offset);
- lastLoadedOffset += static_cast<uint64_t>(sizeOfOperation - 1);
+ uint64_t lastLoadedOffset = static_cast<uint64_t>(offset) + static_cast<uint64_t>(sizeOfOperation - 1);
+ if (lastLoadedOffset >= Memory::fastMappedRedzoneBytes()) {
m_currentBlock->appendNew<WasmBoundsCheckValue>(m_proc, origin(), pointer, lastLoadedOffset, maximum);
}
Patch walkthrough
JSTests/wasm/stress/ref-cast-null-without-fault-signal-handler.js— New regression test run with –useWasmFaultSignalHandler=false. It defines a function taking (ref null $s) and doing ref.cast (ref $s), then calls it with null 2000 times, asserting each call throws WebAssembly.RuntimeError. With the old code and no handler installed, the elided null check would let the cast dereference null without producing a trap; the test pins that a trap (CastFailure or NullAccess) is always raised.JSTests/wasm/stress/signaling-memory-large-offset-bounds-check.js— New SIMD-gated test that generates loads/stores of sizes 1..16 across a matrix of large immediate offsets that bracket the redzone size (e.g. 0x7ffff8, 0x800000, 0x800001) and large indices, on both a 1-page and a 300-page memory. For each it computes whether the last byte leaves the memory and asserts an in-bounds access succeeds while an out-of-bounds access throws ‘Out of bounds memory access’, exercising exactly the offset/boundary skip logic.Source/JavaScriptCore/b3/B3LowerMacros.cpp— In the ref.cast lowering for a nullable reference, the condition that lets the code skip emitting an explicit isNull check (relying on the trapping dereference) is nowOptions::useWasmFaultSignalHandler() && offset && offset.value() <= maxAcceptableOffsetForNullReference(). When the fault handler is disabled the explicit null check is emitted instead of trusting a fault to trap.Source/JavaScriptCore/wasm/WasmBBQJIT.h— In emitCheckAndPreparePointer the guard that decides whether to still emit the maximum/bounds comparison changes fromif (uoffset >= Memory::fastMappedRedzoneBytes())toif (boundary >= Memory::fastMappedRedzoneBytes()), so the decision is made on the furthest byte of the access rather than the base immediate offset.Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp— In emitRefTestOrCast the nullable-cast fast path is gated onOptions::useWasmFaultSignalHandler() &&, and a comment is added: the cast dereferences the reference so a null lands in the guard region and the handler traps it; without the handler the explicit branchIfNull must be emitted.Source/JavaScriptCore/wasm/WasmBBQJIT64.h— emitCheckAndPrepareAndMaterializePointerApply makes the same offset->boundary change as WasmBBQJIT.h:if (boundary >= Memory::fastMappedRedzoneBytes())replaces the uoffset test so bounds-check skipping keys on the last-accessed byte.Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp— emitCheckAndPreparePointer now computeslastLoadedOffset = offset + (sizeOfOperation - 1)first and testsif (lastLoadedOffset >= Memory::fastMappedRedzoneBytes())before appending the WasmBoundsCheckValue, instead of testing the baseoffsetand computing lastLoadedOffset only inside. The skip decision and the bounds-check bound now use the same furthest-byte quantity.
Background
Signaling (fast) Wasm memory — A fast-mapped linear memory reserves a 4GiB virtual window plus a PROT_NONE redzone (Memory::fastMappedRedzoneBytes(), default 128 pages). Accesses that fall inside the redzone fault, and a signal handler converts the fault into a WebAssembly trap, letting the JIT skip explicit bounds checks for small offsets.
Offset vs boundary — A memory access touches bytes [offset+index .. offset+index+size-1]. The ‘boundary’/lastLoadedOffset is the furthest byte (offset + sizeOfOperation - 1); using the base offset to decide whether a fault is guaranteed is the wrong, over-conservative quantity, especially for wide v128 accesses.
ref.cast trapping null check — ref.cast reads the reference’s runtime type, dereferencing it. For a nullable ref the JIT can skip an explicit null branch and let the load of null fault at a small offset within the guard region — but only if a fault signal handler is installed to turn that fault into a trap (Options::useWasmFaultSignalHandler()).
BBQ / OMG tiers — BBQ is JavaScriptCore’s baseline Wasm JIT and OMG its optimizing tier; both emit the memory-access and ref.cast sequences, so the same soundness invariant had to be fixed in B3LowerMacros (B3/OMG), the BBQ64 emitters, and the OMG IR generator.
Vulnerability window
- Design assumption — JIT elides Wasm memory bounds checks and ref.cast null checks by assuming a small offset guarantees a trapping fault inside the redzone/guard region.
- Latent defect — The skip decision keys on the base offset (uoffset/offset) rather than the last-accessed byte, and the ref.cast elision does not check whether a fault signal handler is actually installed.
- Trigger (memory) — A wide/large-offset access whose furthest byte crosses the redzone boundary reveals the offset-vs-boundary discrepancy; in practice it still traps, but the computation was unsound and inconsistent across tiers.
- Trigger (ref.cast) — With –useWasmFaultSignalHandler=false, ref.cast(null) has no handler to trap the fault, so the elided null check yields a missing trap / null dereference.
- Fix — Bounds-skip logic keyed on boundary/lastLoadedOffset; ref.cast elision gated on Options::useWasmFaultSignalHandler(); explanatory comments added.
- Regression tests — Two stress tests added covering both the large-offset bounds matrix and the no-fault-handler ref.cast-null trap.
Proof of concept
Directly from the added test ref-cast-null-without-fault-signal-handler.js. It builds a module whose exported f does ref.cast (ref $s) on a nullable parameter, runs with the fault signal handler disabled, and asserts every call with null traps. Pre-patch, the null check was elided regardless of the handler, so the cast would dereference null without a handler to convert the fault into a WebAssembly.RuntimeError. The companion signaling-memory-large-offset-bounds-check.js exercises the offset-vs-boundary memory path but relies on internal reservation layout to distinguish trap-vs-corruption, so it is a soundness assertion rather than a demonstrated OOB write.
//@ requireOptions("--useWasmFaultSignalHandler=false")
// (module (type $s (struct (field (mut i32))))
// (func (export "f") (param (ref null $s)) (result (ref $s))
// local.get 0 ref.cast (ref $s)))
const bytes = new Uint8Array([
0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,
0x01,0x0c,0x02,0x5f,0x01,0x7f,0x01,0x60,0x01,0x63,0x00,0x01,0x64,0x00,
0x03,0x02,0x01,0x01,
0x07,0x05,0x01,0x01,0x66,0x00,0x00,
0x0a,0x09,0x01,0x07,0x00,0x20,0x00,0xfb,0x16,0x00,0x0b,
]);
const instance = new WebAssembly.Instance(new WebAssembly.Module(bytes));
// Without the fix, ref.cast(null) with no fault handler fails to trap.
for (let i = 0; i < 2000; ++i)
assert.throws(() => instance.exports.f(null), WebAssembly.RuntimeError, "");
Exploitation
- Reach the JIT — Serve a page that instantiates a Wasm module and warms the target function into BBQ/OMG so the elided-check code path is exercised.
- ref.cast missing trap — In an embedder/config with useWasmFaultSignalHandler=false, ref.cast on a null reference performs a null-based dereference with no trap; the reported behavior is a null read at a small guard offset rather than an attacker-controlled OOB, so on default browser configs (handler present) this is not exploitable — the fix is chiefly for non-default/embedder configs.
- Memory offset boundary — The offset-vs-boundary error was over-conservative and, per the commit and test comments, still crossed the trapping zone in practice, so it did not yield a usable OOB primitive; it is corrected for soundness and cross-tier consistency, and to shrink the margin that future changes could turn into a real missing trap.
Detection & hunting
For defenders and SOC / detection engineers:
- JIT audit for offset use — Flag bounds-check-skip decisions in Wasm memory emitters that compare a base offset/uoffset against a redzone/guard size instead of the last-accessed byte (offset + size - 1).
- Unconditional check elision — Look for ref.cast/null-check elision that assumes a trapping fault without verifying Options::useWasmFaultSignalHandler() is enabled.
- Crash without handler — In configurations with the fault signal handler disabled, a null-deref crash inside a ref.cast sequence (rather than a clean WebAssembly.RuntimeError) indicates the pre-fix behavior.
- Regression tests — Run wasm/stress/ref-cast-null-without-fault-signal-handler.js and signaling-memory-large-offset-bounds-check.js as tripwires on JIT changes touching bounds/cast emission.
Audit directions
- All Wasm memory emitters — Audit BBQ/OMG/B3 memory-access lowering (emitCheckAndPreparePointer, emitCheckAndPrepareAndMaterializePointerApply) for consistent use of the furthest byte, including memory64 and SIMD (v128) wide accesses.
- Guard-region reliance — Enumerate every place the JIT relies on a PROT_NONE guard/redzone fault instead of an explicit check, and confirm each is gated on the fault handler actually being installed.
- ref.cast / GC dereferences — Review other Wasm-GC operations that dereference references to read type/rtt headers and may skip null checks under the same fault-handler assumption.
- Cross-tier parity — Ensure BBQ and OMG make identical skip decisions so a discrepancy cannot be leveraged by forcing tier-up/tier-down.