[2] Replayed CreateAudioSourceProvider drops the provider mid-callback
Overwriting a callback was a free, and a real-time thread was inside it.
High. A create-once GPU-process message with no idempotency guard lets a second send destroy the last owners of an object a real-time audio thread may be executing inside. The escalation to a controlled primitive is gated on winning that timing window and reclaiming the freed allocation before the audio thread's next send.
WebKit hosts media decoding and audio tapping in a separate GPU process, which the renderer drives entirely over IPC — every message arriving there is untrusted input. RemoteMediaPlayerProxy is the GPU-side receiver that owns a real WebCore::MediaPlayer on behalf of one <video>/<audio> element, and RemoteAudioSourceProviderProxy is the bridge that carries decoded audio from the platform tap back to the renderer's Web Audio graph. The protocol expects a well-behaved renderer to ask for that bridge exactly once per player, and the object's lifetime is built on that expectation.
The angle: a compromised renderer can send one existing GPU-process message twice while media is playing and drop every strong reference to a live audio-bridge object while a real-time thread is calling into it.
The commit message states the mechanism precisely:
A well-behaved WebContent process sends
CreateAudioSourceProviderat most once perRemoteMediaPlayerProxy. A second message reachesAudioSourceProviderAVFObjC'ssetConfigureAudioStorageCallback/setAudioCallback, which overwrite the callbacks without takingtapStorage->lockwhile a MediaToolbox thread may be invoking them under that lock, freeing the capturedRemoteAudioSourceProviderProxymid-use. Reject the duplicate message with aMESSAGE_CHECK.
Source/WebKit/GPUProcess/media/RemoteMediaPlayerProxy.cpp
LayoutTests/ipc/create-audio-source-provider-twice.html
Patch Details
A one-line MESSAGE_CHECK is added at the top of RemoteMediaPlayerProxy::createAudioSourceProvider(), asserting !m_remoteAudioSourceProvider with the message "RemoteAudioSourceProvider already created." The file also gains the standard local #define MESSAGE_CHECK(assertion, message) MESSAGE_CHECK_WITH_MESSAGE_BASE(assertion, m_webProcessConnection.get(), message) above namespace WebKit and a matching #undef at the bottom, wiring the check to the WebContent-facing IPC::Connection so a violating message terminates that connection instead of proceeding. The rest of the handler (RefPtr player = m_player; if (!player) return; and the subsequent provider construction) is unchanged.
Collateral: a new regression test driven by the IPC testing API, which taps the outgoing RemoteMediaPlayerManagerProxy_CreateMediaPlayer message to learn the MediaPlayerIdentifier, loads a real .mp4 into a <video>, and then sends SetShouldEnableAudioSourceProvider(true) + CreateAudioSourceProvider five times in a row with 20 ms gaps; the expectation file is just "This test passes if WebKit does not crash."
Missing idempotency guard on a create-once protocol message, allowing re-initialization to release the last owners of an object another thread can still be executing inside.
Background
Where this lives.
WebKit hosts media decoding, GPU rendering and audio tapping in a separate com.apple.WebKit.GPU process; the renderer drives it over IPC via Remote*Proxy message receivers.
RemoteMediaPlayerProxy.
The GPU-side object (an IPC::MessageReceiver, RefCounted) that owns a real WebCore::MediaPlayer on behalf of one media element in the renderer.
MESSAGE_CHECK / MESSAGE_CHECK_WITH_MESSAGE_BASE.
WebKit's IPC validation macro. The local #define binds it to a specific IPC::Connection; when the assertion is false, the connection is torn down and the offending process is killed, so the handler never continues.
The audio tap path.
AudioSourceProviderAVFObjC is the Cocoa audio source provider that installs a MediaToolbox audio tap on an AVFoundation playback item; the tap delivers PCM audio on a dedicated real-time MediaToolbox thread, invoking client-supplied callbacks. setAudioCallback and setConfigureAudioStorageCallback are the setters that store those callables — one to allocate the shared audio storage, one to signal new samples.
RemoteAudioSourceProviderProxy.
A ThreadSafeRefCounted GPU-side object holding a MediaPlayerIdentifier and a const Ref<IPC::Connection>; its configureAudioStorage() allocates a ProducerSharedCARingBuffer and messages RemoteAudioSourceProviderManager::AudioStorageChanged, and newAudioSamples() messages SetNeedsFlush.
Ref / ThreadSafeRefCounted.
When the last Ref to a ThreadSafeRefCounted object is destroyed, the object is destroyed. A Ref captured by value inside a lambda keeps the object alive exactly as long as that lambda object exists, so replacing the callable that holds it releases that reference.
IPC testing API (coreipc.js, IPCWireTap).
A test-only facility, enabled per-test via IPCTestingAPIEnabled=true, that lets a layout test observe outgoing IPC and synthesize arbitrary messages to the GPU process — it models what a compromised renderer can send.
Analysis
This is a cross-thread lifetime violation rooted in a missing IPC state-machine check.
WebContent (attacker) GPU main thread MediaToolbox tap thread
──────────────────── ─────────────── ───────────────────────
CreateAudioSourceProvider ► create(): m_remote… = Ref
setAudioCallback([Ref])
─► invoking audioCallback
CreateAudioSourceProvider ► reassign m_remote… (deref) │ reads m_connection
overwrite slots (deref) │ reads m_identifier
── refcount 0 ── free ──────────┤
▼
UAF read / call
createAudioSourceProvider() carried no state guard. The protocol invariant — a renderer sends CreateAudioSourceProvider at most once per proxy — was assumed by the implementation but never enforced against a misbehaving sender. The added assertion tells us RemoteMediaPlayerProxy keeps a member owner for the provider, so a second message re-enters the factory and reassigns that member. (The member declaration sits in a truncated part of the header; that m_remoteAudioSourceProvider is strong-owning is the reading the MESSAGE_CHECK implies.)
The supplied source of RemoteAudioSourceProviderProxy.cpp shows that RemoteAudioSourceProviderProxy::create(identifier, connection, localProvider) does not merely construct the object — before returning it installs two lambdas onto the AudioSourceProviderAVFObjC instance:
localProvider.setConfigureAudioStorageCallback([remoteProvider](auto&&... args) { ... });
localProvider.setAudioCallback([remoteProvider](auto startFrame, auto numberOfFrames, bool needsFlush) { ... });
Both capture remoteProvider (a Ref<RemoteAudioSourceProviderProxy>) by value. There are therefore at least two distinct strong-reference holders for a given provider: the m_remoteAudioSourceProvider member on the proxy, and the two callback captures held by the platform provider. A duplicate CreateAudioSourceProvider drops both sets in the same handler invocation — reassigning the member releases one reference, and overwriting the two callback slots destroys the old callables together with their captured Refs. If those are the only strong references, the first RemoteAudioSourceProviderProxy reaches refcount zero and is destroyed synchronously inside the IPC handler.
The destruction is not the whole problem — the timing is. The callables being overwritten are the ones the MediaToolbox audio tap thread invokes; the object they own dereferences m_connection (a const Ref<IPC::Connection>) and m_identifier inside newAudioSamples() / configureAudioStorage(). Whether the setters serialize slot replacement against tap-thread invocation is the load-bearing assumption for the memory-safety reading — AudioSourceProviderAVFObjC's implementation is not part of the supplied source context, so if a slot can be replaced (and its captured Ref destroyed) while the tap thread is executing it, as the commit message's tapStorage->lock remark asserts, the audio thread reads and calls through freed memory.
The added test walks the trigger:
- Tap the outgoing
RemoteMediaPlayerManagerProxy_CreateMediaPlayermessage to recover theMediaPlayerIdentifieraddressing the GPU-side proxy. - Append a hidden
<video src="../media/content/test.mp4">and wait forloadedmetadata, so a real AVFoundation item with a live audio track exists in the GPU process. - Send
SetShouldEnableAudioSourceProvider(playerId, true)to make the proxy install theAudioSourceProviderAVFObjCpath, thenCreateAudioSourceProvider(playerId). - The first call runs
RemoteAudioSourceProviderProxy::create(...), storing twoRef-capturing lambdas into the platform provider and leaving the member holding the other strong reference. - The 20 ms sleep gives the MediaToolbox tap thread a window to begin calling those callbacks — the test's liveness assumption, not something the supplied code establishes.
- The next loop iteration sends
CreateAudioSourceProvideragain; pre-fix the handler re-enters the factory, reassigns the member and overwrites both callback slots, releasing every strong reference visible in the supplied context and dropping the first proxy to refcount zero.
On exploitability: CreateAudioSourceProvider is a GPU-process IPC endpoint, so an attacker must already be able to emit arbitrary IPC — a compromised or scripted WebContent process, which is exactly what the test simulates. The immediate observable effect would be a GPU-process crash if the audio thread reads the freed proxy's m_connection or m_identifier after destruction, or if the callable object itself is torn down under the invoker's feet. If the attacker times the second message to land inside the tap-thread window and then reclaims the freed allocation with controlled data before m_connection->send(...) executes, the IPC::Connection slot would be attacker-influenced, which could yield a call through an attacker-chosen pointer and a message-send with attacker-chosen contents. Realizing that would require grooming the GPU-process heap bucket into which the small ThreadSafeRefCounted object falls, repeated attempts (the window is not deterministic), and a reclaim primitive in the GPU process the renderer can drive from IPC — shared-buffer and ring-buffer allocation paths such as ProducerSharedCARingBuffer::allocate are the natural candidates since the same code path already lets the renderer influence GPU allocations. Absent that, what remains is a reliably attacker-triggerable GPU-process fault.
This vulnerability weakens the WebContent-to-GPU process isolation boundary. The security model assumes the GPU process treats every renderer message as untrusted and validates protocol state before acting — here a create-once message could be replayed, and the resulting lifetime violation lands in a more privileged process than the sender. An attacker who already holds code execution in a sandboxed WebContent process and who wins the race against the MediaToolbox audio thread could corrupt GPU-process memory; the GPU process has broader access to hardware media codecs and capture devices than the renderer, making this a meaningful step in a sandbox-escape chain.
Insight
The interesting structural detail is how thinly spread the ownership is. create() returns a Ref the caller parks in a member, but it also, before returning, hands two more strong references away as lambda captures on a third object. Both holders are dropped by the same duplicate-message path, so the object's lifetime becomes a side effect of callback-slot assignment: overwriting a callback is silently a deref(). Any API where setFooCallback() can release the previous callback's captured owner is a lifetime hazard whenever the invoker runs on a different thread, and correct refcounting on either side alone would not fix it if slot replacement is not serialized against invocation. Notably the chosen fix is at the protocol layer rather than the synchronization layer; that closes the renderer-driven path, but the underlying shape remains in AudioSourceProviderAVFObjC for any other caller that reaches it.
Audit directions
-
Create-once IPC handlers with no idempotency guard. A privileged-process handler assigns to a member owner (
Ref/RefPtr/unique_ptr) on receipt of a message, implicitly trusting the client to send it exactly once. The invariant at stake is the server, not the client, owns the protocol state machine. Narrow: grepSource/WebKit/GPUProcess(start withmedia/RemoteMediaPlayerProxy.cpp,media/RemoteAudioDestinationManager.cpp,graphics/RemoteRenderingBackend.cpp) for handler bodies whose first statement ism_something = Something::create(...)with no precedingif (m_something)orMESSAGE_CHECK; the syntactic tell is a member owner written unconditionally inside a function that appears in a.messages.infile. Wider: the same class shows up through different mechanisms — handlers thatadd()into aHashMapkeyed by a client-supplied identifier without checkingisNewEntry, and handlers that re-consume()aSandboxExtension::Handle; the shape to notice in code-search results is any client-supplied identifier used as a key without a duplicate-key branch. Widest: the underlying principle is a stateful RPC server must validate message ordering, because "the client only sends this once" is a client-side property — carry the same question into Chromium Mojo interface impls, Android Binder services, and gRPC bidi-stream handlers. Match tell to carry across codebases: a server-side field that is only ever written, never checked, in a message handler. -
Callback slots swapped while another thread may be executing them. A setter overwrites a stored callable without acquiring whatever lock the invoker holds, so destroying the old callable races its own invocation. The invariant is a callable's storage lifetime must be serialized with its execution. Narrow: audit
Source/WebCore/platform/graphics/avfoundation/objc/AudioSourceProviderAVFObjC— enumerate everyset*Callbacksetter and determine whether it takes the same lock the MediaToolbox tap process callback holds; repeat for other WebCore audio-tap and render-callback clients. Wider: the same class expressed differently includesCompletionHandler/Functionmembers reassigned outside the lock that the completion path holds, andRunLoop-dispatched blocks that captureRefs whose owning member is reassigned on another thread — the shape to look for is a member of callable type written on the main thread and read on a real-time or work-queue thread with asymmetric locking. Widest: this is the general "listener slot mutated during dispatch" class — the invariant transfers to any runtime where a handler can be replaced while running (Rust swapping aBox<dyn Fn>behind aMutexthe caller doesn't hold, Java listener fields reassigned during callback, C audio APIs like PortAudio/ALSA where the stream callback pointer is rewritten without stopping the stream). Match tell: find the lock the invoker holds, then check whether every writer of the invoked state takes it. -
Object lifetime spread across references captured inside callbacks stored on a third party. The declaration site shows one owner, but the real reference set includes captures the reader cannot see, and a single API call can drop several of them at once. The invariant is ownership should be legible at the declaration site. Narrow: grep WebKit factory functions that return a
Ref<T>and, before returning, install lambdas capturing that sameRefonto a collaborator —RemoteAudioSourceProviderProxy::createis the template; check otherGPUProcess/mediaproxies andWebCore/platform/mediastreamfor the same install-then-return shape, and for each ask which single code path drops every holder simultaneously. Wider: the same class covers anyThreadSafeRefCountedobject whose strong refs live mostly inFunction/block captures held by a platform framework object (CoreAudio, AVFoundation delegates, CoreMedia listeners) — the tell is a long-lived class with few or noRef<T> m_ownerdeclarations elsewhere in the tree. Widest: the principle is if clearing a callback slot can be the lastderef(), then assigning a callback is afree()— worth carrying into any framework where handler registration transfers ownership (Node.js EventEmitter closures capturing resources, RustArcmoved into a boxed closure handed to a C API, DI containers keeping instances alive only via registered hooks). Match tell: ask "who holds the last ref?" and if the honest answer is "a callback someone else can overwrite", flag it.