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