CVE-2026-117982
Overview
Background
- IonMonkey MIR / `MPhi`
- SpiderMonkey’s optimizing JIT builds an SSA-form MIR graph. An
MPhimerges values from predecessor blocks;MTestbranches 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.
Attack Path
- 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 thatFoldTestscollapses. - Mis-optimize Ion folds the test, discards the phi, and DCE eliminates a phi operand that was not flagged implicitly-used.
- Deopt
Attacker changes an input’s type/shape (e.g.
G.z = "str") to invalidate a speculation and force a bailout to Baseline. - 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.
- 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
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
| Function | Change | Notes |
|---|---|---|
ifjs/src/jit-test/tests/ion/bug2078855.js |
modified | |
withjs/src/jit-test/tests/ion/bug2078855.js |
modified | |
forjs/src/jit-test/tests/ion/bug2078855.js |
modified |
Files Changed
js/src/builtin/Boolean.cppjs/src/jit-test/tests/ion/bug2078855.jsjs/src/jit/FoldTests.cpp
Audit Directions
- Other MIR discardsAudit every
discardPhi/discardinjs/src/jitthat removes an instruction with operands still reachable at a resume point; confirm operands are marked implicitly-used. - Fold/CFG rewritesReview other control-flow folding passes (branch pruning, if-conversion) for the same “mark only the matched input” mistake.
- Bailout livenessLook for optimizations that drop a use without considering resume-point liveness, especially around
setImplicitlyUsedUncheckedcall sites.
Patch
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);
Regression Test / PoC
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);