← WebKit Silent-Fix Report — 2026-W22

20beac1f6e  Use-after-free in NamedSlotAssignment::resolveSlotsAfterSlotMutation

severity high class UAF confidence 0.85 WebCore Shadow DOM SlotAssignment exploitable-grade
Ryosuke Niwa Fri May 29 03:36:21 2026 -0700 full: 20beac1f6ec3a8dae04a9e38e4af992ee8c1e134 bug report ↗ view on GitHub ↗
Primitive: stale slot pointers after mutation without reassign
Triage note: ASAN crash test plus re-validating slot assignment before dereferencing slot elements is a clear use-after-free fix.
Contents

The bug at a glance

NamedSlotAssignment drives shadow-DOM slotting, reachable by any page using attachShadow (the default named mode). The patch ships an ASAN crash regression test and fixes a use-after-free where slot map mutation during iteration frees the SlotInfo being dereferenced. Renderer/DOM UAFs of this kind are a common exploitation surface, hence high.

resolveSlotsAfterSlotMutation iterates over m_slots.values() and called hasAssignedNodes(shadowRoot, *slot), which itself re-resolves and mutates m_slots (assignSlots), invalidating the iterators and the SlotInfo pointers being used. The fix hoists the (re)assignment before the loop and replaces the mutating hasAssignedNodes call with a pure slot->assignedNodes.isEmpty() check.

Root cause

When slot-related attributes change under a shadow host, NamedSlotAssignment::resolveSlotsAfterSlotMutation walks m_slots (a HashMap from slot name to a SlotInfo) to reconcile which <slot> element currently owns each name. For slots whose elements were all removed, it needs to know whether any host children were assigned to that name; the old code asked hasAssignedNodes(shadowRoot, *slot).

hasAssignedNodes, when m_slotAssignmentsIsValid is false, triggers assignSlots(shadowRoot) which rebuilds the entire slot->assignedNodes mapping and can insert new SlotInfo entries into m_slots for freshly-seen slot names (e.g. the ’new0’..’new9’ names in the test). Mutating m_slots while a range-based for loop holds iterators into m_slots.values() invalidates those iterators; the slot reference then points into a rehashed/freed HashMap bucket, and the subsequent slot->oldElement = WTF::move(slot->element) / slot->element = nullptr writes through a dangling SlotInfo - a use-after-free.

The fix does two things. First, it materializes the slot assignment up front: if (!m_slotAssignmentsIsValid) assignSlots(shadowRoot); so m_slots is fully populated and stable before the loop begins, and no reassignment happens mid-iteration. Second, inside the loop it replaces the side-effecting hasAssignedNodes call with the pure predicate !slot->assignedNodes.isEmpty(), which only reads the now-current assignedNodes vector and cannot mutate m_slots. Together these guarantee the container is not modified while it is being iterated.

Key code

resolveSlotsAfterSlotMutation: reassign before iterating, use a pure predicate

+    if (!m_slotAssignmentsIsValid)
+        assignSlots(shadowRoot);
+
     for (auto& slot : m_slots.values()) {
         if (slot->seenFirstElement)
             continue;
@@
         // All slot elements have been removed for this slot.
         slot->seenFirstElement = true;
         ASSERT(slot->element);
-        if (hasAssignedNodes(shadowRoot, *slot))
+        if (!slot->assignedNodes.isEmpty())
             slot->oldElement = WTF::move(slot->element);
         slot->element = nullptr;
     }

Patch walkthrough

  • Source/WebCore/dom/SlotAssignment.cpp — In NamedSlotAssignment::resolveSlotsAfterSlotMutation, before the for (auto& slot : m_slots.values()) loop, add if (!m_slotAssignmentsIsValid) assignSlots(shadowRoot); so slot assignments (and any new m_slots entries) are computed once, up front. Inside the loop, the removed-elements branch changes from if (hasAssignedNodes(shadowRoot, *slot)) - which could call assignSlots and mutate m_slots mid-iteration - to the non-mutating if (!slot->assignedNodes.isEmpty()).
  • LayoutTests/fast/shadow-dom/named-slot-assignment-resolve-after-mutation-crash.html — Added regression test: builds a shadow tree with two same-named slots, adds a host child slotted to that name plus ten host children with fresh slot names not yet in m_slots, then calls container.replaceChildren() to drive resolveSlotsAfterSlotMutation with an invalid assignment. Designed to crash/assert under ASAN pre-patch.
  • LayoutTests/fast/shadow-dom/named-slot-assignment-resolve-after-mutation-crash-expected.txt — Expected output (‘PASS’), asserting no crash/assertion under ASAN.

Background

NamedSlotAssignment — The default (named) slot-assignment strategy for shadow roots. It maintains m_slots, a HashMap keyed by slot name to a SlotInfo describing the owning <slot> element, its assignedNodes, and bookkeeping like seenFirstElement/oldElement, driving where light-DOM children are rendered.

m_slotAssignmentsIsValid / assignSlots — A dirty flag; when a slot attribute changes (didChangeSlot) it is set false. assignSlots(shadowRoot) rebuilds assignedNodes for every slot and can create new SlotInfo entries for slot names newly seen among host children, mutating m_slots.

hasAssignedNodes — A helper that answers whether a slot has any assigned nodes but, crucially, first ensures assignments are valid by calling assignSlots when the dirty flag is set - so it has the side effect of mutating m_slots, which is unsafe to invoke while iterating m_slots.

HashMap iterator invalidation — WTF::HashMap rehashes on insertion; inserting entries during a range-for over values() invalidates the iterator and any reference/pointer obtained from it. Dereferencing the stale SlotInfo (slot->element, slot->assignedNodes) after such a mutation is a use-after-free.

Vulnerability window

  1. Setup — A shadow host has two <slot name=X> and host children: one slotted to X plus several with fresh slot names, and a slot mutation marks m_slotAssignmentsIsValid = false.
  2. Trigger — A DOM mutation inside the shadow tree (e.g. container.replaceChildren()) calls resolveSlotsAfterSlotMutation.
  3. Reentrant mutation — Mid-loop, hasAssignedNodes -> assignSlots rebuilds m_slots and inserts new SlotInfo entries, rehashing the map while the range-for holds iterators into it.
  4. UAF — The loop’s slot reference now dangles; slot->oldElement/slot->element writes hit freed HashMap storage (ASAN crash).
  5. Fix — assignSlots is hoisted before the loop and the in-loop check becomes the non-mutating slot->assignedNodes.isEmpty().

Proof of concept

VERBATIM added LayoutTest. It builds a named-slot shadow tree with two same-named slots, adds a host child slotted to X plus ten children with fresh slot names (forcing assignSlots to insert new m_slots entries), invalidates the assignment via the slot mutation, then calls container.replaceChildren() to enter resolveSlotsAfterSlotMutation. Pre-patch, the mid-iteration assignSlots rehashes m_slots and the loop dereferences a freed SlotInfo (ASAN crash); post-patch it prints PASS.

<!DOCTYPE html>
<body>
<p>This tests inserting a bunch of nodes with slot attributes under shadow host.<br>
WebKit should not crash or hit assertions under ASAN, and you should see PASS below:</p>
<script>

globalThis?.testRunner.dumpAsText();

const host = document.createElement('div');
document.body.appendChild(host);
const shadowRoot = host.attachShadow({mode:'open'}); // NamedSlotAssignment (default)

// Shadow tree: shadowRoot -> c -> [slot1(name=X), slot2(name=X)]
const container = document.createElement('div');
shadowRoot.appendChild(container);

const slot1 = document.createElement('slot');
slot1.setAttribute('name','X');
container.appendChild(slot1);

const slot2 = document.createElement('slot');
slot2.setAttribute('name','X');
container.appendChild(slot2);

// Host child with slot='X' -> didChangeSlot('X') -> m_slotAssignmentsIsValid=false
const spanChild = document.createElement('span');
spanChild.setAttribute('slot','X');
host.appendChild(spanChild);

// Many host children with FRESH slot names not in m_slots.
for (let i = 0; i < 10; i++) {
    const span = document.createElement('span');
    span.setAttribute('slot', 'new' + i);
    host.appendChild(span);
}

// Trigger: removeAllChildren on container (inside shadow tree).
container.replaceChildren();

document.write('PASS');

</script>

Exploitation

  1. Reachability — Pure HTML/JS using standard shadow-DOM APIs; no special configuration. Trivially reachable from a web page.
  2. Grooming — An attacker controls the number and names of slots and host children, hence the size and rehash behavior of m_slots and the layout of freed SlotInfo storage - the raw material for turning the UAF into controlled reuse.
  3. Primitive — Write-through-dangling-pointer into freed HashMap storage (slot->oldElement/element). The public artifact is an ASAN crash; a full exploit would require reclaiming the freed SlotInfo bucket with attacker data before the writes, which the test does not demonstrate.

Detection & hunting

For defenders and SOC / detection engineers:

  • ASAN heap-use-after-free in resolveSlotsAfterSlotMutation
  • Debug assertion ASSERT(slot->element)
  • Crash telemetry in shadow-DOM slotting

Audit directions

  • Other m_slots iterations
  • Side-effecting predicates in loops
  • assignSlots reentrancy
  • SlotInfo lifetime

Before / after

Loading diff…