High CVSS 7.6 firefox Type Confusion 🔧 Commit mapped

Overview

High
Severity
7.6
CVSS
No
Exploited ITW
Fixed
Fix Status
Impacthigh
DescriptionOn 64-bit CPUs, when the JIT compiles WASM i32 return values they can pick up bits from left over memory. This can potentially cause them to be treated as a different type.
ComponentSpiderMonkey
Bug ClassType Confusion
Tracker1946004
Fix commit4c82b20fcda2 (firefox) +3/-1
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:H
CISA KEVNot listed
CreditedXiangwei Zhang and kkdong of Tencent Security YUNDING LAB
Disclosed2025-03-04

Background

WASM JIT entry stub
GenerateJitEntry (in js/src/wasm/WasmStubs.cpp) emits the trampoline that lets JIT-compiled JavaScript call into a WebAssembly function and convert the WASM result back into a boxed JS::Value.
`JS::Value` / NaN-boxing on 64-bit
On 64-bit builds a JS::Value is a single 64-bit word that packs a type tag in the high bits and the payload in the low bits. An int32 value must occupy only the low 32 bits with the upper bits cleared so the tag is well-formed.
`ReturnReg` / `JSReturnOperand`
ReturnReg is the register holding the raw WASM i32 result; boxNonDouble(JSVAL_TYPE_INT32, ReturnReg, JSReturnOperand) tags it into a JS::Value.
`widenInt32`
A MacroAssembler helper that clears/normalizes the upper 32 bits of a 64-bit register so a 32-bit value is cleanly zero-extended before being used as a full word.

Root Cause Analysis

In GenerateJitEntry, the ValType::I32 return case boxed the raw ReturnReg directly with the comment “No widening is required, as the value is boxed.” On a 64-bit CPU the WASM i32 result sits in the low 32 bits of a 64-bit ReturnReg, but the upper 32 bits are not guaranteed to be zero — they can hold leftover bits from prior computation in that register. boxNonDouble(JSVAL_TYPE_INT32, ...) constructs the NaN-boxed JS::Value assuming the payload register holds only a clean 32-bit int in its low half; if the high half contains garbage, those stray bits collide with/corrupt the tag region of the resulting 64-bit JS::Value. The produced value therefore decodes as something other than a clean Int32 — i.e. it can be interpreted as a different JS::Value type, exactly the “treated as a different type” outcome the advisory states. The old assumption (that boxing alone sufficed) was wrong for 64-bit register width.

The patch inserts, guarded by #ifdef JS_64BIT, a masm.widenInt32(ReturnReg) immediately before the boxNonDouble call. This zero-extends the i32 result across the full 64-bit register, clearing the stale high bits so the subsequent box produces a correctly tagged Int32 value. The #ifdef JS_64BIT guard scopes the fix to the architectures where the 64-bit register width creates the exposure. The advisory labels this a logic error; the diff substantiates that the concrete consequence is a mis-typed JS::Value (type confusion) arising from uninitialized/leftover high register bits — I note that distinction rather than smoothing it over.

Key insight
The WASM-to-JIT entry stub boxed a 64-bit register as an int32 without zero-extending the 32-bit result, so leftover high bits corrupted the NaN-boxed value’s tag and the result could be read back as a different JS::Value type; a widenInt32 before boxing clears those bits.

Attack Path

  1. Attacker content Serves a page that loads an attacker-authored WebAssembly module (WASM is web-reachable, no privilege required).
  2. i32-returning export Calls, from JIT-compiled JS, a WASM function that returns an i32, arranged so that ReturnReg’s upper 32 bits carry controlled/leftover nonzero bits at the point of boxing (inferred: precise register-pressure setup not shown by the diff).
  3. Mis-tagged value boxNonDouble produces a JS::Value whose tag bits are corrupted by the stray high bits, so the engine later treats the value as a type other than Int32.
  4. Type confusion Subsequent JS operates on the value under the wrong type assumption.
  5. Primitive A type-confused JS::Value (potentially an object/pointer vs. integer mismatch), the building block for memory corruption; full escalation is inferred and not demonstrated by the diff.

Impact Assessment

This is a content-process (renderer) JIT correctness bug reachable with no special privilege by any page that can run WebAssembly, needing only the interaction of visiting the page. The substantiated primitive is a mis-typed JS::Value on 64-bit builds — a type-confusion seed whose exploitability depends on how the confused value is subsequently used (the CVSS vector lists C:L/I:L/A:H and the advisory rates it moderate, consistent with a confusion that is real but not shown here to yield direct full-strength corruption). Escalation to a controlled read/write or RCE is inferred, not proven by this one-line stub change.

Files Changed

  • js/src/wasm/WasmStubs.cpp

Audit Directions

  • Other i32/narrow-value boxing in stubs
    Review every place in WasmStubs.cpp and the JIT that boxes a sub-64-bit value from a 64-bit register to confirm widening/zero-extension precedes the box on 64-bit.
  • Return-value conversion paths
    Audit WASM result-to-JS::Value and JS::Value-to-WASM conversions for other ValType cases (e.g. i32-carrying refs) that assume clean high bits.
  • `JS_64BIT`-conditional assumptions
    Look for comments/assumptions of the form “no widening needed because boxed” elsewhere; the same reasoning failed here.
diff --git a/js/src/wasm/WasmStubs.cpp b/js/src/wasm/WasmStubs.cpp
index a7e101bd5b3..8d821c40452 100644
--- a/js/src/wasm/WasmStubs.cpp
+++ b/js/src/wasm/WasmStubs.cpp
@@ -1241,7 +1241,9 @@ static bool GenerateJitEntry(MacroAssembler& masm, size_t funcExportIndex,
     switch (results[0].kind()) {
       case ValType::I32:
         GenPrintIsize(DebugChannel::Function, masm, ReturnReg);
-        // No widening is required, as the value is boxed.
+#ifdef JS_64BIT
+        masm.widenInt32(ReturnReg);
+#endif
         masm.boxNonDouble(JSVAL_TYPE_INT32, ReturnReg, JSReturnOperand);
         break;
       case ValType::F32: {
Loading diff…