3b7541f4fce8838e07ceea72cc9f9852d392ea60 Cross-thread use-after-free of AudioWorkletMessagingProxy in AudioWorkletGlobalScope::registerProcessor
Triage note: Shipping #else of RC_REMOVE_SEC_ACCEL_3 adds m_messagingProxyLock guarding AudioWorkletThread::m_messagingProxy, a detachFromThread() called from Worklet's main-thread destructor to sever the back-pointer while the owning ref is held, and Main-thread destruction for the proxy. Fixes a cross-thread use-after-free/race on the proxy back-pointer.
The bug at a glance
AudioWorkletThread (Source/WebCore/Modules/webaudio/AudioWorkletThread.cpp/.h) holds a non-owning CheckedPtr back-pointer m_messagingProxy to its AudioWorkletMessagingProxy and reads it from the audio worklet thread via messagingProxy()/workerLoaderProxy(), while the proxy is destroyed on the main thread; the pre-patch code did so with no synchronization and severed the back-pointer only in the proxy’s destructor. The patch adds m_messagingProxyLock guarding the back-pointer, a detachFromThread() invoked from Worklet’s main-thread destructor to clear it while the owning Ref is still held, and forces WorkletGlobalScopeProxy destruction onto the main thread. This is a cross-thread use-after-free/race on the proxy back-pointer in the WebContent process. The fix’s own comment states the goal is that a worklet thread can never observe a non-null back-pointer to a proxy whose refcount already reached zero, which directly supports a UAF reading; severity high is justified, with the full exploit path beyond the stale read being inferred.
A back-pointer lifetime bug: the audio rendering thread dereferences a CheckedPtr to its messaging proxy, and because CheckedPtr does not keep the proxy alive, the window between the proxy’s refcount hitting zero and the back-pointer being cleared is a cross-thread use-after-free.
Root cause
The invariant is that a thread which can dereference a back-pointer to an object must be prevented from observing that pointer non-null after the object has begun/completed destruction. AudioWorkletThread stored CheckedPtr<AudioWorkletMessagingProxy> m_messagingProxy and exposed AudioWorkletMessagingProxy* messagingProxy() { return m_messagingProxy.get(); } plus WorkerLoaderProxy* workerLoaderProxy() const { return m_messagingProxy.get(); }, both callable from the worklet thread. CheckedPtr is a non-owning raw pointer (with debug use-check accounting); it does not extend lifetime.
Pre-patch cross-thread sequence: 1. The AudioWorkletMessagingProxy is owned on the main thread (its refcount drops to zero there) and its ~AudioWorkletMessagingProxy() calls m_workletThread->stop() then m_workletThread->clearProxies() to null the back-pointer. 2. The audio worklet thread, meanwhile, calls workerLoaderProxy()/messagingProxy() and reads m_messagingProxy with no lock. 3. Between the proxy’s refcount reaching zero / entering its destructor and clearProxies() actually nulling the pointer (or concurrently with it), the worklet thread can read a still-non-null m_messagingProxy and dereference a proxy that is being/has been destroyed. 4. Use-after-free (and an unsynchronized read/write race on the pointer itself).
The fix: AudioWorkletThread gets mutable Lock m_messagingProxyLock and annotates m_messagingProxy WTF_GUARDED_BY_LOCK; clearProxies(), messagingProxy() (now RefPtr-returning, const), and workerLoaderProxy() all take the Locker, so cross-thread reads are serialized and return a RefPtr that pins the proxy for the caller. AudioWorkletMessagingProxy gains detachFromThread() (ASSERT(isMainThread()); m_workletThread->clearProxies();), and its destructor no longer clears proxies in the shipping path — instead Worklet::~Worklet() (ASSERT(isMainThread())) loops for (Ref proxy : m_proxies) proxy->detachFromThread();, severing each back-pointer while the owning Ref in m_proxies is still held, i.e. before the proxy’s refcount can reach zero. Finally WorkletGlobalScopeProxy becomes ThreadSafeRefCounted<WorkletGlobalScopeProxy, WTF::DestructionThread::Main> so the proxy is always destroyed on the main thread. The CheckedPtr type is the proof it was non-owning; the triage bugclass UAF matches.
Key code
Back-pointer severed under the owning Ref; cross-thread reads now locked
// Worklet.cpp main-thread destructor severs each proxy's back-pointer
// while the owning reference is still held:
Worklet::~Worklet() {
ASSERT(isMainThread());
for (Ref proxy : m_proxies)
proxy->detachFromThread();
}
// AudioWorkletThread.cpp: cross-thread reads take the lock and return a RefPtr
RefPtr<AudioWorkletMessagingProxy> AudioWorkletThread::messagingProxy() const {
Locker locker { m_messagingProxyLock };
return m_messagingProxy.get();
}
Fix approach
The patch closes the window with three coordinated changes: (1) m_messagingProxy is placed under mutable Lock m_messagingProxyLock (WTF_GUARDED_BY_LOCK) and every access — clearProxies(), workerLoaderProxy(), and the new RefPtr-returning messagingProxy() — takes the Locker, so cross-thread reads are serialized and hand back a RefPtr that keeps the proxy alive for the duration of use. (2) AudioWorkletMessagingProxy::detachFromThread() (main-thread-asserted) performs the clearProxies(), and Worklet::~Worklet() calls it for each proxy while the owning Ref in m_proxies is still live, guaranteeing the back-pointer is nulled before the refcount can hit zero. (3) WorkletGlobalScopeProxy is tagged ThreadSafeRefCounted<…, DestructionThread::Main> so destruction is always marshalled to the main thread. The worklet thread’s back-pointer read collapses from an unguarded CheckedPtr dereference to a locked RefPtr acquisition that either sees null or pins a live proxy.
Patch walkthrough
Source/WebCore/Modules/webaudio/AudioWorkletMessagingProxy.cpp— In the shipping path the destructor now only calls m_workletThread->stop() (asserting main thread) and no longer clears proxies; a new detachFromThread() (main-thread-asserted) performs m_workletThread->clearProxies() so severing happens before destruction.Source/WebCore/Modules/webaudio/AudioWorkletMessagingProxy.h— Declares detachFromThread() as a final override of the WorkletGlobalScopeProxy hook in the shipping path.Source/WebCore/Modules/webaudio/AudioWorkletThread.cpp— clearProxies() and workerLoaderProxy() take Locker { m_messagingProxyLock }; a new const messagingProxy() returns RefPtr under the lock, so cross-thread reads of the back-pointer are synchronized and lifetime-safe.Source/WebCore/Modules/webaudio/AudioWorkletThread.h— Adds mutable Lock m_messagingProxyLock and annotates CheckedPtr m_messagingProxy WTF_GUARDED_BY_LOCK; messagingProxy() changes signature to a const RefPtr-returning accessor in the shipping path.Source/WebCore/worklets/Worklet.cpp— Includes <wtf/MainThread.h>; Worklet::~Worklet() (main-thread-asserted) iterates m_proxies and calls proxy->detachFromThread() so every worklet thread’s back-pointer is cleared while the owning Ref is still held.Source/WebCore/worklets/WorkletGlobalScopeProxy.h— Base changes to ThreadSafeRefCounted<WorkletGlobalScopeProxy, WTF::DestructionThread::Main> and adds a virtual detachFromThread() hook, ensuring proxies are destroyed on the main thread and can sever their back-pointer.
Background
AudioWorklet / AudioWorkletMessagingProxy — AudioWorklet runs user JS on a dedicated real-time audio rendering thread; the messaging proxy bridges the main thread and that worklet thread for module loading and message passing.
AudioWorkletThread back-pointer — The worklet thread keeps a pointer back to its messaging proxy (m_messagingProxy) to reach loader/debugger proxies; it is a non-owning reference by design to avoid a cycle.
CheckedPtr — A WTF non-owning smart pointer that in debug builds tracks outstanding uses to catch dangling access, but does NOT keep the pointee alive — so a live CheckedPtr to a freed object is still a UAF.
ThreadSafeRefCounted DestructionThread::Main — A ThreadSafeRefCounted policy that marshals the final deref’s destructor to the main thread, so teardown always runs where main-thread invariants (and detachFromThread) hold.
WTF_GUARDED_BY_LOCK / Locker — Clang thread-safety annotation plus RAII lock ensuring every access to the guarded back-pointer is serialized across the main and worklet threads.
Vulnerability window
- Setup — Script creates an AudioWorklet (via an AudioContext.audioWorklet) and loads a module, spinning up the worklet thread and wiring the messaging proxy back-pointer.
- Trigger — The worklet thread performs work that reads workerLoaderProxy()/messagingProxy() (e.g. loader/debugger access) while the main thread tears down the worklet.
- Window — The main thread releases the proxy; its refcount reaches zero and destruction begins before/while clearProxies() nulls the back-pointer (pre-patch, unlocked).
- Fire — The worklet thread reads a still-non-null m_messagingProxy and dereferences a proxy under destruction — cross-thread use-after-free.
Proof of concept
No test ships in the diff. Conceptual trigger (web path): construct an AudioContext, call audioWorklet.addModule() to start the worklet thread, drive activity that makes the worklet thread consult its loader/messaging proxy, then tear down the AudioContext/Worklet on the main thread so the proxy’s refcount reaches zero while the worklet thread still reads the back-pointer. Winning this cross-thread window is timing-dependent; the diff provides no deterministic PoC.
Exploitation
- Reach — AudioWorklet is web-reachable (new AudioContext().audioWorklet.addModule(…)), exercising the main-thread/worklet-thread proxy relationship in the WebContent process.
- Trigger — Overlap worklet-thread reads of messagingProxy()/workerLoaderProxy() with main-thread destruction of the AudioWorkletMessagingProxy.
- Primitive — Primary effect is a cross-thread UAF: the worklet thread dereferences a freed/under-destruction proxy; secondary is a torn read/race on the pointer word itself.
- Escalation (open) — Escalation depends on reclaiming the freed proxy allocation from another thread with controlled bytes in the race window and on whether the worklet thread’s subsequent virtual/loader-proxy calls dispatch through attacker-controlled memory (inferred).
Detection & hunting
For defenders and detection engineers:
- TSan/ASan reports on AudioWorkletThread::m_messagingProxy — Run ASan+TSan with AudioWorklet create/teardown stress and worklet-thread proxy access to surface the race and UAF.
- Worklet-thread crashes in workerLoaderProxy()/messagingProxy() — Inspect crash backtraces where the audio rendering thread dereferences a messaging/loader proxy during context teardown.
Remaining risk in the patch
- Lock ordering under teardown — detachFromThread() takes m_messagingProxyLock via clearProxies() while Worklet::~Worklet holds the owning Refs; if any worklet-thread path holds another lock and then calls a locked accessor, the diff does not establish the absence of a lock-ordering inversion (inferred).
- Non-audio WorkletGlobalScopeProxy subtypes — The base detachFromThread() is an empty default; other WorkletGlobalScopeProxy subclasses (e.g. paint worklet) that keep a thread back-pointer would need their own override, which this diff does not show.
Audit directions
- Thread back-pointers held as CheckedPtr/raw — Audit Worker/Worklet thread classes for non-owning back-pointers to their proxies read off-thread without a lock or DestructionThread::Main policy.
- Other WorkletGlobalScopeProxy implementations — Check PaintWorklet/other worklet proxies for the same detach-before-destroy requirement now expressed by detachFromThread().
- ThreadSafeRefCounted destruction-thread choices — Review cross-thread-shared objects whose destructor touches main-thread-only state but is not tagged DestructionThread::Main.