CVE-2025-43356
Overview
Background
- WebPageProxy (UIProcess)
- The trusted UIProcess-side proxy for a web page that brokers privileged decisions such as media capture and muting on behalf of the sandboxed WebContent process.
- MediaProducerMutedStateFlags
- A bitfield describing which media kinds (e.g. audio capture, video capture) are muted for a page.
- applyWebAppDesiredMutedKinds
- A reconciliation helper that combines a requested muted state with the web app’s own desired muted capture kinds (m_mutedCaptureKindsDesiredByWebApp) to yield the authoritative muted state.
- pageMutedStateChanged
- A UIProcess-to-WebContent notification informing content processes of the page’s current muted state; it must carry the authoritative value.
- getUserMedia capture muting
- The mechanism by which active camera/microphone tracks are suppressed; if the muted state is not enforced, capture continues delivering sensor samples to the page.
Root Cause Analysis
The bug is an ordering/consistency defect in WebPageProxy::setMuted in the UIProcess, the trusted broker that decides whether camera/microphone capture is muted. setMuted receives a requested MediaProducerMutedStateFlags and must reconcile it with the web app’s own desired muted kinds via applyWebAppDesiredMutedKinds(state, m_mutedCaptureKindsDesiredByWebApp) to produce the authoritative newState that is then pushed to the WebContent process(es). In the vulnerable version the reconciliation was performed too late: the function first stored internals().mutedState = state (the raw, un-reconciled request) and first notified every web content process with process.pageMutedStateChanged(pageID, state) using the raw state, and only AFTER those side effects computed newState = applyWebAppDesiredMutedKinds(...). Consequently the UIProcess’s cached muted state and the pageMutedStateChanged notification reflected state rather than the web-app-adjusted newState, while the later async Messages::WebPage::SetMuted used newState — an internal inconsistency. The violated invariant is that all consumers of the page’s muted state (the cached internals().mutedState and every content-process notification) must observe the single authoritative, web-app-reconciled muted state; here two different values were propagated. In practice this meant capture could remain effectively unmuted/active when the reconciled policy should have muted it, allowing a website to access camera/microphone (sensor) data without proper consent — matching the advisory ‘a website may be able to access sensor information without user consent.’
The fix computes newState (via applyWebAppDesiredMutedKinds under ENABLE(MEDIA_STREAM), else newState = state) BEFORE any side effects, stores internals().mutedState = newState, and sends pageMutedStateChanged(pageID, newState), so the cache, the state-changed notification, and the SetMuted message all carry the same reconciled value. The added API test GetUserMediaAfterMuting and the startAudioCapture/startVideoCapture test helpers exercise muting camera and microphone and then re-requesting capture, asserting prompt counts, which validates that muting is applied consistently.
Attack Path
- Obtain capture access A website requests camera/microphone via navigator.mediaDevices.getUserMedia and the user grants it, establishing active capture tracks.
- Induce a setMuted transition A mute state change is triggered (e.g. app/embedder mutes capture, or a state transition flows through WebPageProxy::setMuted) so the reconciliation between requested state and the web app’s desired muted kinds is exercised.
- Exploit the stale propagation
Because pre-patch the UIProcess cached and notified content processes with the raw
stateinstead of the web-app-reconcilednewState, the muted policy is not authoritatively enforced and capture can remain active/unmuted contrary to the intended muted state. - Access sensor data without consent With capture effectively left unmuted, the website continues to receive camera/microphone samples that should have been suppressed, obtaining sensor information without proper user consent.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forEachWebContentProcessSource/WebKit/UIProcess/WebPageProxy.cpp |
modified | |
ifTools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html |
modified |
Files Changed
Source/WebKit/UIProcess/WebPageProxy.cppTools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mmTools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html
Audit Directions
- Ordering of state computation vs. propagation in setMutedIn WebPageProxy.cpp, verify every consumer of muted state uses the reconciled value; grep for
internals().mutedState,pageMutedStateChanged, andapplyWebAppDesiredMutedKindsto confirm the reconciled newState is computed before any assignment or notification. - Other capture/permission state settersAudit sibling setters that reconcile a requested value with app/user policy then notify processes; grep in UIProcess for
forEachWebContentProcess,SetMuted, andMediaProducerMutedStateFlagsto find places that might notify with the pre-reconciliation value. - Raw-vs-derived value drift patternsLook across UIProcess for functions that store or send a parameter and only later transform it; grep for a computed
newState/finalStatelocal used in one message but where the originalstateis used in an earlier cache write or notification within the same function.
Patch
diff --git a/Source/WebKit/UIProcess/WebPageProxy.cpp b/Source/WebKit/UIProcess/WebPageProxy.cpp
index c2857319785b..4a28da3e5e8e 100644
--- a/Source/WebKit/UIProcess/WebPageProxy.cpp
+++ b/Source/WebKit/UIProcess/WebPageProxy.cpp
@@ -9504,7 +9504,14 @@ void WebPageProxy::setMuted(WebCore::MediaProducerMutedStateFlags state, FromApp
if (!isAllowedToChangeMuteState())
state.add(WebCore::MediaProducer::MediaStreamCaptureIsMuted);
- internals().mutedState = state;
+#if ENABLE(MEDIA_STREAM)
+ auto newState = applyWebAppDesiredMutedKinds(state, m_mutedCaptureKindsDesiredByWebApp);
+#else
+ auto newState = state;
+#endif
+ WEBPAGEPROXY_RELEASE_LOG(Media, "setMuted, app state = %d, final state = %d", state.toRaw(), newState.toRaw());
+
+ internals().mutedState = newState;
if (!hasRunningProcess())
return completionHandler();
@@ -9516,16 +9523,9 @@ void WebPageProxy::setMuted(WebCore::MediaProducerMutedStateFlags state, FromApp
#endif // ENABLE(MEDIA_STREAM)
forEachWebContentProcess([&] (auto& process, auto pageID) {
- process.pageMutedStateChanged(pageID, state);
+ process.pageMutedStateChanged(pageID, newState);
});
-#if ENABLE(MEDIA_STREAM)
- auto newState = applyWebAppDesiredMutedKinds(state, m_mutedCaptureKindsDesiredByWebApp);
-#else
- auto newState = state;
-#endif
- WEBPAGEPROXY_RELEASE_LOG(Media, "setMuted, app state = %d, final state = %d", state.toRaw(), newState.toRaw());
-
auto aggregator = CallbackAggregator::create(WTFMove(completionHandler));
forEachWebContentProcess([&](auto& webProcess, auto pageID) {
webProcess.sendWithAsyncReply(Messages::WebPage::SetMuted(newState), [aggregator] { }, pageID);
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mm b/Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mm
index 961f574766e2..8f258d6448ea 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mm
@@ -2054,6 +2054,62 @@ TEST(WebKit2, getUserMediaWithDeviceChangeWebPage)
done = false;
}
+TEST(WebKit2, GetUserMediaAfterMuting)
+{
+ [TestProtocol registerWithScheme:@"https"];
+
+ auto configuration = adoptNS([[WKWebViewConfiguration alloc] init]);
+ configuration.get()._mediaCaptureEnabled = YES;
+ auto preferences = [configuration preferences];
+ preferences._mediaCaptureRequiresSecureConnection = NO;
+ preferences._mockCaptureDevicesEnabled = YES;
+ preferences._getUserMediaRequiresFocus = NO;
+
+ auto webView = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 320, 500) configuration:configuration.get()]);
+
+ auto observer = adoptNS([[MediaCaptureObserver alloc] init]);
+ [webView addObserver:observer.get() forKeyPath:@"microphoneCaptureState" options:NSKeyValueObservingOptionNew context:nil];
+ [webView addObserver:observer.get() forKeyPath:@"cameraCaptureState" options:NSKeyValueObservingOptionNew context:nil];
+
+ auto delegate = adoptNS([[UserMediaCaptureUIDelegate alloc] init]);
+ [webView setUIDelegate:delegate.get()];
+ [webView _setMediaCaptureReportingDelayForTesting:0];
+
+ [webView synchronouslyLoadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://bundle-file/media-session-capture.html"]]];
+
+ microphoneCaptureStateChange = false;
+ [webView stringByEvaluatingJavaScript:@"startAudioCapture()"];
+ EXPECT_TRUE(waitUntilMicrophoneState(webView.get(), WKMediaCaptureStateActive));
+ EXPECT_EQ(1, [delegate numberOfPrompts]);
+
+ cameraCaptureStateChange = false;
+ [webView stringByEvaluatingJavaScript:@"startVideoCapture()"];
+ EXPECT_TRUE(waitUntilCameraState(webView.get(), WKMediaCaptureStateActive));
+ EXPECT_EQ(2, [delegate numberOfPrompts]);
+
+ cameraCaptureStateChange = false;
+ [webView setCameraCaptureState:WKMediaCaptureStateMuted completionHandler:nil];
+ EXPECT_TRUE(waitUntilCameraState(webView.get(), WKMediaCaptureStateMuted));
+
+ microphoneCaptureStateChange = false;
+ [webView setMicrophoneCaptureState:WKMediaCaptureStateMuted completionHandler:nil];
+ EXPECT_TRUE(waitUntilMicrophoneState(webView.get(), WKMediaCaptureStateMuted));
+
+ microphoneCaptureStateChange = false;
+ [webView stringByEvaluatingJavaScript:@"startAudioCapture()"];
+ EXPECT_TRUE(waitUntilMicrophoneState(webView.get(), WKMediaCaptureStateActive));
+ EXPECT_EQ(3, [delegate numberOfPrompts]);
+
+ cameraCaptureStateChange = false;
+ [webView stringByEvaluatingJavaScript:@"startVideoCapture()"];
+ EXPECT_TRUE(waitUntilCameraState(webView.get(), WKMediaCaptureStateActive));
+ EXPECT_EQ(4, [delegate numberOfPrompts]);
+
+ [webView removeObserver:observer.get() forKeyPath:@"microphoneCaptureState"];
+ [webView removeObserver:observer.get() forKeyPath:@"cameraCaptureState"];
+}
+
+
#if PLATFORM(IOS_FAMILY) && !PLATFORM(MACCATALYST)
TEST(WebKit, GetUserMediaWithWebThread)
{
diff --git a/Tools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html b/Tools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html
index 0996ab2e0415..66fc37c7d535 100644
--- a/Tools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html
+++ b/Tools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html
@@ -28,6 +28,34 @@ function startCapture()
});
}
+function startAudioCapture()
+{
+ navigator.mediaDevices.getUserMedia({ audio:true }).then(stream => {
+ window.actionState = "";
+ video.srcObject = stream;
+
+ if (window.webkit)
+ window.webkit.messageHandlers.gum.postMessage("PASS");
+ }, (e) => {
+ if (window.webkit)
+ window.webkit.messageHandlers.gum.postMessage("FAIL: " + e);
+ });
+}
+
+function startVideoCapture()
+{
+ navigator.mediaDevices.getUserMedia({ video:true }).then(stream => {
+ window.actionState = "";
+ video.srcObject = stream;
+
+ if (window.webkit)
+ window.webkit.messageHandlers.gum.postMessage("PASS");
+ }, (e) => {
+ if (window.webkit)
+ window.webkit.messageHandlers.gum.postMessage("FAIL: " + e);
+ });
+}
+
function startScreenshareCapture()
{
if (!window.internals) {
Regression Test / PoC
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mm b/Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mm
index 961f574766e2..8f258d6448ea 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mm
@@ -2054,6 +2054,62 @@ TEST(WebKit2, getUserMediaWithDeviceChangeWebPage)
done = false;
}
+TEST(WebKit2, GetUserMediaAfterMuting)
+{
+ [TestProtocol registerWithScheme:@"https"];
+
+ auto configuration = adoptNS([[WKWebViewConfiguration alloc] init]);
+ configuration.get()._mediaCaptureEnabled = YES;
+ auto preferences = [configuration preferences];
+ preferences._mediaCaptureRequiresSecureConnection = NO;
+ preferences._mockCaptureDevicesEnabled = YES;
+ preferences._getUserMediaRequiresFocus = NO;
+
+ auto webView = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 320, 500) configuration:configuration.get()]);
+
+ auto observer = adoptNS([[MediaCaptureObserver alloc] init]);
+ [webView addObserver:observer.get() forKeyPath:@"microphoneCaptureState" options:NSKeyValueObservingOptionNew context:nil];
+ [webView addObserver:observer.get() forKeyPath:@"cameraCaptureState" options:NSKeyValueObservingOptionNew context:nil];
+
+ auto delegate = adoptNS([[UserMediaCaptureUIDelegate alloc] init]);
+ [webView setUIDelegate:delegate.get()];
+ [webView _setMediaCaptureReportingDelayForTesting:0];
+
+ [webView synchronouslyLoadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://bundle-file/media-session-capture.html"]]];
+
+ microphoneCaptureStateChange = false;
+ [webView stringByEvaluatingJavaScript:@"startAudioCapture()"];
+ EXPECT_TRUE(waitUntilMicrophoneState(webView.get(), WKMediaCaptureStateActive));
+ EXPECT_EQ(1, [delegate numberOfPrompts]);
+
+ cameraCaptureStateChange = false;
+ [webView stringByEvaluatingJavaScript:@"startVideoCapture()"];
+ EXPECT_TRUE(waitUntilCameraState(webView.get(), WKMediaCaptureStateActive));
+ EXPECT_EQ(2, [delegate numberOfPrompts]);
+
+ cameraCaptureStateChange = false;
+ [webView setCameraCaptureState:WKMediaCaptureStateMuted completionHandler:nil];
+ EXPECT_TRUE(waitUntilCameraState(webView.get(), WKMediaCaptureStateMuted));
+
+ microphoneCaptureStateChange = false;
+ [webView setMicrophoneCaptureState:WKMediaCaptureStateMuted completionHandler:nil];
+ EXPECT_TRUE(waitUntilMicrophoneState(webView.get(), WKMediaCaptureStateMuted));
+
+ microphoneCaptureStateChange = false;
+ [webView stringByEvaluatingJavaScript:@"startAudioCapture()"];
+ EXPECT_TRUE(waitUntilMicrophoneState(webView.get(), WKMediaCaptureStateActive));
+ EXPECT_EQ(3, [delegate numberOfPrompts]);
+
+ cameraCaptureStateChange = false;
+ [webView stringByEvaluatingJavaScript:@"startVideoCapture()"];
+ EXPECT_TRUE(waitUntilCameraState(webView.get(), WKMediaCaptureStateActive));
+ EXPECT_EQ(4, [delegate numberOfPrompts]);
+
+ [webView removeObserver:observer.get() forKeyPath:@"microphoneCaptureState"];
+ [webView removeObserver:observer.get() forKeyPath:@"cameraCaptureState"];
+}
+
+
#if PLATFORM(IOS_FAMILY) && !PLATFORM(MACCATALYST)
TEST(WebKit, GetUserMediaWithWebThread)
{
diff --git a/Tools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html b/Tools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html
index 0996ab2e0415..66fc37c7d535 100644
--- a/Tools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html
+++ b/Tools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html
@@ -28,6 +28,34 @@ function startCapture()
});
}
+function startAudioCapture()
+{
+ navigator.mediaDevices.getUserMedia({ audio:true }).then(stream => {
+ window.actionState = "";
+ video.srcObject = stream;
+
+ if (window.webkit)
+ window.webkit.messageHandlers.gum.postMessage("PASS");
+ }, (e) => {
+ if (window.webkit)
+ window.webkit.messageHandlers.gum.postMessage("FAIL: " + e);
+ });
+}
+
+function startVideoCapture()
+{
+ navigator.mediaDevices.getUserMedia({ video:true }).then(stream => {
+ window.actionState = "";
+ video.srcObject = stream;
+
+ if (window.webkit)
+ window.webkit.messageHandlers.gum.postMessage("PASS");
+ }, (e) => {
+ if (window.webkit)
+ window.webkit.messageHandlers.gum.postMessage("FAIL: " + e);
+ });
+}
+
function startScreenshareCapture()
{
if (!window.internals) {