[1] Media track opaque-root race on the concurrent GC thread
WebCore's media tracks wrote their own GC-thread warning, then raced past it.
High, gated by a race. Ordinary page script can put the background marker inside a check-then-call window on main-thread-owned objects; turning the resulting GC-thread UAF into more than a crash needs repeatable race wins plus reclaim of a small freed heap object.
Browser engines have to keep JavaScript wrappers alive based on references that exist only in C++, and JSC computes that reachability on background marking threads that run while the main thread keeps executing script and DOM code. WebCore bridges the gap with opaque roots — a pointer each wrapper reports so that wrappers sharing it live or die together — and the track lists behind video.audioTracks (TrackListBase, the per-element collection of audio, video or text tracks) borrow theirs from the owning media element. Whatever the marker reads to compute a root must stay alive and unchanged for the whole read.
The angle: a page that creates media track lists and then tears down or re-parents the owning element under GC pressure can race the background marker into calling through freed memory inside the renderer — a crash by default, a control-flow candidate only with reclaim control.
Data race in TrackBase::opaqueRoot during GC leading to use-after-free
To fix the data race, we introduce SourceBuffer and HTMLMediaElement as opaque roots for Track* classes and *TrackList classes instead of using the root node of HTMLMediaElement which can change over time.
We introduce TrackOpaqueRoot, which is a thin ThreadSafeRefCounted wrapper around WebCoreOpaqueRoot, and initialize it with WebCoreOpaqueRoot pointing to SourceBuffer or HTMLMediaElement.
Each Track and TrackList class will have RefPtr
and reads the opaque root directly from a GC thread without relying on any pointer indirections. No new tests since there is no reliable way to test this data race.
Source/WebCore/html/HTMLMediaElement.cpp
Source/WebCore/html/track/TrackListBase.cpp
Patch Details
The change adds a thread-safe holder for a precomputed root and seeds it from the two owners, threads that holder into every track list and track in place of the old lookup chain, and adds a custom mark function so the media element's own wrapper reports the new root.
The holder is TrackOpaqueRoot (new file TrackOpaqueRoot.h), a ThreadSafeRefCounted wrapper around one WebCoreOpaqueRoot value with opaqueRoot() and clear() accessors. HTMLMediaElement drops m_opaqueRootProvider, a WTF::Observer<WebCoreOpaqueRoot()> whose lambda captured WeakPtr { *this } and called weakThis->opaqueRoot(), and instead constructs m_trackOpaqueRoot = TrackOpaqueRoot::create(WebCoreOpaqueRoot { this }). ~HTMLMediaElement calls m_trackOpaqueRoot->clear(), and ensureAudioTracks/ensureTextTracks/ensureVideoTracks call setOpaqueRoot(m_trackOpaqueRoot) instead of setOpaqueRootObserver(m_opaqueRootProvider). SourceBuffer gets the same treatment: m_trackOpaqueRoot(TrackOpaqueRoot::create(opaqueRoot())), clear() in its destructor, and setOpaqueRoot in videoTracks(), audioTracks() and textTracks().
On the read side, TrackListBase replaces WeakPtr<OpaqueRootObserver> m_opaqueRootObserver with RefPtr<TrackOpaqueRoot> m_trackOpaqueRoot. A new virtual setOpaqueRoot() propagates the holder to every track in m_inbandTracks, and TextTrackList::setOpaqueRoot overrides it to also cover m_addTrackTracks and m_elementTracks. TrackBase gains its own RefPtr<TrackOpaqueRoot>, set in setTrackList() and cleared in clearTrackList(); TrackBase::opaqueRoot() reads it directly rather than dereferencing m_trackList.
Finally, HTMLMediaElement.idl gains JSCustomMarkFunction, and a new JSHTMLMediaElementCustom.cpp implements visitAdditionalChildrenInGCThread, adding wrapped().trackOpaqueRoot().opaqueRoot() as an opaque root whenever the element's wrapper is visited.
Before (GC thread): After (GC thread):
TrackBase::opaqueRoot() TrackBase::opaqueRoot()
└─► m_trackList.get() (WeakPtr) └─► copy RefPtr m_trackOpaqueRoot
└─► TrackListBase::opaqueRoot() └─► ->opaqueRoot() (stored value)
└─► m_opaqueRootObserver.get()
└─► (*observer)() ─► lambda
└─► weakThis->opaqueRoot()
└─► DOM tree-root walk
A concurrent-collector root lookup that traverses mutator-owned weak references and mutable graph links without holding any reference, instead of reading a stable, pinned value.
Background
Where this lives. Everything here runs in the WebContent process — the sandboxed renderer that executes page script. DOM objects are C++ objects; the JavaScript objects scripts touch are thin wrappers that JSC's garbage collector manages.
Concurrent marking.
JSC's marker runs on helper threads while the main thread — the mutator — keeps executing JavaScript and DOM operations. Functions named ...InGCThread, and any opaqueRoot() implementation they call, execute concurrently with main-thread code.
Opaque roots.
The GC cannot see C++ references between DOM objects. WebCore therefore lets each wrapper's C++ object report an opaque root pointer: if any visited wrapper adds that pointer via addOpaqueRoot, every wrapper whose isReachableFromOpaqueRoots returns the same pointer is kept alive. The design keeps a whole cluster of related wrappers alive from a single reachable member, without the GC having to understand C++ ownership.
Node opaque roots. For DOM nodes, the opaque root is conventionally the root of the node's tree. Detached subtrees and connected documents therefore keep their wrappers alive as a unit.
Track ownership.
HTMLMediaElement (the C++ object behind <video>/<audio>) and MSE SourceBuffer (the Media Source Extensions object that feeds script-supplied media segments) each lazily own an AudioTrackList, VideoTrackList and TextTrackList. Each list holds its tracks (TrackBase subclasses), and each track points back to its list via WeakPtr<TrackListBase> m_trackList. List and track wrappers use the owner's opaque root so that a script holding video.audioTracks[0] stays consistent with the element.
WeakPtr and ThreadSafeRefCounted.
WeakPtr is a non-owning pointer that becomes null when its target is destroyed; checking it does not prevent destruction afterward. ThreadSafeRefCounted uses atomic refcounts, so references can be taken and dropped from any thread.
WTF::Observer<Sig>.
A small heap object wrapping a WTF::Function. Invoking it calls the stored callable through the function wrapper's virtual call operator.
Analysis
This is a data race between the concurrent GC marker and the main-thread mutator that produces a use-after-free on the GC thread.
GC marker thread Main thread
─────────────────────────────── ─────────────────────────────────
TrackListBase::opaqueRoot()
obs = m_opaqueRootObserver.get()
obs != null (ok)
last ref to media element drops
~HTMLMediaElement()
frees Observer (m_opaqueRootProvider)
(*obs)() ──► dispatch through freed Observer <-- UAF on GC thread
In the diagram, the left column's get() only proves the Observer was alive at the instant of the check. Nothing on the GC thread holds a reference across the gap — the removed code says so explicitly ("Cannot ref the observer as this gets called on a GC thread") — so when the main thread destroys the owning HTMLMediaElement or SourceBuffer, its const Ref member frees the Observer and the marker's subsequent call dispatches through freed memory.
That window is one hop of a longer chain, and every hop has the same check-then-use shape:
TrackBase::opaqueRoot()readm_trackList(aWeakPtr<TrackListBase>) into a raw pointer underSUPPRESS_UNCOUNTED_LOCAL.- It called
TrackListBase::opaqueRoot()on that pointer. - That read
m_opaqueRootObserver(aWeakPtrto theObserver) into a raw pointer. - It invoked
(*rootObserver)(), running the stored lambda. - For
HTMLMediaElement, the lambda dereferencedweakThisand calledHTMLMediaElement::opaqueRoot(), which the commit message identifies as the element's DOM root node — "which can change over time" as script inserts or removes the element.
Concurrently the main thread can clear a track's list, destroy the owning element or SourceBuffer, or re-parent the element and rewrite the parent chain the root lookup traverses. The same race shape applies between steps 1 and 2 if the list is destroyed. Independently of any free, if the step-5 tree-root walk reads parent pointers mid-rewrite, it would return an inconsistent root, so a reachable track or list wrapper could be judged unreachable.
The fix collapses the chain to one RefPtr load and one pointer-sized value read. For the media element, that value is the element's own address, fixed for its lifetime and never dereferenced on the GC thread; clear() nulls it in the owner's destructor. The commit message describes the SourceBuffer itself as its new root, while the constructor seeds the holder from SourceBuffer::opaqueRoot(), whose body is not in the supplied context.
The removed code was already annotated with SUPPRESS_UNCOUNTED_LOCAL and with comments acknowledging GC-thread execution — exactly the kind of site WebKit's smart-pointer static analyzer surfaces — which points to code review or static analysis as the discovery route. A concurrent-GC crash from fuzzing DOM mutation plus media track access under GC pressure would fit the shape as well; the commit ships no test because there is no reliable way to hit the window.
The bug is reachable from web content through media element and MSE track lists, combined with element teardown or DOM re-parenting that races the concurrent marker. The immediate effect would be a GC-thread crash on freed or inconsistent state. The primary primitive is a GC-thread use-after-free on a small fixed-size object, the WTF::Observer owned by m_opaqueRootProvider in either HTMLMediaElement or SourceBuffer. If the freed slot were reclaimed before the call, invoking the observer would dispatch through the stored function wrapper, which could make it a control-flow candidate. The open questions are whether the window between m_opaqueRootObserver.get() and (*rootObserver)() can be widened and repeated reliably enough to reclaim the Observer storage in time, and whether the reclaimed bytes could steer the WTF::Function dispatch; if both hold, this would move from a crash to a renderer control-flow candidate. A secondary primitive of the same shape is a stale or freed TrackListBase read via TrackBase::m_trackList. A tertiary effect, the inconsistent tree-root result, would misjudge wrapper reachability — collecting a wrapper while its C++ object lives — rather than corrupt memory directly. The whole bug lives in the WebContent process on the marker thread, so impact beyond the renderer needs a separate sandbox escape.
The post-fix code is worth one more look. TrackBase::m_trackOpaqueRoot and TrackListBase::m_trackOpaqueRoot are plain RefPtrs, copied on the GC thread while setTrackList, clearTrackList and setOpaqueRoot reassign them on the main thread. The owner's own reference to the holder normally keeps it alive through such a reassignment, which narrows the residual window rather than closing it by construction. The added #include <atomic> in TrackBase.h goes unused in the diff, a hint that atomic publication was considered.
Boundary weakened. This vulnerability weakens memory safety inside the WebContent process by breaking the contract that code run from the concurrent marker touches only data that is stable or ref-held for the duration of marking. The assumption it violated — a WeakPtr that is non-null at check time stays valid through the following call — holds only on a single thread. An attacker who wins the race would get the marker executing through freed memory, ranging from a renderer crash to, under heap-reclaim control, a renderer control-flow candidate, plus the secondary risk of losing JS-visible state on track objects through misjudged liveness.
Insight. The removed code carried its own warning signs: "This gets called on a GC thread so we cannot ref this", "Cannot ref the observer as this gets called on a GC thread", and SUPPRESS_UNCOUNTED_LOCAL on each hop. The comments correctly recognized that refcounting was off-limits from the GC thread but replaced it with no other lifetime guarantee. The fix pattern — precompute the GC-relevant answer on the main thread and publish it through a thread-safe holder — is reusable, and it also changes semantics: the root becomes the media element itself rather than its tree root, with the element wrapper bridging to it via the new custom mark function.
Audit directions
- GC-thread root lookups that dereference mutator-owned objects. Code run from the concurrent marker must read only values that are immutable or ref-held for the whole call; opaque-root and visit callbacks that dereference
WeakPtror raw pointers to main-thread-owned objects break that invariant. - Narrow: grep WebCore for
opaqueRoot()implementations andvisitAdditionalChildrenInGCThreadbodies containingSUPPRESS_UNCOUNTED_LOCAL,WeakPtr::get(), or comments like "called on a GC thread". Start with otherActiveDOMObject/EventTargetlists keyed to an owner element (e.g.SourceBufferList,TextTrackCueList,MediaController). - Wider: the same shape appears wherever a concurrent visitor follows owner back-pointers, for example
WeakPtrback-pointers inisReachableFromOpaqueRoots, or observer/callback objects invoked from marking. - Widest: any concurrent or incremental collector whose root-enumeration hooks run user-defined C++ while the mutator runs, such as V8 embedder tracing (
TraceWrappers/CppHeapvisitors) and Gecko cycle-collector traverse hooks. - Match tell: a GC-callable function that loads a pointer, checks it, then calls through it with no ref held across the call.
- Weak-reference check-then-use across threads. A
WeakPtrthat is non-null at check time does not keep its target alive, so a single-thread-safe idiom becomes a UAF once a second thread can free the target. - Narrow: search WebCore and WebKit for
WeakPtrmembers read from non-main threads (audio render threads, media decoding queues, GC threads) where the read is followed by a call rather than an immediate copy of plain data. - Wider: the same shape occurs with
CheckedPtr, raw back-pointers guarded by a flag, andWTF::Observerwrappers stored behindWeakPtr. - Widest: this is the generic "weak ref upgraded without atomic promotion" bug — the reason
std::weak_ptr::lock()and Rust'sWeak::upgradereturn an owning handle. Any codebase with a non-owning weak pointer that is checked and dereferenced separately on a background thread has it. - Match tell:
if (auto* p = weak.get()) p->call()reachable off the owning thread. - Identity values derived from a mutable graph and consumed concurrently. A key such as a tree root or parent chain recomputed by walking links the mutator rewrites lets the reader observe a torn or freed path.
- Narrow: audit WebCore objects whose wrapper reachability depends on
root(Node*)/Node::opaqueRoot()computed from a non-Node object's back-pointer to an element, e.g. other element-owned collections or helpers that forward toelement->opaqueRoot(). - Wider: examine any cached key or hash computed on a background thread from DOM or render-tree pointers, such as accessibility caches or style-sharing keys built off-thread.
- Widest: a concurrent reader should consume a published snapshot, never traverse the live structure. The principle covers RCU readers in kernels, lock-free index structures, and concurrent GC barriers in other runtimes (Go, ART).
- Match tell: a background-thread function containing a loop over
parent/nextpointers of objects the main thread can mutate. - Non-atomic smart-pointer slot shared between a writer thread and a concurrent reader. A
RefPtrcopy-construct racing with reassignment can read a pointer whose last reference is concurrently dropped. - Narrow: check the post-fix
TrackBase::m_trackOpaqueRootandTrackListBase::m_trackOpaqueRoot, copied inopaqueRoot()on the GC thread whilesetTrackList,clearTrackListandsetOpaqueRootreassign them. Confirm the owner'sRef<TrackOpaqueRoot>always outlives every racing reassignment, including during owner destruction. - Wider: grep for
RefPtr/Refmembers ofThreadSafeRefCountedtypes that are read from GC or worker threads without a lock. - Widest: this is the same class as
std::shared_ptrnon-atomic load/store races, which C++20std::atomic<std::shared_ptr>exists to address. - Match tell: a smart-pointer member reassigned on one thread and copied on another with no lock or atomic wrapper; in review, a
RefPtrmember read inside any...InGCThreadpath deserves a comment naming what keeps its target alive.