[6] WebCore DOM UAF in Node::m_shadowIncludingRoot via destructor cascade
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
Source/WebCore/dom/Node.cpp
Patch Details
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.
Background
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.
Analysis
The bug is a use-after-free of Node::m_shadowIncludingRoot. Pre-fix document teardown via Document::removedLastRef → removeDetachedChildrenInContainer 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.
Reaching this from web content follows the path noted by the commit author: load a page that instantiates an HTMLMediaElement (e.g., <video> with a VTT <track>) so the media element registers as an ActiveDOMObject and survives beyond its containing document. Force document teardown by navigating away or otherwise releasing the last document reference. During teardown, the outer removeDetachedChildrenInContainer(document) takes the isInTreeScope() branch, calls notifyChildNodeRemoved, and clears IsConnected/IsInShadowTree on every descendant. The loop's RefPtr then frees <html>; the destructor cascade visits <body> and <video>, but the nested removeDetachedChildrenInContainer calls see isInTreeScope() == false for every direct child and only call resetShadowIncludingRoot() on the child itself — never descending into the media element's shadow tree (VTT cue display containers). Those shadow-tree nodes still cache the freed <html> in m_shadowIncludingRoot. The media element remains live; a subsequent event-loop task such as a VTT cue display update calls shadowIncludingRoot() on a shadow-tree node, reading freed memory.
The immediate primitive is a use-after-free read on a Node-sized allocation that previously held the document's <html> element. To turn the stale read into a corruption primitive, an attacker would groom the renderer heap so the freed slot is reclaimed with attacker-influenced data before the VTT update runs; the dereference would then deliver a controlled-pointer read against any downstream consumer of shadowIncludingRoot(). Escalation to a write primitive depends on which consumer dereferences the result; the diff does not pin this down. The commit author's media/track/webvtt-parser-does-not-leak.html reference confirms the VTT cue display tree as a reachable consumer.
This vulnerability weakens memory safety inside the WebContent process for any DOM subtree containing nodes kept alive by C++-side mechanisms across document teardown. The invariant "every reachable node's m_shadowIncludingRoot points to a live node" is violated whenever an externally referenced subtree survives the destructor cascade of its former shadow-including root.
This is a textbook example of a destructive-walk hazard: phase 1 of teardown both fixes up state and clears the flag that phase 2 uses to decide whether the fix-up walk needs to run again on its inner frames. WebKit DOM teardown has historically relied on notifyChildNodeRemoved as a one-shot "do everything" walk, but the destructor cascade through ~ContainerNode → removeDetachedChildrenInContainer re-enters the same algorithm in a different state regime. Any cached cross-pointer (shadow-including root, focus owner, form controller, tree-scope back-pointers) that is only refreshed via the top branch of that algorithm is a candidate for the same pattern.
Note: Some implementation details — the precise call path through Document::removedLastRef, the identification of HTMLMediaElement's ActiveDOMObject survival as the specific vector, and VTT cue display as the reachable consumer — are inferred from the commit message and surrounding code patterns rather than directly visible in the diff. The core teardown flow and the missing-fix-up window are consistently supported by the patch.
Audit directions
- A fix-up walk gated on a state flag that the same walk clears. Audit any WebCore teardown path that destructures a tree in multiple passes and relies on
isInTreeScope(),isConnected(),isInShadowTree(), or similar flag predicates to decide whether to traverse into the subtree. Start withnotifyNodeRemovedFromDocument/notifyNodeRemovedFromTreeinSource/WebCore/dom/ContainerNodeAlgorithms.cppand grep for other call sites ofsetTreeScopeRecursivelyandresetShadowIncludingRootthat are followed by conditional descents. - Cached cross-pointers on
Node/Elementthat outlive the object they point at because the cache is only refreshed under a single condition. GrepSource/WebCore/dom/Node.hfor raw-pointer caches similar tom_shadowIncludingRoot(e.g., focused element back-pointers, hover ancestor caches, tree-scope back-pointers) and verify every code path that frees the pointee also walks every node that might cache it. The check should specifically include subtrees kept alive byActiveDOMObject-derived classes —HTMLMediaElement,WebSocket-attached nodes, pending fetches. - Objects that survive document teardown via
ActiveDOMObjectand retain DOM substructure. Audit eachActiveDOMObjectsubclass that owns or attaches shadow trees (start withHTMLMediaElementand audio/video derivatives; also checkMediaSession,IntersectionObservertargets) for assumptions about ancestor liveness that hold during normal removal but not duringDocument::removedLastRefteardown. - Recursive destructor cascade re-entering a top-level algorithm whose precondition was set up by an outer caller. Audit any
~ContainerNode/~ShadowRootpath that calls back into a tree-removal helper and verify that helper's loop invariants are still satisfied in the inner frame — especially when the outer frame mutated per-node flags.