CVE-2025-43356
Overview
Impact:
Description: A website may be able to access sensor information without user consent
Researchers: Jaydev Ahire
Key insight. The authoritative, web-app-reconciled muted state must be computed before it is cached or broadcast; propagating the raw requested state to internals().mutedState and to content processes while only the later async message used the reconciled state left capture effectively unmuted, exposing sensors without consent.
Patch Details
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) {
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2025-43356 |
| Bugzilla | 296153 |
| Component | WebKit |
| Bug Class | LogicError |
| Severity | medium |
| Commit | c420ed2f891b1948… |
| Advisory | Apple Advisory |
Terminology
- 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
1. Obtain capture access
A website requests camera/microphone via navigator.mediaDevices.getUserMedia and the user grants it, establishing active capture tracks.
2. 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.
3. Exploit the stale propagation
Because pre-patch the UIProcess cached and notified content processes with the raw state instead of the web-app-reconciled newState, the muted policy is not authoritatively enforced and capture can remain active/unmuted contrary to the intended muted state.
4. 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
This is a privacy/consent defect, not a memory-safety issue: the patch shows an ordering bug where the UIProcess propagated a stale (un-reconciled) muted state to its cache and to content processes, allowing camera/microphone capture to remain active when it should have been muted. The realistic impact is unauthorized access to sensor (audio/video) data by a website without proper user consent, as stated in the advisory; there is no memory corruption, OOB, or code-execution primitive implied by the diff. The logic resides in the UIProcess but the consequence is that the sandboxed WebContent process (and thus the site) receives capture it should not; no sandbox escape is involved.
Audit Directions
Ordering of state computation vs. propagation in setMuted
In WebPageProxy.cpp, verify every consumer of muted state uses the reconciled value; grep for internals().mutedState, pageMutedStateChanged, and applyWebAppDesiredMutedKinds to confirm the reconciled newState is computed before any assignment or notification.
Other capture/permission state setters
Audit sibling setters that reconcile a requested value with app/user policy then notify processes; grep in UIProcess for forEachWebContentProcess, SetMuted, and MediaProducerMutedStateFlags to find places that might notify with the pre-reconciliation value.
Raw-vs-derived value drift patterns
Look across UIProcess for functions that store or send a parameter and only later transform it; grep for a computed newState/finalState local used in one message but where the original state is used in an earlier cache write or notification within the same function.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
WebPageProxy::setMuted |
Source/WebKit/UIProcess/WebPageProxy.cpp |
modified | Computes the web-app-reconciled newState before any side effects, and now stores internals().mutedState = newState and calls pageMutedStateChanged with newState, so the cache and all content-process notifications carry the single authoritative reconciled muted state instead of the raw request. |
GetUserMediaAfterMuting (API test) |
Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mm |
added | New test that starts audio and video capture, mutes camera and microphone, re-requests capture, and asserts capture-state transitions and prompt counts to verify muting is consistently applied. |
startAudioCapture / startVideoCapture (test helpers) |
Tools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html |
added | JS helpers that request audio-only and video-only getUserMedia and report PASS/FAIL, used by the new test to drive per-kind capture. |
Files Changed
Source Files
Source/WebKit/UIProcess/WebPageProxy.cpp
Test Files
Tools/TestWebKitAPI/Tests/WebKit/GetUserMedia.mmTools/TestWebKitAPI/Tests/WebKitCocoa/media-session-capture.html
Regression Test
The fix ships this proof-of-concept / regression test:
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) {