← WebKit Silent-Fix Report — 2026-W23

2757278b899d7543c54534314ddf8d7026ce7349  [intersection-observer] IntersectionObserver::updateObservations could modify m_observationTargets when iterating through it

severity high class UAF confidence 0.90 WebCore IntersectionObserver exploitable-grade
Kiet Ho Mon Jun 1 13:46:49 2026 -0700 full: 2757278b899d7543c54534314ddf8d7026ce7349 bug report ↗ view on GitHub ↗
Primitive: Iterator invalidation of m_observationTargets in updateObservations
Triage note: IntersectionObserver::updateObservations iterated observationTargets() directly while loop bodies (callbacks/JS) could mutate m_observationTargets; fix iterates over a copied vector of Ref targets, closing an iterator-invalidation use-after-free reachable from script.
Contents

The bug at a glance

The bug is reachable purely from untrusted script: any page that registers an implicit-root IntersectionObserver on an element whose owning document is not fully active drives updateObservations into the vulnerable branch. Reaching the freed iterator yields a use-after-free on a ref-counted Element during layout/rendering, giving an attacker a controllable freed-object reuse primitive in the WebContent process. High severity with CVSS 8.1 matches the assigned rating: no user interaction beyond visiting a page, and memory corruption impact, though the trigger window depends on refcount timing.

This is a textbook iterator-invalidation UAF hiding inside a safety check that was itself added to prevent a different leak. Commit 313834@main taught updateObservations to skip targets whose document isn’t fully active and to drop the first-observation keep-alive for them via m_targetsWaitingForFirstObservation.removeFirstMatching. But that keep-alive was sometimes the last strong reference to the Element, so dropping it inside the loop can synchronously destroy the very Element being iterated, and the Element’s destructor re-enters IntersectionObserver::unobserve to mutate the same m_observationTargets the loop is walking. The range-based for over observationTargets() then keeps stepping through a vector that just had an element yanked out from under it. The fix is one line of hygiene: iterate a copy.

Root cause

IntersectionObserver holds two containers that matter here: m_observationTargets, the live set of observed Elements, and m_targetsWaitingForFirstObservation, a set of Ref-counted Elements kept alive only until their first observation is delivered. updateObservations(const Frame&) iterates the observation targets to compute intersections. Before the fix it wrote for (auto& target : observationTargets()), taking references directly into the backing store of m_observationTargets.

The reaching path is entirely script-driven. A page creates an implicit-root observer (no explicit root()) and observes an Element whose owning Document is not fully active — for example an element adopted from a document created via document.implementation.createHTMLDocument, or one detached from the active tree. On the next observation pass the branch if (!root() && !target.document().isFullyActive()) is taken, and the code calls m_targetsWaitingForFirstObservation.removeFirstMatching(…) to drop that Element’s first-observation keep-alive.

That removal is unsafe because the keep-alive can be the last strong reference. When removeFirstMatching destroys the Ref<Element>, the Element’s destructor runs synchronously, and because the Element is still registered with this observer its teardown calls IntersectionObserver::unobserve, which removes the Element from m_observationTargets. The range-based for loop holds an iterator (and a reference target) into m_observationTargets, so the container it is walking is mutated mid-iteration. The reference target now aliases freed/relocated storage, and continued loop iterations — or the very continue that advances the invalidated iterator — dereference freed memory: a use-after-free. ASan caught it on media/destructor-logging-crash.html.

The fix snapshots the container before iterating: auto observationTargets = m_observationTargets; copies the vector, and for (Ref target : observationTargets) iterates the copy while holding its own strong Ref on each target. Now any reentrant unobserve() mutates only the original m_observationTargets, leaving the loop’s copy and its iterators valid; and because each target is a Ref, the Element cannot be freed for the duration of the loop body even if its keep-alive is dropped. All body uses are rewritten from target. to target->.

Key code

IntersectionObserver::updateObservations — iterate a copy, hold a Ref per target

-    for (auto& target : observationTargets()) {
+    // Iterate on a copy of m_observationTargets, in case something in the loop mutates it.
+    auto observationTargets = m_observationTargets;
+    for (Ref target : observationTargets) {
         // ... skip targets whose document is not fully active ...
-        if (!root() && !target.document().isFullyActive()) {
+        if (!root() && !target->document().isFullyActive()) {
             m_targetsWaitingForFirstObservation.removeFirstMatching([&](auto& pendingTarget) {
-                return pendingTarget.ptr() == &target;
+                return pendingTarget.ptr() == target.ptr();
             });
             continue;
         }
-        auto& targetRegistrations = target.intersectionObserverDataIfExists()->registrations;
+        auto& targetRegistrations = target->intersectionObserverDataIfExists()->registrations;

Patch walkthrough

  • Source/WebCore/page/IntersectionObserver.cpp — The single hunk in updateObservations replaces for (auto& target : observationTargets()) with a local snapshot auto observationTargets = m_observationTargets; iterated as for (Ref target : observationTargets). This decouples the loop’s iteration domain from the live m_observationTargets that reentrant unobserve() mutates, and the Ref gives each target a strong reference for the body’s lifetime. The pointer-identity check inside removeFirstMatching is updated from pendingTarget.ptr() == &target to pendingTarget.ptr() == target.ptr(), and every member access (target.document(), target.intersectionObserverDataIfExists(), target.renderer()) is converted to arrow syntax to match the new Ref type.

Background

IntersectionObserver::updateObservations — Per-frame routine that walks a WebContent observer’s registered targets, computes each target’s intersection with the root, and queues notifications. It runs during the rendering update, so any reentrancy it triggers happens synchronously within layout.

m_targetsWaitingForFirstObservation — A set of Ref<Element> that keeps observed elements alive until their first observation is delivered. Because it can hold the only strong reference to an Element, removing an entry can synchronously destroy that Element.

IntersectionObserver::unobserve reentrancy — An observed Element’s destructor de-registers the element from its observers, which mutates m_observationTargets. This is the reentrant mutation that invalidates an in-flight iterator over the same container.

313834@main — The prior commit that introduced the not-fully-active skip branch and the removeFirstMatching keep-alive drop. It fixed a document-leak but created the reentrancy window this patch closes.

Vulnerability window

  1. Pre-existing design — updateObservations iterated m_observationTargets by reference via observationTargets(), assuming the container is stable across the loop body.
  2. Regression introduced (313834@main) — A skip branch for not-fully-active targets began calling m_targetsWaitingForFirstObservation.removeFirstMatching inside the loop, which can drop the last reference to a target and synchronously destroy it.
  3. Latent UAF — Element destruction reenters unobserve(), mutating m_observationTargets while the for loop iterates it; the iterator/reference now aliases freed storage.
  4. Discovery — The ASan bot flagged the use-after-free running media/destructor-logging-crash.html (bug 315957, rdar://178339073).
  5. Fix (314315@main) — updateObservations now snapshots m_observationTargets into a local vector and iterates Refs of it, isolating the loop from reentrant mutation and pinning each target alive.

Proof of concept

Reconstructed conceptually from the patch narrative (the actual repro is the internal media/destructor-logging-crash.html). The idea: register an implicit-root IntersectionObserver on an element whose owning document is not fully active, so updateObservations enters the removeFirstMatching branch and drops the element’s only remaining reference during iteration. Reliability depends on the element’s refcount being held solely by m_targetsWaitingForFirstObservation at that moment, which is timing-sensitive; no memory-control primitive is claimed here.

<script>
// Conceptual trigger, not a weaponized exploit.
const io = new IntersectionObserver(() => {});
// Element belonging to a not-fully-active document.
const doc = document.implementation.createHTMLDocument('');
const target = doc.createElement('div');
io.observe(target);   // adds to m_observationTargets + first-observation keep-alive
// Drop all script references so the keep-alive is the last strong ref.
// On the next rendering update, updateObservations takes the
// !isFullyActive() branch, removeFirstMatching destroys the element,
// its destructor reenters unobserve() and mutates the vector being iterated.
</script>

Exploitation

  1. Trigger — Script observes an implicit-root target whose document is not fully active and arranges for the first-observation keep-alive to hold the sole strong reference, then waits for a rendering update to enter updateObservations.
  2. Free during iteration — removeFirstMatching destroys the Element; its destructor calls unobserve(), removing the entry from m_observationTargets while the range-based for still references it — the loop now walks freed/relocated storage.
  3. Reuse (theoretical) — Turning the freed Element access into a controlled read/write would require heap grooming to reoccupy the freed allocation before the loop dereferences it; the patch provides no primitive and the window is narrow, so weaponization is non-trivial.

Detection & hunting

For defenders and SOC / detection engineers:

  • ASan use-after-free in IntersectionObserver::updateObservations — A heap-use-after-free with the free frame in an Element destructor / IntersectionObserver::unobserve and the use frame inside the updateObservations loop is the exact signature; media/destructor-logging-crash.html reproduces it.
  • Reentrant container mutation — Instrument m_observationTargets mutation (unobserve) and assert it is not called while updateObservations is iterating the live container in unpatched builds.
  • Crash telemetry near rendering update — Segfaults or vtable-corruption crashes whose stacks pass through IntersectionObserver::updateObservations during the page rendering update warrant inspection for this pattern.

Audit directions

  • Loops over ref-counted containers in WebCore — Audit any for (auto& x : someMembers()) where the body can drop the last reference to an element (keep-alive sets, removeFirstMatching, clear) and thereby reenter code that mutates the same container. Prefer snapshot-and-Ref iteration.
  • Other IntersectionObserver mutation sites — Review takeRecords, notify, and root/target teardown paths for the same live-container-during-callback hazard, since observer callbacks run arbitrary script.
  • Keep-alive sets as sole owners — Wherever a Ref<Element> set (like m_targetsWaitingForFirstObservation) may be the last owner, treat removal as potentially destructive/reentrant and sequence it outside active iteration.

Before / after

Loading diff…