High firefox Logic Error 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impacthigh
DescriptionJIT miscompilation in the JavaScript: WebAssembly component
ComponentSpiderMonkey
Bug ClassLogic Error
Tracker2047689
Fix commitd5141eb7fa74 (firefox) +61/-35
CISA KEVNot listed
CreditedNebula Security
Disclosed2026-07-21

Background

Stackmap
A compiler-emitted record, keyed by a code offset, that tells the GC and stack-walker which stack slots and registers hold live GC-managed pointers at a given safepoint.
`wasm::Trap::ThrowReported`
A failure mode used by fallible Wasm instance calls that have already reported a JS exception, causing the generated code to branch into the runtime to unwind the stack.
Builtin instance method call
A Wasm-emitted call into a C++ runtime helper (wasm::SymbolicAddress) that implicitly passes the current Instance and may fail, trap, or trigger GC.
`BaseCompiler`
SpiderMonkey’s WebAssembly baseline JIT (Rabaldr), which compiles Wasm bytecode directly to machine code and is responsible for emitting stackmaps at each safepoint.

Root Cause Analysis

When the baseline compiler emitted a fallible builtin instance call via BaseCompiler::emitInstanceCall, it obtained a single return-address offset from builtinInstanceMethodCall and created exactly one stackmap keyed at the call’s return point. For a call whose failureMode produces a wasm::Trap::ThrowReported trap, the code additionally branches to wasmTrap and, on the throwing path, transfers control into the runtime to unwind the stack — a second safepoint whose GC roots must be described by a stackmap keyed at the instruction following the trap. That trap-site stackmap was never generated, so the invariant “every point at which the stack can be walked for GC must have a matching stackmap” was violated. During exception unwinding after such a trap, the stack-walker consulted no (or the wrong) stackmap for that frame, so live GC references were mis-traced.

The fix makes wasmTrapOnFailedInstanceCall return the trap-continuation CodeOffset for ThrowReported, threads it through wasmCallBuiltinInstanceMethod and builtinInstanceMethodCall as a second out-parameter (trapStackMapKey), and has emitInstanceCall create a second stackmap at that key so the unwinder always finds correct root information.

Key insight
The single core mistake was emitting only one stackmap (for the call return) while a Trap::ThrowReported failure path introduces a second GC safepoint at the trap that had no stackmap; the fix propagates and keys a separate trapStackMapKey so the trap’s unwind point is fully described.

Attack Path

  1. Craft a fallible instance call Author a WebAssembly module that invokes a builtin instance method whose failureMode maps to wasm::Trap::ThrowReported, ensuring the baseline compiler (Rabaldr) compiles the call.
  2. Arrange live GC references Shape the surrounding Wasm frame so that live GC-managed pointers occupy stack slots across the instance call, slots that only a correct trap-site stackmap would describe.
  3. Force the reported-throw path Drive the runtime helper to fail and report an exception, taking the ThrowReported trap that begins stack unwinding.
  4. Trigger a mis-traced GC walk During unwinding the stack-walker finds no matching stackmap, so live references are not traced (or stale slots are misread as pointers), corrupting GC state.
  5. Leverage the corruption Use the resulting stale/incorrectly-collected object to obtain a use-after-free or type confusion primitive.

Impact Assessment

An attacker who can run crafted WebAssembly gains a JIT miscompilation in the SpiderMonkey content process that corrupts GC root tracking during exception unwinding, yielding memory corruption (e.g., use-after-free) that can be escalated toward arbitrary code execution in the sandboxed content process. Preconditions are only that the target executes attacker-controlled Wasm compiled by the baseline compiler and that a builtin instance call with a ThrowReported failure mode is exercised on its throwing path. No special privileges or user interaction beyond loading a malicious page are required.

Changed Functions

FunctionChangeNotes
if
js/src/jit/CodeGenerator.cpp
modified
if
js/src/jit/MacroAssembler.cpp
modified
switch
js/src/jit/MacroAssembler.cpp
modified

Files Changed

  • js/src/jit/CodeGenerator.cpp
  • js/src/jit/MacroAssembler.cpp
  • js/src/jit/MacroAssembler.h
  • js/src/wasm/WasmBCClass.h
  • js/src/wasm/WasmBaselineCompile.cpp

Audit Directions

  • Missing trap-site stackmaps
    Audit every path that calls wasmTrap after a call for whether the trap introduces an unwind safepoint that needs its own stackmap, not just the call return.
  • Signature-change fan-out
    Where a helper’s return value encodes a safepoint offset, verify all callers (Ion and baseline) actually consume and register it rather than dropping it as unused_*.
  • `FailureMode`/`Trap` coverage
    Review each wasm::FailureMode and wasm::Trap combination in fallible instance calls to confirm GC-root descriptions exist for both the success and throwing continuations.
diff --git a/js/src/jit/CodeGenerator.cpp b/js/src/jit/CodeGenerator.cpp
index a46730a5f3d..e76e3d52f98 100644
--- a/js/src/jit/CodeGenerator.cpp
+++ b/js/src/jit/CodeGenerator.cpp
@@ -10410,11 +10410,13 @@ void CodeGenerator::visitWasmCall(LWasmCall* lir) {
       reloadPinnedRegs = true;
       switchRealm = false;
       break;
-    case wasm::CalleeDesc::BuiltinInstanceMethod:
-      retOffset = masm.wasmCallBuiltinInstanceMethod(
-          desc, callBase->instanceArg(), callee.builtin(),
-          callBase->builtinMethodFailureMode(),
-          callBase->builtinMethodFailureTrap());
+    case wasm::CalleeDesc::BuiltinInstanceMethod: {
+      CodeOffset unused_trapStackMapKey;
+      masm.wasmCallBuiltinInstanceMethod(desc, callBase->instanceArg(),
+                                         callee.builtin(),
+                                         callBase->builtinMethodFailureMode(),
+                                         callBase->builtinMethodFailureTrap(),
+                                         &retOffset, &unused_trapStackMapKey);
       // The builtin ABI preserves the instance and pinned registers. However,
       // builtins may grow the memory which requires us to reload the pinned
       // registers.
@@ -10422,6 +10424,7 @@ void CodeGenerator::visitWasmCall(LWasmCall* lir) {
       reloadPinnedRegs = true;
       switchRealm = false;
       break;
+    }
     case wasm::CalleeDesc::FuncRef:
       if (isReturnCall) {
         ReturnCallAdjustmentInfo retCallInfo(
diff --git a/js/src/jit/MacroAssembler.cpp b/js/src/jit/MacroAssembler.cpp
index 11c67b355a1..09d2a24b2bd 100644
--- a/js/src/jit/MacroAssembler.cpp
+++ b/js/src/jit/MacroAssembler.cpp
@@ -6613,10 +6613,11 @@ CodeOffset MacroAssembler::wasmReturnCall(
   return offset;
 }
 
-CodeOffset MacroAssembler::wasmCallBuiltinInstanceMethod(
+void MacroAssembler::wasmCallBuiltinInstanceMethod(
     const wasm::CallSiteDesc& desc, const ABIArg& instanceArg,
     wasm::SymbolicAddress builtin, wasm::FailureMode failureMode,
-    wasm::Trap failureTrap) {
+    wasm::Trap failureTrap, CodeOffset* callStackMapKey,
+    CodeOffset* trapStackMapKey) {
   MOZ_ASSERT(instanceArg != ABIArg());
   MOZ_ASSERT_IF(!wasm::NeedsBuiltinThunk(builtin),
                 failureMode == wasm::FailureMode::Infallible ||
@@ -6643,7 +6644,7 @@ CodeOffset MacroAssembler::wasmCallBuiltinInstanceMethod(
   }
 #endif
 
-  CodeOffset ret = call(desc, builtin);
+  *callStackMapKey = call(desc, builtin);
 
 #ifdef JS_CHECK_UNSAFE_CALL_WITH_ABI
   if (checkUnsafeCallWithABI) {
@@ -6651,19 +6652,18 @@ CodeOffset MacroAssembler::wasmCallBuiltinInstanceMethod(
   }
 #endif
 
-  wasmTrapOnFailedInstanceCall(ReturnReg, failureMode, failureTrap,
-                               desc.toTrapSiteDesc());
-  return ret;
+  *trapStackMapKey = wasmTrapOnFailedInstanceCall(
+      ReturnReg, failureMode, failureTrap, desc.toTrapSiteDesc());
 }
 
-void MacroAssembler::wasmTrapOnFailedInstanceCall(
+CodeOffset MacroAssembler::wasmTrapOnFailedInstanceCall(
     Register resultRegister, wasm::FailureMode failureMode,
     wasm::Trap failureTrap, const wasm::TrapSiteDesc& trapSiteDesc) {
   Label noTrap;
   switch (failureMode) {
     case wasm::FailureMode::Infallible:
       MOZ_ASSERT(failureTrap == wasm::Trap::Limit);
-      return;
+      return CodeOffset();
     case wasm::FailureMode::FailOnNegI32:
       branchTest32(Assembler::NotSigned, resultRegister, resultRegister,
                    &noTrap);
@@ -6684,6 +6684,14 @@ void MacroAssembler::wasmTrapOnFailedInstanceCall(
   }
   wasmTrap(failureTrap, trapSiteDesc);
   bind(&noTrap);
+
+  if (failureTrap != wasm::Trap::ThrowReported) {
+    return CodeOffset();
+  }
+
+  // For Trap::ThrowReported, the caller(s) may want to create a stackmap, so
+  // tell them what its key must be.
+  return CodeOffset(masm.currentOffset());
 }
 
 // In principle, call_indirect requires an expensive context switch to the
diff --git a/js/src/jit/MacroAssembler.h b/js/src/jit/MacroAssembler.h
index 6920cafa0d1..ba70caf10a8 100644
--- a/js/src/jit/MacroAssembler.h
+++ b/js/src/jit/MacroAssembler.h
@@ -3986,20 +3986,26 @@ class MacroAssembler : public MacroAssemblerSpecific {
   // This function takes care of loading the pointer to the current instance
   // as the implicit first argument. It preserves instance and pinned registers.
   // (instance & pinned regs are non-volatile registers in the system ABI).
-  CodeOffset wasmCallBuiltinInstanceMethod(const wasm::CallSiteDesc& desc,
-                                           const ABIArg& instanceArg,
-                                           wasm::SymbolicAddress builtin,
-                                           wasm::FailureMode failureMode,
-                                           wasm::Trap failureTrap);
+  // Offsets for stackmaps for the call and (if required) for the following
+  // failure trap, are returned.
+  void wasmCallBuiltinInstanceMethod(const wasm::CallSiteDesc& desc,
+                                     const ABIArg& instanceArg,
+                                     wasm::SymbolicAddress builtin,
+                                     wasm::FailureMode failureMode,
+                                     wasm::Trap failureTrap,
+                                     CodeOffset* callStackMapKey,
+                                     CodeOffset* trapStackMapKey);
 
   // Performs the appropriate check based on the instance call's FailureMode,
   // and traps if the check fails. The resultRegister should likely be
   // ReturnReg, but this depends on whatever you do with registers immediately
-  // after the call.
-  void wasmTrapOnFailedInstanceCall(Register resultRegister,
-                                    wasm::FailureMode failureMode,
-                                    wasm::Trap failureTrap,
-                                    const wasm::TrapSiteDesc& trapSiteDesc);
+  // after the call. In the case where a trap is generated, the returned
+  // CodeOffset points at the first byte of the instruction following the trap,
+  // and that is where a stackmap, if needed, should be keyed.  If a trap is not
+  // generated, a CodeOffset satisfying !CodeOffset::bound() is returned.
+  CodeOffset wasmTrapOnFailedInstanceCall(
+      Register resultRegister, wasm::FailureMode failureMode,
+      wasm::Trap failureTrap, const wasm::TrapSiteDesc& trapSiteDesc);
 
   // Performs a bounds check for ranged wasm operations like memory.fill or
   // array.fill. This handles the bizarre edge case in the wasm spec where a
diff --git a/js/src/wasm/WasmBCClass.h b/js/src/wasm/WasmBCClass.h
index 4abcde369bb..3ff004c4655 100644
--- a/js/src/wasm/WasmBCClass.h
+++ b/js/src/wasm/WasmBCClass.h
@@ -1053,9 +1053,11 @@ struct BaseCompiler final {
   void returnCallRef(const Stk& calleeRef, const FunctionCall& call,
                      const FuncType& funcType);
   CodeOffset builtinCall(SymbolicAddress builtin, const FunctionCall& call);
-  CodeOffset builtinInstanceMethodCall(const SymbolicAddressSignature& builtin,
-                                       const ABIArg& instanceArg,
-                                       const FunctionCall& call);
+  void builtinInstanceMethodCall(const SymbolicAddressSignature& builtin,
+                                 const ABIArg& instanceArg,
+                                 const FunctionCall& call,
+                                 CodeOffset* callStackMapKey,
+                                 CodeOffset* trapStackMapKey);
 
   // Helpers to pick up the returned value from the return register.
   inline RegI32 captureReturnedI32();
diff --git a/js/src/wasm/WasmBaselineCompile.cpp b/js/src/wasm/WasmBaselineCompile.cpp
index c9dd7dac3c3..54d0a3c7fb6 100644
--- a/js/src/wasm/WasmBaselineCompile.cpp
+++ b/js/src/wasm/WasmBaselineCompile.cpp
@@ -2254,17 +2254,18 @@ CodeOffset BaseCompiler::builtinCall(SymbolicAddress builtin,
   return callSymbolic(builtin, call);
 }
 
-CodeOffset BaseCompiler::builtinInstanceMethodCall(
+void BaseCompiler::builtinInstanceMethodCall(
     const SymbolicAddressSignature& builtin, const ABIArg& instanceArg,
-    const FunctionCall& call) {
+    const FunctionCall& call, CodeOffset* callStackMapKey,
+    CodeOffset* trapStackMapKey) {
 #ifndef RABALDR_PIN_INSTANCE
   // Builtin method calls assume the instance register has been set.
   fr.loadInstancePtr(InstanceReg);
 #endif
   CallSiteDesc desc(bytecodeOffset(), CallSiteKind::Symbolic);
-  return masm.wasmCallBuiltinInstanceMethod(desc, instanceArg, builtin.identity,
-                                            builtin.failureMode,
-                                            builtin.failureTrap);
+  masm.wasmCallBuiltinInstanceMethod(desc, instanceArg, builtin.identity,
+                                     builtin.failureMode, builtin.failureTrap,
+                                     callStackMapKey, trapStackMapKey);
 }
 
 //////////////////////////////////////////////////////////////////////////////
@@ -6553,12 +6554,18 @@ bool BaseCompiler::emitInstanceCall(const SymbolicAddressSignature& builtin) {
     }
     passArg(t, peek(numNonInstanceArgs - i), &baselineCall);
   }
-  CodeOffset raOffset =
-      builtinInstanceMethodCall(builtin, instanceArg, baselineCall);
-  if (!createStackMap("emitInstanceCall", raOffset)) {
+
+  CodeOffset callStackMapKey;
+  CodeOffset trapStackMapKey;
+  builtinInstanceMethodCall(builtin, instanceArg, baselineCall,
+                            &callStackMapKey, &trapStackMapKey);
+  if (!createStackMap("emitInstanceCall-call", callStackMapKey)) {
+    return false;
+  }
+  if (trapStackMapKey.bound() &&
+      !createStackMap("emitInstanceCall-trap", trapStackMapKey)) {
     return false;
   }
-
   endCall(baselineCall, stackSpace);
 
   popValueStackBy(numNonInstanceArgs);
Loading diff…