f0847302d1f02f0f22786e16d18e12c7f7c542a3 [JSC] IRO's ArithAbs transform can pin freed range bound proofs
Triage note: The fixed (#else) branch changes RangeBound::proof from 'const Relationship* proof' (set to &relationship, a pointer into a Vector<Relationship> inside RelationshipMap) to a by-value 'Relationship proof', eliminating a dangling pointer later dereferenced in setNodeMustGenerate via bound.proof->right()/left(). Dangling-pointer UAF in the DFG JIT range optimizer, attacker-controlled via JS.
The bug at a glance
This is a dangling-pointer use-after-free in JavaScriptCore’s DFG optimizer: IntegerRangeOptimizationPhase::RangeBound stored const Relationship* proof pointing at an element of a Vector<Relationship> held inside the RelationshipMap, and that pointer was later dereferenced in setNodeMustGenerate (bound.proof->right()/left()) after the vector/map could have been mutated and reallocated. The use is a read of a Relationship out of possibly-freed vector storage, followed by mergeFlags(NodeMustGenerate) on the Node* decoded from it. It is reachable from attacker JavaScript that drives DFG compilation, and corrupting the compiler’s IR/flags via a stale pointer is a serious primitive, so high severity is defensible; but the diff proves a lifetime defect (and an IR-flag write on a possibly type-confused Node*), and escalation to a controlled read/write or RCE is (inferred).
The hook is that the optimizer cached a ‘proof’ for a computed range as a bare pointer into a HashMap-of-Vector that the same fixpoint keeps mutating — so by the time setNodeMustGenerate dereferences the proof to mark its operands, the Relationship it points at may have been moved by a vector growth or map rehash.
Root cause
The violated invariant is that a raw pointer into a Vector<Relationship> (the value type of UncheckedKeyHashMap<NodeFlowProjection, Vector<Relationship>>, i.e. RelationshipMap) is only valid while that vector is neither grown nor its containing map rehashed; the integer-range fixpoint does exactly that kind of mutation. In Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp the vulnerable (RC_REMOVE_SEC_ACCEL_3) RangeBound holds const Relationship* proof { nullptr }. The pre-patch sequence is: 1. rangeFor(Node*) iterates the relationships for a node and, when a candidate tightens the bound, records minBound.proof = &relationship / maxBound.proof = &relationship, where relationship is a reference to an element living inside a Vector stored in the RelationshipMap. 2. rangeFor returns the std::tuple<RangeBound, RangeBound> by value, but each RangeBound still carries the raw interior pointer. 3. Between capturing the bound and using it, the phase continues its fixpoint and mutates the RelationshipMap — adding relationships appends to vectors (reallocating on growth) and inserting new keys can rehash the map — any of which moves or frees the backing storage the proof points at. 4. setNodeMustGenerate(bound) later does if (Node* right = bound.proof->right().node(); ...) and bound.proof->left(), dereferencing the dangling pointer to read NodeFlowProjection fields and then mergeFlags(NodeMustGenerate) on the decoded Node*.
The lifetime facts: Relationship is a plain value type (NodeFlowProjection m_left/m_right, a Kind, an int offset) stored inline inside Vector buckets, with no refcount or GC keeping it alive; &relationship is raw aliasing of vector storage. So step 4 reads a Relationship out of reallocated/freed memory, and the NodeFlowProjection it decodes is whatever now occupies those bytes — a potentially stale or type-confused Node* that then gets NodeMustGenerate OR-ed into its flags.
This matches the triage bugclass (UAF via dangling interior pointer). The fix (#else) changes the member to Relationship proof { } held by value and assigns minBound.proof = relationship / maxBound.proof = relationship (copies), and switches the uses to bound.proof.right() / bound.proof.left(); it also gives Relationship default member initializers (m_left { nullptr }, m_right { nullptr }, m_kind { Equal }, m_offset { 0 }) and Relationship() = default so a default-constructed proof is well-defined. The retained if (!bound.proof) return; guard now tests the by-value Relationship for its empty/default state, relying on an existing boolean/empty conversion on Relationship that the default initializers make reliable (the conversion operator itself is not shown in this diff, so that detail is (inferred)). The free (vector reallocation / map rehash) is standard container behavior; whether the resulting IR-flag corruption is steerable beyond a crash/miscompile is (inferred).
Key code
RangeBound::proof changes from a raw interior pointer to a by-value Relationship
struct RangeBound {
int32_t value;
// vulnerable (RC_REMOVE_SEC_ACCEL_3):
const Relationship* proof { nullptr };
// fix (#else):
Relationship proof { };
};
// capture: vulnerable `minBound.proof = &relationship;` -> fix `minBound.proof = relationship;`
// use: vulnerable `bound.proof->right()` -> fix `bound.proof.right()`
Fix approach
The patch removes the interior pointer entirely: RangeBound::proof becomes a by-value Relationship that copies the chosen relationship (minBound.proof = relationship) at capture time, so nothing aliases the RelationshipMap’s vector storage and later mutation/reallocation cannot dangle it. Supporting changes make Relationship safe to hold by value and default-construct: Relationship() = default plus default member initializers (m_left{nullptr}, m_right{nullptr}, m_kind{Equal}, m_offset{0}); the if (!bound.proof) guard is kept and now evaluates the empty state of a default-constructed Relationship. The semantic change is that proof is a snapshot copy rather than a live pointer — slightly more copying in rangeFor, but setNodeMustGenerate reads a stable value. After the fix the hot path collapses to copy-at-capture, read-copy-at-use with no lifetime coupling to the map.
Patch walkthrough
Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp— RangeBound::proof changes fromconst Relationship*to a by-valueRelationship; the two capture sites in rangeFor assign by copy instead of taking&relationship; setNodeMustGenerate dereferences with.instead of->. Relationship gains= defaultconstructor and default member initializers so the by-value proof and the!bound.proofemptiness check are well-defined.
Background
IntegerRangeOptimizationPhase — A DFG optimization phase that proves integer ranges/relationships between nodes to remove bounds checks and overflow checks; it runs a fixpoint that repeatedly updates a map of relationships.
Relationship / RelationshipMap — Relationship encodes left <kind> (right + offset) between NodeFlowProjections; RelationshipMap is UncheckedKeyHashMap<NodeFlowProjection, Vector<Relationship>>, so relationships live inside per-key vectors in a hash map.
Vector / HashMap reallocation — A WTF Vector moves its elements to new storage when it grows past capacity, and an UncheckedKeyHashMap rehash reallocates its buckets; both invalidate raw pointers/references into the old storage.
RangeBound::proof — Per-bound record of which Relationship justified a min/max bound; used by setNodeMustGenerate to mark the relationship’s operand nodes as NodeMustGenerate so they are not eliminated.
NodeFlowProjection / NodeMustGenerate — NodeFlowProjection wraps a DFG Node* (plus a flow kind); mergeFlags(NodeMustGenerate) ORs a flag into a Node’s flags to force it to be materialized. A stale Relationship decodes to a possibly invalid Node*.
Vulnerability window
- Setup — Attacker JS defines code with arithmetic/bounds-checkable integer relationships hot enough to reach DFG and exercise the integer-range phase.
- Capture — rangeFor records
bound.proof = &relationship, a raw pointer into a Vector inside the RelationshipMap. - Mutation — The fixpoint continues, adding relationships; a vector growth or map rehash moves/frees the storage the proof points at.
- UAF use — setNodeMustGenerate dereferences the dangling
bound.proof->right()/left(), reading a Relationship from reclaimed memory. - Fallout — The decoded Node* (now stale/type-confused) is mergeFlags’d, corrupting compiler IR state and causing a crash or miscompile (inferred as steerable).
Proof of concept
No regression test ships in the diff. Conceptual trigger: construct a hot JS function with many interrelated integer comparisons/arithmetic (array index bounds, loop induction variables) so IntegerRangeOptimizationPhase builds a large RelationshipMap whose vectors grow during the fixpoint while some RangeBound still carries a proof pointer into an earlier allocation; force DFG tier-up. Reliably hitting the reallocation window and shaping the reclaimed bytes is (inferred).
Exploitation
- Reach — Renderer-process JavaScript that triggers DFG compilation of integer-heavy code reaches the phase; no special surface needed.
- Trigger — Grow the RelationshipMap/vectors during the fixpoint so a captured proof pointer is reallocated before setNodeMustGenerate runs.
- Primitive — Primary: UAF read of a Relationship from freed/moved vector storage. Secondary: mergeFlags(NodeMustGenerate) applied to the Node* decoded from stale bytes, corrupting IR flags.
- Open questions — Escalation depends on whether the reclaimed storage can be filled with attacker-chosen bytes so the decoded NodeFlowProjection becomes a controlled Node*, and whether a mis-flagged node yields an exploitable downstream miscompile rather than just an assert/crash — not shown by the diff.
Detection & hunting
For defenders and detection engineers:
- Crash/UAF in IntegerRangeOptimizationPhase setNodeMustGenerate / rangeFor — ASan reports dereferencing freed Vector storage during DFG compilation.
- Reads through RangeBound::proof after map mutation — Audit for any raw pointer/reference into RelationshipMap values retained across map/vector modification in this phase.
- Builds defining RC_REMOVE_SEC_ACCEL_3 — The raw-pointer proof is selected by that macro; flag configs enabling it.
Remaining risk in the patch
- Relationship copy cost / correctness of empty test — proof is now copied by value and the
!bound.proofguard depends on Relationship’s boolean/empty conversion, which is not shown in the supplied diff; the default member initializers make a default proof empty, but that conversion path is unverified here. - Other interior pointers in the phase — Only RangeBound::proof is converted to by-value; other local raw pointers/references into the RelationshipMap, if any, are untouched by this diff.
Audit directions
- Raw pointers into HashMap-of-Vector values — Scan DFG/FTL phases for
&elementtaken from a Vector stored in a HashMap value and retained across mutation of that map. - Other DFG optimizer proof/witness caches — Review phases that cache a pointer to IR metadata (abstract values, relationships) for similar lifetime coupling to mutable containers.
- NodeFlowProjection decode sites — Audit where a cached Relationship/NodeFlowProjection is decoded to a Node* long after capture, to ensure the source storage is stable.