CVE-2025-46298
Overview
Background
- 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
- 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. - 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];orif (flag) arr[0]=v; return arr[0];. - 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.
- 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifJSTests/stress/array-sink-conditional-initialization.js |
modified | |
forJSTests/stress/array-sink-conditional-initialization.js |
modified | |
forJSTests/stress/array-sink-diamond-initialization-then-read.js |
modified | |
forJSTests/stress/array-sink-read-uninitialized-hole.js |
modified |
Files Changed
JSTests/stress/array-sink-conditional-initialization.jsJSTests/stress/array-sink-diamond-initialization-then-read.jsJSTests/stress/array-sink-read-uninitialized-hole.jsSource/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp
Audit Directions
- Other promoted-location reads in the sinking phaseIn DFGObjectAllocationSinkingPhase.cpp, audit every place that builds an exactRead / promotes a load; grep for
ArrayIndexedPropertyPLoc,exactRead, andPromotedLocationDescriptorreads to ensure each read of a sunk field first proves initialization (mirroring the new isIndexInitialized/escapeChildren check). - Named-property / object-field initialization trackingCheck whether sunk plain-object field reads have the analogous ‘read before any store’ hazard; grep for
NamedPropertyPLocand m_fields lookups to confirm reads of unwritten fields escape rather than forwarding a default/uninitialized value. - Merge-time set operationsReview all merge helpers (mergeStructures, mergePointerSets, and the new mergeInitializedIndices); grep for
&=,|=, andmergein the phase to verify initialized-facts are intersected (not unioned) so a fact holds only when true on every predecessor edge. - Similar phases across JITsLook at other optimization passes that model arrays by length alone; grep across dfg/ and ftl/ for
->asInt32()used as an array size,newAllocation(...ArrayButterfly...), andPublicLengthPLocto find places assuming all indices up to length are readable.
Patch
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;
Fields m_fields;
IndexingType m_indexingType { NoIndexingShape };
- unsigned m_length { 0 };
+ FastBitVector m_initializedIndices;
// This set of structures is the intersection of structures seen at control flow edges. It's used
// for checks and speculation since it can't be widened.
@@ -494,6 +513,7 @@ public:
} else {
mergePointerSets(allocationEntry.value.fields(), allocationIter->value.fields(), toEscape);
allocationEntry.value.mergeStructures(allocationIter->value);
+ allocationEntry.value.mergeInitializedIndices(allocationIter->value);
}
}
@@ -971,7 +991,7 @@ private:
goto escapeChildren;
unsigned arraySize = node->child1()->asInt32();
- target = &m_heap.newAllocation(node, Allocation::Kind::ArrayButterfly, node->indexingType(), arraySize);
+ target = &m_heap.newAllocation(node, Allocation::Kind::ArrayButterfly, node->indexingType());
writes.add(PromotedLocationDescriptor(ArrayButterflyPublicLengthPLoc), LazyNode(ensureConstant(arraySize)));
}
break;
@@ -1060,8 +1080,12 @@ private:
if (!isProvenValidTypeForIndexingShapeStorage(target->indexingType(), typeFilterFor(value.useKind())))
goto escapeChildren;
writes.add(PromotedLocationDescriptor(ArrayIndexedPropertyPLoc, index->asInt32()), LazyNode(value.node()));
- } else
+ target->setIndexInitialized(index->asInt32());
+ } else {
+ if (!target->isIndexInitialized(index->asInt32()))
+ goto escapeChildren;
exactRead = PromotedLocationDescriptor(ArrayIndexedPropertyPLoc, index->asInt32());
+ }
} else
goto escapeChildren;
break;
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);
+}
+
Regression Test / PoC
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);
+}
+