← All issues

[2] DFG Object.defineProperty descriptor cell confusion

A hot defineProperty fold kept a shape but dropped the check it was an object.

Severity: High | Component: JSC DFG JIT | 10b3c73

Rated High — the check the fold skipped is exactly the check that separates "an object with this structure" from "an undefined immediate that happens to share the structure set". Reachable from plain renderer script; the diff establishes a reliable invalid-cell dereference, with the step up to a controlled read gated on merging an attacker-influenced immediate rather than undefined.

WebKit's mid-tier JavaScript optimizer speculates on value types and can strength-reduce operations once it believes it has proven a type, and a class of bug appears when such a rewrite drops the very speculation that justified it. When a hot function's shapes are known, the DFG folds Object.defineProperty into ObjectDefinePropertyFromFields — a specialized form that reads the descriptor object's fields directly by memory offset instead of going through the general runtime. That fold leans on the abstract interpreter, which tracks each value as an AbstractValue carrying an m_type (a set such as SpecFinalObject | SpecOther) and an m_structure set that describes only the cell portion of the value, on the invariant that a direct-offset load is only emitted when the value is provably a cell.

The angle: any renderer script can route a descriptor value that is sometimes an object and sometimes undefined into a hot Object.defineProperty call and have the JIT dereference the undefined immediate as an object pointer — a reliable renderer crash, and a controlled read primitive if the non-object branch can carry an attacker-influenced immediate.

When constant-folding ObjectDefineProperty, the pass should emit the checks attached to the node's edges so that these filters continue to work. In this case the descriptor's ObjectUse edge filter was missing. The fix inserts one line before the fold rewrites the node.

Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp

Edge keyEdge = node->child2();
Edge descriptorEdge = node->child3();
 
+ m_insertionSet.insertCheck(m_graph, indexInBlock, node);
+
std::array<Edge, Node::numberOfDescriptorSlots> slotEdges;
Node* butterfly = nullptr;
Node* emptyConstant = nullptr;

JSTests/stress/object-define-property-fields-merged-non-object-descriptor.js

+// `descriptor` is a Phi merging a FinalObject literal and `undefined`.
+function defineWithMaybeDescriptor(target, useObject, v) {
+ const descriptor = useObject
+ ? { value: v, writable: true, enumerable: true, configurable: true }
+ : undefined;
+ Object.defineProperty(target, "p", descriptor);
+}
+noInline(defineWithMaybeDescriptor);

A single-line insertion in ConstantFoldingPhase::foldConstants, in the case handling the ObjectDefineProperty node. Before the fold reads the descriptor's structure from AbstractValue::m_structure and rewrites the node into ObjectDefinePropertyFromFields with KnownCellUse GetByOffset loads over the descriptor's slots, the patch inserts m_insertionSet.insertCheck(m_graph, indexInBlock, node). insertCheck materializes the type-check speculations attached to the node's edges — notably the descriptor edge's ObjectUse filter — as explicit Check nodes, so they survive the transformation instead of being dropped when the original node is replaced. The commit adds a regression test exercising a descriptor value that is a Phi merging a FinalObject literal with undefined.

Dropping edge-attached type speculations when a compiler fold rewrites a node, treating a proven cell-portion structure as proof the whole value is a cell.

DFG speculation. The DFG JIT compiles hot JavaScript functions using speculative type checks. Edges between DFG nodes carry a useKind: an ObjectUse edge emits a runtime speculation that OSR-exits (bails to a lower tier) if the value is not an object, whereas a KnownCellUse edge asserts the value is already proven to be a cell and emits no check at all.

The abstract interpreter. The abstract interpreter is a static analysis over the DFG node graph; it tracks each value as an AbstractValue with an m_type — a SpeculatedType set such as SpecFinalObject, SpecOther — and an m_structure set. Crucially, m_structure describes only the cell portion of a value. SpecOther covers the null/undefined immediates, which are non-pointer boxed values, not cells.

Folding define-property. ObjectDefineProperty is the DFG node for Object.defineProperty. When the descriptor's shape is known, the compiler can fold it into ObjectDefinePropertyFromFields, reading the descriptor's value/writable/enumerable/configurable fields directly by offset.

insertCheck. insertCheck in InsertionSet re-materializes the speculations attached to a node's edges as standalone Check nodes, so they are preserved when the node itself is transformed or removed.

The root cause is a JIT speculation-check elision producing a type confusion. Before the fix, when the fold consulted the descriptor's m_structure to synthesize direct KnownCellUse GetByOffset loads, it never re-emitted the type-check speculations carried on the original node's edges. The trap is that m_structure only constrains the cell portion of a value.

  Before fix (fold, no insertCheck)      After fix (fold, insertCheck)
  descriptor AbstractValue:              descriptor AbstractValue:
    m_structure = {FinalObject}            m_structure = {FinalObject}
    m_type = SpecFinalObject|SpecOther     m_type = SpecFinalObject|SpecOther
  -> "has a concrete structure"          -> Check(ObjectUse) re-emitted
  -> emit KnownCellUse GetByOffset        -> non-object path OSR-exits,
     (NO runtime speculation)               runtime throws TypeError

When the descriptor value is a Phi that merges an object literal (a concrete FinalObject structure) on one path with undefined on another, the merged abstract value retains the FinalObject structure while m_type widens to SpecFinalObject | SpecOther — it is not proven to be an object. The fold treated "has a concrete structure" as "is a cell/object" and emitted KnownCellUse edges, which perform no runtime speculation. At runtime, when the value takes the non-object path, the generated code loads descriptor slots via GetByOffset off the raw undefined immediate as though it were a valid cell pointer.

The test drives exactly this: defineWithMaybeDescriptor returns either an object literal or undefined on separate branches, run hot until the DFG compiles it. The abstract value for the descriptor carries the FinalObject structure but type SpecFinalObject | SpecOther; on the undefined branch the pre-fix code performs KnownCellUse GetByOffset loads directly off the immediate. The branch-merged descriptor shape also fits a Fuzzilli-style path generating define-property calls with descriptors that merge object and non-object values across control flow.

The immediate observed effect is an invalid cell dereference — a near-null read in JIT code from web content, a reliable renderer crash. The escalation to a memory-disclosure primitive is a projected direction: if the non-object branch can be an attacker-influenced immediate rather than undefined (a boxed double or int32 merged with the FinalObject structure), the GetByOffset load off that value would treat an attacker-influenced bit pattern as a cell pointer, which could give a controlled relative/absolute read off the descriptor field offsets. Everything executes in the WebContent process; an R/W escalation would still require a separate sandbox escape.

This vulnerability weakens memory-type safety inside the renderer. The DFG's contract that a KnownCellUse edge is only emitted when the value is provably a cell was violated, so JIT-compiled code could dereference a non-object JSValue as an object pointer.