Moderate firefox Logic Error 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionInvalid pointer in the JavaScript Engine: JIT component
ComponentSpiderMonkey
Bug ClassLogic Error
Tracker2078855
Fix commita0f4e7365034 (firefox) +51/-20
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

Background

IonMonkey MIR / `MPhi`
SpiderMonkey’s optimizing JIT builds an SSA-form MIR graph. An MPhi merges values from predecessor blocks; MTest branches on a boolean value.
Implicitly-used flag
setImplicitlyUsedUnchecked() marks a MIR definition as still needed even if it has no explicit MIR uses, so later passes (DCE, register allocation) keep it materialized for bailout/resume.
Bailout to Baseline
When Ion deoptimizes, the Baseline interpreter reconstructs the frame from resume points and may read values the MIR graph no longer explicitly uses.

Root Cause Analysis

When FoldTests collapses the pattern, it discards the phi that fed the folded MTest. The old UpdateGotoSuccessor marked only blockResult — the single phi input corresponding to the block being rewired — as implicitly-used, and the phi itself was discarded with phiBlock->discardPhi(*phiBlock->phisBegin()). The phi’s other operands (the inputs from the branches that were not the current blockResult) lost their only use when the phi was removed and were never flagged implicitly-used. DCE could then eliminate those operand definitions. Because the folded-away MTest’s original phi value is still live at a bailout, Baseline could later read those now-eliminated operands, yielding a stale/invalid value — the advisory’s “invalid pointer.”

The patch adds DiscardTestedPhi(MPhi* phi), which iterates all phi->numOperands() and calls setImplicitlyUsedUnchecked() on each before discardPhi(phi). UpdateGotoSuccessor drops its blockResult parameter and no longer sets the flag itself. Every diamond/triangle call site replaces the old single-phi discard with DiscardTestedPhi(phi), so every operand the phi fed is preserved for a possible bailout. Separately, js::ToBooleanSlow upgrades MOZ_ASSERT(v.isObject()) to MOZ_RELEASE_ASSERT, hardening the invariant that reaches this path in release builds. The regression test bug2078855.js runs under --fast-warmup and flips a value’s type (G.z = "str") after warmup to force the bailout path.

Key insight
The fold marked only the matched phi input as implicitly-used, so the phi’s other operands were dropped by DCE even though a bailout still needed them — reading them afterward produced an invalid/stale value. The fix marks every phi operand implicitly-used before discarding the phi.

Attack Path

  1. Warm up Script runs a hot loop so the function tiers up into Ion (test uses --fast-warmup), building a diamond/triangle where a phi feeds a test that FoldTests collapses.
  2. Mis-optimize Ion folds the test, discards the phi, and DCE eliminates a phi operand that was not flagged implicitly-used.
  3. Deopt Attacker changes an input’s type/shape (e.g. G.z = "str") to invalidate a speculation and force a bailout to Baseline.
  4. Stale read Baseline reconstructs the frame and reads the eliminated operand’s slot, obtaining a stale/invalid value where a live JS value was expected.
  5. Primitive Use of an invalid/stale value (an “invalid pointer”); corruption of JS-value/pointer state usable as type confusion is the inferred exploitable consequence, not shown directly by the diff.

Impact Assessment

This is a JIT miscompilation in the content-process JavaScript engine, reachable from ordinary web script (no special privilege). The diff substantiates that a bailout can read a MIR operand DCE removed, producing an invalid/stale value (“invalid pointer”). The crisp security primitive — reliably reading an attacker-chosen stale pointer for a controlled read/write or type confusion — is not demonstrated by the diff and is inferred; such Ion liveness bugs are generally exploitable but the strength here is not proven by the code. Classified LogicError because the diff shows a JIT value-liveness correctness fault; the memory-safety escalation beyond “invalid pointer” is inferred. The advisory rates it moderate.

Changed Functions

FunctionChangeNotes
if
js/src/jit-test/tests/ion/bug2078855.js
modified
with
js/src/jit-test/tests/ion/bug2078855.js
modified
for
js/src/jit-test/tests/ion/bug2078855.js
modified

Files Changed

  • js/src/builtin/Boolean.cpp
  • js/src/jit-test/tests/ion/bug2078855.js
  • js/src/jit/FoldTests.cpp

Audit Directions

  • Other MIR discards
    Audit every discardPhi/discard in js/src/jit that removes an instruction with operands still reachable at a resume point; confirm operands are marked implicitly-used.
  • Fold/CFG rewrites
    Review other control-flow folding passes (branch pruning, if-conversion) for the same “mark only the matched input” mistake.
  • Bailout liveness
    Look for optimizations that drop a use without considering resume-point liveness, especially around setImplicitlyUsedUnchecked call sites.
diff --git a/js/src/builtin/Boolean.cpp b/js/src/builtin/Boolean.cpp
index e6e4af83785..edf89ad1cac 100644
--- a/js/src/builtin/Boolean.cpp
+++ b/js/src/builtin/Boolean.cpp
@@ -168,6 +168,6 @@ JS_PUBLIC_API bool js::ToBooleanSlow(HandleValue v) {
     return !v.toBigInt()->isZero();
   }
 
-  MOZ_ASSERT(v.isObject());
+  MOZ_RELEASE_ASSERT(v.isObject());
   return !EmulatesUndefined(&v.toObject());
 }
diff --git a/js/src/jit/FoldTests.cpp b/js/src/jit/FoldTests.cpp
index c39a64906dd..73a0cbfce85 100644
--- a/js/src/jit/FoldTests.cpp
+++ b/js/src/jit/FoldTests.cpp
@@ -123,20 +123,22 @@ static bool IsTestInputMaybeToBool(MTest* test, MDefinition* value) {
   }
 }
 
+// |phi| is the input of an MTest instruction we're folding away. Discard it
+// and mark its operands as implicitly-used, because Baseline may still read
+// them after a bailout.
+static void DiscardTestedPhi(MPhi* phi) {
+  for (size_t i = 0; i < phi->numOperands(); i++) {
+    phi->getOperand(i)->setImplicitlyUsedUnchecked();
+  }
+  phi->block()->discardPhi(phi);
+}
+
 // Change |block| so that it ends in a goto to the specific |target| block.
 // |existingPred| is an existing predecessor of the block.
-//
-// |blockResult| is the value computed by |block|. This was a phi input but the
-// caller has determined that |blockResult| matches the input of an earlier
-// MTest instruction and we don't need to test it a second time. Mark it as
-// implicitly-used because we're removing a use.
 [[nodiscard]] static bool UpdateGotoSuccessor(TempAllocator& alloc,
                                               MBasicBlock* block,
-                                              MDefinition* blockResult,
                                               MBasicBlock* target,
                                               MBasicBlock* existingPred) {
-  blockResult->setImplicitlyUsedUnchecked();
-
   MInstruction* ins = block->lastIns();
   MOZ_RELEASE_ASSERT(ins->isGoto());
   ins->toGoto()->target()->removePredecessor(block);
@@ -303,14 +305,14 @@ static bool IsDiamondPattern(MBasicBlock* initialBlock) {
   // OK, we found the desired pattern, now transform the graph.
 
   // Remove the phi from phiBlock.
-  phiBlock->discardPhi(*phiBlock->phisBegin());
+  DiscardTestedPhi(phi);
 
   // Change the end of the block to a test that jumps directly to successors of
   // testBlock, rather than to testBlock itself.
 
   if (IsTestInputMaybeToBool(initialTest, trueResult)) {
-    if (!UpdateGotoSuccessor(graph.alloc(), trueBranch, trueResult,
-                             finalTest->ifTrue(), testBlock)) {
+    if (!UpdateGotoSuccessor(graph.alloc(), trueBranch, finalTest->ifTrue(),
+                             testBlock)) {
       return false;
     }
   } else {
@@ -322,8 +324,8 @@ static bool IsDiamondPattern(MBasicBlock* initialBlock) {
   }
 
   if (IsTestInputMaybeToBool(initialTest, falseResult)) {
-    if (!UpdateGotoSuccessor(graph.alloc(), falseBranch, falseResult,
-                             finalTest->ifFalse(), testBlock)) {
+    if (!UpdateGotoSuccessor(graph.alloc(), falseBranch, finalTest->ifFalse(),
+                             testBlock)) {
       return false;
     }
   } else {
@@ -500,7 +502,7 @@ static bool IsTrianglePattern(MBasicBlock* initialBlock) {
   // OK, we found the desired pattern, now transform the graph.
 
   // Remove the phi from phiBlock.
-  phiBlock->discardPhi(*phiBlock->phisBegin());
+  DiscardTestedPhi(phi);
 
   // Change the end of the block to a test that jumps directly to successors of
   // testBlock, rather than to testBlock itself.
@@ -512,8 +514,8 @@ static bool IsTrianglePattern(MBasicBlock* initialBlock) {
       return false;
     }
   } else if (IsTestInputMaybeToBool(initialTest, trueResult)) {
-    if (!UpdateGotoSuccessor(graph.alloc(), trueBranch, trueResult,
-                             finalTest->ifTrue(), testBlock)) {
+    if (!UpdateGotoSuccessor(graph.alloc(), trueBranch, finalTest->ifTrue(),
+                             testBlock)) {
       return false;
     }
   } else {
@@ -531,8 +533,8 @@ static bool IsTrianglePattern(MBasicBlock* initialBlock) {
       return false;
     }
   } else if (IsTestInputMaybeToBool(initialTest, falseResult)) {
-    if (!UpdateGotoSuccessor(graph.alloc(), falseBranch, falseResult,
-                             finalTest->ifFalse(), testBlock)) {
+    if (!UpdateGotoSuccessor(graph.alloc(), falseBranch, finalTest->ifFalse(),
+                             testBlock)) {
       return false;
     }
   } else {
@@ -709,7 +711,7 @@ static bool IsTrianglePattern(MBasicBlock* initialBlock) {
   // OK, we found the desired pattern, now transform the graph.
 
   // Remove the phi from phiBlock.
-  phiBlock->discardPhi(*phiBlock->phisBegin());
+  DiscardTestedPhi(phi);
 
   // Create the new test instruction.
   if (!UpdateTestSuccessors(graph.alloc(), newTestBlock, newTestInput,
diff --git a/js/src/jit-test/tests/ion/bug2078855.js b/js/src/jit-test/tests/ion/bug2078855.js
new file mode 100644
index 00000000000..c39fd967365
--- /dev/null
+++ b/js/src/jit-test/tests/ion/bug2078855.js
@@ -0,0 +1,29 @@
+// |jit-test| --fast-warmup; --no-threads
+var G = {q: 0, z: 1};
+function g(a, b, e) {
+  var x = a & b;
+  var t = !!x;
+  G.q = a;
+  G.z | 0;
+  if (t) {
+    x = e;
+  }
+  return x;
+}
+function f(a, b, e) {
+  if (g(a, b, e)) {
+    return 1;
+  }
+  return 2;
+}
+function main() {
+  with ({}) {}
+  var r = 0;
+  for (var i = 0; i < 1000; i++) {
+    r += f(i & 1, 1, "s");
+  }
+  return r;
+}
+assertEq(main(), 1500);
+G.z = "str";
+assertEq(f(0, 1, "s"), 2);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/js/src/jit-test/tests/ion/bug2078855.js b/js/src/jit-test/tests/ion/bug2078855.js
new file mode 100644
index 00000000000..c39fd967365
--- /dev/null
+++ b/js/src/jit-test/tests/ion/bug2078855.js
@@ -0,0 +1,29 @@
+// |jit-test| --fast-warmup; --no-threads
+var G = {q: 0, z: 1};
+function g(a, b, e) {
+  var x = a & b;
+  var t = !!x;
+  G.q = a;
+  G.z | 0;
+  if (t) {
+    x = e;
+  }
+  return x;
+}
+function f(a, b, e) {
+  if (g(a, b, e)) {
+    return 1;
+  }
+  return 2;
+}
+function main() {
+  with ({}) {}
+  var r = 0;
+  for (var i = 0; i < 1000; i++) {
+    r += f(i & 1, 1, "s");
+  }
+  return r;
+}
+assertEq(main(), 1500);
+G.z = "str";
+assertEq(f(0, 1, "s"), 2);
Loading diff…