← All reports

[2] Replayed CreateAudioSourceProvider drops the provider mid-callback

HighWebKit GPU process media pipelineAuthBypass

Overwriting a callback was a free, and a real-time thread was inside it.

0181991

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 CreateAudioSourceProvider at most once per RemoteMediaPlayerProxy. A second message reaches AudioSourceProviderAVFObjC's setConfigureAudioStorageCallback / setAudioCallback, which overwrite the callbacks without taking tapStorage->lock while a MediaToolbox thread may be invoking them under that lock, freeing the captured RemoteAudioSourceProviderProxy mid-use. Reject the duplicate message with a MESSAGE_CHECK.

Source/WebKit/GPUProcess/media/RemoteMediaPlayerProxy.cpp

+#define MESSAGE_CHECK(assertion, message) MESSAGE_CHECK_WITH_MESSAGE_BASE(assertion, m_webProcessConnection.get(), message)
+
namespace WebKit {
...
void RemoteMediaPlayerProxy::createAudioSourceProvider()
{
#if ENABLE(WEB_AUDIO) && PLATFORM(COCOA)
+ MESSAGE_CHECK(!m_remoteAudioSourceProvider, "RemoteAudioSourceProvider already created.");
+
RefPtr player = m_player;
if (!player)
return;
...
+#undef MESSAGE_CHECK

LayoutTests/ipc/create-audio-source-provider-twice.html

+<!-- webkit-test-runner [ IPCTestingAPIEnabled=true ] -->
+ const tap = new IPCWireTap('GPU', 'Outgoing');
+ tap.tapNext(CoreIPC.messages['RemoteMediaPlayerManagerProxy_CreateMediaPlayer'].name,
+ (proc, connId, msgName, typed, parsed) => { playerId = parsed.identifier; resolve(); });
...
+ for (let i = 0; i < 5; i++) {
+ CoreIPC.GPU.RemoteMediaPlayerProxy.SetShouldEnableAudioSourceProvider(playerId, { shouldEnable: true });
+ CoreIPC.GPU.RemoteMediaPlayerProxy.CreateAudioSourceProvider(playerId, {});
+ await new Promise(resolve => setTimeout(resolve, 20));
+ }

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.

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.

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:

  1. Tap the outgoing RemoteMediaPlayerManagerProxy_CreateMediaPlayer message to recover the MediaPlayerIdentifier addressing the GPU-side proxy.
  2. Append a hidden <video src="../media/content/test.mp4"> and wait for loadedmetadata, so a real AVFoundation item with a live audio track exists in the GPU process.
  3. Send SetShouldEnableAudioSourceProvider(playerId, true) to make the proxy install the AudioSourceProviderAVFObjC path, then CreateAudioSourceProvider(playerId).
  4. The first call runs RemoteAudioSourceProviderProxy::create(...), storing two Ref-capturing lambdas into the platform provider and leaving the member holding the other strong reference.
  5. 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.
  6. The next loop iteration sends CreateAudioSourceProvider again; 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.

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.