← All reports

[1] ObjectCreate constant fold drops its prototype speculation

CriticalJSC DFG JITTypeConfusion

Constant folding deleted the one check the rest of the compile had already spent

cd30bb7

Critical. Constant folding removes a speculation the rest of the compilation had already been specialized against, so the branch that was proven dead at compile time executes at runtime with no exit left to catch it. What comes back to script is a double's raw bit pattern typed as a JSValue.

Speculative JIT compilation is a bargain: the optimizer assumes a value's type, emits a cheap runtime check that bails out when the assumption fails, then compiles everything downstream as if it always holds. WebKit's DFG mid-tier optimizer pairs that check with an abstract interpreter — a compile-time pass that propagates what is provably known about each value through the control-flow graph. ConstantFoldingPhase, which rewrites nodes whose operands the interpreter has proven constant, sits at the join: any check a node carries has to survive the rewrite, because other phases have already spent that proof.

The angle: script that can shape DFG profiles gets a loop body compiled as a check-free contiguous array load, then runs that body against a double-shaped array and reads raw double bits back as a JSValue.

The change sits on the ObjectCreate -> NewObject conversion in ConstantFoldingPhase::foldConstants. FixupPhase turns Object.create(x) with an object prediction into an ObjectCreate node carrying an ObjectUse edge on child1 — a speculation that exits at runtime if the prototype operand is not an object. When the abstract interpreter has proven child1 constant, the fold picks a structure from the cache and calls convertToNewObject(), whose children.reset() deletes that edge outright. The fix keeps the speculation alive across the rewrite rather than letting it disappear with the node's edges.

A speculation check deleted by a node rewrite after downstream phases had already consumed it as a proof.

  Before the fix                        After the fix
  --------------                        -------------
  ObjectCreate @ObjectUse(child1)       ObjectCreate @ObjectUse(child1)
    CFA proves child1 == jsNull           CFA proves child1 == jsNull
    ObjectUse filter contradicts          ObjectUse filter contradicts
        |                                     |
  convertToNewObject()                  the speculation survives the
    children.reset() -> edge gone       rewrite -> exit still reachable
        |                                     |
  else-branch runs with no exit         else-branch exits to baseline
  obj is ArrayWithDouble                before the check-free load runs
  check-free Contiguous GetByVal
    -> double bits returned as JSValue

Where this lives. The DFG is WebKit's mid-tier JavaScript optimizer. It compiles bytecode into an SSA node graph, speculates on profiled types, and exits back to the baseline JIT (OSR exit) whenever a speculation fails at runtime. Speculation is what makes the tier fast: once the check is emitted, everything after it can be compiled as though the type is guaranteed.

Abstract interpretation and edges. Alongside the runtime checks, the DFG runs a compile-time abstract interpreter (CFA) that computes, for each node, a set of values the node could produce. Edges carry use kinds — ObjectUse means "this operand is speculated to be an object" — and the interpreter filters a proven value through the edge's use kind. When the filter empties the value set, the abstract state at that block's tail is marked invalid: the interpreter has proven that control cannot reach the end of that block.

Constant folding. ConstantFoldingPhase walks the graph and rewrites nodes whose operands the interpreter has proven constant. convertToNewObject() is one such rewrite: an ObjectCreate whose prototype is a known constant becomes a plain NewObject on a cached structure.

Structure checks and array modes. Separately, the array-mode selection and structure-check elimination passes read the same abstract state to decide whether an indexed load needs a runtime structure check at all. TypeCheckHoistingPhase can also hoist a CheckStructure onto a SetLocal when the votes for a variable agree on one structure. Both are downstream consumers of the interpreter's conclusions.

Watchpoints and constant properties. Graph::tryGetConstantProperty folds a property load to a compile-time constant when a still-valid replacement watchpoint guarantees the slot has not been rewritten since the watchpoint was created.

The root cause is an ordering hazard between a compile-time proof and the runtime check that produced it. During CFA, the PoC arranges — via a replacement watchpoint on G.k created after the last write — for tryGetConstantProperty to fold getKey(G) to jsNull(). Filtering the proven null through the ObjectUse edge produces a contradiction, so the block's tail state becomes invalid and that predecessor merges nothing into the loop header's valuesAtHead.

That is the load-bearing consequence. The loop header now retains only the ArrayWithContiguous structure set contributed by the other predecessor, and that is exactly what justifies compiling the payload obj[0] as a Contiguous GetByVal with the structure check eliminated. TypeCheckHoistingPhase is defeated in parallel by the two obj === o votes, so no CheckStructure gets hoisted onto the SetLocal in the else-branch either.

Then ConstantFoldingPhase reaches the same ObjectCreate, sees forNode(child1).m_value == jsNull, and calls convertToNewObject(). children.reset() deletes the ObjectUse edge, and no Check was inserted to preserve it. The compile-time-dead branch is now runtime-live: nothing exits any more.

At runtime the else-branch executes, obj becomes an ArrayWithDouble, the folded NewObject runs unconditionally without any exit, and the next iteration's check-free Contiguous load reads the 8-byte slot of a double butterfly and hands the raw bit pattern back as a JSValue. The primitive is therefore a script-chosen 64-bit pattern delivered to JavaScript as a cell — the standard starting point for forging an object, though building a read/write primitive on top of it would require heap layout control that this bug does not itself supply.

This vulnerability weakens the DFG's core soundness property: that every assumption a phase relies on is backed by a check that still exists in the emitted code. Type confusion produced this way is not gated on any exotic API — profile shaping and a watchpoint-protected constant property are both reachable from ordinary script.