Released August 17, 2026

CVE-2026-65331

CVSS 4.3 severity medium CWE-703 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L

Overview

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected Safari crash

Researchers: OpenAI Codex Security - Amy Burnett

Key insight. IRO eliminated checks based on a proven range but did not keep alive the nodes that proved the range, so DCE could delete them and leave an unchecked op without its guarantee; pinning the proof producers with NodeMustGenerate preserves the runtime condition.

Patch Details

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}`);
diff --git a/Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp b/Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
index 552c0d8c1a16..48bb3950f815 100644
--- a/Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
+++ b/Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
@@ -1021,24 +1021,62 @@ public:
     {
     }
 
-    std::optional<std::tuple<int32_t, int32_t>> NODELETE rangeFor(Node* node)
+    struct RangeBound {
+        int32_t value;
+        const Relationship* proof { nullptr };
+    };
+
+    std::optional<std::tuple<RangeBound, RangeBound>> NODELETE 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 = WTF::max(minValue, relationship.minValueOfLeft());
-            maxValue = WTF::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,
@@ -1314,7 +1352,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));
 
@@ -1326,12 +1366,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;
@@ -1348,12 +1391,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);
 
@@ -1371,6 +1418,7 @@ public:
 
                     dataLogLnIf(DFGIntegerRangeOptimizationPhaseInternal::verbose, "    It's in bounds.");
 
+                    pinRangeBounds(leftMin, leftMax, rightMin, rightMax);
                     executeNode(block->at(nodeIndex));
                     node->setArithMode(Arith::Unchecked);
                     changed = true;
@@ -1386,12 +1434,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);
 
@@ -1409,6 +1461,7 @@ public:
 
                     dataLogLnIf(DFGIntegerRangeOptimizationPhaseInternal::verbose, "    It's in bounds.");

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-65331
Bugzilla 317611
Component JSC
Bug Class TypeConfusion
Severity medium
Commit 03a07e4200891271…
Advisory Apple Advisory

Terminology

  • Integer Range Optimization (IRO) — A DFG phase that proves integer value ranges to eliminate redundant overflow/bounds checks.
  • Relationship / range proof — IRO derives a node’s range from relationships to other nodes; those other nodes’ computations underpin the proof.
  • NodeMustGenerate / DCE — A flag that forces a node to be kept; without it dead-code elimination can remove a producer whose result an eliminated check silently depended on.

Root Cause Analysis

This fixes an unsafe check elimination in JavaScriptCore’s DFG Integer Range Optimization (IRO) phase: it removed overflow/bounds checks based on a proven integer range but let the nodes that PROVED that range be deleted, so the runtime condition IRO relied on could vanish. IRO’s rangeFor(node) computes a [min, max] range for a node by intersecting the Relationships known about it, and IRO uses that range to, for example, convert a checked arithmetic op to Arith::Unchecked. Pre-patch, rangeFor returned only the numeric bounds and did not track WHICH relationship established each bound; the producer nodes behind those relationships were not kept alive. A later phase (dead-code elimination) could then remove those producer nodes because nothing referenced them, even though IRO had already dropped a check that depended on the values they compute — so the unchecked op runs without the guarantee the removed producers provided, allowing the very integer overflow / out-of-bounds the check was meant to catch. The fix augments rangeFor to return RangeBound { value, proof } where proof points to the Relationship that set each bound, and adds pinRangeBounds/pinRangeBoundProof which mark the upstream left/right nodes of those proof relationships with NodeMustGenerate before a checked op is flipped to unchecked, so DCE keeps the range-proving producers alive. The restored invariant is that when IRO removes a check based on a range proof, the computations establishing that proof are pinned and cannot be eliminated. (A FIXME notes NodeMustGenerate is a conservative, sticky pin pending reference-counted effect edges.)

Attack Path

1. Reach the DFG IRO phase

Run JS whose arithmetic/array indexing the DFG optimizes with Integer Range Optimization.

2. Prove a range, drop a check

IRO proves a value’s range from relationships and converts a checked op to Arith::Unchecked (removing the overflow/bounds check).

3. Delete the proof producers

DCE removes the now-unreferenced nodes that established the range, since IRO didn’t pin them.

4. Overflow / out-of-bounds

The unchecked op runs without the guaranteeing computation, producing the integer overflow / OOB the check prevented — memory corruption in WebContent.

Impact Assessment

A JIT miscompilation in the WebContent process: IRO could remove an overflow/bounds check while DCE deleted the computation that justified removal, so the unchecked operation overflows or reads out of bounds. The advisory rates it a crash; removed-check bugs in the DFG are strong routes to controlled OOB and memory corruption.

Audit Directions

Range-proof lifetime

Audit IRO (and other range/relationship-based check-removal) for cases that drop a check based on a proof whose producer nodes are not pinned or otherwise kept alive.

Check-to-unchecked conversions

Grep the DFG for Arith::Unchecked / check-removal transforms and verify the values justifying them survive later DCE.

Changed Functions

Function File Change Note
IntegerRangeOptimization rangeFor / RangeBound Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp modified Returns RangeBound { value, proof } tracking the Relationship that set each min/max bound instead of bare int32 bounds.
pinRangeBounds / pinRangeBoundProof Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp added Marks the upstream left/right nodes of a bound’s proof relationship with NodeMustGenerate before flipping a checked op to unchecked, so DCE cannot remove the range-proving producers.

Files Changed

Source Files

  • Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp

Test Files

  • JSTests/stress/arith-abs-checked-input-range.js

Regression Test

The fix ships this proof-of-concept / regression test:

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}`);