CVE-2026-43731
Overview
Background
- GPU process capture path
- Media capture (microphone/camera) is brokered by UserMediaCaptureManagerProxy in the GPU process; WebContent drives it over IPC with messages like StartProducingData.
- Capture thread
- A separate thread delivers audio samples via audioSamplesAvailable() while the source is producing, concurrent with the IPC-handling thread.
- Idempotency
- An operation that has the same effect whether run once or many times; start() was not idempotent, so repeated calls re-ran setup.
Root Cause Analysis
This fixes a race in the GPU process’s audio capture path reachable from a compromised WebContent process. A capture source’s start() (in UserMediaCaptureManagerProxy) reset state and called m_source->start(); a compromised or malicious WebContent could send StartProducingData over IPC repeatedly, so start() re-ran source setup (including prepareAudioDescription()) while the capture thread was already delivering samples via audioSamplesAvailable(). The two threads then raced on the source’s audio description/state, producing memory corruption in the GPU process. The added comment states this directly.
The fix makes start() idempotent by returning early when already observing: if (m_isObservingMedia) return;, so repeated StartProducingData messages cannot re-initialize a source that is concurrently producing data.
The restored invariant is that source setup runs at most once while capturing, never concurrently with the capture thread’s sample callback. The regression test uses the IPC testing API to send StartProducingData many times against one source id to drive the race. Established by the diff. Note this is a WebContent->GPU process boundary issue: it presupposes a WebContent process already able to send crafted IPC (e.g. compromised via another bug), which the advisory reflects as the exploitation prerequisite.
if (m_isObservingMedia) return; restores the invariant that setup runs at most once while capturing.Attack Path
- Obtain crafted IPC to the GPU process A compromised WebContent process (or IPC testing) can send UserMediaCaptureManagerProxy messages for a capture source.
- Create a capture source and start producing It creates a media source and sends StartProducingData so the capture thread begins delivering audioSamplesAvailable().
- Flood StartProducingData It repeatedly sends StartProducingData for the same source id while capture is active.
- Race source setup against the capture thread Each pre-patch start() re-runs prepareAudioDescription() concurrently with audioSamplesAvailable(), racing shared state and corrupting memory in the GPU process.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
Source::start (UserMediaCaptureManagerProxy)Source/WebKit/GPUProcess/webrtc/UserMediaCaptureManagerProxy.cpp |
modified | Adds an early `if (m_isObservingMedia) return;` guard so repeated StartProducingData messages cannot re-initialize a source that is already producing data, eliminating the race with the capture thread's audioSamplesAvailable(). |
Files Changed
LayoutTests/ipc/usermedia-capture-start-producing-data-race-expected.txtLayoutTests/ipc/usermedia-capture-start-producing-data-race.htmlLayoutTests/platform/glib/TestExpectationsSource/WebKit/GPUProcess/webrtc/UserMediaCaptureManagerProxy.cpp
Audit Directions
- Non-idempotent IPC handlersReview other GPU-process capture/media control messages that re-run initialization; any handler reachable repeatedly over IPC must be safe against re-entry while a worker thread is active.
- State guarded only after setupLook for setup routines that mutate shared source state without first checking a ‘already running’ flag like m_isObservingMedia.
Patch
diff --git a/LayoutTests/ipc/usermedia-capture-start-producing-data-race-expected.txt b/LayoutTests/ipc/usermedia-capture-start-producing-data-race-expected.txt
new file mode 100644
index 000000000000..654ddf7f17ef
--- /dev/null
+++ b/LayoutTests/ipc/usermedia-capture-start-producing-data-race-expected.txt
@@ -0,0 +1 @@
+This test passes if it does not crash.
diff --git a/LayoutTests/ipc/usermedia-capture-start-producing-data-race.html b/LayoutTests/ipc/usermedia-capture-start-producing-data-race.html
new file mode 100644
index 000000000000..f681a37dc945
--- /dev/null
+++ b/LayoutTests/ipc/usermedia-capture-start-producing-data-race.html
@@ -0,0 +1,118 @@
+<!DOCTYPE html><!-- webkit-test-runner [ IPCTestingAPIEnabled=true ] -->
+<html>
+<head>
+<script>
+if (window.testRunner) {
+ testRunner.dumpAsText();
+ testRunner.waitUntilDone();
+ testRunner.setUserMediaPermission(true);
+}
+
+function done() {
+ document.body.textContent = "This test passes if it does not crash.";
+ if (window.testRunner)
+ testRunner.notifyDone();
+}
+
+function failTest(error) {
+ document.body.textContent = `Fail: ${error.message}`;
+ if (window.testRunner)
+ testRunner.notifyDone();
+}
+
+async function runTest()
+{
+ if (!window.IPC) {
+ done();
+ return;
+ }
+
+ const { CoreIPC } = await import('./coreipc.js');
+
+ let stream;
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ } catch (error) {
+ throw error;
+ }
+
+ // Hardcoded ID chosen to be much larger than the WebProcess's
+ // RealtimeMediaSourceIdentifier monotonic counter could ever reach in a
+ // single layout-test session, avoiding collision with the id allocated
+ // for the getUserMedia() source above.
+ //
+ // WARNING: RealtimeMediaSourceIdentifier is a process-global monotonic
+ // counter (ObjectIdentifier::generate()) that is never reset across
+ // tests sharing a WebProcess (e.g. mac-wk2). A small hardcoded value
+ // like 100 will eventually collide with the id auto-allocated for the
+ // getUserMedia() source above once enough prior tests have advanced the
+ // counter, tripping ASSERT(!m_proxies.contains(id)) in
+ // UserMediaCaptureManagerProxy::createMediaSourceForCaptureDeviceWithConstraints
+ // and crashing the GPU process on debug bots. Any future IPC test that
+ // hardcodes a RealtimeMediaSourceIdentifier must pick a value well above
+ // any plausible counter (and below the UINT64_MAX hash-traits sentinel).
+ const sourceId = 0xFFFFFFFFn;
+
+ const emptyConstraintMap = {
+ m_width: {}, m_height: {}, m_sampleRate: {}, m_sampleSize: {},
+ m_aspectRatio: {}, m_frameRate: {}, m_volume: {},
+ m_echoCancellation: {}, m_displaySurface: {}, m_logicalSurface: {},
+ m_facingMode: {}, m_deviceId: {}, m_groupId: {}, m_whiteBalanceMode: {},
+ m_zoom: {}, m_torch: {}, m_backgroundBlur: {}, m_powerEfficient: {}
+ };
+
+ let timer;
+ await new Promise((resolve, reject) => {
+ CoreIPC.GPU.UserMediaCaptureManagerProxy.CreateMediaSourceForCaptureDeviceWithConstraints(0, {
+ id: sourceId,
+ device: {
+ persistentId: '239c24b0-2b15-11e3-8224-0800200c9a66',
+ type: 2,
+ label: 'Mock audio device 1',
+ groupId: 'microphonegroup',
+ enabled: true,
+ isDefault: true,
+ isMockDevice: true,
+ isEphemeral: false
+ },
+ hashSalts: { persistentDeviceSalt: 'a', ephemeralDeviceSalt: 'b' },
+ constraints: { mandatoryConstraints: emptyConstraintMap, advancedConstraints: [], isValid: true },
+ shouldUseGPUProcessRemoteFrames: false,
+ pageIdentifier: IPC.pageID
+ }, () => {
+ resolve();
+ });
+
+ timer = setTimeout(() => reject(`Timed out waiting for CreateMediaSourceForCaptureDeviceWithConstraints`), 5000);
+ });
+ clearTimeout(timer);
+
+ CoreIPC.GPU.UserMediaCaptureManagerProxy.StartProducingData(0, { id: sourceId, pageIdentifier: IPC.pageID });
+
+ let count = 0;
+ const interval = setInterval(() => {
+ for (let i = 0; i < 10; i++)
+ CoreIPC.GPU.UserMediaCaptureManagerProxy.StartProducingData(0, { id: sourceId, pageIdentifier: IPC.pageID });
+ if (++count > 20) {
+ clearInterval(interval);
+ stream.getTracks().forEach(t => t.stop());
+
+ // Tear down the GPU-side proxy we created via raw IPC so layout-test
+ // runs that share this WebProcess don't collide on the hardcoded sourceId.
+ CoreIPC.GPU.UserMediaCaptureManagerProxy.RemoveSource(0, { id: sourceId });
+ done();
+ }
+ }, 10);
+}
+
+window.addEventListener('load', async event => {
+ runTest().catch((error) => {
+ failTest(error);
+ });
+});
+
+</script>
+</head>
+<body>
+</body>
+</html>
diff --git a/LayoutTests/platform/glib/TestExpectations b/LayoutTests/platform/glib/TestExpectations
index 11ff43480d47..99c2828898e3 100644
--- a/LayoutTests/platform/glib/TestExpectations
+++ b/LayoutTests/platform/glib/TestExpectations
@@ -3606,6 +3606,7 @@ ipc/pasteboard-write-custom-data.html [ Skip ]
ipc/remotedisplaylistrecorder-drawcontrolpart-slidertrackpart-crash.html [ Skip ]
ipc/media-containment-bypass-create-player.html [ Skip ]
http/tests/ipc/gpu-load-error-empty-user-info-crssh.html [ Skip ]
+ipc/usermedia-capture-start-producing-data-race.html [ Skip ]
# These tests are specific to Cocoa's definition of WebCore::PlatformColorSpace which is different from Skia's.
ipc/decode-feConvolveMatrix-kernelSize-overflow.html [ Skip ]
diff --git a/Source/WebKit/GPUProcess/webrtc/UserMediaCaptureManagerProxy.cpp b/Source/WebKit/GPUProcess/webrtc/UserMediaCaptureManagerProxy.cpp
index 9d2a2f433b78..2c178ee0ba65 100644
--- a/Source/WebKit/GPUProcess/webrtc/UserMediaCaptureManagerProxy.cpp
+++ b/Source/WebKit/GPUProcess/webrtc/UserMediaCaptureManagerProxy.cpp
@@ -140,6 +140,11 @@ class UserMediaCaptureManagerProxySourceProxy final
void start()
{
+ // A compromised WebContent process may send StartProducingData repeatedly. Once we are
+ // observing, prepareAudioDescription() would race the capture thread's audioSamplesAvailable().
+ if (m_isObservingMedia)
+ return;
+
m_shouldReset = true;
m_isStopped = false;
m_source->start();