CVE-2026-65331
Overview
Background
- DFG Integer Range Optimization (IRO)
- A JavaScriptCore DFG JIT phase (
Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp) that proves static bounds on integer nodes fromRelationships (e.g.a < b,a <= const) and uses them to flip overflow-checked arithmetic toArith::Unchecked. - `rangeFor(node)`
- Returns the proven
[min, max]integer range of a node by intersecting allRelationships recorded for it. - `Relationship`
- A proven fact relating a left node to a right node/constant;
minValueOfLeft()/maxValueOfLeft()derive a bound for the left node from that fact. The right (and sometimes left) node is the producer whose presence makes the bound true at runtime. - `Arith::Unchecked` / `NodeMustGenerate`
- An unchecked arithmetic op omits the overflow check.
NodeMustGeneratemarks a node so later phases (DCE) cannot delete it. - DCE
- Dead Code Elimination; removes nodes whose results appear unused.
Root Cause Analysis
IRO’s rangeFor computed a node’s [min, max] purely as integer values, intersecting the bounds contributed by each Relationship. Several call sites (ArithAbs, ArithAdd/Sub/Mul-style handlers) used those bounds to prove an operation could not overflow and then called node->setArithMode(Arith::Unchecked) — permanently removing the overflow check. The flaw: the proof that justified removing the check depended on other DFG nodes (the right()/left() producers referenced by the winning Relationships), but nothing kept those producer nodes alive. A later phase such as DCE could delete a producer whose value was only consulted by IRO. At runtime the bound IRO relied on was then no longer enforced, yet the arithmetic had already been converted to Arith::Unchecked, so the operation could overflow silently and produce a value outside the range IRO believed it had proven.
The patch makes the proof dependency explicit. rangeFor now returns RangeBound { value, proof } tuples that remember which Relationship established each bound (the first candidate that tightened it). New helpers pinRangeBounds(...)/pinRangeBoundProof(...) walk each winning relationship’s non-constant left()/right() nodes and mergeFlags(NodeMustGenerate) on them. Every site that flips an op to Arith::Unchecked (ArithAbs negate/unchecked branches, and the Add/Sub/Mul “it’s in bounds” branches) now calls pinRangeBounds(...) on the bounds it relied on before setting the unchecked mode, pinning the producers so DCE cannot remove them. The regression test (JSTests/stress/arith-abs-checked-input-range.js) trains probe(-2) so IRO proves a tiny range for Math.abs(x + 2), then calls probe(2147483646) so x + 2 overflows to INT_MIN; it asserts the bounds branch is never entered and no wrapped index is computed, i.e. the overflow check must still hold.
Attack Path
- Delivery Attacker runs JavaScript on a victim page containing a hot function that IRO will optimize (web-reachable).
- Train
Call the function repeatedly with benign inputs so the DFG records
Relationships giving a narrow range for an integer expression (e.g.Math.abs(x + 2)constrained small), and IRO converts the op toArith::Unchecked. - Prune Arrange the IR so the node that established the bound is otherwise unused, letting DCE delete the producer IRO consulted.
- Overflow
Invoke the function with an input (
2147483646) that makes the now-unchecked arithmetic overflow (x + 2 → INT_MIN), yielding a value outside IRO’s assumed range. - Bypass bound
The overflowed value flows into a subsequent
index < lengthcomparison that IRO’s stale reasoning folded/assumed, so the guard no longer protects the access. - Primitive (inferred)
A wrapped/attacker-influenced integer index reaches a typed-array access, giving an out-of-bounds read/write; the diff proves the overflow-check elision, OOB is the inferred memory-safety sink demonstrated by the test’s
target[index]pattern.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forSource/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp |
modified | |
ifSource/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp |
modified | |
ifJSTests/stress/arith-abs-checked-input-range.js |
modified |
Files Changed
JSTests/stress/arith-abs-checked-input-range.jsSource/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
Audit Directions
- Other check-elision phasesReview any DFG/FTL phase that converts checked ops to unchecked (or folds comparisons) based on inferred facts, confirming the facts’ supporting nodes are pinned against DCE.
- Proof lifetimeAudit other
Relationship/abstract-value consumers that read a producer’s value without a liveness edge; the FIXME notesNodeMustGenerateis sticky and suggests reference-counted effect edges, a pattern to look for elsewhere. - Range-based bounds-check removalExamine where IRO-proven ranges feed array/typed-array bounds-check elimination, the most likely place a stale range becomes an OOB.
Patch
diff --git a/Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp b/Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
index 6ddc64110ee8..24b0327725d6 100644
--- a/Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
+++ b/Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
@@ -1019,24 +1019,62 @@ public:
{
}
- std::optional<std::tuple<int32_t, int32_t>> rangeFor(Node* node)
+ struct RangeBound {
+ int32_t value;
+ const Relationship* proof { nullptr };
+ };
+
+ std::optional<std::tuple<RangeBound, RangeBound>> rangeFor(Node* node)
{
if (node->isInt32Constant()) {
int32_t value = node->asInt32();
- return std::tuple { value, value };
+ return std::tuple { RangeBound { value }, RangeBound { value } };
}
auto iter = m_relationships.find(node);
if (iter == m_relationships.end())
return std::nullopt;
- int32_t minValue = std::numeric_limits<int32_t>::min();
- int32_t maxValue = std::numeric_limits<int32_t>::max();
- for (Relationship relationship : iter->value) {
- minValue = std::max(minValue, relationship.minValueOfLeft());
- maxValue = std::min(maxValue, relationship.maxValueOfLeft());
+ RangeBound minBound { std::numeric_limits<int32_t>::min() };
+ RangeBound maxBound { std::numeric_limits<int32_t>::max() };
+ for (const Relationship& relationship : iter->value) {
+ int32_t candidateMin = relationship.minValueOfLeft();
+ if (candidateMin > minBound.value) {
+ minBound.value = candidateMin;
+ minBound.proof = &relationship;
+ }
+ int32_t candidateMax = relationship.maxValueOfLeft();
+ if (candidateMax < maxBound.value) {
+ maxBound.value = candidateMax;
+ maxBound.proof = &relationship;
+ }
}
- return std::tuple { minValue, maxValue };
+ return std::tuple { minBound, maxBound };
+ }
+
+ // Pin the upstream nodes referenced by the proof relationships behind one
+ // or more range bounds. Call before flipping a checked op to
+ // Arith::Unchecked. Prevents later phases (DCE, etc.) from removing the
+ // producers IRO consulted.
+ //
+ // FIXME: NodeMustGenerate is sticky. If the IRO-unmarked consumer later
+ // dies, its pinned dependency stays alive uselessly. Switch to
+ // reference-counted effect edges (from the unmarked node to each pinned
+ // producer, counted by DCE) so the pin self-cleans when the consumer is removed.
+ template<typename... Bounds>
+ void pinRangeBounds(const Bounds&... bounds)
+ {
+ (pinRangeBoundProof(bounds), ...);
+ }
+
+ void pinRangeBoundProof(const RangeBound& bound)
+ {
+ if (!bound.proof)
+ return;
+ if (Node* right = bound.proof->right().node(); right && !right->isConstant())
+ right->mergeFlags(NodeMustGenerate);
+ if (Node* left = bound.proof->left().node(); left && !left->isConstant())
+ left->mergeFlags(NodeMustGenerate);
}
// Be careful: do not use this to infer a relationship that will not be pruned later,
@@ -1311,7 +1349,9 @@ public:
auto range = rangeFor(node->child1().node());
if (!range)
break;
- auto [minValue, maxValue] = range.value();
+ auto [minBound, maxBound] = range.value();
+ int32_t minValue = minBound.value;
+ int32_t maxValue = maxBound.value;
executeNode(block->at(nodeIndex));
@@ -1323,12 +1363,15 @@ public:
bool absIsUnchecked = !shouldCheckOverflow(node->arithMode());
if (maxValue < 0 || (absIsUnchecked && maxValue <= 0)) {
node->convertToArithNegate();
- if (absIsUnchecked || minValue > std::numeric_limits<int>::min())
+ if (absIsUnchecked || minValue > std::numeric_limits<int>::min()) {
+ pinRangeBounds(minBound, maxBound);
node->setArithMode(Arith::Unchecked);
+ }
changed = true;
continue;
}
if (minValue > std::numeric_limits<int>::min()) {
+ pinRangeBounds(minBound);
node->setArithMode(Arith::Unchecked);
changed = true;
continue;
@@ -1345,12 +1388,16 @@ public:
auto leftRange = rangeFor(node->child1().node());
if (!leftRange)
break;
- auto [leftMinValue, leftMaxValue] = leftRange.value();
+ auto [leftMin, leftMax] = leftRange.value();
+ int32_t leftMinValue = leftMin.value;
+ int32_t leftMaxValue = leftMax.value;
auto rightRange = rangeFor(node->child2().node());
if (!rightRange)
break;
- auto [rightMinValue, rightMaxValue] = rightRange.value();
+ auto [rightMin, rightMax] = rightRange.value();
+ int32_t rightMinValue = rightMin.value;
+ int32_t rightMaxValue = rightMax.value;
dataLogLnIf(DFGIntegerRangeOptimizationPhaseInternal::verbose, " leftMinValue = ", leftMinValue, ", leftMaxValue = ", leftMaxValue, ", rightMinValue = ", rightMinValue, ", rightMaxValue = ", rightMaxValue);
@@ -1368,6 +1415,7 @@ public:
dataLogLnIf(DFGIntegerRangeOptimizationPhaseInternal::verbose, " It's in bounds.");
+ pinRangeBounds(leftMin, leftMax, rightMin, rightMax);
executeNode(block->at(nodeIndex));
node->setArithMode(Arith::Unchecked);
changed = true;
@@ -1383,12 +1431,16 @@ public:
auto leftRange = rangeFor(node->child1().node());
if (!leftRange)
break;
- auto [leftMinValue, leftMaxValue] = leftRange.value();
+ auto [leftMin, leftMax] = leftRange.value();
+ int32_t leftMinValue = leftMin.value;
+ int32_t leftMaxValue = leftMax.value;
auto rightRange = rangeFor(node->child2().node());
if (!rightRange)
break;
- auto [rightMinValue, rightMaxValue] = rightRange.value();
+ auto [rightMin, rightMax] = rightRange.value();
+ int32_t rightMinValue = rightMin.value;
+ int32_t rightMaxValue = rightMax.value;
dataLogLnIf(DFGIntegerRangeOptimizationPhaseInternal::verbose, " leftMinValue = ", leftMinValue, ", leftMaxValue = ", leftMaxValue, ", rightMinValue = ", rightMinValue, ", rightMaxValue = ", rightMaxValue);
@@ -1406,6 +1458,7 @@ public:
dataLogLnIf(DFGIntegerRangeOptimizationPhaseInternal::verbose, " It's in bounds.");
+ pinRangeBounds(leftMin, leftMax, rightMin, rightMax);
executeNode(block->at(nodeIndex));
node->setArithMode(Arith::Unchecked);
changed = true;
@@ -1421,12 +1474,16 @@ public:
auto leftRange = rangeFor(node->child1().node());
if (!leftRange)
break;
- auto [leftMinValue, leftMaxValue] = leftRange.value();
+ auto [leftMin, leftMax] = leftRange.value();
+ int32_t leftMinValue = leftMin.value;
+ int32_t leftMaxValue = leftMax.value;
auto rightRange = rangeFor(node->child2().node());
if (!rightRange)
break;
- auto [rightMinValue, rightMaxValue] = rightRange.value();
+ auto [rightMin, rightMax] = rightRange.value();
+ int32_t rightMinValue = rightMin.value;
+ int32_t rightMaxValue = rightMax.value;
dataLogLnIf(DFGIntegerRangeOptimizationPhaseInternal::verbose, " leftMinValue = ", leftMinValue, ", leftMaxValue = ", leftMaxValue, ", rightMinValue = ", rightMinValue, ", rightMaxValue = ", rightMaxValue);
@@ -1446,14 +1503,17 @@ public:
executeNode(block->at(nodeIndex));
if (node->arithMode() == Arith::CheckOverflow) {
+ pinRangeBounds(leftMin, leftMax, rightMin, rightMax);
node->setArithMode(Arith::Unchecked);
changed = true;
} else {
// If both sign are the same, negative zero never appears.
if (leftMinValue >= 0 && rightMinValue >= 0) {
+ pinRangeBounds(leftMin, leftMax, rightMin, rightMax);
node->setArithMode(Arith::Unchecked);
changed = true;
} else if (leftMaxValue < 0 && rightMaxValue < 0) {
+ pinRangeBounds(leftMin, leftMax, rightMin, rightMax);
node->setArithMode(Arith::Unchecked);
changed = true;
}
diff --git a/JSTests/stress/arith-abs-checked-input-range.js b/JSTests/stress/arith-abs-checked-input-range.js
new file mode 100644
index 000000000000..09b95fa54ac8
--- /dev/null
+++ b/JSTests/stress/arith-abs-checked-input-range.js
@@ -0,0 +1,42 @@
+let enteredFirstBranch = 0;
+let enteredBoundsBranch = 0;
+let lastIndex = 0;
+
+function probe(x)
+{
+ const target = new Uint8Array(new ArrayBuffer(32));
+
+ let dead = x;
+ dead++;
+ dead++;
+
+ const first = Math.abs(x + 2);
+ if (first < 1) {
+ enteredFirstBranch++;
+ const index = first + 16;
+ lastIndex = index;
+ if (index < target.length) {
+ enteredBoundsBranch++;
+ return target[index];
+ }
+ }
+ return 0;
+}
+noInline(probe);
+
+for (let i = 0; i < 30000; ++i)
+ probe(-2);
+
+enteredFirstBranch = 0;
+enteredBoundsBranch = 0;
+lastIndex = 0;
+
+const result = probe(2147483646);
+if (result !== 0)
+ throw new Error(`Bad result: ${result}`);
+if (enteredFirstBranch)
+ throw new Error(`Entered first branch: ${enteredFirstBranch}`);
+if (enteredBoundsBranch)
+ throw new Error(`Entered bounds branch: ${enteredBoundsBranch}`);
+if (lastIndex)
+ throw new Error(`Computed wrapped index: ${lastIndex}`);
Regression Test / PoC
diff --git a/JSTests/stress/arith-abs-checked-input-range.js b/JSTests/stress/arith-abs-checked-input-range.js
new file mode 100644
index 000000000000..09b95fa54ac8
--- /dev/null
+++ b/JSTests/stress/arith-abs-checked-input-range.js
@@ -0,0 +1,42 @@
+let enteredFirstBranch = 0;
+let enteredBoundsBranch = 0;
+let lastIndex = 0;
+
+function probe(x)
+{
+ const target = new Uint8Array(new ArrayBuffer(32));
+
+ let dead = x;
+ dead++;
+ dead++;
+
+ const first = Math.abs(x + 2);
+ if (first < 1) {
+ enteredFirstBranch++;
+ const index = first + 16;
+ lastIndex = index;
+ if (index < target.length) {
+ enteredBoundsBranch++;
+ return target[index];
+ }
+ }
+ return 0;
+}
+noInline(probe);
+
+for (let i = 0; i < 30000; ++i)
+ probe(-2);
+
+enteredFirstBranch = 0;
+enteredBoundsBranch = 0;
+lastIndex = 0;
+
+const result = probe(2147483646);
+if (result !== 0)
+ throw new Error(`Bad result: ${result}`);
+if (enteredFirstBranch)
+ throw new Error(`Entered first branch: ${enteredFirstBranch}`);
+if (enteredBoundsBranch)
+ throw new Error(`Entered bounds branch: ${enteredBoundsBranch}`);
+if (lastIndex)
+ throw new Error(`Computed wrapped index: ${lastIndex}`);