← All issues

[6] WebCore DOM UAF in Node::m_shadowIncludingRoot via destructor cascade

Severity: High | Component: WebCore DOM | 9414a97

Rated High because the diff confirms that document teardown left m_shadowIncludingRoot pointing at a freed <html> element on shadow-tree nodes hanging off externally-referenced HTMLMediaElement subtrees; subsequent VTT cue display updates dereference this stale cache, giving a renderer-reachable UAF read on a Node-sized allocation whose freed slot can be reclaimed via heap grooming.

When a document is torn down via Document::removedLastRef through removeDetachedChildrenInContainer, the <html> element is removed from the document. Since <html> is still in tree scope at this point, notifyChildNodeRemoved is called, which walks the entire subtree — including shadow roots — and sets m_shadowIncludingRoot to <html> for all descendants. This is correct at that moment.

Then <html> is freed when the loop's RefPtr releases it (children do not ref-count their parents — m_parentNode is CheckedPtr). This triggers a destructor cascade: ~ContainerNode(<html>) via removeDetachedChildrenInContainer(<html>) processes <body>, then ~ContainerNode(<body>) processes the <video> element, and so on. Each step calls resetShadowIncludingRoot() on the direct child, fixing that node's cache. However, since IsConnected and IsInShadowTree flags were cleared during the initial notifyChildNodeRemoved walk, isInTreeScope() returns false, so notifyChildNodeRemoved is skipped in this case. This means the shadow root and its descendants are never updated — m_shadowIncludingRoot of these nodes still point to the now-freed <html>.

Nodes kept alive by mechanisms other than JS wrappers — such as HTMLMediaElement which survives as an ActiveDOMObject — retain their shadow DOM with dangling m_shadowIncludingRoot pointers. When these nodes are subsequently used (e.g., VTT cue display tree updates via an event loop task), the stale pointer is dereferenced, causing a use-after-free.

This PR fixes the bug by updating m_shadowIncludingRoot for removed subtrees when the root's refCount is greater than 1 (i.e. there is an external reference to the node beyond the RefPtr in removeDetachedChildrenInContainer).

No new tests since existing media tests such as media/track/webvtt-parser-does-not-leak.html would hit debug assertions without this fix, and this bug requires a node to be kept alive by C++ code.

Source/WebCore/dom/ContainerNodeAlgorithms.cpp

node->setTreeScopeRecursively(Ref<Document> { container.document() });
if (node->isInTreeScope())
notifyChildNodeRemoved(container, *node);
+ else if (node->refCount() > 1)
+ node->updateShadowIncludingRootForSubtree();
ASSERT_WITH_SECURITY_IMPLICATION(!node->isInTreeScope());

Source/WebCore/dom/Node.cpp

+void Node::updateShadowIncludingRootForSubtree()
+{
+ SUPPRESS_UNCOUNTED_LOCAL for (auto* current = this; current; current = NodeTraversal::next(*current, this)) {
+ current->updateShadowIncludingRoot();
+ SUPPRESS_UNCOUNTED_LOCAL if (auto* shadowRoot = current->shadowRoot())
+ shadowRoot->updateShadowIncludingRootForSubtree();
+ }
+}

The patch adds a new Node::updateShadowIncludingRootForSubtree() method (declared in Node.h, defined in Node.cpp) that walks a subtree via NodeTraversal::next and recursively descends into shadow roots, calling updateShadowIncludingRoot() on every node. In removeDetachedChildrenInContainer (ContainerNodeAlgorithms.cpp), a new else if (node->refCount() > 1) node->updateShadowIncludingRootForSubtree(); branch is added immediately after the existing if (node->isInTreeScope()) notifyChildNodeRemoved(...) check. This covers the case where a removed top-level child is no longer in tree scope (so the standard removal walk is skipped) but is externally referenced (refCount > 1), ensuring the m_shadowIncludingRoot cache on the subtree — including shadow roots — is refreshed before the previous shadow-including root is freed.

Cached root pointer left dangling because the cache-refresh walk is skipped on later iterations whose precondition was destructively cleared by an earlier iteration.

In WebKit's DOM ownership model, a child node holds its parent via CheckedPtr m_parentNode, not a RefPtr — so children do not keep parents alive; the document tree is kept alive top-down via RefPtr linkage from parents and from external owners. m_shadowIncludingRoot is a cached raw pointer on every Node storing the result of the shadow-including-root computation so callers (style, accessibility, VTT cue plumbing, and others) do not need to walk to the top on every query. The cache is normally maintained by notifyChildNodeRemoved, which traverses the removed subtree and descends through shadow roots to recompute or reset each node's m_shadowIncludingRoot.

Document::removedLastRef is the deferred path that fires when the last reference to a Document is released; it calls removeDetachedChildrenInContainer to detach the document's children before the document itself is destroyed. ActiveDOMObject is a mechanism that lets certain DOM objects — HTMLMediaElement is the canonical example — outlive their normal DOM lifetime because they have pending asynchronous work registered with the script-execution context.

IsInShadowTree/IsConnected are per-node state flags that isInTreeScope() consults; they are cleared as part of removingSteps. NodeTraversal::next(current, root) is the standard pre-order subtree walker used throughout WebCore.

The bug is a use-after-free of Node::m_shadowIncludingRoot. Pre-fix document teardown via Document::removedLastRefremoveDetachedChildrenInContainer followed a single pass that walked top-level children. On the first iteration with <html>, isInTreeScope() was true, so notifyChildNodeRemoved walked the entire subtree (including shadow roots) running removingSteps, which cleared the IsConnected and IsInShadowTree flags on every descendant. When the loop's RefPtr<Node> then released <html>, the destructor cascade ran: ~ContainerNode(<html>)removeDetachedChildrenInContainer(<html>) processed <body>, then ~ContainerNode(<body>) processed children like <video>, recursively. At each nested level, only resetShadowIncludingRoot() was called on the direct child being detached; the descent into notifyChildNodeRemoved was guarded by isInTreeScope(), which already returned false because the earlier walk had cleared the flags. The result is that shadow roots hanging off <body>, <video>, and every other descendant never had their m_shadowIncludingRoot updated, so they still pointed at the original <html> — which had just been freed when its RefPtr released.

🔒

Explores how a single destructive teardown walk silently invalidates the precondition its own recursive re-entry depends on, and how far the resulting dangling cache can reach.

Subscribe to read more

🔒

Four reusable audit patterns covering destructive teardown walks, cached cross-pointers on DOM nodes, ActiveDOMObject lifetime survival, and recursive destructor re-entry — each with concrete starting points in WebCore DOM internals.

Subscribe to read more