Medium CVSS 6.5 webkit Type Confusion 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentJSC DFG
Bug ClassType Confusion
Tracker301468
Fix commita1a6185cc83e (WebKit/WebKit) +152/-10
CWECWE-119 (Buffer bounds error)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedHossein Lotfi (@hosselot) of Trend Micro Zero Day Initiative, Nan Wang (@eternalsakura13)
Disclosed2025-12-12

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.

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.

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.

Changed Functions

FunctionChangeNotes
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

  • JSTests/stress/array-sink-conditional-initialization.js
  • JSTests/stress/array-sink-diamond-initialization-then-read.js
  • JSTests/stress/array-sink-read-uninitialized-hole.js
  • Source/JavaScriptCore/dfg/DFGObjectAllocationSinkingPhase.cpp

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.

Original Bug Report

The reporter's bug is still restricted on the tracker.