← All reports

[5] LazyLoadVideoObserver use-after-free after document teardown

MediumWebCore DOM / HTML mediaUAF

A document at refcount zero is still alive — and still cloneable.

b74177d

Medium because the free-to-use window contains no script: the free and the use are consecutive statements in one frame, so an attacker cannot spray into the gap, and the object is TZone type-partitioned. The trigger itself is fully deterministic from ordinary web content, which is what keeps it off the Low end.

C++ destroys an unnamed temporary at the semicolon, which makes any reference borrowed through a temporary smart pointer a lifetime hazard the compiler will not flag unless told to. In WebCore that hazard has a second edge: a Document is reference-counted, but nodes keep it alive through a separate counting mechanism, so releasing the last strong reference on a detached document runs a teardown path rather than deleting the object — and the document then keeps existing at refcount zero, re-referenceable later. Lazy <video> loading depends on a helper the Document owns, and the invariant it needs is that the strong reference protecting that owner outlives every raw reference the owner hands out.

The angle: a page can create a <video> in a detached document, drop it to refcount zero, and then have cloneNode() read from and write into a Document-owned helper after the only reference protecting it was released.

Fix a case where early destruction of the Document could cause a use-after-free of the LazyLoadVideoObserver in LazyLoadVideoObserver::observe(), given that the Document owns the LazyLoadVideoObserver. LIFETIME_BOUND is added to the lazyLoadVideoObserver() getter, as this would have caught the unsafe code; Node::cloneNode is fixed to protect the Document; and the observer is additionally held in a CheckedPtr for extra safety, since Document::m_lazyLoadObserver is nullable. The code change and test are based almost entirely on initial work from Kristian Monsen.

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);
}
 
IntersectionObserver* LazyLoadVideoObserver::intersectionObserver(Document& document)
...
- m_observer = observer.returnValue().ptr();
+ lazyInitialize(m_observer, observer.releaseReturnValue());

Source/WebCore/html/LazyLoadVideoObserver.h

-class LazyLoadVideoObserver {
+class LazyLoadVideoObserver final : public CanMakeCheckedPtr<LazyLoadVideoObserver> {
WTF_MAKE_TZONE_ALLOCATED(LazyLoadVideoObserver);
+ WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR(LazyLoadVideoObserver);
...
- RefPtr<IntersectionObserver> m_observer;
+ const RefPtr<IntersectionObserver> m_observer;

Source/WebCore/dom/Document.h

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

Source/WebCore/dom/Node.cpp

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

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");
+}
+(async function () {
+ const video = createVideoInDetachedDocument();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ if (typeof GCController !== "undefined")
+ GCController.collect();
+ video.cloneNode(false);
+ ...
+})();

LazyLoadVideoObserver::observe() is rewritten: instead of auto& observer = protect(element.document())->lazyLoadVideoObserver(); — which binds a raw reference into Document-owned storage while the protecting Ref<Document> is only a temporary that dies at the semicolon — the function hoists Ref document = element.document(); into a named local living for the whole body, and wraps the observer itself in protect(document->lazyLoadVideoObserver()) (a CheckedRef, since the argument is an lvalue reference and the class is now CanMakeCheckedPtr) before calling ->intersectionObserver(document).

The three supporting changes harden rather than fix. Document.h annotates the getter with LIFETIME_BOUND so the compiler flags exactly the pattern the old observe() used. Node::cloneNode changes cloneNodeInternal(document(), ...) to cloneNodeInternal(protect(document()), ...), pinning the count for the whole clone. And LazyLoadVideoObserver.h makes the class final and CanMakeCheckedPtr with WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR, and turns RefPtr<IntersectionObserver> m_observer into const RefPtr<IntersectionObserver> m_observer initialized once via lazyInitialize(...) instead of a plain assignment.

Binding a long-lived reference to a sub-object reached through a temporary owning smart pointer, so the owner is destroyed at end-of-statement while the reference is still in use.

Smart pointers and protect(...). Ref<T>/RefPtr<T> are WebKit's strong reference-counting smart pointers; the object is destroyed when the last one is released. protect(x) wraps x in the appropriate smart pointer — Ref/RefPtr for reference-counted types, CheckedRef/CheckedPtr for CanMakeCheckedPtr types. Assigning to a RefPtr<T> releases whatever it previously held before storing the new value.

Lifetime of temporaries. An unnamed temporary created inside an expression is destroyed at the end of the full-expression — the semicolon — unless bound directly to a named reference.

LIFETIME_BOUND. A Clang attribute marking that a returned reference's validity is tied to the lifetime of the object the method was called on; the compiler warns when such a return value outlives a temporary receiver. It is a nearly free, compiler-enforced mitigation for an entire bug class.

CanMakeCheckedPtr / CheckedPtr / CheckedRef. Non-owning pointer and reference types with a per-object outstanding-pointer counter; destroying an object while checked pointers to it exist trips a release assertion instead of silently leaving a dangling pointer. WTF_MAKE_TZONE_ALLOCATED separately allocates instances from a type-partitioned heap, so objects of unrelated types are not normally placed in the same slots. lazyInitialize(member, value) is the idiom for assigning a const-declared member exactly once at first use.

Document lifetime in WebCore. A Document is reference-counted, but nodes also keep it alive through a separate node-referencing count. When the last strong reference is released while nodes still reference the document, a last-ref teardown path runs — dropping Document-owned state — instead of deleting the object; the document then continues to exist at refcount zero and can be re-referenced later.

The lazy-video path. IntersectionObserver is WebCore's implementation of the Intersection Observer API. Lazy video loading uses one internal IntersectionObserver per Document, created on first use by LazyLoadVideoObserver::intersectionObserver(), and LazyLoadVideoObserver itself is a Document-owned helper driven from HTMLVideoElement::create() and its destructor. Node::cloneNode / cloneNodeInternal is the DOM clone path; cloning an element constructs a fresh element in the same document. Document.parseHTMLUnsafe() parses a string into a brand-new document not attached to any frame.

The root cause is a dangling reference to a sub-object borrowed through a temporary. protect(element.document()) materialises a Ref<Document> temporary; the getter it calls returns LazyLoadVideoObserver&, a reference into storage owned by that document — which is exactly what the new LIFETIME_BOUND annotation now declares. The temporary dies at the semicolon, so the named reference observer outlives the only strong reference keeping its owner alive, and the next statement touches that storage.

  refcount  state
  --------  --------------------------------------------------
     0      detached Document, alive only via node references
     1      protect(element.document())          <- temporary Ref
     1      lazyLoadVideoObserver() -> LazyLoadVideoObserver&
     0      ';'  temporary released -> last-ref teardown re-runs
     0      observer.intersectionObserver(...)   <- FAILURE WINDOW

In normal operation the document has other strong references, so releasing the temporary is harmless and the bug stays latent. The timeline above is the state the test drives it into, where the temporary holds the only strong reference: re-acquiring at refcount zero and releasing again re-runs the last-ref teardown path. The load-bearing step for the "freed memory" framing is that this teardown destroys or clears the Document-owned LazyLoadVideoObserver; the supplied context does not include Document::removedLastRef() or the member declaration, so that step is inferred from the fix's shape — a whole-function Ref<Document> plus a CheckedRef around the observer is what you write when the observer can die with its owner — and from the test name. If it holds, the subsequent call reads m_observer from freed memory and, on the null branch, executes the pre-patch m_observer = observer.returnValue().ptr();, a RefPtr assignment that both dereferences the stale value it read out of the freed slot and stores a new pointer back into it. If teardown instead leaves the observer allocated but stale, the same statements are a use of logically-dead state rather than freed memory. Node::cloneNode had the same shape one frame up: it passed a raw Document& down, so any transient Ref<Document> taken deeper in the clone produced the same zero→one→zero round trip.

The path is reachable from ordinary web content, and the added layout test is a working trigger:

  1. Document.parseHTMLUnsafe("") creates a frameless document.
  2. createElementNS(..., "video") allocates an HTMLVideoElement inside it and returns only the element to script, so the document's JS wrapper becomes unreachable.
  3. await setTimeout(0) lets the current task finish so nothing on the C++ stack still protects the document, and GCController.collect() collects the wrapper, releasing the last strong reference — the document survives at refcount zero because the video node still references it.
  4. video.cloneNode(false) enters Node::cloneNode and reaches HTMLVideoElement::create()LazyLoadVideoObserver::observe().
  5. protect(element.document()) re-acquires the sole strong reference, the getter hands back the observer reference, and the temporary releases at the semicolon.
  6. The next statement operates on that storage.

Escalation past a crash would require several things to hold simultaneously: the freed slot reclaimed with attacker-influenced bytes, which TZone type-partitioning constrains; reclamation happening inside the free→use window, where no JavaScript runs — the free and the use are consecutive statements in the same frame, so an attacker could not spray there and would have to rely on allocations performed by the teardown itself or by IntersectionObserver::create() landing in the freed slot; and the reclaimed contents at the m_observer offset being a pointer the attacker controls. If all three held, the RefPtr assignment's release of the stale value could give a controlled-pointer refcount decrement, and on the non-null path intersectionObserver->observe(element) could give a controlled-pointer dereference and virtual call. Absent those, the observed effect is a deterministic renderer crash.

This vulnerability weakens memory safety inside the WebContent process. The assumption at stake is that a Document-owned helper remains valid for as long as WebCore code holds a reference to it; before the fix, a page could drive a detached document into the post-teardown, zero-refcount state and then make WebCore read from and write into that helper after the protecting reference had been released — entirely from script. Everything here is renderer-side; a separate escape would still be required for anything beyond renderer compromise.

Takeaway: LIFETIME_BOUND on getters that return T& into lazily-created owned members is a compiler-enforced fix for this whole class, and WebCore is full of Document/TreeScope getters that do not yet carry it — annotating those is a mechanical, high-yield hardening pass.