← All reports

[3] UAF in ManualSlotAssignment via WeakHashMap rehash during reentrant composed-tree teardown

HighWebCore DOM shadow-tree slot assignmentUAF

A shadow-DOM cache that outlived the table it was stored inside.

0bab5af

High. A one-line borrowed pointer survives a render-tree teardown that can rehash the very table it points into. Ordinary script drives every step — including the GC pressure the sweep needs — so the free is attacker-timed; escalation past the read depends on reclaiming the bucket array inside the teardown window.

Shadow DOM lets a component project host children into <slot> elements, and in manual-assignment mode script decides that mapping explicitly rather than by attribute matching. WebKit caches each slot's resolved node list in a per-slot record held inside a weak-keyed hash table, so repeated queries do not re-walk the tree. Changing an assignment invalidates the composed tree and drives render-tree teardown for the nodes that are no longer slotted — and throughout that operation, the cached list is expected to stay where it is.

The angle: a page can free the hash-table storage backing a slot's cached assignment while the mutation handler is still reading through a pointer into it, yielding a script-timed read of freed renderer heap.

ManualSlotAssignment::slotManualAssignmentDidChange computed effectiveCurrent by calling assignedNodesForSlot, which returns a raw pointer to the cachedAssignment Vector inside a Slot value stored directly in the m_slots WeakHashMap bucket array. It then called RenderTreeUpdater::tearDownRenderersAfterSlotChange, whose composed-tree traversal can call HTMLSlotElement::assignedNodes on a sibling slot and re-enter ManualSlotAssignment::assignedNodesForSlot. The reentrant m_slots.ensure call may invoke the WeakHashMap amortized cleanup, sweep null-keyed entries left by previously inserted, removed and GC-collected slot elements, and rehash the table, freeing the bucket array effectiveCurrent points into. The stale pointer was then dereferenced in scheduleSlotChangeEventIfNeeded. The fix computes effectiveCurrent as a local Vector via effectiveAssignedNodes, mirroring effectivePrevious, so no pointer into m_slots is held across the render-tree teardown.

Source/WebCore/dom/SlotAssignment.cpp

++m_slottableVersion;
- auto effectiveCurrent = assignedNodesForSlot(slot, shadowRoot);
+ // Compute effectiveCurrent as a local copy rather than via assignedNodesForSlot, which would
+ // return a raw pointer into m_slots. tearDownRenderersAfterSlotChange below can re-enter
+ // assignedNodesForSlot via ComposedTreeIterator and trigger a WeakHashMap rehash, freeing the
+ // bucket array such a pointer would address.
+ auto effectiveCurrent = effectiveAssignedNodes(shadowRoot, current);
 
auto scheduleSlotChangeEventIfNeeded = [&]() {
- if (effectivePrevious.size() != (effectiveCurrent ? effectiveCurrent->size() : 0)) {
+ if (effectivePrevious.size() != effectiveCurrent.size()) {
slot.enqueueSlotChangeEvent();
return;
}
- for (unsigned i = 0; i < effectivePrevious.size();++i) {
- if (effectivePrevious[i] != effectiveCurrent->at(i)) {
+ for (unsigned i = 0; i < effectivePrevious.size(); ++i) {
+ if (effectivePrevious[i] != effectiveCurrent[i]) {
slot.enqueueSlotChangeEvent();
return;
}

LayoutTests/fast/shadow-dom/manual-slot-assign-renderer-teardown-crash.html

+ const shadowRoot = host.attachShadow({ mode: 'open', slotAssignment: 'manual' });
+ const targetSlot = shadowRoot.appendChild(document.createElement('slot'));
+ const otherSlot = shadowRoot.appendChild(document.createElement('slot'));
+ targetSlot.assign(childA);
+ otherSlot.assign(childB);
+ // Insert and remove ghost slots so m_slots accumulates entries whose WeakPtr keys go null after GC.
+ for (let i = 0; i < ghosts; ++i)
+ shadowRoot.appendChild(document.createElement('slot')).remove();
...
+ // Advance the WeakHashMap operation counter toward its amortized-cleanup threshold.
+ for (let i = 0; i < pumps; ++i)
+ targetSlot.assignedNodes();
+
+ // Reassigning targetSlot tears down renderers; the composed-tree iterator visits otherSlot,
+ // re-enters assignedNodesForSlot, and may trigger a rehash that frees the bucket array.
+ targetSlot.assign(host.appendChild(document.createElement('span')));
+ host.remove();

One production-code change, inside ManualSlotAssignment::slotManualAssignmentDidChange. The call to assignedNodesForSlot(slot, shadowRoot) — which per the interface declaration in SlotAssignment.h returns const Vector<WeakPtr<Node, WeakPtrImplWithEventTargetData>>*, a raw pointer to the cachedAssignment Vector inside a Slot value stored by value in the WeakHashMap<HTMLSlotElement, Slot, WeakPtrImplWithEventTargetData> m_slots bucket array — is replaced with auto effectiveCurrent = effectiveAssignedNodes(shadowRoot, current);, producing a local Vector copy and mirroring how effectivePrevious is already computed.

The scheduleSlotChangeEventIfNeeded lambda is updated accordingly: the null-check ternary (effectiveCurrent ? effectiveCurrent->size() : 0) becomes effectiveCurrent.size(), and effectiveCurrent->at(i) becomes effectiveCurrent[i]. A comment documents that tearDownRenderersAfterSlotChange can re-enter assignedNodesForSlot through ComposedTreeIterator and trigger a WeakHashMap rehash. Collateral: a new regression test plus its expectation file, which deliberately grooms the WeakHashMap operation counter and null-keyed ghost entries to force the rehash during teardown.

Holding a raw pointer into a container's internal storage across a call that can re-enter and rehash that container.

Manual slot assignment. A shadow root created with attachShadow({ slotAssignment: 'manual' }) does not match slottables by slot= attribute; instead script explicitly calls HTMLSlotElement.assign(...nodes) to set which host children a given <slot> renders. ManualSlotAssignment is the SlotAssignment subclass implementing this mode.

Slot and m_slots. Per SlotAssignment.h, each slot's resolved assignment is cached in struct Slot { Vector<WeakPtr<Node, WeakPtrImplWithEventTargetData>> cachedAssignment; uint64_t cachedVersion; }, and these Slot values are stored by value as the value type of WeakHashMap<HTMLSlotElement, Slot, WeakPtrImplWithEventTargetData> m_slots. A pointer to a Slot, or to its cachedAssignment, is therefore a pointer into the hash table's bucket allocation.

assignedNodesForSlot. The virtual declared on SlotAssignment returns const Vector<WeakPtr<Node, WeakPtrImplWithEventTargetData>>* — a borrowed raw pointer, not a copy.

WeakHashMap amortized cleanup. WeakHashMap keys are weak references that become null when the key object is collected. Rather than reacting to each collection, it counts operations and periodically sweeps null-keyed entries and rehashes; a rehash allocates a fresh bucket array and frees the old one. Insertion-shaped operations such as ensure() are the points at which this cleanup can run. The design exists so a weak table does not need a collector callback per entry — the cost is paid in batches on whichever operation happens to cross the threshold.

WeakPtr equality. Comparing two WeakPtrs resolves each through its WeakPtrImpl control block to obtain the raw object pointer, so the comparison reads memory beyond the WeakPtr slot itself.

Composed tree and render-tree teardown. The composed tree is the flattened view of host children projected into shadow slots; ComposedTreeIterator walks it. When a slot's assignment changes, RenderTreeUpdater::tearDownRenderersAfterSlotChange walks the affected composed subtree to destroy renderers for nodes that are no longer slotted, and that walk consults slot elements' assigned nodes.

slotchange. A slot fires slotchange when its assigned-node list actually changes; slotManualAssignmentDidChange therefore compares the previous effective assignment against the current one before calling enqueueSlotChangeEvent.

This is a container-invalidation use-after-free rather than an object-lifetime one — the HTMLSlotElement and the ShadowRoot are all still alive; what is freed is the WeakHashMap bucket array backing the Slot value.

  slotManualAssignmentDidChange          re-entrant path
  ─────────────────────────────          ───────────────
  ++m_slottableVersion
  effectiveCurrent = &bucket[k].cachedAssignment ──┐
  tearDownRenderersAfterSlotChange() ──────────────┤
        ComposedTreeIterator visits otherSlot      │
        HTMLSlotElement::assignedNodes()           │
        assignedNodesForSlot() -> m_slots.ensure() │
              amortized cleanup crosses threshold  │
              sweep null keys, rehash              │
              free(old bucket array) ──────────────┤
  scheduleSlotChangeEventIfNeeded()                │
        effectiveCurrent->size()  ◄────────────────┘  UAF read
        effectivePrevious[i] != effectiveCurrent->at(i)

In the sequence above, the function snapshots the effective current assignment as a raw pointer and then triggers render-tree teardown. Per the commit message, that teardown's composed-tree traversal reaches sibling slot elements and calls HTMLSlotElement::assignedNodes, which re-enters ManualSlotAssignment::assignedNodesForSlot. That re-entrant path performs m_slots.ensure(...), an insertion-shaped operation, and when the operation counter crosses the amortized-cleanup threshold the table sweeps null-keyed entries and rehashes — allocating a new bucket array and freeing the old one.

The stale effectiveCurrent then addresses freed memory. effectiveCurrent->size() reads the Vector header out of the freed allocation, and effectiveCurrent->at(i) reads elements through a buffer pointer also read from freed memory. Because WeakPtr equality resolves through the WeakPtrImpl, the comparison effectivePrevious[i] != effectiveCurrent->at(i) dereferences a pointer sourced from the freed region. The fix restores the invariant by making effectiveCurrent an owned local Vector, structurally identical to effectivePrevious, so no pointer into m_slots exists across the teardown window.

The regression test is essentially a minimized PoC and every step is ordinary shadow-DOM script:

  1. host.attachShadow({ mode: 'open', slotAssignment: 'manual' }) installs a ManualSlotAssignment with an empty m_slots.
  2. targetSlot.assign(childA) and otherSlot.assign(childB) create two live m_slots entries, each holding a Slot with a populated cachedAssignment.
  3. The ghost loop — shadowRoot.appendChild(document.createElement('slot')).remove() — inserts and detaches N throwaway slot elements; each insertion leaves an m_slots entry whose HTMLSlotElement weak key goes null once GCController.collect() runs, so the table accumulates sweepable garbage that makes a later cleanup pass actually rehash rather than no-op.
  4. document.body.offsetHeight forces layout so each host has a renderer, which is what makes tearDownRenderersAfterSlotChange perform a real composed-tree walk.
  5. The pump loop drives assignedNodesForSlot repeatedly, advancing the operation counter to just below the threshold — the test brute-forces a band of pump values around it (commented as roughly 2 * table size) precisely because the exact crossing point is implementation-defined.
  6. targetSlot.assign(newSpan) enters slotManualAssignmentDidChange, takes effectiveCurrent as a raw pointer, tears down renderers, and the re-entrant ensure tips the counter over — sweep, rehash, free.
  7. Control returns and scheduleSlotChangeEventIfNeeded reads out of the freed allocation.

For escalation beyond the crash, an attacker would need to reclaim the freed bucket array between the rehash and the read. The free and the read are separated only by the remainder of the teardown walk, so the reclaim would have to come from allocations the teardown itself performs, or from a same-size-class free-list hit engineered beforehand. If a reclaim with attacker-shaped bytes succeeded, the fabricated Vector header at the cachedAssignment offset could yield a controlled buffer pointer and length, and the subsequent WeakPtr comparison would then dereference that controlled pointer to fetch a WeakPtrImpl object pointer — that step could give a probe of arbitrary addresses. Because the only script-observable result is whether slot.enqueueSlotChangeEvent() fires, the realistic best case would be an address-probe / comparison oracle rather than a direct disclosure channel; extracting bytes would require repeating the whole sequence per bit. No write primitive is visible on this path — scheduleSlotChangeEventIfNeeded only reads and compares.

This vulnerability weakens memory safety inside the WebContent process. The security model assumes that DOM-internal caches remain valid for the duration of a single DOM mutation, and that a mutation-triggered render-tree update cannot invalidate state the mutating code is still holding; before the fix that assumption was violated whenever a manual slot reassignment happened to cross the amortized-cleanup threshold. The whole sequence is driven entirely from untrusted script, so an attacker controls both the timing of the free and the heap state around it. Shadow DOM slot assignment and render-tree teardown all execute in the renderer, so this alone would not cross the sandbox boundary; a separate escape would still be required.

The most instructive detail is the asymmetry between the two SlotAssignment subclasses behind the same virtual signature. assignedNodesForSlot returns a borrowed const Vector<...>* for both implementations, but NamedSlotAssignment stores its slots as HashMap<AtomString, std::unique_ptr<Slot>> — the unique_ptr indirection means the Slot and its Vector live in a stable heap allocation that a rehash does not move — whereas ManualSlotAssignment stores Slot inline as the WeakHashMap value type, so the identical borrowed-pointer idiom is pointer-unstable. An interface that hands out interior pointers pushes the lifetime contract onto each implementation, and here one implementation silently violated what the other guaranteed. Two secondary points: WeakHashMap's amortized cleanup makes an apparently benign ensure() a table-reallocating operation, so the usual "only explicit removal invalidates" mental model does not hold; and the exploit conditions require garbage in the table — the ghost-slot loop exists purely to give the sweep something to collect, meaning the bug needs GC pressure on the weak keys, not just re-entrancy.