[CoreIPC][GPUProcess] UserMediaCaptureManagerProxy::startProducingData races prepareAudioDescription() against audioSamplesAvailable() lead to various UAF/write-after-unmap
CVE: CVE-2026-43731 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may lead to memory corruption Apple's description: A use-after-free issue was addressed with improved memory management. Credit: dr3dd
High. The message arguments are entirely benign — only the repetition is hostile, and a second StartProducingData frees a ring buffer that a live capture thread is mid-write into. Reaching it requires WebContent code execution already, so this is a chain link into the GPU sandbox rather than an escape on its own.
Microphone and camera bytes do not flow through the renderer anymore. A page that calls getUserMedia() ends up with a capture source living entirely in the GPU process, which owns the device handles and pushes audio frames back across a shared-memory ring buffer. The GPU-side object that brokers this — UserMediaCaptureManagerProxySourceProxy — has two distinct lives once capture begins: it services IPC messages on the GPU main thread, and it services the platform capture unit's sample callbacks on a background work queue. The invariant that holds those two lives apart is simple and unwritten: once the proxy has registered itself as a sample observer, the buffers and semaphores it handed the capture thread belong to that thread until the observer is removed.
The angle: An attacker who already controls the renderer can send one start-capture message twice and make the audio thread keep writing sample data into a ring buffer that has just been freed and unmapped underneath it.
Source/WebKit/GPUProcess/webrtc/UserMediaCaptureManagerProxy.cpp
LayoutTests/ipc/usermedia-capture-start-producing-data-race.html
Patch Details
The entire production-code change is four lines at the top of UserMediaCaptureManagerProxySourceProxy::start(): an early return when m_isObservingMedia is already set. Nothing below it changed. The function still sets m_shouldReset, clears m_isStopped, starts the underlying RealtimeMediaSource, rebuilds the audio plumbing through prepareAudioDescription() for audio sources, and calls observeMedia() — it just no longer does any of that on a second entry while the observer is live.
The interesting shape of this patch is what it doesn't do. observeMedia() was already idempotent; it early-returns on the same m_isObservingMedia flag the new guard consults. So the re-registration at the bottom of start() was never the problem. The guard was in the wrong place — one function too deep — and everything above the call site ran unconditionally:
Before: After:
start() (2nd StartProducingData) start() (2nd StartProducingData)
├─► m_shouldReset = true └─► if (m_isObservingMedia) return; ← guard here
├─► m_source->start()
├─► prepareAudioDescription() ◄── frees + reallocates shared state
└─► observeMedia()
└─► if (m_isObservingMedia)
return; ← guard was only here
m_isObservingMedia is cleared only by unobserveMedia(), which runs from stop() and from the proxy's destructor. A well-behaved stop()/start() pair therefore still takes the full path — the guard suppresses only the repeated-start case.
The remaining three files are test collateral: a new IPC layout test that drives the bug from JavaScript, its one-line expectation (This test passes if it does not crash.), and a Skip entry in the glib TestExpectations, since the affected code is behind #if PLATFORM(COCOA) && ENABLE(MEDIA_STREAM).
Background
GPU-process capture architecture. In modern WebKit the WebContent process holds no camera or microphone access at all. navigator.mediaDevices.getUserMedia() turns into IPC to the GPU process, where UserMediaCaptureManagerProxy creates one UserMediaCaptureManagerProxySourceProxy per RealtimeMediaSourceIdentifier. That proxy wraps a WebCore::RealtimeMediaSource — the platform-level capture object — and is responsible for relaying media back to the renderer. Control messages for this proxy (CreateMediaSourceForCaptureDeviceWithConstraints, StartProducingData, StopProducingData, RemoveSource) are dispatched on the GPU process main thread.
The sample-observer callback path. RealtimeMediaSource::AudioSampleObserver is the interface through which the platform capture unit hands over audio frames. Its audioSamplesAvailable() method is invoked on a dedicated background capture WorkQueue, not the main thread — so a registered observer genuinely runs on two threads concurrently. Registration and removal go through addAudioSampleObserver() / removeAudioSampleObserver(), which this class wraps as observeMedia() / unobserveMedia(), both gated on the m_isObservingMedia flag.
The shared-memory transport. ProducerSharedCARingBuffer is a CoreAudio ring buffer whose backing store is a SharedMemory region mapped into both the GPU and WebContent processes; the GPU side is the producer, writing sample frames into that mapping. Destroying the ring buffer object tears down the producer's mapping. Alongside it, an IPC::Semaphore signals the consumer that new frames have landed. prepareAudioDescription() is the function that builds this set — the semaphore, the ring buffer, the shared-memory handle sent to the renderer, and the cached stream description.
IPC Testing API. A test-only facility, enabled per-test with IPCTestingAPIEnabled=true, that lets a layout test construct and send raw IPC messages to other processes directly from JavaScript. WebKit uses it to model exactly what a compromised WebContent process can do: arbitrary messages, arbitrary arguments, arbitrary order.
Analysis
This is a reinitialize-after-publish race: state that had already been handed to a concurrently-running consumer was destroyed and rebuilt by a message handler on another thread, with no lock and no prior detach.
GPU main thread capture WorkQueue
─────────────── ─────────────────
start() #1
prepareAudioDescription()
m_ringBuffer = RB_A
m_captureSemaphore = SEM_A
observeMedia() ──────────────► audioSamplesAvailable()
reads m_ringBuffer → RB_A
start() #2 (attacker) ↓
prepareAudioDescription() │ (still inside)
~RB_A → SharedMemory unmapped │
~SEM_A → freed │
m_ringBuffer = RB_B ▼
store frames into RB_A ← UAF /
write-after-unmap
The left column is the whole bug. On the first StartProducingData, start() runs the setup and then publishes the proxy to the capture unit; from that instant the right column is live and dereferencing m_ringBuffer, m_captureSemaphore, m_audioHandle and m_description on its own thread. On the second StartProducingData — same source identifier, same benign arguments — m_isObservingMedia is already true, so observeMedia() at the bottom of start() correctly does nothing. But prepareAudioDescription() above it has no such check and reassigns every one of those members: the previous ProducerSharedCARingBuffer is destructed (taking its SharedMemory mapping with it), the previous IPC::Semaphore is freed, and fresh objects take their place. Nothing serializes this against the capture thread that is holding raw references to the outgoing objects.
Two distinct memory-safety outcomes fall out, and the commit message names both. The first is an ordinary heap use-after-free: the capture thread touches the freed ring-buffer object or signals the freed semaphore. The second is more unusual — a write-after-unmap. The ring buffer's backing store is a SharedMemory region, and destroying the producer tears down that mapping; the capture thread's next store of sample frames lands in an address range the GPU process no longer owns. Attacker influence over the content of those writes is real but indirect: the bytes are audio sample data, and an attacker who controls the microphone environment controls what gets written, even though the diff and surrounding source do not establish the ring buffer's field layout well enough to say what offset control looks like.
The trigger sequence is exactly what the new layout test performs, and it needs nothing exotic:
- Obtain a real audio track via
getUserMedia({ audio: true })so the mock capture unit is live and delivering samples. - Create a second proxy over raw IPC with a hardcoded
RealtimeMediaSourceIdentifier(0xFFFFFFFF, chosen high enough to never collide with the process-global monotonic counter). - Send
StartProducingDataonce — the source starts, the observer registers, the capture thread begins calling back. - Send
StartProducingDataten more times every 10 ms, for 20 rounds. - Each repeat re-runs
prepareAudioDescription()against a live capture thread. Passing means not crashing.
What makes the pre-fix code look safe on a skim is the presence of m_isObservingMedia at all. The class has an idempotence flag, and both observeMedia() and unobserveMedia() consult it — but the flag guarded only the registration step, not the setup work that produces the state the registration exposes. A guard placed one call frame too deep protects the cheap operation and leaves the destructive one open.
The fix takes the blunt route, and it's the right one here: rather than introducing a lock the capture thread would also have to take, it removes the racing mutation entirely. With the guard at the top of start(), the shared members are written only in the window before the observer is registered and after stop() has removed it — the invariant the code always assumed is now structurally enforced rather than merely honored by convention. Legitimate stop()/start() cycles are unaffected, since stop() runs unobserveMedia() and clears the flag before the next start() sees it. This is a post-compromise pivot rather than a drive-by: the renderer can't emit raw IPC without already being owned, but the GPU process holds camera and microphone device access under a distinct, more permissive sandbox profile, so a successful pivot broadens capability well beyond the WebContent sandbox — advancing a chain rather than completing it.
A second StartProducingData message re-ran audio setup on the GPU main thread, freeing and unmapping a ring buffer the capture thread was concurrently writing sample frames into.
Insight
A partially-applied idempotence guard is more dangerous than none at all, because the code reads as protected. When a handler does setup(); publish_to_other_thread(); and only the publish step is guarded, a repeated message reinitializes live shared state — check that the guard sits at the entry point, not inside the last call.
Audit directions
-
Members written outside the observe/unobserve bracket. Narrow: sweep the rest of
UserMediaCaptureManagerProxySourceProxyand its siblings inSource/WebKit/GPUProcess/webrtc/—RemoteCaptureSampleManager, the video path throughaddVideoFrameObserver()/videoFrameAvailable(), andapplyConstraints/updateVideoConstraints()which recomputesm_widthConstraint/m_heightConstraint— for any member assigned while the observer is live. The tell is an assignment to a member that also appears in a background-thread callback, in a function reachable from an IPC handler, with no lock held. Wider: grepSource/WebKit/GPUProcess/media/for classes that both implement a platform observer/callback interface and ownSharedMemory,IPC::Semaphore, or*SharedCARingBuffermembers — the same shape recurs wherever a handler thread and aWorkQueue,MediaTimeclock callback, or CoreAudio/CoreMedia render callback share fields. Widest: this is the general reinitialize-after-publish class, not a WebKit one — Chromium's Mojo receivers rebinding a shared buffer while a media thread reads it hit the identical invariant, as does Rust code reaching for plainArcwhere the shape demandsArc<Mutex<>>. The universal question is: can the control message that performs setup be sent twice while the data plane is running, and what does the second setup free? -
Handlers whose safety depends on message ordering rather than arguments. Narrow: for every
StartX/StopX/EnableXpair inUserMediaCaptureManagerProxyMessages.inandRemoteMediaPlayerProxy, ask what a secondStartwithout an interveningStopdoes. Wider: the same reasoning covers any two-phase protocol across a trust boundary in WebKit's IPC layer —Create/Destroy,BeginX/EndX, buffer-handle installation followed by use; the tell there is a handler whose only guard lives inside a helper it calls rather than at its own entry. Widest: this is the "protocol state machine trusted across a privilege boundary" class, and the invariant is portable to any RPC surface — the privileged side must own the state machine and ignore or reject out-of-sequence transitions, never assume the unprivileged side follows it. Match tell at the widest aperture: any endpoint where calling the same method twice in a row is not obviously a no-op. -
Side effects the new early return now skips. Narrow: trace the readers of
m_shouldReset— the audio callback path that decides whether to reinitialize the ring buffer — andm_isStopped, and confirm no legitimate sequence now reaches the callback expecting a reset thatstart()no longer performs. Wider: whenever a fix is "return early on a flag" rather than "take a lock", every consumer of the skipped side effects needs re-checking; the tell is a guard inserted at the top of a function that performs three or more distinct mutations. Widest: an early-return idempotence guard must be justified against every side effect in the function it short-circuits, not just the one that caused the crash. Confirming the audio-callback interaction here is hard to settle by static reading alone and would likely want a timing-sensitive test or a TSan run. -
The video branch of the same class. Narrow:
start()now guards both branches, butobserveMedia()registers the video path with constraint parameters —addVideoFrameObserver(*this, { m_widthConstraint, m_heightConstraint }, m_frameRateConstraint)— thatupdateVideoConstraints()mutates independently; check every caller ofupdateVideoConstraints()andapplyConstraintsinUserMediaCaptureManagerProxy.cppfor paths that run whileisObservingMedia()is true. Wider: audit other WebKit classes that branch onRealtimeMediaSource::Typeor a similar type enum inside lifecycle methods — a fix motivated by one branch routinely leaves the other unaudited, and the tell is a type switch inside a start/stop/reconfigure method. Widest: the class is "polymorphic lifecycle method where only the branch that produced the crash report was hardened", and the rule to carry is that a lifecycle guard must be validated against every type branch the method dispatches to, because the reporter's PoC only ever exercised one.