9c6099bea06bf812084dc9118bbae1dcda5937a0 Keep observed nodes alive across MutationObserver delivery
Triage note: deliver() calls takeTransientRegistrations() which drops each registration's only strong reference to its observed node while the registration is still used; a GC during the callback frees the node. Fix adds a Vector<GCReachableRef<Node>> declared before the registrations to keep observed nodes (and JS wrappers) alive across delivery.
Contents
The bug at a glance
The bug is reachable from any web page that installs a MutationObserver with a subtree registration and then triggers node removal plus garbage collection, all of which are directly script-controllable. A freed Node is dereferenced through a dangling WeakRef both on the main thread (during disconnect()) and on a GC thread (during isReachableFromOpaqueRoots()), giving an attacker a controllable use-after-free of a DOM node object, which is the classic foundation for renderer RCE, justifying CVSS 8.8.
MutationObserver keeps observed nodes alive through a chain of ownership that quietly relies on a transient registration holding the last strong reference. During delivery, WebKit deliberately drops those transient registrations to reset observation state, but it forgot that this is the observed node’s only remaining owner. So deliver() saws off the branch it is sitting on: it holds the registration alive across the mutation callback, but the node the registration points to has already been freed, leaving a dangling WeakRef<Node> that both disconnect() and the GC thread will happily dereference.
Root cause
A MutationObserverRegistration owns its observed node through a strong reference stored in MutationObserverRegistration::m_nodeKeptAlive, while its structural pointer to the node is a WeakRef<Node>. When a subtree observation sees a descendant removed, a transient registration is created for the removed node, and that transient registration is what pins the observed root node alive. In the test, after div = null and gc() collect the JS wrapper, the registration created by div.removeChild(span) becomes div’s only owner.
MutationObserver::deliver() first walks m_registrations and copies every registration that hasTransientRegistrations() into a local Vector<Ref<MutationObserverRegistration>, 1> transientRegistrations, keeping each registration alive for the rest of the function including across the user callback. It then loops over that vector calling registration->takeTransientRegistrations(), which clears MutationObserverRegistration::m_nodeKeptAlive — dropping the registration’s only strong reference to the observed node.
If that was the last reference anywhere to the observed node, the Node is destroyed immediately, yet the registration object itself is still alive (held by transientRegistrations) and still linked into MutationObserver::m_registrations. The registration’s WeakRef<Node> to the now-freed node is dangling. Any subsequent dereference hits the RELEASE_ASSERT in WeakRef::ptr(): MutationObserver::disconnect() dereferences it on the main thread (the test calls observer.disconnect() inside the callback), and MutationObserverRegistration::isReachableFromOpaqueRoots() dereferences it on a GC marking thread.
The fix declares a Vector<GCReachableRef<Node>, 1> observedNodesToKeepAlive before the transientRegistrations vector so the nodes are destroyed after the registrations, and appends registration->node() into it before each takeTransientRegistrations() call. GCReachableRef additionally keeps the node’s JS wrapper alive, matching how deliver() already protects the transient registration nodes via nodesToKeepAlive.
Key code
MutationObserver::deliver() now pins observed nodes across delivery
void MutationObserver::deliver()
{
ASSERT(canDeliver());
// takeTransientRegistrations() below drops each registration's only strong reference to its
// observed node. Keep those nodes alive, declared first so they outlive the registrations.
// GCReachableRef keeps their JS wrappers alive too.
Vector<GCReachableRef<Node>, 1> observedNodesToKeepAlive;
// Calling takeTransientRegistrations() can modify m_registrations, ...
Vector<Ref<MutationObserverRegistration>, 1> transientRegistrations;
...
if (registration->hasTransientRegistrations())
transientRegistrations.append(WTF::move(registration));
}
for (auto& registration : transientRegistrations) {
observedNodesToKeepAlive.append(registration->node());
nodesToKeepAlive.append(registration->takeTransientRegistrations());
}
Patch walkthrough
Source/WebCore/dom/MutationObserver.cpp— In MutationObserver::deliver(), a newVector<GCReachableRef<Node>, 1> observedNodesToKeepAliveis declared at the very top of the function, deliberately placed before the transientRegistrations vector so that C++ reverse-order destruction tears down the registrations before the nodes they observe. The delivery loop that calls takeTransientRegistrations() now first appends registration->node() into this vector, ensuring the observed node (and, via GCReachableRef, its JS wrapper) survives every dereference that happens after m_nodeKeptAlive is cleared, including the mutation callback, disconnect(), and concurrent GC marking.LayoutTests/fast/dom/MutationObserver/observed-node-collected-during-delivery-crash.html— A regression test that constructs the exact ownership collapse: it observes a div with subtree:true, removes a child span to spawn a transient registration that becomes the div’s sole owner, nulls the div and gc()s to drop its wrapper, then mutates the span to enqueue a record. Inside the callback it calls observer.disconnect() and gc() to force dereference of the (previously dangling) node reference on both the main and GC threads. The expected-txt asserts the page does not crash.
Background
MutationObserverRegistration ownership — Each registration links an observer to an observed node. Its pointer to the node is a WeakRef<Node>, so it does not itself keep the node alive structurally; a separate strong reference in m_nodeKeptAlive is what actually pins the node. Clearing m_nodeKeptAlive without any other owner immediately frees the node while the registration’s weak pointer dangles.
Transient registrations — For subtree observation, when an observed node’s descendant is removed from the tree, a transient registration is created so the observer keeps receiving mutations from the detached subtree. This transient registration also holds the observed node alive, and takeTransientRegistrations() consumes and clears them during delivery.
GCReachableRef — A smart-pointer that both keeps a Node C++ object alive and marks its JavaScript wrapper as reachable to JavaScriptCore’s garbage collector. Using it here is required because a concurrent GC thread walks isReachableFromOpaqueRoots(); a plain Ref would keep the C++ node alive but not stop wrapper collection or protect against the wrapper-vs-node lifetime mismatch.
WeakRef::ptr() RELEASE_ASSERT — WTF WeakRef enforces at runtime that the referenced object is still alive when dereferenced. A dangling dereference does not silently read freed memory; it trips a RELEASE_ASSERT, which is the crash signature observed both on the main thread and on GC marking threads.
Vulnerability window
- Setup — Page creates a MutationObserver and observes a div with {attributes:true, subtree:true}, and the div contains a span.
- Transient registration created — div.removeChild(span) detaches the span subtree, spawning a transient registration that becomes the mechanism keeping div alive.
- Wrapper collected — The script nulls its div reference and calls gc(), collecting div’s JS wrapper and leaving the registration as div’s only remaining owner.
- Record enqueued — span.setAttribute() enqueues a mutation record, scheduling MutationObserver delivery.
- Node freed during delivery — deliver() copies the registration into transientRegistrations, then calls takeTransientRegistrations(), clearing m_nodeKeptAlive and destroying div while the registration remains alive and linked in m_registrations.
- Dangling dereference — The callback runs observer.disconnect() (main thread) and gc() triggers isReachableFromOpaqueRoots() (GC thread), each dereferencing the registration’s dangling WeakRef<Node> and hitting the WeakRef::ptr() RELEASE_ASSERT.
Proof of concept
Taken directly from the shipped regression test. It requires exposed gc() (present in test/debug builds; in the wild an attacker drives GC via allocation pressure rather than an explicit call). The sequence reliably reduces div’s ownership to the single transient registration, then forces deliver() to free div and dereference the resulting dangling reference. It demonstrates the crash but is not a weaponized exploit.
<script>
function callback(records, observer) {
// deliver() dropped the transient registration that kept div alive, but still holds the
// registration. disconnect() and gc() both dereference its reference to div.
observer.disconnect();
gc();
}
let observer = new MutationObserver(callback);
let div = document.createElement("div");
let span = div.appendChild(document.createElement("span"));
observer.observe(div, {attributes: true, subtree: true});
div.removeChild(span); // Creates a transient registration for span, which keeps div alive.
div = null;
gc(); // Collects div's wrapper, leaving the registration as div's only owner.
span.setAttribute("foo", "bar"); // Enqueues a record targeting span, not div.
</script>
Exploitation
- Reduce ownership — Script arranges for a subtree-observed node’s only strong reference to be a transient registration by removing a descendant, dropping all JS references to the node, and forcing wrapper collection through allocation pressure.
- Trigger free-during-delivery — A mutation on the detached subtree schedules delivery; takeTransientRegistrations() then frees the observed node mid-callback, opening the UAF window while the registration and its dangling weak pointer stay alive.
- Reclaim and exploit — An attacker would spray to reoccupy the freed Node allocation with a controlled object, then trigger the dereference (disconnect or GC marking) so the type-confused/dangling access reads or writes attacker-controlled memory. Note the WeakRef RELEASE_ASSERT makes naive dereference crash rather than silently confuse, so practical exploitation depends on winning the race before the assert or on paths that read raw freed memory.
Detection & hunting
For defenders and SOC / detection engineers:
- Crash in WeakRef::ptr() —
- ASAN heap-use-after-free on Node during MutationObserver delivery —
- Suspicious script pattern —
Audit directions
- take/clear on last-owner references** —
- Lifetime ordering of keep-alive vectors —
- Weak references crossed by GC threads —