← All reports

[5] Subscriber: teardown callbacks freed while a GC thread walks the snapshot

MediumWebCore DOM Observables — SubscriberRace

db743cc

Medium. The lock was scoped to producing the snapshot, not to using it — so the returned raw pointers carried exactly zero of the lifetime guarantee the lock existed to provide. No reliable reproduction accompanies the fix, which caps practical exploitability at winning an unsynchronised main-thread/GC-thread race.

JavaScriptCore marks the heap on dedicated GC threads that run alongside the main thread, so any C++ object those threads dereference during marking must stay alive for the whole dereference window — normally guaranteed either by the GC's own object lifetimes or by a lock that also serialises destruction. WebKit's DOM bindings participate in that marking through custom mark functions: for wrappers whose C++ side holds JS values the generated bindings cannot see, a visitAdditionalChildrenInGCThread implementation walks those values and reports them. Code reached from a GC thread must not touch refcounts of main-thread-owned objects, so these functions deliberately borrow raw pointers instead — which means something other than refcounting has to keep the referents alive for the duration.

The angle: a page that drops a subscription's teardown callbacks at the moment a GC thread is marking that Subscriber can have the collector read freed memory and feed whatever occupies it to the slot visitor.

This PR fixes a race condition in JSSubscriber::visitAdditionalChildren which results in a use-after-free of VoidCallback objects. While Subscriber::teardownCallbacksConcurrently grabs a lock and creates a Vector of VoidCallback*, the main thread can still go ahead and destroy VoidCallback while a GC thread is calling visitJSFunction on that VoidCallback. No new tests since there is no reliable reproduction.

Source/WebCore/dom/Subscriber.cpp

-Vector<VoidCallback*> Subscriber::teardownCallbacksConcurrently()
+template<typename Visitor>
+void Subscriber::visitAdditionalChildrenInGCThread(Visitor& visitor)
{
- Locker locker { m_teardownsLock };
- return m_teardowns.map([](auto& callback) {
- return callback.ptr();
- });
-}
+ // Do not ref anything in this function, which runs in a GC thread concurrently to the main thread.
+ {
+ Locker locker { m_teardownsLock };
+ SUPPRESS_UNCOUNTED_LOCAL for (auto& teardown : m_teardowns)
+ SUPPRESS_UNCOUNTED_ARG teardown->visitJSFunctionInGCThread(visitor);
+ }
 
-InternalObserver* Subscriber::observerConcurrently()
-{
- return &m_observer.get();
+ SUPPRESS_UNRETAINED_ARG m_observer->visitAdditionalChildrenInGCThread(visitor);
}
 
-void Subscriber::visitAdditionalChildrenInGCThread(JSC::AbstractSlotVisitor& visitor)
-{
- // We cannot ref `teardown` here as this may get called from a GC thread.
- SUPPRESS_UNRETAINED_ARG for (auto* teardown : teardownCallbacksConcurrently())
- teardown->visitJSFunctionInGCThread(visitor);
-...
+DEFINE_VISIT_ADDITIONAL_CHILDREN_IN_GC_THREAD(Subscriber);

Source/WebCore/bindings/js/JSSubscriberCustom.cpp

- for (auto* teardown : wrapped().teardownCallbacksConcurrently())
- teardown->visitJSFunctionInGCThread(visitor);
-
- wrapped().observerConcurrently()->visitAdditionalChildrenInGCThread(visitor);
+ wrapped().visitAdditionalChildrenInGCThread(visitor);

Source/WebCore/dom/Subscriber.h

Vector<Ref<VoidCallback>> m_teardowns WTF_GUARDED_BY_LOCK(m_teardownsLock);
// ActiveDOMObject
void stop() final
{
Locker locker { m_teardownsLock };
m_teardowns.clear();
}

The patch rewrites how JSSubscriber's custom mark function reaches the Subscriber's script-visible children. Previously JSSubscriber::visitAdditionalChildrenInGCThread called wrapped().teardownCallbacksConcurrently(), which took m_teardownsLock, snapshotted m_teardowns into a Vector<VoidCallback*> of raw pointers via m_teardowns.map([](auto& callback) { return callback.ptr(); }), released the lock on return, and only then iterated the snapshot calling teardown->visitJSFunctionInGCThread(visitor).

The patch deletes teardownCallbacksConcurrently() and observerConcurrently() entirely and replaces the non-template Subscriber::visitAdditionalChildrenInGCThread(JSC::AbstractSlotVisitor&) with a template<typename Visitor> version that holds Locker locker { m_teardownsLock } across the whole iteration, walking m_teardowns in place instead of copying pointers out. The observer visit moves outside the locked scope and goes directly through m_observer->visitAdditionalChildrenInGCThread(visitor). DEFINE_VISIT_ADDITIONAL_CHILDREN_IN_GC_THREAD(Subscriber) instantiates the template for both visitor types, and Subscriber.h narrows the public surface to the single template method. SUPPRESS_UNCOUNTED_LOCAL / SUPPRESS_UNCOUNTED_ARG / SUPPRESS_UNRETAINED_ARG annotations plus the comment "Do not ref anything in this function, which runs in a GC thread concurrently to the main thread" keep WebKit's static ref-checker quiet about deliberately not ref'ing objects from a GC thread.

Lock scope covering the snapshot of a collection but not the dereference of the borrowed, non-owning pointers taken from it.

Observables and Subscriber. The DOM Observable API hands a Subscriber to the subscribe callback; script calls subscriber.addTeardown(fn) to register cleanup callbacks, which WebCore stores as Vector<Ref<VoidCallback>> m_teardowns. A subscription is torn down when it completes, errors, or its AbortSignal fires, and also when the ActiveDOMObject is stopped at document teardown or navigation.

VoidCallback. A generated WebIDL callback wrapper — a RefCounted C++ object that owns a reference to the underlying JS function object.

Custom mark functions. For wrappers whose C++ side holds JS values the generated bindings cannot see, WebKit defines visitAdditionalChildren / visitAdditionalChildrenInGCThread in a *Custom.cpp file. DEFINE_VISIT_ADDITIONAL_CHILDREN_IN_GC_THREAD(X) instantiates the template for both visitor types.

Concurrent marking. JSC marks the heap on dedicated GC threads that run alongside the main thread. AbstractSlotVisitor is the visitor type used on those threads; SlotVisitor is the main-thread variant. Code reached from a GC thread must not touch refcounts of main-thread-owned objects, which is why WebKit annotates these paths with SUPPRESS_UNCOUNTED_ARG / SUPPRESS_UNRETAINED_ARG to silence its static ref-checker.

Ref<T> versus T*. Ref<T> owns a reference and keeps the object alive; T::ptr() yields a bare pointer with no ownership. Clearing a Vector<Ref<T>> releases every reference it held.

Lock, Locker, WTF_GUARDED_BY_LOCK. WTF's mutex, its RAII scoped acquisition, and a static annotation declaring which lock guards a field.

The root cause is a lock-scope error producing a use-after-free. teardownCallbacksConcurrently() used the lock only to build a snapshot, not to protect the use of that snapshot. It converted Ref<VoidCallback> elements into bare VoidCallback*, returned them by value, and the lock was released at function exit. The refcount was deliberately never incremented — ref'ing from a GC thread is unsafe in WebKit's model — so the returned vector carried zero lifetime guarantee. The caller then dereferenced each pointer outside any lock.

  GC thread                          Main thread
  ─────────                          ───────────
  lock m_teardownsLock
  copy N raw VoidCallback*
  unlock  ────────────────────┐
                              │      lock m_teardownsLock
                              │      stop(): m_teardowns.clear()
                              │        └─ last Ref dropped
                              │             └─ ~VoidCallback()
                              │      unlock
  ┌───────────────────────────┘
  ▼
  teardown->visitJSFunctionInGCThread(visitor)   ← UAF read of freed object

m_teardowns is Vector<Ref<VoidCallback>>, so the Subscriber holds the owning reference to every teardown callback. Two main-thread paths destroy those refs, both under m_teardownsLock: Subscriber::stop() does m_teardowns.clear(), and Subscriber::close() iterates and invokes teardowns with stop() called immediately after. Concurrent GC marking runs the custom mark function on a GC thread while the main thread is not stopped for the whole marking phase, which opens the interleaving above. The call reads the callback's stored JS function slot out of a destroyed heap object and hands the resulting value to the slot visitor — the exact body of visitJSFunctionInGCThread is inferred from its name and the general WebIDL-callback idiom, since VoidCallback.h/.cpp is not in the supplied context.

Holding the lock for the duration of the loop restores the invariant that every element of m_teardowns observed by the GC thread stays alive for the entire visit, because every destroy path also serialises on m_teardownsLock. The m_observer half was never a lifetime problem — m_observer is declared const Ref<InternalObserver>, so it is neither reassignable nor releasable for the lifetime of the Subscriber; removing observerConcurrently() is cleanup, not a fix.

This vulnerability weakens memory safety inside the WebContent process at the boundary between the main thread and JSC's concurrent GC threads. The security model assumption at stake is that any object a GC thread dereferences during marking remains alive for the whole dereference window. Before the fix, that guarantee was absent for teardown callbacks, so an attacker who wins the race could have a GC thread read a freed VoidCallback and feed whatever occupies that memory into the slot visitor as a JS cell reference. Beyond the immediate crash, an attacker who groomed the freed slot could plausibly influence what the collector treats as a live object-graph node, which would be a corruption-class outcome rather than a mere availability one — though that step is speculation about post-free heap state rather than something the diff establishes.

The deleted helper is a textbook example of a lock that looks correct at the call site and is useless in practice: teardownCallbacksConcurrently() acquires m_teardownsLock, so a reviewer scanning it sees synchronisation — but the value it returns is a vector of non-owning pointers whose validity is exactly what the lock was protecting. The moment the lock is scoped to the accessor rather than the use, the returned data is stale by construction. WebKit's own ref-checker annotations arguably contributed: the author correctly knew not to ref from a GC thread and silenced the analyzer, but silencing the analyzer removed the one signal that would have prompted the question "then what keeps this alive?" Any borrow-a-raw-pointer-because-we-cannot-take-a-reference pattern needs the lock, or some other keep-alive, to extend to the last dereference, and that is precisely the shape the fix imposes.