← Chrome — 2026-W38

6814ac2453bd6b1d966278866bd277ad062e35ca  [M144-LTS] Fix slot assignment recalculation depth check and add defensive copies

severity high class UAF CVSS 8.8 confidence 0.85 signal score 9.8 third_party/blink/renderer patch-now AI review CONFIRMED
Primitive: Re-entrant slot-assignment recalc (depth>=2) clears assigned_nodes_ during iteration -> use-after-free in the renderer.
Triage note: IsInSlotAssignmentRecalc() checked ==1 so depth>=2 re-entrancy wasn't detected, allowing assigned_nodes_ to be cleared mid-iteration; web-reachable via shadow DOM slots. Fixed:553121008. Backports: M144-LTS/M150.

The bug at a glance

This is a use-after-free in the Blink renderer reachable entirely from untrusted web content through the standard shadow-DOM slotting machinery, so the attack surface is any page that can run script and declare a shadow root with elements. The primitive is a dangling Member dereference inside HTMLSlotElement iteration after a re-entrant slot-assignment recalc mutates and frees entries of assigned_nodes_ mid-loop, giving an attacker a controllable freed-object dereference that maps well to the classic renderer UAF exploitation chain. CVSS 8.8 (AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H) reflects network reachability, low complexity, no privileges, and the requirement that a victim load the attacker’s page (UI:R). It is not scored critical because it remains confined to the sandboxed renderer process (scope unchanged) and does not by itself cross the sandbox boundary; a full compromise would require chaining a separate sandbox escape (inferred). It is not lower than high because the freed object is a live DOM node whose lifetime and layout state the attacker influences, which is a strong foundation for a reliable exploit rather than a mere crash.

The tell is the one-character-class change in Document::IsInSlotAssignmentRecalc(): the old code asserted DCHECK_LE(slot_assignment_recalc_depth_, 1u) and returned depth == 1, encoding a hard assumption that slot-assignment recalc could never recurse past depth 1. The fix quietly deletes that DCHECK and returns depth > 0, which is an admission that the codebase actually reached depth 2 in production. That is interesting because the guard was not merely wrong at the boundary, it silently returned false at depth >= 2, meaning the very re-entrancy protection that assigned_nodes_ iteration relied on was disabled during the exact nested case that was dangerous. The second tell is that the same patch adds defensive const-copy snapshots of assigned_nodes_ in two loops, a belt-and-suspenders acknowledgement that the collection can be reallocated or cleared while being iterated. A depth check that used == instead of >= is a canonical off-by-one that turns an invariant into an exploitable window.

Root cause

Blink implements the flat-tree distribution of shadow DOM by assigning light-DOM children to elements. Each HTMLSlotElement caches its assigned nodes in a HeapVector<Member> named assigned_nodes_. The assignment is recomputed lazily in a slot-assignment recalculation pass, and Document tracks how deeply nested that pass currently is via slot_assignment_recalc_depth_. Document::IsInSlotAssignmentRecalc() is the predicate other code consults to decide whether it is safe to mutate slot state or whether a recalc is already in flight and must be deferred.

The root cause is that IsInSlotAssignmentRecalc() previously returned slot_assignment_recalc_depth_ == 1, guarded by DCHECK_LE(slot_assignment_recalc_depth_, 1u). The comment in the removed code states the assumption directly: “Since we forbid recursive slot assignment recalc, the depth should be <= 1.” But that invariant did not actually hold. When a slot-assignment recalc synchronously triggered work that re-entered the recalc machinery, the depth incremented to 2 (or more). At depth 2, the predicate returned false, incorrectly reporting that no slot-assignment recalc was in progress. Any caller that used this predicate as a guard against re-entrant mutation of slot state therefore proceeded as if it were safe, and the inner recalc was free to run to completion, mutating or clearing the outer frame’s assigned_nodes_.

The consequence surfaces in HTMLSlotElement::UpdateFlatTreeNodeDataForAssignedNodes() and in the DetachLayoutTree loop (the second hunk, around line 311), both of which iterate directly over assigned_nodes_ by reference: for (auto& current : assigned_nodes_). Inside these loops the code touches each node’s style and layout state (current->NeedsStyleRecalc(), current->GetLayoutObject(), current->GetForceReattachLayoutTree(), current->DetachLayoutTree()). Detaching layout trees and recalculating style can synchronously drive another slot-assignment recalc. Because the depth guard falsely reported “not in recalc” at depth 2, that nested pass could clear or reallocate the very assigned_nodes_ HeapVector being iterated. Once the backing store is cleared or reallocated, the loop’s iterator and the current reference point at freed or stale Member storage, and the next dereference is a use-after-free.

The fix has two independent halves. First, IsInSlotAssignmentRecalc() now returns slot_assignment_recalc_depth_ > 0, so any nonzero depth, including the previously-missed depth 2, is correctly reported as “in recalc,” restoring the re-entrancy guard that other code depends on. Second, both HTMLSlotElement loops now iterate over a defensive copy: const HeapVector<Member> assigned_nodes = assigned_nodes_; and then loop over assigned_nodes. Iterating a local snapshot means that even if a synchronous recalc clears or reallocates the member assigned_nodes_ mid-loop, the loop body still walks a valid, independently-owned vector of Member handles, and the GC keeps those referenced nodes alive for the duration of the local’s scope. The combination closes both the detection gap and the direct iterator-invalidation window.

Key code

Off-by-one depth check plus unguarded iteration over the live member vector

// document.h - before
bool IsInSlotAssignmentRecalc() const {
  // Since we forbid recursive slot assignement recalc, the depth should be
  // <= 1.
  DCHECK_LE(slot_assignment_recalc_depth_, 1u);
  return slot_assignment_recalc_depth_ == 1;
}
// document.h - after
bool IsInSlotAssignmentRecalc() const {
  return slot_assignment_recalc_depth_ > 0;
}

// html_slot_element.cc - before
for (auto& current : assigned_nodes_) { ... }
// html_slot_element.cc - after
const HeapVector<Member<Node>> assigned_nodes = assigned_nodes_;
for (auto& current : assigned_nodes) { ... }

Patch walkthrough

  • third_party/blink/renderer/core/dom/document.h — Rewrites Document::IsInSlotAssignmentRecalc() to return slot_assignment_recalc_depth_ > 0 instead of == 1, and deletes both the DCHECK_LE(…, 1u) assertion and the comment claiming recursive slot-assignment recalc is forbidden. This is the actual root-cause fix: the predicate now correctly reports that a recalc is in progress at any nesting depth, so callers that relied on it to prevent re-entrant mutation of slot state are no longer fooled at depth 2 or greater.
  • third_party/blink/renderer/core/html/html_slot_element.cc — In HTMLSlotElement::UpdateFlatTreeNodeDataForAssignedNodes() (around line 263), the loop now iterates over a local const HeapVector<Member> assigned_nodes copied from the member assigned_nodes_ rather than the member directly. This defends the flat-tree node-data update loop against synchronous mutation/clearing of assigned_nodes_ during iteration.
  • third_party/blink/renderer/core/html/html_slot_element.cc — In the DetachLayoutTree loop (around line 311), inside a StyleEngine::DetachLayoutTreeScope, the same defensive local copy pattern is applied. This loop calls current->DetachLayoutTree(), which is precisely the kind of layout mutation that can re-enter slot-assignment recalc, so snapshotting assigned_nodes_ here removes the direct UAF window even independently of the depth-check fix.

Background

Shadow DOM slots and flat-tree assignment — A shadow tree can contain elements that project the host’s light-DOM children into rendering positions. Blink computes this projection (the flat tree) by assigning nodes to slots, and each HTMLSlotElement caches its result in assigned_nodes_. Changes to the light DOM or shadow tree invalidate assignments and schedule a recalc.

Slot-assignment recalc depth — Document maintains slot_assignment_recalc_depth_, incremented on entry to and decremented on exit from a slot-assignment recalc pass. IsInSlotAssignmentRecalc() exposes whether a pass is active so other code can avoid re-entrant or unsafe mutation. The bug was that this predicate only recognized depth exactly 1.

HeapVector<Member> and Oilpan GC — assigned_nodes_ is a HeapVector<Member>, a garbage-collected Blink container. Copying it into a local const HeapVector produces an independent snapshot whose Member handles keep the referenced nodes reachable for the local’s lifetime, which is why the defensive copy both stops iterator invalidation and prevents premature collection of the nodes during the loop.

Synchronous re-entrancy via style and layout — Operations like DetachLayoutTree() and style recalculation performed inside the slot loops can synchronously trigger further DOM/style/slotting work. Without an accurate in-recalc guard, that nested work can mutate the collection the outer loop is walking, which is the classic setup for iterator-invalidation use-after-free (inferred that the specific re-entry path runs through detach/style, based on the operations the two patched loops perform).

Vulnerability window

  1. Setup — Attacker page builds a shadow tree with one or more elements and light-DOM children assigned to them, arranging structure such that a slot-assignment recalc will be needed.
  2. Enter recalc (depth 1) — A DOM/style change triggers HTMLSlotElement iteration over assigned_nodes_ (e.g. UpdateFlatTreeNodeDataForAssignedNodes or the DetachLayoutTree loop); slot_assignment_recalc_depth_ becomes 1.
  3. Re-entry (depth 2) — Work inside the loop (layout detach / style recalc) synchronously re-enters slot-assignment recalc, raising depth to 2. IsInSlotAssignmentRecalc() returns false at depth 2, so the re-entrancy guard fails to fire (inferred that the nested trigger reaches this code through the detach/style operations in the patched loops).
  4. Free during iteration — The nested recalc clears or reallocates the outer frame’s assigned_nodes_ HeapVector, invalidating the outer loop’s iterator and the current reference.
  5. Use-after-free — Control returns to the outer loop, which dereferences current (a now-stale Member), reading and acting on freed backing storage.

Proof of concept

The shipped diff contains no test or layout-test file, so no proof-of-concept is reconstructed from the commit; the recipe below is a concrete conceptual trigger derived only from the code paths the patch touches and is not verified to crash. The goal is to force HTMLSlotElement to iterate assigned_nodes_ while a synchronous, nested slot-assignment recalc clears that same vector, so that the depth-2 path (which the old == 1 check failed to detect) is exercised. A real trigger would attach shadow roots with children, then during a slot/flat-tree update mutate slotting from within a synchronously-invoked callback (for example by moving assigned children between hosts or toggling slot names) so recalc re-enters at depth 2 and empties assigned_nodes_ mid-iteration. This proves reachability and the UAF condition only if run under ASan; without a shipped test it should be treated as (inferred).

Exploitation

  1. Trigger the freed dereference — Drive HTMLSlotElement’s assigned_nodes_ iteration into the depth-2 re-entrant path so a Node’s backing storage is freed and then dereferenced, yielding a UAF read/use on a controllable DOM node (inferred that the primitive is turned into a controllable dereference; the diff proves the free-during-iteration condition but not attacker control of the freed contents).
  2. Heap grooming — Allocate replacement objects of a compatible size class into the freed slot so the stale Member points at attacker-influenced memory; in Oilpan this typically means spraying DOM objects to reoccupy the reclaimed node (inferred).
  3. Type confusion / vtable control — Use the confused object to gain a read/write primitive, for example by controlling a pointer or vtable dereferenced through the stale node during style/layout access (inferred; not demonstrated by the diff).
  4. Renderer RCE and sandbox chaining — Escalate the memory-corruption primitive to arbitrary code execution in the renderer, then chain a separate sandbox-escape bug to affect the host (inferred; out of scope of this single-process DOM fix).

Detection & hunting

For defenders and detection engineers:

  • ASan heap-use-after-free in HTMLSlotElement — Look for ASan/LSan reports whose free and use stacks pass through HTMLSlotElement::UpdateFlatTreeNodeDataForAssignedNodes or the DetachLayoutTree loop and touch HeapVector<Member> assigned_nodes_ backing storage during slot-assignment recalc; the read on the stale current is the crash site.
  • Depth invariant violation — In instrumented/debug builds, the removed DCHECK_LE(slot_assignment_recalc_depth_, 1u) is exactly the assertion that would fire at depth 2; monitoring or re-adding a depth counter tripwire (alert when slot_assignment_recalc_depth_ exceeds 1) flags re-entrant recalc that legacy builds silently permitted.
  • Fuzzing shadow-DOM slotting — Domato/DOM fuzzers that build shadow roots with elements and mutate slot assignment from within style/layout callbacks under ASan will surface iterator-invalidation UAFs of this class; seed corpora that move assigned nodes between hosts mid-recalc are high yield.
  • Telemetry on crash clustering — Cluster renderer crash dumps by faulting module/function; a spike of renderer crashes in html_slot_element.cc with GC-heap read faults correlated with pages using web components is a defender-side indicator of exploitation attempts (inferred).

Audit directions

  • Other consumers of IsInSlotAssignmentRecalc() — Enumerate every caller of Document::IsInSlotAssignmentRecalc() and confirm each behaves correctly now that it returns true at depth >= 2; any code that previously relied on the == 1 semantics to permit action at depth 2 may now over- or under-guard and needs review.
  • Loops that iterate GC container members by reference — Grep Blink DOM/HTML for for (auto& x : some_member_) patterns over HeapVector<Member<…» members where the loop body can synchronously mutate DOM, style, or layout (DetachLayoutTree, style recalc, event/callback dispatch); these are candidate iterator-invalidation UAFs and should adopt the defensive-local-copy pattern used here.
  • Depth/reentrancy counters checked with == instead of >=/> — Audit other recursion/reentrancy guards in Blink (style recalc, layout, lifecycle, slot assignment) for exact-equality depth checks or DCHECKs asserting a maximum depth; the same off-by-one class (== 1 vs > 0) can hide re-entrancy anywhere an invariant is assumed rather than enforced.
  • Flat-tree and slot assignment mutation paths — Trace which operations inside slot recalc can synchronously re-enter SlotAssignment/flat-tree recalculation and whether assigned_nodes_ (and sibling caches) can be cleared or reallocated during those paths; verify the two patched loops are the only assigned_nodes_ iterations exposed, and check related caches for the same live-mutation hazard.

Before / after

Loading diff…