CVE-2025-46298
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected process crash
Researchers: Hossein Lotfi (@hosselot) of Trend Micro Zero Day Initiative, Nan Wang (@eternalsakura13)
Key insight. Allocation sinking modelled array length but not per-index initialization, so it forwarded reads of never-written holes as if they were stored values; correctness requires tracking which indices are provably initialized on all reaching paths (intersecting at merges) and escaping any read of an uninitialized index.
Patch Details
diff --git a/JSTests/stress/array-sink-conditional-initialization.js b/JSTests/stress/array-sink-conditional-initialization.js
new file mode 100644
index 000000000000..e609efd0f65f
--- /dev/null
+++ b/JSTests/stress/array-sink-conditional-initialization.js
@@ -0,0 +1,58 @@
+function shouldBe(actual, expected) {
+ if (actual !== expected)
+ throw new Error('bad value: ' + actual);
+}
+
+// Test with Int32 array
+function testInt32ArraySinkingWithConditionalWrite(shouldInitialize) {
+ let arr = new Array(2);
+
+ if (shouldInitialize) {
+ arr[0] = 42;
+ }
+
+ return arr[0];
+}
+noInline(testInt32ArraySinkingWithConditionalWrite);
+
+// Test with Double array
+function testDoubleArraySinkingWithConditionalWrite(shouldInitialize) {
+ let arr = new Array(2);
+
+ if (shouldInitialize) {
+ arr[0] = 42.5;
+ }
+
+ return arr[0];
+}
+noInline(testDoubleArraySinkingWithConditionalWrite);
+
+// Test with Contiguous (JSValue) array
+function testContiguousArraySinkingWithConditionalWrite(shouldInitialize) {
+ let arr = new Array(2);
+
+ if (shouldInitialize) {
+ arr[0] = {value: 42};
+ }
+
+ return arr[0];
+}
+noInline(testContiguousArraySinkingWithConditionalWrite);
+
+for (let i = 0; i < testLoopCount; ++i) {
+ // Test Int32 array - initialized path
+ shouldBe(testInt32ArraySinkingWithConditionalWrite(true), 42);
+ // Test Int32 array - uninitialized path (hole)
+ shouldBe(testInt32ArraySinkingWithConditionalWrite(false), undefined);
+
+ // Test Double array - initialized path
+ shouldBe(testDoubleArraySinkingWithConditionalWrite(true), 42.5);
+ // Test Double array - uninitialized path (hole)
+ shouldBe(testDoubleArraySinkingWithConditionalWrite(false), undefined);
+
+ // Test Contiguous array - initialized path
+ let resultInitialized = testContiguousArraySinkingWithConditionalWrite(true);
+ shouldBe(resultInitialized.value, 42);
+ // Test Contiguous array - uninitialized path (hole)
+ shouldBe(testContiguousArraySinkingWithConditionalWrite(false), undefined);
+}
diff --git a/JSTests/stress/array-sink-diamond-initialization-then-read.js b/JSTests/stress/array-sink-diamond-initialization-then-read.js
new file mode 100644
index 000000000000..acccfa91994e
--- /dev/null
+++ b/JSTests/stress/array-sink-diamond-initialization-then-read.js
@@ -0,0 +1,15 @@
+function test1(obj, flag) {
+ let array = new Array(4);
+ if (flag)
+ array[0] = 1;
+ else
+ array[0] = obj.foo;
+ return array[0];
+}
+noInline(test1);
+
+
+let obj1 = { foo: 1 };
+for (let i = 0; i < testLoopCount; ++i) {
+ test1(obj1, i % 2);
+}
\ No newline at end of file
diff --git a/JSTests/stress/array-sink-read-uninitialized-hole.js b/JSTests/stress/array-sink-read-uninitialized-hole.js
new file mode 100644
index 000000000000..cd940523cfb5
--- /dev/null
+++ b/JSTests/stress/array-sink-read-uninitialized-hole.js
@@ -0,0 +1,45 @@
+function test1() {
+ function fn6(obj) {
+ let array = new Array(4);
+ array[0] = obj[1];
+ var test = {
+ f: array[1] ? array : 0
+ };
+ return obj;
+ }
+}
+noInline(test1);
+
+function test2() {
+ function fn6(obj) {
+ let array = new Array(4);
+ array[0] = obj[1];
+ var test = {
+ f: array[1] ? array : 0
+ };
+ return obj;
+ }
+}
+noInline(test2);
+
+function test3() {
+ function fn6(obj) {
+ let array = new Array(4);
+ array[0] = obj[1];
+ var test = {
+ f: array[1] ? array : 0
+ };
+ return obj;
+ }
+}
+noInline(test3);
+
+let obj1 = { 1: 1 };
+let obj2 = { 1: 1.4 };
+let obj3 = { 1: { } };
+for (let i = 0; i < testLoopCount; ++i) {
+ test1(obj1);
+ test2(obj2);
+ test3(obj3);
+}
+
diff --git a/Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp b/Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp
index 2286c9077e36..403a4c05be9c 100644
--- a/Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp
+++ b/Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp
@@ -155,7 +155,7 @@ public:
: m_identifier(identifier)
, m_kind(kind)
, m_indexingType(indexingType)
- , m_length(length)
+ , m_initializedIndices(length)
{
}
@@ -199,7 +199,26 @@ public:
unsigned length() const
{
- return m_length;
+ return m_initializedIndices.size();
+ }
+
+ bool isIndexInitialized(unsigned index) const
+ {
+ ASSERT(index < length());
+ return m_initializedIndices[index];
+ }
+
+ void setIndexInitialized(unsigned index)
+ {
+ ASSERT(index < length());
+ m_initializedIndices[index] = true;
+ }
+
+ Allocation& mergeInitializedIndices(const Allocation& other)
+ {
+ ASSERT(length() == other.length());
+ m_initializedIndices &= other.m_initializedIndices;
+ return *this;
}
bool hasStructures() const
@@ -297,14 +316,14 @@ public:
void dumpInContext(PrintStream& out, DumpContext* context) const
{
+ CommaPrinter comma;
out.print(m_kind, "Allocation("_s);
if (!m_structuresForMaterialization.isEmpty())
out.print(inContext(m_structuresForMaterialization.toStructureSet(), context));
- if (!m_fields.isEmpty()) {
- if (!m_structuresForMaterialization.isEmpty())
- out.print(", ");
- out.print(mapDump(m_fields, " => #"_s, ", "_s));
- }
+ if (!m_fields.isEmpty())
+ out.print(comma, mapDump(m_fields, " => #"_s, ", "_s));
+ if (!m_initializedIndices.isEmpty())
+ out.print(comma, "initialized: ["_s, m_initializedIndices, "]"_s);
out.print(")"_s);
}
@@ -313,7 +332,7 @@ private:
Kind m_kind;
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2025-46298 |
| Bugzilla | 301468 |
| Component | JSC |
| Bug Class | TypeConfusion |
| Severity | medium |
| Commit | a1a6185cc83ec556… |
| Advisory | Apple Advisory |
Terminology
- Object Allocation Sinking — A DFG JIT optimization that eliminates allocations which do not escape by deferring/removing the allocation and routing stores and loads through virtual ‘promoted’ locations.
- Promoted location (PLoc) — A virtual slot (e.g. ArrayIndexedPropertyPLoc for element index i) representing a field/element of a sunk allocation, used to forward values without real heap storage.
- Escape — Deciding that a sunk allocation must be materialized as a real object because an operation cannot be safely modelled virtually; in this phase done via
goto escapeChildren. - Hole — An array index in a
new Array(n)that has never been assigned, which must read back as undefined rather than as arbitrary stored data. - FastBitVector — A compact bitset used here as m_initializedIndices to track, per index, whether that array element has been proven initialized, and to intersect this fact across control-flow edges.
- Indexing shape (Int32/Double/Contiguous) — The typed storage format of a JS array’s elements; forwarding an uninitialized slot risks interpreting bits under the wrong shape (type confusion).
Root Cause Analysis
The patch modifies DFG’s Object Allocation Sinking phase, an optimization that removes (‘sinks’) array/object allocations that do not escape, materializing them lazily and forwarding stores/loads through virtual promoted locations instead of touching real heap storage. For a sunk ArrayButterfly allocation created from new Array(n), the phase modelled the array with only its length (m_length) and tracked field stores, but it did NOT track which specific indices had actually been initialized by a store. When the phase encountered a read of an array element, it would promote that read to the corresponding sunk promoted location (ArrayIndexedPropertyPLoc) regardless of whether any store had ever written that index. For an index that was never written (a hole in a freshly new Array(n)), this forwarded a load from an uninitialized virtual location, so the optimized code produced whatever stale/garbage value occupied that promoted slot instead of the correct hole/undefined value — a read of uninitialized data whose apparent type depends on the array’s indexing shape (Int32, Double, or Contiguous/JSValue). The violated invariant is that a sunk array element may be read only if it was provably stored on all reaching control-flow paths; otherwise the read must fall back to a real materialized array (escape). The regression tests make this concrete: array[0] read after only a conditional store (conditional-initialization), after a diamond where the two branches initialize with different types, and reading array[1] which is never written (read-uninitialized-hole). The fix replaces the scalar m_length with a FastBitVector m_initializedIndices sized to length, adds isIndexInitialized/setIndexInitialized, and marks setIndexInitialized(index) whenever a store to a constant index is promoted. On a read, if the target index isIndexInitialized is false, the code now does goto escapeChildren, forcing the allocation to escape/materialize rather than forwarding an uninitialized load. At control-flow merge points, mergeInitializedIndices intersects the two predecessors’ bit vectors with &=, so an index counts as initialized only if it was initialized on every incoming edge — correctly handling the diamond case where a branch initializes an index the other does not. The constructor and length() are reworked so length is derived from the bit vector size, and dumpInContext prints the initialized set for debugging. Together these restore the ‘read only provably-initialized indices’ invariant.
Attack Path
1. Get the victim function DFG/FTL-compiled
Repeatedly call a function containing let arr = new Array(n) in a hot loop (testLoopCount iterations) so the Object Allocation Sinking phase runs and sinks the array allocation.
2. Create an uninitialized index reachable by a read
Write only some indices (or write an index on only one branch of a conditional/diamond) and then read an index that was never stored on all paths, e.g. array[0] = x; return array[1]; or if (flag) arr[0]=v; return arr[0];.
3. Trigger the buggy read forwarding
Pre-patch, the phase promotes the read of the uninitialized index to a sunk promoted location and forwards a value that was never written, yielding an uninitialized/stale result rather than a hole.
4. Exploit type confusion / observe wrong value
Because the forwarded value’s interpretation follows the array’s indexing shape (Int32/Double/JSValue), an attacker can attempt to read a value of one type as another, producing an incorrect value or, per the advisory, an unexpected process crash when the malformed value is used.
Impact Assessment
The patch establishes that optimized code could read an array index that was never stored, forwarding an uninitialized/stale value whose interpretation follows the array’s indexing shape — a potential type-confusion primitive (e.g. reading a JSValue slot as a Double or vice versa) that can leak or misinterpret memory contents within the JIT. The advisory scopes the observed effect to an unexpected process crash, and the added tests assert only that holes read back as undefined, so the demonstrated impact is a controlled crash / incorrect-value condition rather than a proven arbitrary read; however, uninitialized-element type confusion in the DFG is historically a strong stepping stone toward OOB read/write and, with heap grooming, potential RCE. It is confined to the sandboxed WebContent process JIT; no sandbox escape is implied by the diff.
Audit Directions
Other promoted-location reads in the sinking phase
In DFGObjectAllocationSinkingPhase.cpp, audit every place that builds an exactRead / promotes a load; grep for ArrayIndexedPropertyPLoc, exactRead, and PromotedLocationDescriptor reads to ensure each read of a sunk field first proves initialization (mirroring the new isIndexInitialized/escapeChildren check).
Named-property / object-field initialization tracking
Check whether sunk plain-object field reads have the analogous ‘read before any store’ hazard; grep for NamedPropertyPLoc and m_fields lookups to confirm reads of unwritten fields escape rather than forwarding a default/uninitialized value.
Merge-time set operations
Review all merge helpers (mergeStructures, mergePointerSets, and the new mergeInitializedIndices); grep for &=, |=, and merge in the phase to verify initialized-facts are intersected (not unioned) so a fact holds only when true on every predecessor edge.
Similar phases across JITs
Look at other optimization passes that model arrays by length alone; grep across dfg/ and ftl/ for ->asInt32() used as an array size, newAllocation(...ArrayButterfly...), and PublicLengthPLoc to find places assuming all indices up to length are readable.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
Allocation::Allocation (constructor) |
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
modified | Initializes the new FastBitVector m_initializedIndices with the given length instead of storing a scalar m_length. |
Allocation::length |
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
modified | Now returns m_initializedIndices.size() instead of the removed m_length field. |
Allocation::isIndexInitialized |
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
added | Queries whether a specific index has been proven initialized in the sunk allocation. |
Allocation::setIndexInitialized |
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
added | Marks an index as initialized when a store to that constant index is promoted. |
Allocation::mergeInitializedIndices |
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
added | Intersects (&=) the initialized-index bit vectors at control-flow merges so an index is initialized only if set on all incoming edges. |
Allocation::dumpInContext |
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
modified | Uses a CommaPrinter and prints the initialized-index set for debugging output. |
LocalHeap merge (allocation merge loop) |
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
modified | Calls mergeInitializedIndices alongside mergePointerSets/mergeStructures when merging matching allocations across edges. |
handleNode / newAllocation for ArrayButterfly (NewArray path) |
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
modified | Creates the ArrayButterfly allocation without passing a scalar length; public length is recorded via the ArrayButterflyPublicLengthPLoc write. |
handleNode (indexed property store/read handling) |
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
modified | On a store, marks setIndexInitialized(index); on a read, escapes the allocation (goto escapeChildren) if the index is not proven initialized instead of forwarding an uninitialized load. |
Files Changed
Source Files
Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp
Test Files
JSTests/stress/array-sink-conditional-initialization.jsJSTests/stress/array-sink-diamond-initialization-then-read.jsJSTests/stress/array-sink-read-uninitialized-hole.js
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/JSTests/stress/array-sink-conditional-initialization.js b/JSTests/stress/array-sink-conditional-initialization.js
new file mode 100644
index 000000000000..e609efd0f65f
--- /dev/null
+++ b/JSTests/stress/array-sink-conditional-initialization.js
@@ -0,0 +1,58 @@
+function shouldBe(actual, expected) {
+ if (actual !== expected)
+ throw new Error('bad value: ' + actual);
+}
+
+// Test with Int32 array
+function testInt32ArraySinkingWithConditionalWrite(shouldInitialize) {
+ let arr = new Array(2);
+
+ if (shouldInitialize) {
+ arr[0] = 42;
+ }
+
+ return arr[0];
+}
+noInline(testInt32ArraySinkingWithConditionalWrite);
+
+// Test with Double array
+function testDoubleArraySinkingWithConditionalWrite(shouldInitialize) {
+ let arr = new Array(2);
+
+ if (shouldInitialize) {
+ arr[0] = 42.5;
+ }
+
+ return arr[0];
+}
+noInline(testDoubleArraySinkingWithConditionalWrite);
+
+// Test with Contiguous (JSValue) array
+function testContiguousArraySinkingWithConditionalWrite(shouldInitialize) {
+ let arr = new Array(2);
+
+ if (shouldInitialize) {
+ arr[0] = {value: 42};
+ }
+
+ return arr[0];
+}
+noInline(testContiguousArraySinkingWithConditionalWrite);
+
+for (let i = 0; i < testLoopCount; ++i) {
+ // Test Int32 array - initialized path
+ shouldBe(testInt32ArraySinkingWithConditionalWrite(true), 42);
+ // Test Int32 array - uninitialized path (hole)
+ shouldBe(testInt32ArraySinkingWithConditionalWrite(false), undefined);
+
+ // Test Double array - initialized path
+ shouldBe(testDoubleArraySinkingWithConditionalWrite(true), 42.5);
+ // Test Double array - uninitialized path (hole)
+ shouldBe(testDoubleArraySinkingWithConditionalWrite(false), undefined);
+
+ // Test Contiguous array - initialized path
+ let resultInitialized = testContiguousArraySinkingWithConditionalWrite(true);
+ shouldBe(resultInitialized.value, 42);
+ // Test Contiguous array - uninitialized path (hole)
+ shouldBe(testContiguousArraySinkingWithConditionalWrite(false), undefined);
+}
diff --git a/JSTests/stress/array-sink-diamond-initialization-then-read.js b/JSTests/stress/array-sink-diamond-initialization-then-read.js
new file mode 100644
index 000000000000..acccfa91994e
--- /dev/null
+++ b/JSTests/stress/array-sink-diamond-initialization-then-read.js
@@ -0,0 +1,15 @@
+function test1(obj, flag) {
+ let array = new Array(4);
+ if (flag)
+ array[0] = 1;
+ else
+ array[0] = obj.foo;
+ return array[0];
+}
+noInline(test1);
+
+
+let obj1 = { foo: 1 };
+for (let i = 0; i < testLoopCount; ++i) {
+ test1(obj1, i % 2);
+}
\ No newline at end of file
diff --git a/JSTests/stress/array-sink-read-uninitialized-hole.js b/JSTests/stress/array-sink-read-uninitialized-hole.js
new file mode 100644
index 000000000000..cd940523cfb5
--- /dev/null
+++ b/JSTests/stress/array-sink-read-uninitialized-hole.js
@@ -0,0 +1,45 @@
+function test1() {
+ function fn6(obj) {
+ let array = new Array(4);
+ array[0] = obj[1];
+ var test = {
+ f: array[1] ? array : 0
+ };
+ return obj;
+ }
+}
+noInline(test1);
+
+function test2() {
+ function fn6(obj) {
+ let array = new Array(4);
+ array[0] = obj[1];
+ var test = {
+ f: array[1] ? array : 0
+ };
+ return obj;
+ }
+}
+noInline(test2);
+
+function test3() {
+ function fn6(obj) {
+ let array = new Array(4);
+ array[0] = obj[1];
+ var test = {
+ f: array[1] ? array : 0
+ };
+ return obj;
+ }
+}
+noInline(test3);
+
+let obj1 = { 1: 1 };
+let obj2 = { 1: 1.4 };
+let obj3 = { 1: { } };
+for (let i = 0; i < testLoopCount; ++i) {
+ test1(obj1);
+ test2(obj2);
+ test3(obj3);
+}
+