200be66fc92b2ef1bd3c008a5dd5a83b0cb67b26 UAF: Data race in IntersectionObserver::isReachableFromOpaqueRoots - GC helper reads main-thread-freed WeakListHashSet node
Triage note: Adds m_observationTargetsLock (shipping #else of RC_REMOVE_SEC_ACCEL_3) guarding m_observationTargets/m_pendingTargets/m_targetsWaitingForFirstObservation, including in isReachableFromOpaqueRoots which runs on the GC/AbstractSlotVisitor thread concurrently with observe/unobserve/notify mutating the WeakListHashSet - an unsynchronized read/write race.
The bug at a glance
IntersectionObserver (Source/WebCore/page/IntersectionObserver.cpp/.h) read its m_observationTargets WeakListHashSet from isReachableFromOpaqueRoots(), which runs on the JSC garbage-collection marking thread (JSC::AbstractSlotVisitor), with no synchronization against observe()/unobserve()/notify()/targetDestroyed()/appendQueuedEntry() mutating the same WeakListHashSet and Vectors on the main thread. The patch adds a m_observationTargetsLock (WTF_GUARDED_BY_LOCK) around all accesses, including the GC-thread read. This is a genuine cross-thread data race on a hash-set/vector in the WebContent process; concurrent read during a bucket mutation/rehash can observe a torn or dangling bucket pointer. Severity high is reasonable as a race; escalation to a readable UAF of a freed bucket is plausible but inferred from the data structure, not shown as an exploit in the diff.
The dangerous reader is the GC marking thread: isReachableFromOpaqueRoots walks the observer’s target set concurrently with ordinary DOM-script mutations, so a routine observe()/unobserve() races a collector thread touching the same WeakListHashSet.
Root cause
The invariant is that a container touched by the GC marking thread (via isReachableFromOpaqueRoots) must be synchronized with the main-thread mutators of that container. IntersectionObserver stores WeakListHashSet<Element, WeakPtrImplWithEventTargetData> m_observationTargets, plus Vector<GCReachableRef<Element>> m_pendingTargets and m_targetsWaitingForFirstObservation. isReachableFromOpaqueRoots(JSC::AbstractSlotVisitor& visitor) iterates for (auto& target : m_observationTargets) if (containsWebCoreOpaqueRoot(visitor, target)) return true; to keep the observer’s JS wrapper alive while it observes live targets.
Pre-patch race: 1. The GC marking thread calls isReachableFromOpaqueRoots and begins iterating m_observationTargets with no lock. 2. Concurrently on the main thread, script calls observe() → m_observationTargets.add(target) (and appends to m_targetsWaitingForFirstObservation), or unobserve()/targetDestroyed() → m_observationTargets.remove(...), or notify()/appendQueuedEntry() mutating m_pendingTargets. 3. An add that triggers a rehash reallocates the hash table’s backing, or a remove rearranges/invalidates buckets, while the GC thread is mid-iteration over the old layout. 4. The GC-thread iterator dereferences a stale/freed bucket or reads a half-updated entry — an unsynchronized read/write race with undefined behavior.
Concrete types matter: m_observationTargets is a WeakListHashSet keyed by WeakPtrImplWithEventTargetData, so it is a real intrusive hash container being resized, not an atomic. GCReachableRef keeps elements alive for GC purposes but does not serialize structural mutation of the Vectors. The patch adds mutable Lock m_observationTargetsLock and annotates all three members WTF_GUARDED_BY_LOCK, taking a Locker in isObserving, observationTargets(), takeAllTargets(), takeTargetsWaitingForFirstObservation(), observe()’s add block, unobserve(), takeRecords(), targetDestroyed(), appendQueuedEntry(), hasObservationTargets/hasPendingTargets/hasTargetsWaitingForFirstObservation, and isReachableFromOpaqueRoots. The triage bugclass (Race) matches the diff exactly.
Key code
GC-thread opaque-root walk now takes the same lock main-thread mutators use
bool IntersectionObserver::isReachableFromOpaqueRoots(JSC::AbstractSlotVisitor& visitor) const
{
#ifndef RC_REMOVE_SEC_ACCEL_3
Locker locker { m_observationTargetsLock };
#endif
for (auto& target : m_observationTargets) {
if (containsWebCoreOpaqueRoot(visitor, target))
return true;
// members now: m_observationTargets WTF_GUARDED_BY_LOCK(m_observationTargetsLock); (and m_pendingTargets, m_targetsWaitingForFirstObservation)
Fix approach
The patch introduces mutable Lock m_observationTargetsLock and places m_observationTargets, m_pendingTargets, and m_targetsWaitingForFirstObservation under WTF_GUARDED_BY_LOCK, then wraps every access — main-thread mutators and the GC-thread isReachableFromOpaqueRoots read alike — in a Locker. It also factors accessors (observationTargets() now returns a copied Vector<Ref> instead of exposing the raw set, takeAllTargets()/takeTargetsWaitingForFirstObservation() std::exchange under the lock) so callers operate on snapshots. updateObservations now iterates this->observationTargets() (a locked copy) instead of copying the raw member. The GC marking thread’s walk collapses to a lock-protected iteration over a stable set, and ASSERTs are reworded to use locked helpers (hasTargetsWaitingForFirstObservation/hasPendingTargets).
Patch walkthrough
Source/WebCore/page/IntersectionObserver.cpp— All accessors/mutators of the target collections (isObserving, observe, unobserve, disconnect, takeRecords, targetDestroyed, removeAllTargets, appendQueuedEntry, notify, updateObservations) and the GC-thread isReachableFromOpaqueRoots take a Locker on m_observationTargetsLock; new helpers observationTargets()/takeAllTargets()/takeTargetsWaitingForFirstObservation() return locked snapshots.Source/WebCore/page/IntersectionObserver.h— Adds <wtf/Lock.h>, declaresmutable Lock m_observationTargetsLock, annotates m_observationTargets/m_pendingTargets/m_targetsWaitingForFirstObservation with WTF_GUARDED_BY_LOCK, replaces the raw-set observationTargets() getter with a Vector<Ref>-returning one, and adds locked hasPendingTargets()/hasTargetsWaitingForFirstObservation().
Background
isReachableFromOpaqueRoots — A WebCore/JSC GC hook: during marking, the collector asks whether a C++ object’s JS wrapper should stay alive because it transitively reaches live opaque roots (here, observed Elements).
JSC::AbstractSlotVisitor / GC marking thread — JSC marks the heap on one or more collector threads that run concurrently with the main thread; code reached from a visitor must be thread-safe against main-thread mutation.
WeakListHashSet — A WTF hash set of weak references (WeakPtrImplWithEventTargetData here) that drops entries when the referent dies; structurally mutated by add/remove and rehash like any hash table.
WTF_GUARDED_BY_LOCK / Locker — WTF’s Clang thread-safety annotation plus RAII lock; GUARDED_BY_LOCK makes the compiler flag unlocked accesses to the annotated member.
GCReachableRef — A smart ref that keeps a DOM node alive for GC-reachability bookkeeping; it governs lifetime, not concurrent structural access to the Vectors that hold it.
Vulnerability window
- Setup — Script creates an IntersectionObserver and observes a set of elements, populating m_observationTargets and m_targetsWaitingForFirstObservation.
- Trigger — A JSC garbage collection begins and the marking thread calls isReachableFromOpaqueRoots, iterating m_observationTargets.
- Race — Concurrently the main thread runs observe()/unobserve()/targetDestroyed(), adding/removing entries and possibly rehashing the WeakListHashSet.
- Fault — The GC-thread iterator reads a reallocated/half-updated bucket, yielding a torn read or a dangling pointer dereference (UB/crash).
Proof of concept
No test ships in the diff. Conceptual trigger: create an IntersectionObserver with many targets and, in a tight loop, churn observe()/unobserve() (and drop element references to drive targetDestroyed) while forcing frequent GC (allocate aggressively) so the collector’s isReachableFromOpaqueRoots walk overlaps a main-thread rehash of m_observationTargets. The race is timing-dependent; the diff provides no deterministic PoC, and reliably winning the window is the open question.
Exploitation
- Reach — IntersectionObserver is a standard web API; observing/unobserving elements and allocating to trigger GC are all pure web content.
- Trigger — Overlap GC marking (isReachableFromOpaqueRoots) with main-thread structural mutation of the WeakListHashSet.
- Primitive — Primary effect is a concurrent read/write data race on an intrusive hash container; secondary effect is a potential dangling-bucket read on the collector thread when an add triggers reallocation.
- Escalation (open) — Whether the racy read can be steered into a UAF-style read of freed bucket memory with attacker-influenced contents, and whether that can leak a pointer through GC behavior, gates any move beyond a race-induced crash (inferred).
Detection & hunting
For defenders and detection engineers:
- TSan reports on IntersectionObserver::isReachableFromOpaqueRoots vs observe/unobserve — Run ThreadSanitizer builds with IntersectionObserver churn plus forced GC to surface the read/write race.
- GC-thread crashes iterating m_observationTargets — Look for collector-thread backtraces through containsWebCoreOpaqueRoot / WeakListHashSet iteration.
Remaining risk in the patch
- Lock scope vs registration side effects — observe() still mutates target.ensureIntersectionObserverData().registrations outside the m_observationTargetsLock block; any concurrency assumption about that registration data is not covered by this lock (not shown unsafe in the diff, but outside its scope).
- Snapshot-vs-live divergence — observationTargets() now returns a copied Vector, so callers like updateObservations operate on a snapshot that can go stale relative to concurrent mutations; correctness relies on that being acceptable, which the diff asserts by construction but does not prove.
Audit directions
- Other isReachableFromOpaqueRoots implementations — Audit every WebCore class whose isReachableFromOpaqueRoots reads collections that the main thread mutates (ResizeObserver, MutationObserver, event-target registries) for the same missing GC-thread synchronization.
- WeakListHashSet shared across threads — Find WeakListHashSet/WeakHashSet members read by GC hooks but mutated by DOM APIs without a guarding lock.
- GCReachableRef Vectors — Review containers of GCReachableRef touched by both the collector and the main thread for structural-mutation races.