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 |
|---|---|---|
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::lengthSource/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
modified | Now returns m_initializedIndices.size() instead of the removed m_length field. |
Allocation::isIndexInitializedSource/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
added | Queries whether a specific index has been proven initialized in the sunk allocation. |
Allocation::setIndexInitializedSource/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp |
added | Marks an index as initialized when a store to that constant index is promoted. |
Allocation::mergeInitializedIndicesSource/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::dumpInContextSource/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
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.