CVE-2026-74936
Overview
Background
- `JitActivation`
- a SpiderMonkey object representing a span of JIT/Wasm execution on the stack, holding trap and exit-frame bookkeeping for that activation.
- `wasm::Code`
- a reference-counted Wasm code container owning the executable machine-code segment(s) that trap stubs and compiled functions run inside.
- Wasm trap
- a runtime fault (such as an indirect-call signature mismatch) that transfers control to a trap stub, which unwinds and raises a JavaScript
RuntimeError. - Tail call
- a Wasm call that reuses/unwinds the caller’s frame, so the calling instance’s frame is gone by the time the callee traps.
Root Cause Analysis
When a Wasm trap fired, JitActivation::startWasmTrap looked up the trapping wasm::Code via wasm::LookupCode(pc) but retained no owning reference to it; it only recorded the effective-instance frame pointer through setWasmExitFP(fp). The implicit invariant was that some live wasm::Frame on the stack keeps the trapping code segment alive for the duration of trap handling, but for an IndirectCallBadSig trap reached through a tail call, the trapping code belongs to a different instance whose frame has already been unwound, so nothing on the stack keeps that code alive. A garbage collection during trap handling (for example while allocating the RuntimeError) could then free the wasm::Code segment that the trap stub was still executing in, producing a use-after-free.
The fix adds a RefPtr<const wasm::Code> wasmTrapCode_ member that startWasmTrap populates with wasm::LookupCode(state.pc) and asserts non-null, holding a strong reference across the GC; finishWasmTrap deliberately leaves it set because execution is still returning into that code, releasing it only on the next trap or when the activation is destroyed.
RefPtr to the trapping wasm::Code so it survives any GC until the trap finishes.Attack Path
- Craft the modules
An attacker supplies Wasm that sets up a cross-instance tail call, where the callee lives in a different
wasm::Codethan the effective instance. - Trigger the trap
The tail-called function performs an indirect call with a mismatched signature, raising an
IndirectCallBadSigtrap after the caller’s frame has been unwound. - Force a collection
Trap handling allocates a
RuntimeError, which can drive a garbage collection while no stack frame references the trapping code segment. - Free the code
The GC reclaims the
wasm::Codesegment that the trap stub is still executing in, leaving a dangling executable region. - Exploit the dangling code Continued execution runs inside freed (and potentially reallocated) memory, yielding a use-after-free that can corrupt state or redirect control flow.
Impact Assessment
Files Changed
js/src/vm/JitActivation.cppjs/src/vm/JitActivation.h
Audit Directions
- Lifetime tied to stack framesAudit trap, exit, and unwinding paths that assume a
wasm::Framekeeps awasm::Code/instance alive; tail calls and other frame-eliding optimizations can invalidate that assumption. - GC across native handling windowsLook for allocations (like
RuntimeErrorconstruction) reachable during low-level handlers where currently-executing code or data is not explicitly rooted by aRefPtror GC root. - `LookupCode` result usageReview call sites that use
wasm::LookupCode(pc)results transiently without taking an owning reference, especially where the looked-up code differs from the effective instance.
Patch
diff --git a/js/src/vm/JitActivation.cpp b/js/src/vm/JitActivation.cpp
index 5fff795afc9..4c98a2ec424 100644
--- a/js/src/vm/JitActivation.cpp
+++ b/js/src/vm/JitActivation.cpp
@@ -244,6 +244,14 @@ void js::jit::JitActivation::startWasmTrap(wasm::Trap trap,
const wasm::Code& code = wasm::GetNearestEffectiveInstance(fp)->code();
MOZ_RELEASE_ASSERT(&code == wasm::LookupCode(pc));
+ // Keep the trapping code alive across a GC. For an IndirectCallBadSig trap
+ // reached through a tail call, this code can belong to a different instance
+ // than the effective one above, whose frame has already been unwound. Nothing
+ // else then keeps alive the code segment we are about to run the trap stub
+ // in.
+ wasmTrapCode_ = wasm::LookupCode(state.pc);
+ MOZ_RELEASE_ASSERT(wasmTrapCode_);
+
setWasmExitFP(fp);
wasmTrapData_.emplace();
wasmTrapData_->resumePC =
@@ -271,6 +279,9 @@ void js::jit::JitActivation::finishWasmTrap() {
MOZ_ASSERT(hasWasmExitFP());
MOZ_ASSERT(isWasmTrapping());
wasmTrapData_.reset();
+ // Keep wasmTrapCode_ alive: we are still executing inside this code and are
+ // about to return into it, so releasing it now could free it. It is released
+ // on the next trap or when the activation is destroyed.
packedExitFP_ = nullptr;
MOZ_ASSERT(!isWasmTrapping());
}
diff --git a/js/src/vm/JitActivation.h b/js/src/vm/JitActivation.h
index 4f1b2d4c248..884ff99bf4b 100644
--- a/js/src/vm/JitActivation.h
+++ b/js/src/vm/JitActivation.h
@@ -8,6 +8,7 @@
#include "mozilla/Assertions.h" // MOZ_ASSERT
#include "mozilla/Atomics.h" // mozilla::Atomic, mozilla::Relaxed
#include "mozilla/Maybe.h" // mozilla::Maybe
+#include "mozilla/RefPtr.h" // RefPtr
#include <stddef.h> // size_t
#include <stdint.h> // uint8_t, uint32_t, uintptr_t
@@ -94,6 +95,12 @@ class JitActivation : public Activation {
// purposes. Wasm code can't trap reentrantly.
mozilla::Maybe<wasm::TrapData> wasmTrapData_;
+ // Keeps the trapping code alive while the trap is handled. With tail calls
+ // the trapping frame may already be unwound, so no wasm::Frame keeps it alive
+ // and a GC (e.g. while building the RuntimeError) could otherwise free the
+ // code segment we are still executing in.
+ RefPtr<const wasm::Code> wasmTrapCode_;
+
#ifdef CHECK_OSIPOINT_REGISTERS
protected:
// Used to verify that live registers don't change between a VM call and