← All issues

[1] LazyLoadVideoObserver use-after-free on cloning a video in a torn-down document

Severity: High | Component: WebCore DOM / HTML lazy-loading | b74177d

Rated High because the diff fixes a web-reachable use-after-free in the renderer: cloneNode() on a video element in a detached, GC-eligible document reaches a freed Document-owned LazyLoadVideoObserver, and the dereference happens on a deterministic, attacker-driven trigger; escalation to a stronger primitive requires reclaiming the freed TZone slot under heap grooming, which the object's limited attacker-controlled fields make non-trivial but do not block.

A REGRESSION(314874@main) crash report traced a use-after-free of the LazyLoadVideoObserver in LazyLoadVideoObserver::observe() after early destruction of the owning Document. Because the Document owns the LazyLoadVideoObserver, an operation that uses the observer must keep the document alive for the full duration. The fix holds a Ref to the Document across the clone, adds LIFETIME_BOUND to the lazyLoadVideoObserver() getter so the static analyzer would have flagged the unsafe code, and hardens LazyLoadVideoObserver with CanMakeCheckedPtr. The code and test derive largely from initial work by Kristian Monsen.

Source/WebCore/dom/Node.cpp

Ref<Node> Node::cloneNode(bool deep) const
{
RefPtr registry = CustomElementRegistry::registryForNodeOrTreeScope(*this, treeScope());
- return cloneNodeInternal(document(), deep ? CloningOperation::Everything : CloningOperation::SelfOnly, registry.get());
+ return cloneNodeInternal(protect(document()), deep ? CloningOperation::Everything : CloningOperation::SelfOnly, registry.get());
}

Source/WebCore/html/LazyLoadVideoObserver.cpp

void LazyLoadVideoObserver::observe(HTMLVideoElement& element)
{
- auto& observer = protect(element.document())->lazyLoadVideoObserver();
- RefPtr intersectionObserver = observer.intersectionObserver(protect(element.document()));
- if (!intersectionObserver)
- return;
- intersectionObserver->observe(element);
+ Ref document = element.document();
+ if (RefPtr intersectionObserver = protect(document->lazyLoadVideoObserver())->intersectionObserver(document))
+ intersectionObserver->observe(element);
}

Source/WebCore/dom/Document.h

- LazyLoadVideoObserver& lazyLoadVideoObserver();
+ LazyLoadVideoObserver& lazyLoadVideoObserver() LIFETIME_BOUND;

LayoutTests/fast/dom/lazy-video-clone-after-document-teardown-crash.html

+function createVideoInDetachedDocument() {
+ const document = Document.parseHTMLUnsafe("");
+ return document.createElementNS("http://www.w3.org/1999/xhtml", "video");
+}
+ const video = createVideoInDetachedDocument();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ GCController.collect();
+ video.cloneNode(false);

Three production changes plus hardening. Node::cloneNode now wraps the document in protect(document()) before calling cloneNodeInternal, holding a Ref<Document> for the entire clone. LazyLoadVideoObserver::observe is rewritten to take a single function-scoped Ref document = element.document() and to wrap the document-owned observer in protect(...) (now a CheckedRef, since the type became CanMakeCheckedPtr) before calling intersectionObserver(document). The old code obtained auto& observer = protect(element.document())->lazyLoadVideoObserver(); under a statement-scoped temporary Ref and then used observer on the next statement. Document::lazyLoadVideoObserver() gains LIFETIME_BOUND; LazyLoadVideoObserver is made final and CanMakeCheckedPtr, and m_observer becomes a const RefPtr<IntersectionObserver> initialized via lazyInitialize.

Failure to hold an owning reference to a parent object across an operation that uses one of its owned sub-objects, letting the sub-object's reference dangle when the parent is destroyed.

WebCore manages object lifetime with Ref<T>/RefPtr<T>, smart pointers that keep an object alive while held. A Ref created from a temporary expression — protect(x) used inline — lives only until the end of the full statement (the semicolon). A Document owns various helper objects as members, including m_lazyLoadObserver, a LazyLoadVideoObserver; when the Document is destroyed, those members are destroyed with it. LazyLoadVideoObserver lazily creates an IntersectionObserver (m_observer) to watch <video> elements and trigger loading when they intersect the viewport; its observe()/intersectionObserver() entry points fetch the per-document instance and operate on it.

Node::cloneNode delegates to cloneNodeInternal, which constructs a copy of the element and runs element setup that, for video elements, registers the clone with the document's LazyLoadVideoObserver. LIFETIME_BOUND is a Clang annotation that makes the static analyzer flag callers who let a returned reference outlive the object it was derived from. CanMakeCheckedPtr/CheckedPtr is a WebKit smart-pointer scheme that, in hardened builds, asserts the pointee has not been freed when dereferenced.

This is a use-after-free from a lifetime-management error: a reference into an owned sub-object outliving its owner. Before the fix, Node::cloneNode passed document() — a bare Document& — into cloneNodeInternal without taking a Ref, so nothing on the stack kept the document alive while the cloned video element was constructed and registered for lazy loading. Cloning a video element reaches LazyLoadVideoObserver::observe(element), which obtained auto& observer = protect(element.document())->lazyLoadVideoObserver();. Here protect(element.document()) produces only a statement-scoped temporary Ref<Document> released at the semicolon, while observer is a long-lived reference into the document-owned object.

Once the protecting Ref is gone and the document is the detached, GC-eligible one from the trigger, the Document can be torn down, destroying the embedded LazyLoadVideoObserver; the subsequent observer.intersectionObserver(...) call dereferences freed memory.

🔒

The ownership and lifetime model behind this crash is traced end to end, with an assessment of whether the freed object can be turned into more than a crash.

Subscribe to read more

🔒

Multiple reusable audit patterns identified for finding sibling lifetime bugs across the DOM and lazy-loading subsystems, with concrete starting points.

Subscribe to read more