Medium webkit Integer Overflow 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected Safari crash
DescriptionThis issue was addressed through improved state management.
ComponentJSC
Bug ClassInteger Overflow
Tracker317611
Fix commitf778c76c61d1 (webkit) +118/-16
CISA KEVNot listed
CreditedOpenAI Codex Security - Amy Burnett
Disclosed2026-09-14

Background

DFG Integer Range Optimization (IRO)
A JavaScriptCore DFG JIT phase (Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp) that proves static bounds on integer nodes from Relationships (e.g. a < b, a <= const) and uses them to flip overflow-checked arithmetic to Arith::Unchecked.
`rangeFor(node)`
Returns the proven [min, max] integer range of a node by intersecting all Relationships 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. NodeMustGenerate marks 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.

Key insight
IRO removed overflow checks based on range proofs whose supporting producer nodes it never pinned, so DCE could delete the evidence; the arithmetic then ran unchecked and could overflow past the range IRO assumed, defeating a subsequent bounds comparison.

Attack Path

  1. Delivery Attacker runs JavaScript on a victim page containing a hot function that IRO will optimize (web-reachable).
  2. 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 to Arith::Unchecked.
  3. Prune Arrange the IR so the node that established the bound is otherwise unused, letting DCE delete the producer IRO consulted.
  4. 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.
  5. Bypass bound The overflowed value flows into a subsequent index < length comparison that IRO’s stale reasoning folded/assumed, so the guard no longer protects the access.
  6. 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

Runs in the renderer (Safari web content) process at normal web privilege via the DFG JIT. The diff substantiates an integer-overflow bug: overflow checks are removed on the basis of range proofs whose supporting nodes are not pinned, so DCE can invalidate the proof and let the arithmetic overflow at runtime. The advisory calls this an “unexpected Safari crash” and “improved state management”; the diff shows the stronger primitive — an overflow that can defeat a downstream bounds check and (inferred, from the test’s typed-array access) yield an out-of-bounds memory access. Strength beyond a controlled wrapped index is inferred, not proven by the diff. Gating: requires JIT tier-up (the hot loop) and an IR shape where the proof producer is DCE-eligible.

Changed Functions

FunctionChangeNotes
for
Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
modified
if
Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
modified
if
JSTests/stress/arith-abs-checked-input-range.js
modified

Files Changed

  • JSTests/stress/arith-abs-checked-input-range.js
  • Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp

Audit Directions

  • Other check-elision phases
    Review 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 lifetime
    Audit other Relationship/abstract-value consumers that read a producer’s value without a liveness edge; the FIXME notes NodeMustGenerate is sticky and suggests reference-counted effect edges, a pattern to look for elsewhere.
  • Range-based bounds-check removal
    Examine where IRO-proven ranges feed array/typed-array bounds-check elimination, the most likely place a stale range becomes an OOB.
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}`);
Loading diff…

Regression Test / PoC

shipped with the fix
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}`);
Loading diff…