[3] UAF in ManualSlotAssignment via WeakHashMap rehash during reentrant composed-tree teardown
A shadow-DOM cache that outlived the table it was stored inside.
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
LayoutTests/fast/shadow-dom/manual-slot-assign-renderer-teardown-crash.html
Patch Details
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.
Background
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.
Analysis
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:
host.attachShadow({ mode: 'open', slotAssignment: 'manual' })installs aManualSlotAssignmentwith an emptym_slots.targetSlot.assign(childA)andotherSlot.assign(childB)create two livem_slotsentries, each holding aSlotwith a populatedcachedAssignment.- The ghost loop —
shadowRoot.appendChild(document.createElement('slot')).remove()— inserts and detaches N throwaway slot elements; each insertion leaves anm_slotsentry whoseHTMLSlotElementweak key goes null onceGCController.collect()runs, so the table accumulates sweepable garbage that makes a later cleanup pass actually rehash rather than no-op. document.body.offsetHeightforces layout so each host has a renderer, which is what makestearDownRenderersAfterSlotChangeperform a real composed-tree walk.- The pump loop drives
assignedNodesForSlotrepeatedly, advancing the operation counter to just below the threshold — the test brute-forces a band of pump values around it (commented as roughly2 * table size) precisely because the exact crossing point is implementation-defined. targetSlot.assign(newSpan)entersslotManualAssignmentDidChange, takeseffectiveCurrentas a raw pointer, tears down renderers, and the re-entrantensuretips the counter over — sweep, rehash, free.- Control returns and
scheduleSlotChangeEventIfNeededreads 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.
Insight
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.
Audit directions
-
An accessor that returns a raw pointer or reference into a container's internal storage, whose result is then held across a call that can insert into or otherwise reallocate that container. The invariant is an interior pointer into a hash table or vector is valid only until the next operation that may reallocate the table — hard to hold because the invalidating operation is often several frames deep and looks read-only from the call site. Narrow: grep
Source/WebCore/domandSource/WebCore/htmlfor methods whose return type isconst Vector<...>*/T*sourced from aHashMap/WeakHashMapvalue, and check each caller for an intervening call into style or render-tree machinery —SlotAssignment::assignedNodesForSlotis the archetype, andShadowRoot/HTMLSlotElementaccessors are the immediate neighbourhood. Wider: the same class shows up with any borrowed handle into resizable storage — a cachedVector::data()pointer, aHashMapiterator held acrossadd(), aStringImplcharacter buffer captured before a mutation — audit anywhere WebCore caches such a handle before calling into layout, style resolution, or event dispatch. Widest: this is the classic iterator/reference-invalidation class present in any language with unstable container storage — C++std::unordered_maprehash, Go map growth, Java'sConcurrentModificationException, and the exact class Rust's borrow checker exists to forbid. Match tell (narrow): a local raw pointer assigned from a container lookup with any non-trivial call between the assignment and its last dereference. Match tell (wider): a borrowed handle whose provenance is a resizable buffer, crossing a call boundary the auditor cannot prove is allocation-free. Match tell (widest): ask "between borrow and use, can anything reach a code path that grows or compacts the owner?" — if the answer requires reading three call levels down, treat it as a hit until proven otherwise. -
Containers with hidden self-maintenance, where a nominally read-ish or merely-insert operation triggers a sweep-and-rehash that invalidates outstanding references. The danger is that the invalidation point is invisible at the call site and probabilistic — it depends on an internal counter and on how much garbage has accumulated, so the bug is latent under light load and fires only under attacker-chosen pressure. Narrow: grep
Source/WebCoreforWeakHashMap<andWeakHashSet<declarations whose value type is a non-pointer struct stored inline (as withManualSlotAssignment::m_slotsholdingSlotby value), then audit every.ensure(/.add(/.get(on those tables for callers that retain a pointer or reference to the value. Wider: the same shape applies to any lazily-compacting structure in the tree — caches that evict on lookup, GC-swept side tables, refcount-sweeping registries — where the auditor should look for an operation documented as amortized or deferred that nonetheless reallocates. Widest: any container that defers cleanup can perform that cleanup during an operation the caller thinks is cheap, invalidating every outstanding reference at that moment — it holds for Java'sWeakHashMap.expungeStaleEntries, Python'sWeakValueDictionarycallbacks, ephemeron tables in Lua/JSWeakMapimplementations, and Go's incremental map growth. Match tell (narrow): an inline-value weak table whose value is handed out by address. Match tell (wider): a container API where insertion and cleanup share an entry point. Match tell (widest): carry the question "does this container ever do work on behalf of dead entries, and if so, when?" — if the answer is "on the next operation, whichever that is", every borrowed reference into it is suspect. -
A DOM mutation handler that snapshots state, calls into style or render-tree update, and then consumes the pre-call snapshot — where the update path can re-enter the very DOM structure that produced the snapshot. The invariant is render-tree teardown and style resolution are re-entrancy boundaries into DOM data structures, not leaf calls. Narrow: trace callers of
RenderTreeUpdater::tearDownRenderersAfterSlotChangeand the other teardown/update entry points inSource/WebCore/rendering/updating/RenderTreeUpdater.cpp, and for each caller check whether any local computed before the call is still read after it —slotManualAssignmentDidChangeis the fixed instance, and its sibling handlers (didRemoveManuallyAssignedNode,slotFallbackDidChange,willRemoveAssignedNode,didRemoveAllChildrenOfShadowHost) are the immediate candidates. Wider: the same class covers any WebCore method that caches state and then calls something that can reachComposedTreeIterator, forced layout,Style::TreeResolver, event dispatch, or a script callback — enumerate methods that callinvalidateStyleAndRenderersForSubtreeor force layout mid-mutation and audit their post-call reads. Widest: this is the general callback- or reentrancy-invalidated cached state class, applicable to any system where a mutation notification can synchronously re-enter the mutated structure — DOM observer delivery, UI framework layout passes that fire measure callbacks, and database triggers that re-enter the table being written. Match tell (narrow): a local pointer, index, or iterator whose definition precedes a teardown/update call and whose last use follows it. Match tell (wider): any function where the same object graph appears on both sides of a call into layout/style/script. Match tell (widest): "after handing control to a subsystem that can call back into me, nothing I computed beforehand about shared mutable state is still trustworthy".