934ecdc220856de5f97da087b09923355c2c7414 Heap use-after-free in Node::rootNode via stale m_shadowIncludingRoot
Triage note: removeDetachedChildrenInContainer() cleared parent/sibling links but left m_shadowIncludingRoot pointing at a freed tree root, so shadowIncludingRoot()/rootNode() dereferenced freed memory (reproduced via TreeWalker.currentNode on a GC'd detached radio input). Fix adds resetShadowIncludingRoot() to reset the pointer to the node itself.
Contents
The bug at a glance
Triggerable from ordinary web content: a page builds a small detached DOM subtree, keeps only a TreeWalker referencing an inner node, lets GC reclaim the surrounding tree, then touches the node — no privileged access needed. The stale Node::m_shadowIncludingRoot then points into freed memory, and shadowIncludingRoot()/rootNode() return a reference to a destroyed Node, giving a use-after-free read (and subsequent method dispatch, e.g. RadioButtonGroups updates on checked=true) that is a strong RCE-capable corruption primitive in the WebContent process. Web-reachable heap UAF in the DOM core justifies 8.8 High.
The fun here is that WebKit caches a node’s ‘shadow-including root’ as a raw back-pointer (m_shadowIncludingRoot) for fast rootNode() lookups, and that cache is only ever pointed at other nodes — never at the node itself while it is orphaned. When removeDetachedChildrenInContainer() tears down a subtree during GC, it dutifully nulls parent and sibling links so the node looks detached, but it forgets to fix the cached root pointer, leaving it aimed at a tree root that is about to be freed. So you get the classic dangling-cache UAF: the object survives (held by a TreeWalker.currentNode / JS wrapper) but its cached pointer to its old root outlives the root. The one-liner fix — resetShadowIncludingRoot() sets m_shadowIncludingRoot = this — is the tell that the invariant was ‘a detached node is its own root.’
Root cause
Every Node caches Node::m_shadowIncludingRoot, a raw pointer used by the inline rootNode()/shadowIncludingRoot() accessors (shadowIncludingRoot() literally does return *m_shadowIncludingRoot;). While a node is attached, this pointer refers to the top of its (shadow-including) tree so root lookups are O(1) instead of walking parents. The invariant is that for a node with no parent — a detached, standalone node — the pointer must refer to the node itself.
removeDetachedChildrenInContainer() in Source/WebCore/dom/ContainerNodeAlgorithms.cpp runs when a container’s children are being torn down (notably during garbage collection of a disconnected subtree). For each child it does node->setNextSibling(nullptr); node->setParentNode(nullptr); and re-links the container’s first child, correctly severing structural links. But it never updated m_shadowIncludingRoot. A child that survives the teardown (because something outside the subtree still references it) is therefore left with m_shadowIncludingRoot still pointing at the old tree root — which is exactly the memory being freed as the detached tree is collected.
The PoC captures this precisely: an IIFE creates a detached div root containing a radio input, then returns document.createTreeWalker(input). Only the TreeWalker (and thus its currentNode, the input) escapes; the div root has no other reference. GCController.collect() reclaims the div, running removeDetachedChildrenInContainer over its children. The input survives via the TreeWalker/JS wrapper, but its cached m_shadowIncludingRoot still points at the now-freed div. Then treeWalker.currentNode.checked = true operates on the surviving input; setting a radio’s checked state consults the shadow-including root / rootNode() to find the RadioButtonGroups scope, dereferencing the freed div — a heap use-after-free read, followed by operating on the reconstructed ‘root’ object.
The fix adds Node::resetShadowIncludingRoot() (void resetShadowIncludingRoot() { m_shadowIncludingRoot = this; }) in Node.h and calls it inside removeDetachedChildrenInContainer() right after clearing the parent pointer. This restores the invariant that a freshly detached node is its own shadow-including root, so subsequent rootNode()/shadowIncludingRoot() calls on a surviving orphan return the node itself rather than a dangling pointer into the freed tree.
Key code
Detached child left with a dangling cached root; fix repoints it at itself
// ContainerNodeAlgorithms.cpp, removeDetachedChildrenInContainer()
next = node->nextSibling();
node->setNextSibling(nullptr);
node->setParentNode(nullptr);
+node->resetShadowIncludingRoot(); // NEW: m_shadowIncludingRoot = this
container.setFirstChild(next.get());
if (next)
next->setPreviousSibling(nullptr);
// Node.h
Node& shadowIncludingRoot() const { return *m_shadowIncludingRoot; } // deref of stale ptr
+void resetShadowIncludingRoot() { m_shadowIncludingRoot = this; }
Patch walkthrough
Source/WebCore/dom/ContainerNodeAlgorithms.cpp— In removeDetachedChildrenInContainer(), after setNextSibling(nullptr) and setParentNode(nullptr), a call to node->resetShadowIncludingRoot() is inserted. This is the actual fix: as each child is severed from the container it now also repoints its cached shadow-including-root at itself, so a child that survives teardown no longer retains a pointer into the freed tree root.Source/WebCore/dom/Node.h— Adds the inline helpervoid resetShadowIncludingRoot() { m_shadowIncludingRoot = this; }next to shadowIncludingRoot()/rootNode(). It encodes the detached-node invariant (a parentless node is its own root) and gives removeDetachedChildrenInContainer a single, correct way to clear the stale cache.LayoutTests/fast/forms/radio-checked-detached-tree-gc-crash.html— Regression test: it builds a detached div>input(radio) tree, keeps only a TreeWalker on the input, forces GCController.collect() to free the div, then sets currentNode.checked = true. Before the fix this dereferences the stale m_shadowIncludingRoot into freed memory; after the fix the input is its own root and the test prints PASS without crashing.LayoutTests/fast/forms/radio-checked-detached-tree-gc-crash-expected.txt— Expected result file containing just PASS, asserting the corrected code completes without an ASAN UAF report.
Background
m_shadowIncludingRoot cache — A raw Node* each node stores so rootNode()/shadowIncludingRoot() return the tree (or shadow-including) root in O(1) without walking parents. Being a raw, non-owning cache it must be invalidated whenever the node’s position in the tree changes, or it dangles.
removeDetachedChildrenInContainer — The routine that dismantles a container’s child list when a disconnected subtree is being destroyed (e.g. during GC of an unreferenced DOM fragment). It nulls parent/sibling links per child; the bug was that it did not also reset the cached root.
DOM node lifetime vs GC — A DOM node stays alive as long as any JS wrapper or engine object (like a TreeWalker’s currentNode) references it, even after its surrounding tree is collected. This lets a child outlive its former root, which is precisely the condition that turns a stale root cache into a use-after-free.
Radio checked -> rootNode() — Setting a radio input’s checked state must find its RadioButtonGroups scope, which is keyed off the element’s (shadow-including) root; that lookup is what dereferences m_shadowIncludingRoot in the PoC, converting the stale cache into an observable UAF.
Vulnerability window
- Setup — Script builds a detached tree: div (root) with a radio input child, and returns document.createTreeWalker(input) so only the input is externally referenced.
- Cache populated — While attached to the div, the input’s m_shadowIncludingRoot points at the div root.
- Free — GCController.collect() reclaims the unreferenced div; removeDetachedChildrenInContainer clears parent/sibling links but leaves the input’s m_shadowIncludingRoot pointing at the now-freed div.
- Dangling survivor — The input survives via the TreeWalker, holding a stale root pointer into freed memory.
- Use — treeWalker.currentNode.checked = true triggers a rootNode()/shadowIncludingRoot() lookup, dereferencing the freed div and operating on it — the use-after-free.
Proof of concept
This is the shipped regression test. The IIFE ensures the div root has no surviving reference while the radio input does (via the TreeWalker), so collect() frees the div but keeps the input alive with a stale cached root. Assigning checked drives the radio-group lookup through shadowIncludingRoot()/rootNode(), which dereferences the freed div. GCController.collect() is available in test builds; a real-world trigger would rely on natural GC (allocation pressure / idle collection) to reclaim the detached root instead.
<script>
var treeWalker = (function() {
var root = document.createElement('div');
var input = document.createElement('input');
input.type = 'radio';
input.name = 'g';
root.appendChild(input);
return document.createTreeWalker(input); // only 'input' escapes; 'root' is unreferenced
})();
GCController.collect(); // frees the div root; input's m_shadowIncludingRoot now dangles
treeWalker.currentNode.checked = true; // rootNode() deref of freed div -> UAF
</script>
Exploitation
- Free-and-reclaim grooming — Replace GCController.collect() with heap-spray + allocation pressure to force collection of the detached root, then immediately allocate attacker-controlled objects of the freed Node’s size class so the dangling m_shadowIncludingRoot points at controlled bytes.
- Fake-root construction — Shape the reclaiming allocation so the bytes read via shadowIncludingRoot()/rootNode() (and the subsequent RadioButtonGroups/tree operations) act on attacker-chosen fields — e.g. faking type/flags so a downstream cast or virtual dispatch lands on controlled data.
- Escalate the read into a primitive — Chain the dereference into a controlled virtual call or a pointer read to build addrof/fake-object; difficulty is moderate — the free is deterministic under test GC but requires reliable reclaim timing in the wild, and the read site (root lookup) constrains what fields are naturally touched.
Detection & hunting
For defenders and SOC / detection engineers:
- ASan heap-use-after-free in Node methods — UAF reports whose read site is WebCore::Node::shadowIncludingRoot / rootNode / traverseToRootNode, especially reached from RadioButtonGroups or HTMLInputElement::setChecked, and whose freed allocation was a Node destroyed via removeDetachedChildrenInContainer.
- Detached-tree + TreeWalker + GC pattern — Fuzzers can prioritize corpora that create detached subtrees, retain inner nodes via TreeWalker/NodeIterator/Range, force GC, then mutate the retained node — the exact lifetime shape this bug needs.
- Invariant assertion — A debug assertion that a parentless node’s m_shadowIncludingRoot == this (or that shadowIncludingRoot() never returns a freed cell) would flag this and any sibling regression at the point of corruption rather than at use.
Audit directions
- Other m_shadowIncludingRoot writers — Audit every site that mutates parent/tree structure (adoption, moves, shadow attach/detach, fragment teardown) to confirm m_shadowIncludingRoot is updated in lockstep; removeDetachedChildrenInContainer was one missed spot, others may exist in Node/ContainerNode removal paths.
- Other cached tree pointers — Look for analogous raw caches (e.g. treeScope, rootNode-style caches, connected/parent caches) that could be left stale when a surviving node is detached during GC teardown.
- Survivor-during-teardown consumers — Review APIs that can hold a live reference to a node inside a subtree being destroyed — TreeWalker/NodeIterator currentNode, Range boundary points, Selection, MutationObserver records — since they create the outlive-your-root condition that exposes stale caches.