CVE-2026-79134
Overview
Background
- `getUserMedia` / video capture
- The Web API pipeline (
MediaStreamManager,MediaStreamUIProxy,VideoCaptureManager) that grants a page access to camera devices. - ChromeOS login/lock screen capture
- On ChromeOS, only origins allow-listed by the
ash::kLoginVideoCaptureAllowedUrlspolicy may capture video on the SAML login/lock screen; all others must be denied while the screen is locked. - `ChromeOSLoginAndLockMediaAccessHandler`
- The Chrome-layer handler that decides whether a given origin is permitted video capture on the login/lock screen.
- `is_allowed_while_screen_locked`
- A new boolean plumbed through the capture pipeline, per session, indicating this specific session was policy-authorized for lock-screen capture.
Root Cause Analysis
CheckMediaAccessPermission and the deferral machinery, and sessions started while the screen was locked were merely deferred rather than being definitively accepted-if-allowed or rejected. The refactor extracts the policy check into a static IsVideoCaptureAllowedForOrigin, exposes it through ContentBrowserClient::IsVideoCaptureAllowedWhileScreenLocked, and computes a per-session is_allowed_while_screen_locked in MediaStreamUIProxy::Core::ProcessAccessRequestResponse only when the result is OK and the granted device is a real DEVICE_VIDEO_CAPTURE. This flag is threaded through HandleAccessRequestResponse into the DeviceRequest, queried later by IsSessionAllowedOnLockScreen, and consumed by VideoCaptureHost/VideoCaptureManager::ConnectClient so the manager enforces lock-screen policy at connect time; the commit also changes the behavior for sessions started while locked from deferral to outright rejection. The net effect is that lock-screen video-capture authorization is now derived from the request origin’s policy status and consistently enforced at the point of client connection, closing gaps where non-allow-listed origins could capture (or where locked-session handling was ambiguous).is_allowed_while_screen_locked from the origin policy and enforces it at ConnectClient, rejecting unauthorized locked-screen sessions outright.Attack Path
- Screen is locked The device is on the ChromeOS SAML login/lock screen where only policy-allow-listed origins may use the camera.
- Non-allow-listed origin requests capture
A page whose origin is not in
ash::kLoginVideoCaptureAllowedUrlsinitiates agetUserMediavideo request. - Weak/deferred gate
Pre-patch, the lock-screen prevention relied on
CheckMediaAccessPermission/deferral logic that did not consistently reject the session, so capture could proceed or be improperly deferred(inferred)for a disallowed origin. - Unauthorized camera access The video-capture client connects without the origin having passed the lock-screen policy check, yielding camera access on the lock screen for a non-authorized origin.
Impact Assessment
getUserMedia capture pipeline (browser process) specific to ChromeOS lock/login screens, where camera-capture prevention was not reliably enforced for non-allow-listed origins; preconditions are a locked ChromeOS screen and a capture request from a disallowed origin. The advisory states no exploited-in-the-wild status; the concrete privacy impact (unauthorized camera frames while locked) is what the patch guards against, and any broader escalation is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/media/chromeos_login_and_lock_media_access_handler.cc |
modified | |
ifcontent/browser/renderer_host/media/media_stream_manager.cc |
modified | |
ifcontent/browser/renderer_host/media/media_stream_ui_proxy.cc |
modified |
Files Changed
content/browser/renderer_host/media/video_capture_manager.hchrome/browser/media/chromeos_login_and_lock_media_access_handler.hcontent/browser/renderer_host/media/video_capture_browsertest.cccontent/browser/renderer_host/media/media_stream_ui_proxy_unittest.cccontent/browser/renderer_host/media/video_capture_manager_unittest.ccchrome/browser/chrome_content_browser_client.hcontent/browser/renderer_host/media/media_stream_manager.cccontent/public/browser/content_browser_client.cccontent/browser/renderer_host/media/media_stream_ui_proxy.cccontent/browser/renderer_host/media/video_capture_manager.cccontent/browser/renderer_host/media/video_capture_host.cccontent/public/browser/content_browser_client.hchrome/browser/media/chromeos_login_and_lock_media_access_handler.cccontent/browser/renderer_host/media/media_stream_manager_unittest.ccchrome/browser/chrome_content_browser_client.cccontent/browser/renderer_host/media/media_stream_ui_proxy.hcontent/browser/renderer_host/media/media_stream_manager.h
Audit Directions
- Authorization decided far from enforcementAudit capture/permission flows where an allow-list decision is made in the Chrome layer but must be carried (as trusted state) all the way to the connect/use point; missing plumbing lets unauthorized sessions slip through.
- Defer vs. reject semanticsReview lock-screen/idle handling that “defers” requests, since deferral without a definitive reject can leave a window in which disallowed origins proceed.
- Type-and-origin couplingCheck that per-session privilege flags are set only for the exact device type and successful result they were validated for (here
DEVICE_VIDEO_CAPTURE+OK), and never leak to audio-only or failed requests.
Patch
From bae48a9a83c164bc58a871e1e32fc48df6ddb1a5 Mon Sep 17 00:00:00 2001
From: Dale Curtis <dalecurtis@chromium.org>
Date: Thu, 23 Jul 2026 17:31:58 -0700
Subject: [PATCH] Improve screen lock camera capture prevention
Fixes some bugs and simplifies the code to cover a few more cases. The
behavior of sessions started while the screen is locked has changed from
deferring to outright rejection.
Fixed: 522351802
Change-Id: Ie84bb7159edded0602ab99368c6bb37f344c29c2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8104242
Commit-Queue: Dale Curtis <dalecurtis@chromium.org>
Auto-Submit: Dale Curtis <dalecurtis@chromium.org>
Reviewed-by: Guido Urdaneta <guidou@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1667537}
---
diff --git a/chrome/browser/chrome_content_browser_client.cc b/chrome/browser/chrome_content_browser_client.cc
index cdd8b44..c84ab56 100644
--- a/chrome/browser/chrome_content_browser_client.cc
+++ b/chrome/browser/chrome_content_browser_client.cc
@@ -501,6 +501,7 @@
#include "chrome/browser/ash/profiles/profile_helper.h"
#include "chrome/browser/ash/smb_client/fileapi/smbfs_file_system_backend_delegate.h"
#include "chrome/browser/ash/system/input_device_settings.h"
+#include "chrome/browser/media/chromeos_login_and_lock_media_access_handler.h"
#include "chrome/browser/media/webrtc/multi_capture/multi_capture_data_service.h"
#include "chrome/browser/media/webrtc/multi_capture/multi_capture_data_service_factory.h"
#include "chrome/browser/speech/tts_chromeos.h"
@@ -2811,6 +2812,16 @@
#endif // BUILDFLAG(IS_CHROMEOS)
}
+bool ChromeContentBrowserClient::IsVideoCaptureAllowedWhileScreenLocked(
+ const url::Origin& origin) {
+#if BUILDFLAG(IS_CHROMEOS)
+ return ChromeOSLoginAndLockMediaAccessHandler::IsVideoCaptureAllowedForOrigin(
+ origin);
+#else
+ return false;
+#endif
+}
+
content::WebContents*
ChromeContentBrowserClient::GetWebContentsFromWindowIfCaptureHandleAllowed(
gfx::NativeWindow window) {
diff --git a/chrome/browser/chrome_content_browser_client.h b/chrome/browser/chrome_content_browser_client.h
index a6356adf..96cdc99d 100644
--- a/chrome/browser/chrome_content_browser_client.h
+++ b/chrome/browser/chrome_content_browser_client.h
@@ -318,6 +318,8 @@
bool IsTopChromeWebUIURL(const GURL& url) override;
bool IsMultiCaptureAllowed(
content::RenderFrameHost* render_frame_host) override;
+ bool IsVideoCaptureAllowedWhileScreenLocked(
+ const url::Origin& origin) override;
content::WebContents* GetWebContentsFromWindowIfCaptureHandleAllowed(
gfx::NativeWindow window) override;
bool IsFileAccessAllowed(const base::FilePath& path,
diff --git a/chrome/browser/media/chromeos_login_and_lock_media_access_handler.cc b/chrome/browser/media/chromeos_login_and_lock_media_access_handler.cc
index 6b3d93d9..eb78f06 100644
--- a/chrome/browser/media/chromeos_login_and_lock_media_access_handler.cc
+++ b/chrome/browser/media/chromeos_login_and_lock_media_access_handler.cc
@@ -46,14 +46,9 @@
web_contents == lock_screen_online_reauth_dialog->GetWebContents();
}
-bool ChromeOSLoginAndLockMediaAccessHandler::CheckMediaAccessPermission(
- content::RenderFrameHost* render_frame_host,
- const url::Origin& security_origin,
- blink::mojom::MediaStreamType type,
- const extensions::Extension* extension) {
- if (type != blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE)
- return false;
-
+// static
+bool ChromeOSLoginAndLockMediaAccessHandler::IsVideoCaptureAllowedForOrigin(
+ const url::Origin& security_origin) {
const ash::CrosSettings* const settings = ash::CrosSettings::Get();
if (!settings)
return false;
@@ -83,6 +78,18 @@
return false;
}
+bool ChromeOSLoginAndLockMediaAccessHandler::CheckMediaAccessPermission(
+ content::RenderFrameHost* render_frame_host,
+ const url::Origin& security_origin,
+ blink::mojom::MediaStreamType type,
+ const extensions::Extension* extension) {
+ if (type != blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE) {
+ return false;
+ }
+
+ return IsVideoCaptureAllowedForOrigin(security_origin);
+}
+
void ChromeOSLoginAndLockMediaAccessHandler::HandleRequest(
content::WebContents* web_contents,
const content::MediaStreamRequest& request,
diff --git a/chrome/browser/media/chromeos_login_and_lock_media_access_handler.h b/chrome/browser/media/chromeos_login_and_lock_media_access_handler.h
index c1fc55c1..71197d1f 100644
--- a/chrome/browser/media/chromeos_login_and_lock_media_access_handler.h
+++ b/chrome/browser/media/chromeos_login_and_lock_media_access_handler.h
@@ -14,6 +14,12 @@
ChromeOSLoginAndLockMediaAccessHandler();
~ChromeOSLoginAndLockMediaAccessHandler() override;
+ // Returns true if the origin is permitted video capture access by policy
+ // (ash::kLoginVideoCaptureAllowedUrls) on the SAML login/lock screen.
+ // May only be called on the browser UI thread.
+ static bool IsVideoCaptureAllowedForOrigin(
+ const url::Origin& security_origin);
+
// MediaAccessHandler implementation.
bool SupportsStreamType(content::RenderFrameHost* render_frame_host,
const blink::mojom::MediaStreamType type,
diff --git a/content/browser/renderer_host/media/media_stream_manager.cc b/content/browser/renderer_host/media/media_stream_manager.cc
index e65c5ec1..b0dd16f 100644
--- a/content/browser/renderer_host/media/media_stream_manager.cc
+++ b/content/browser/renderer_host/media/media_stream_manager.cc
@@ -925,6 +925,8 @@
PermissionController::SubscriptionId video_subscription_id;
+ bool is_allowed_while_screen_locked = false;
+
virtual base::WeakPtr<DeviceRequest> GetWeakPtr() = 0;
private:
@@ -2731,9 +2733,15 @@
// owned by BrowserMainLoop.
screen_enumerator->EnumerateScreens(
request->video_type(),
- base::BindOnce(&MediaStreamManager::HandleAccessRequestResponse,
- base::Unretained(this), label,
- media::AudioParameters()));
+ base::BindOnce(
+ [](MediaStreamManager* manager, const std::string& label,
+ const blink::mojom::StreamDevicesSet& stream_devices_set,
+ blink::mojom::MediaStreamRequestResult result) {
+ manager->HandleAccessRequestResponse(
+ label, media::AudioParameters(), stream_devices_set, result,
+ /*is_allowed_while_screen_locked=*/false);
+ },
+ base::Unretained(this), label));
return;
}
@@ -3609,7 +3617,8 @@
const std::string& label,
const media::AudioParameters& output_parameters,
const blink::mojom::StreamDevicesSet& stream_devices_set,
- MediaStreamRequestResult result) {
+ MediaStreamRequestResult result,
+ bool is_allowed_while_screen_locked) {
DCHECK_CURRENTLY_ON(BrowserThread::IO);
DCHECK((result == MediaStreamRequestResult::OK &&
!stream_devices_set.stream_devices.empty()) ||
@@ -3622,6 +3631,7 @@
return;
}
DeviceRequest* const request = request_it->second.get();
+ request->is_allowed_while_screen_locked = is_allowed_while_screen_locked;
SendLogMessage(base::StringPrintf(
"HandleAccessRequestResponse({label=%s}, {request=%s}, {result=%s})",
@@ -4511,6 +4521,16 @@
return request->salt_and_origin.origin();
}
+bool MediaStreamManager::IsSessionAllowedOnLockScreen(
+ const base::UnguessableToken& session_id) {
+ SessionType actual_type;
+ DeviceRequest* request = FindRequestBySessionId(session_id, &actual_type);
+ if (request == nullptr || actual_type != SessionType::kVideo) {
+ return false;
+ }
+ return request->is_allowed_while_screen_locked;
+}
+
// static
PermissionControllerImpl* MediaStreamManager::GetPermissionController(
GlobalRenderFrameHostId requesting_render_frame_host_id) {
diff --git a/content/browser/renderer_host/media/media_stream_manager.h b/content/browser/renderer_host/media/media_stream_manager.h
index 0893f38..547be8f 100644
--- a/content/browser/renderer_host/media/media_stream_manager.h
+++ b/content/browser/renderer_host/media/media_stream_manager.h
@@ -483,6 +483,8 @@
std::optional<url::Origin> GetOriginByVideoSessionId(
const base::UnguessableToken& session_id);
+ bool IsSessionAllowedOnLockScreen(const base::UnguessableToken& session_id);
+
// Validates that the renderer-supplied `session_id` is authorized for use by
// the calling `render_frame_host_id`.
//
@@ -555,7 +557,8 @@
const std::string& label,
const media::AudioParameters& output_parameters,
const blink::mojom::StreamDevicesSet& stream_devices_set,
- blink::mojom::MediaStreamRequestResult result);
+ blink::mojom::MediaStreamRequestResult result,
+ bool is_allowed_while_screen_locked = false);
void HandleChangeSourceRequestResponse(
const std::string& label,
DeviceRequest* request,
diff --git a/content/browser/renderer_host/media/media_stream_ui_proxy.cc b/content/browser/renderer_host/media/media_stream_ui_proxy.cc
index 47da3b5..d6576d1db 100644
--- a/content/browser/renderer_host/media/media_stream_ui_proxy.cc
+++ b/content/browser/renderer_host/media/media_stream_ui_proxy.cc
@@ -81,6 +81,7 @@
void ProcessAccessRequestResponseForPostTask(
int render_process_id,
int render_frame_id,
+ const url::Origin& request_origin,
blink::mojom::StreamDevicesSetPtr stream_devices_set_ptr,
blink::mojom::MediaStreamRequestResult result,
std::unique_ptr<MediaStreamUI> stream_ui);
@@ -88,6 +89,7 @@
void ProcessAccessRequestResponse(
int render_process_id,
int render_frame_id,
+ const url::Origin& request_origin,
const blink::mojom::StreamDevicesSet& stream_devices_set,
blink::mojom::MediaStreamRequestResult result,
std::unique_ptr<MediaStreamUI> stream_ui);
@@ -153,7 +155,7 @@
// Tab may have gone away, or has no delegate from which to request access.
if (!render_delegate) {
ProcessAccessRequestResponse(request->render_process_id,
- request->render_frame_id,
+ request->render_frame_id, request->url_origin,
blink::mojom::StreamDevicesSet(),
blink::mojom::MediaStreamRequestResult::
FAILED_DUE_TO_SHUTDOWN_NO_DELEGATE,
@@ -167,7 +169,8 @@
render_delegate->RequestMediaAccessPermission(
host, *request,
base::BindOnce(&Core::ProcessAccessRequestResponse, weak_this_,
- request->render_process_id, request->render_frame_id));
+ request->render_process_id, request->render_frame_id,
+ request->url_origin));
}
void MediaStreamUIProxy::Core::RequestSelectAudioOutput(
@@ -256,19 +259,21 @@
void MediaStreamUIProxy::Core::ProcessAccessRequestResponseForPostTask(
int render_process_id,
int render_frame_id,
+ const url::Origin& request_origin,
blink::mojom::StreamDevicesSetPtr stream_devices_set_ptr,
blink::mojom::MediaStreamRequestResult result,
std::unique_ptr<MediaStreamUI> stream_ui) {
DCHECK_CURRENTLY_ON(BrowserThread::UI);
DCHECK(stream_devices_set_ptr);
ProcessAccessRequestResponse(render_process_id, render_frame_id,
- *stream_devices_set_ptr, result,
+ request_origin, *stream_devices_set_ptr, result,
std::move(stream_ui));
}
void MediaStreamUIProxy::Core::ProcessAccessRequestResponse(
int render_process_id,
int render_frame_id,
+ const url::Origin& request_origin,
const blink::mojom::StreamDevicesSet& stream_devices_set,
blink::mojom::MediaStreamRequestResult result,
std::unique_ptr<MediaStreamUI> stream_ui) {
@@ -288,6 +293,16 @@
}
auto* host = RenderFrameHostImpl::FromID(render_process_id, render_frame_id);
+ bool is_allowed_while_screen_locked = false;
+ if (result == blink::mojom::MediaStreamRequestResult::OK &&
+ devices.video_device.has_value() &&
+ devices.video_device->type ==
+ blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE) {
+ is_allowed_while_screen_locked =
+ GetContentClient()->browser()->IsVideoCaptureAllowedWhileScreenLocked(
+ request_origin);
+ }
+
if (devices.audio_device.has_value()) {
const blink::MediaStreamDevice& audio_device = devices.audio_device.value();
if (audio_device.type !=
@@ -330,7 +345,8 @@
GetIOThreadTaskRunner({})->PostTask(
FROM_HERE,
base::BindOnce(&MediaStreamUIProxy::ProcessAccessRequestResponse, proxy_,
- std::move(filtered_devices_set), result));
+ std::move(filtered_devices_set), result,
+ is_allowed_while_screen_locked));
}
void MediaStreamUIProxy::Core::ProcessStopRequestFromUI() {
@@ -485,11 +501,13 @@
void MediaStreamUIProxy::ProcessAccessRequestResponse(
blink::mojom::StreamDevicesSetPtr stream_devices_set,
- blink::mojom::MediaStreamRequestResult result) {
+ blink::mojom::MediaStreamRequestResult result,
+ bool is_allowed_while_screen_locked) {
DCHECK_CURRENTLY_ON(BrowserThread::IO);
if (response_callback_) {
- std::move(response_callback_).Run(*stream_devices_set, result);
+ std::move(response_callback_)
+ .Run(*stream_devices_set, result, is_allowed_while_screen_locked);
}
}
@@ -568,7 +586,8 @@
base::BindOnce(
&MediaStreamUIProxy::Core::ProcessAccessRequestResponseForPostTask,
core_->GetWeakPtr(), request->render_process_id,
- request->render_frame_id, blink::mojom::StreamDevicesSet::New(),
+ request->render_frame_id, request->url_origin,
+ blink::mojom::StreamDevicesSet::New(),
blink::mojom::MediaStreamRequestResult::PERMISSION_DENIED,
std::unique_ptr<MediaStreamUI>()));
return;
@@ -621,7 +640,8 @@
base::BindOnce(
&MediaStreamUIProxy::Core::ProcessAccessRequestResponseForPostTask,
core_->GetWeakPtr(), request->render_process_id,
- request->render_frame_id, std::move(devices_set_to_use),
+ request->render_frame_id, request->url_origin,
+ std::move(devices_set_to_use),
is_devices_empty ? blink::mojom::MediaStreamRequestResult::NO_HARDWARE
: blink::mojom::MediaStreamRequestResult::OK,
std::unique_ptr<MediaStreamUI>()));
diff --git a/content/browser/renderer_host/media/media_stream_ui_proxy.h b/content/browser/renderer_host/media/media_stream_ui_proxy.h
index 3af8af3..bba19501 100644
--- a/content/browser/renderer_host/media/media_stream_ui_proxy.h
+++ b/content/browser/renderer_host/media/media_stream_ui_proxy.h
@@ -33,7 +33,8 @@
public:
using ResponseCallback = base::OnceCallback<void(
const blink::mojom::StreamDevicesSet& stream_devices_set,
- blink::mojom::MediaStreamRequestResult result)>;
+ blink::mojom::MediaStreamRequestResult result,
+ bool is_allowed_while_screen_locked)>;
using WindowIdCallback =
base::OnceCallback<void(gfx::NativeViewId window_id)>;
@@ -125,7 +126,8 @@
void ProcessAccessRequestResponse(
blink::mojom::StreamDevicesSetPtr stream_devices_set,
- blink::mojom::MediaStreamRequestResult result);
+ blink::mojom::MediaStreamRequestResult result,
+ bool is_allowed_while_screen_locked);
void ProcessStopRequestFromUI();
void ProcessChangeSourceRequestFromUI(const DesktopMediaID& media_id,
bool captured_surface_control_active);
diff --git a/content/browser/renderer_host/media/video_capture_host.cc b/content/browser/renderer_host/media/video_capture_host.cc
index 0e6c0bc..4c1001ae 100644
--- a/content/browser/renderer_host/media/video_capture_host.cc
+++ b/content/browser/renderer_host/media/video_capture_host.cc
@@ -595,9 +595,11 @@
VideoCaptureManager::DoneCB done_cb) {
std::optional<url::Origin> origin =
media_stream_manager_->GetOriginByVideoSessionId(session_id);
+ bool is_allowed_on_lock_screen =
+ media_stream_manager_->IsSessionAllowedOnLockScreen(session_id);
media_stream_manager_->video_capture_manager()->ConnectClient(
session_id, params, controller_id, render_frame_host_id, this,
- std::move(origin), std::move(done_cb));
+ std::move(origin), is_allowed_on_lock_screen, std::move(done_cb));
}
} // namespace content
diff --git a/content/browser/renderer_host/media/video_capture_manager.cc b/content/browser/renderer_host/media/video_capture_manager.cc
index e3cc36b..82159982 100644
--- a/content/browser/renderer_host/media/video_capture_manager.cc
+++ b/content/browser/renderer_host/media/video_capture_manager.cc
@@ -32,6 +32,7 @@
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/desktop_media_id.h"
+#include "content/public/common/content_client.h"
#include "content/public/common/content_features.h"
#include "media/base/media_switches.h"
#include "media/base/video_facing.h"
@@ -237,8 +238,7 @@
const bool was_locked = locked_it != locked_sessions_.end();
if (was_locked)
locked_sessions_.erase(locked_it);
- if (locked_sessions_.empty() && !lock_time_.is_null()) {
- lock_time_ = base::TimeTicks();
+ if (locked_sessions_.empty()) {
idle_close_timer_.Stop();
}
} else {
@@ -272,7 +272,6 @@
scoped_refptr<VideoCaptureController> controller,
const media::VideoCaptureParams& params) {
DCHECK_CURRENTLY_ON(BrowserThread::IO);
- DCHECK(lock_time_.is_null());
... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/renderer_host/media/media_stream_manager_unittest.cc b/content/browser/renderer_host/media/media_stream_manager_unittest.cc
index 7365ef3..82afed0a 100644
--- a/content/browser/renderer_host/media/media_stream_manager_unittest.cc
+++ b/content/browser/renderer_host/media/media_stream_manager_unittest.cc
@@ -293,6 +293,14 @@
return std::make_unique<ScreenEnumeratorMock>(screen_count_);
}
+ bool IsVideoCaptureAllowedWhileScreenLocked(
+ const url::Origin& origin) override {
+ return is_video_capture_allowed_while_screen_locked_;
+ }
+ void set_is_video_capture_allowed_while_screen_locked(bool allowed) {
+ is_video_capture_allowed_while_screen_locked_ = allowed;
+ }
+
MOCK_METHOD(void,
NotifyMultiCaptureStateChanged,
(GlobalRenderFrameHostId render_frame_host_id,
@@ -303,6 +311,7 @@
private:
raw_ptr<MediaObserver> media_observer_;
raw_ptr<const size_t> screen_count_;
+ bool is_video_capture_allowed_while_screen_locked_ = false;
};
class MockMediaStreamUIProxy : public FakeMediaStreamUIProxy {
@@ -738,9 +747,11 @@
const std::string& label,
const media::AudioParameters& output_parameters,
const blink::mojom::StreamDevicesSet& stream_devices_set,
- blink::mojom::MediaStreamRequestResult result) {
+ blink::mojom::MediaStreamRequestResult result,
+ bool is_allowed_while_screen_locked = false) {
media_stream_manager_->HandleAccessRequestResponse(
- label, output_parameters, stream_devices_set, result);
+ label, output_parameters, stream_devices_set, result,
+ is_allowed_while_screen_locked);
}
std::string GetLatestLabel() const {
@@ -750,7 +761,8 @@
std::string GenerateStreamsAndWaitForApproval(
const blink::StreamControls& controls,
- MediaStreamManager::DeviceStoppedCallback stopped_cb =
+ MediaStreamManager::DeviceStoppedCallback stopped_cb = base::DoNothing(),
+ MediaStreamManager::GenerateStreamsCallback generate_stream_cb =
base::DoNothing()) {
base::RunLoop run_loop;
EXPECT_CALL(*media_observer_,
@@ -763,7 +775,7 @@
kRenderFrameHostId, /*requester_id=*/1, /*page_request_id=*/1, controls,
MediaDeviceSaltAndOrigin::Empty(), /*user_gesture=*/true,
blink::mojom::StreamSelectionInfo::NewSearchOnlyByDeviceId({}),
- base::DoNothing(), std::move(stopped_cb), base::DoNothing(),
+ std::move(generate_stream_cb), std::move(stopped_cb), base::DoNothing(),
base::DoNothing(), base::DoNothing(), base::DoNothing(),
base::DoNothing());
@@ -957,6 +969,59 @@
run_loop_.Run();
}
+TEST_F(MediaStreamManagerTest, IsSessionAllowedOnLockScreen) {
+ media_stream_manager_->UseFakeUIFactoryForTests(base::BindRepeating([]() {
+ auto fake_ui = std::make_unique<FakeMediaStreamUIProxy>(
+ /*tests_use_fake_render_frame_hosts=*/true);
+ fake_ui->AddAvailableDevices({blink::MediaStreamDevice(
+ blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE, "Camera",
+ "Camera")});
+ return fake_ui;
+ }));
+
+ browser_content_client_->set_is_video_capture_allowed_while_screen_locked(
+ true);
+
+ EXPECT_CALL(*media_observer_, OnMediaRequestStateChanged(_, _, _, _, _, _))
+ .Times(testing::AnyNumber());
+
+ blink::StreamControls controls(/*request_audio=*/false,
+ /*request_video=*/true);
+ base::UnguessableToken video_session_id;
+ base::RunLoop run_loop;
+ MediaStreamManager::GenerateStreamsCallback generate_stream_cb =
+ base::BindOnce(
+ [](base::UnguessableToken* out_session_id, base::RunLoop* run_loop,
+ blink::mojom::MediaStreamRequestResult result,
+ const std::string& label,
+ blink::mojom::StreamDevicesSetPtr stream_devices_set,
+ bool pan_tilt_zoom_allowed) {
+ if (stream_devices_set &&
+ !stream_devices_set->stream_devices.empty() &&
+ stream_devices_set->stream_devices[0]
+ ->video_device.has_value()) {
+ *out_session_id = stream_devices_set->stream_devices[0]
+ ->video_device->session_id();
+ }
+ run_loop->Quit();
+ },
+ &video_session_id, &run_loop);
+
+ media_stream_manager_->GenerateStreams(
+ kRenderFrameHostId, /*requester_id=*/1, /*page_request_id=*/1, controls,
+ MediaDeviceSaltAndOrigin::Empty(), /*user_gesture=*/true,
+ blink::mojom::StreamSelectionInfo::NewSearchOnlyByDeviceId({}),
+ std::move(generate_stream_cb), base::DoNothing(), base::DoNothing(),
+ base::DoNothing(), base::DoNothing(), base::DoNothing(),
+ base::DoNothing());
+
+ run_loop.Run();
+
+ EXPECT_FALSE(video_session_id.is_empty());
+ EXPECT_TRUE(
+ media_stream_manager_->IsSessionAllowedOnLockScreen(video_session_id));
+}
+
TEST_F(MediaStreamManagerTest, MakeAndCancelMultipleRequests) {
std::string label1 = MakeMediaAccessRequest(0);
std::string label2 = MakeMediaAccessRequest(1);
diff --git a/content/browser/renderer_host/media/media_stream_ui_proxy_unittest.cc b/content/browser/renderer_host/media/media_stream_ui_proxy_unittest.cc
index 79425aa..c5ad3d5 100644
--- a/content/browser/renderer_host/media/media_stream_ui_proxy_unittest.cc
+++ b/content/browser/renderer_host/media/media_stream_ui_proxy_unittest.cc
@@ -63,9 +63,10 @@
class MockResponseCallback {
public:
- MOCK_METHOD2(OnAccessRequestResponse,
+ MOCK_METHOD3(OnAccessRequestResponse,
void(const blink::mojom::StreamDevicesSet& stream_devices_set,
- blink::mojom::MediaStreamRequestResult result));
+ blink::mojom::MediaStreamRequestResult result,
+ bool is_allowed_while_screen_locked));
MOCK_METHOD1(OnCheckResponse, void(bool have_access));
};
@@ -172,11 +173,10 @@
std::unique_ptr<MediaStreamUI>());
blink::mojom::StreamDevicesSetPtr response;
- EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _))
+ EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _, _))
.WillOnce([&response](const blink::mojom::StreamDevicesSet& arg0,
- blink::mojom::MediaStreamRequestResult arg1) {
- response = arg0.Clone();
- });
+ blink::mojom::MediaStreamRequestResult arg1,
+ bool arg2) { response = arg0.Clone(); });
base::RunLoop().RunUntilIdle();
EXPECT_TRUE(blink::ToMediaStreamDevicesList(*response).empty());
@@ -219,11 +219,10 @@
std::move(ui));
blink::mojom::StreamDevicesSetPtr response;
- EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _))
+ EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _, _))
.WillOnce([&response](const blink::mojom::StreamDevicesSet& arg0,
- blink::mojom::MediaStreamRequestResult arg1) {
- response = arg0.Clone();
- });
+ blink::mojom::MediaStreamRequestResult arg1,
+ bool arg2) { response = arg0.Clone(); });
base::RunLoop().RunUntilIdle();
EXPECT_FALSE(blink::ToMediaStreamDevicesList(*response).empty());
@@ -235,6 +234,81 @@
base::RunLoop().RunUntilIdle();
}
+namespace {
+class TestContentBrowserClientForLockScreen : public ContentBrowserClient {
+ public:
+ explicit TestContentBrowserClientForLockScreen(
+ const url::Origin& allowed_origin)
+ : allowed_origin_(allowed_origin) {}
+
+ bool IsVideoCaptureAllowedWhileScreenLocked(
+ const url::Origin& origin) override {
+ return origin == allowed_origin_;
+ }
+
+ private:
+ url::Origin allowed_origin_;
+};
+} // namespace
+
+TEST_F(MediaStreamUIProxyTest, AcceptVideoCaptureAllowedWhileScreenLocked) {
+ const url::Origin allowed_origin =
+ url::Origin::Create(GURL("http://origin/"));
+ TestContentBrowserClientForLockScreen test_browser_client(allowed_origin);
+ ContentBrowserClient* old_browser_client =
+ SetBrowserClientForTesting(&test_browser_client);
+
+ auto request = std::make_unique<MediaStreamRequest>(
+ 0, 0, 0, allowed_origin, false, blink::MEDIA_GENERATE_STREAM,
+ /*requested_audio_device_ids=*/std::vector<std::string>{},
+ /*requested_video_device_ids=*/std::vector<std::string>{},
+ blink::mojom::MediaStreamType::NO_SERVICE,
+ blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE,
+ /*disable_local_echo=*/false, /*request_pan_tilt_zoom_permission=*/false,
+ /*captured_surface_control_active=*/false);
+ request->url_origin = allowed_origin;
+ MediaStreamRequest* request_ptr = request.get();
+ proxy_->RequestAccess(
+ std::move(request),
+ base::BindOnce(&MockResponseCallback::OnAccessRequestResponse,
+ base::Unretained(&response_callback_)));
+ MediaResponseCallback callback;
+ base::RunLoop run_loop1;
+ EXPECT_CALL(delegate_,
+ RequestMediaAccessPermission(SameRequest(request_ptr), _))
+ .WillOnce(
+ [&callback, &run_loop1](testing::Unused, MediaResponseCallback* cb) {
+ callback = std::move(*cb);
+ run_loop1.Quit();
+ });
+ run_loop1.Run();
+ ASSERT_FALSE(callback.is_null());
+
+ blink::mojom::StreamDevicesSet stream_devices_set;
+ stream_devices_set.stream_devices.emplace_back(
+ blink::mojom::StreamDevices::New());
+ blink::mojom::StreamDevices& devices = *stream_devices_set.stream_devices[0];
+ devices.video_device = blink::MediaStreamDevice(
+ blink::mojom::MediaStreamType::DEVICE_VIDEO_CAPTURE, "Camera", "Camera");
+ std::move(callback).Run(stream_devices_set,
+ blink::mojom::MediaStreamRequestResult::OK,
+ /*ui=*/nullptr);
+
+ bool is_allowed_while_screen_locked = false;
+ base::RunLoop run_loop2;
+ EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _, _))
+ .WillOnce([&is_allowed_while_screen_locked, &run_loop2](
+ const blink::mojom::StreamDevicesSet& arg0,
+ blink::mojom::MediaStreamRequestResult arg1, bool arg2) {
+ is_allowed_while_screen_locked = arg2;
+ run_loop2.Quit();
+ });
+ run_loop2.Run();
+
+ EXPECT_TRUE(is_allowed_while_screen_locked);
+ SetBrowserClientForTesting(old_browser_client);
+}
+
// Verify that the proxy can be deleted before the request is processed.
TEST_F(MediaStreamUIProxyTest, DeleteBeforeAccepted) {
auto request = std::make_unique<MediaStreamRequest>(
@@ -315,11 +389,10 @@
std::move(ui));
blink::mojom::StreamDevicesSetPtr response;
- EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _))
+ EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _, _))
.WillOnce([&response](const blink::mojom::StreamDevicesSet& arg0,
- blink::mojom::MediaStreamRequestResult arg1) {
- response = arg0.Clone();
- });
+ blink::mojom::MediaStreamRequestResult arg1,
+ bool arg2) { response = arg0.Clone(); });
base::RunLoop().RunUntilIdle();
EXPECT_FALSE(blink::ToMediaStreamDevicesList(*response).empty());
@@ -373,7 +446,7 @@
std::move(callback).Run(stream_devices_set,
blink::mojom::MediaStreamRequestResult::OK,
std::move(ui));
- EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _));
+ EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _, _));
MockStopStreamHandler handler;
EXPECT_CALL(handler, OnWindowId(kWindowId));
@@ -431,11 +504,10 @@
std::move(ui));
blink::mojom::StreamDevicesSetPtr response;
- EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _))
+ EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _, _))
.WillOnce([&response](const blink::mojom::StreamDevicesSet& arg0,
- blink::mojom::MediaStreamRequestResult arg1) {
- response = arg0.Clone();
- });
+ blink::mojom::MediaStreamRequestResult arg1,
+ bool arg2) { response = arg0.Clone(); });
base::RunLoop().RunUntilIdle();
EXPECT_FALSE(blink::ToMediaStreamDevicesList(*response).empty());
@@ -506,12 +578,11 @@
std::move(ui));
blink::mojom::StreamDevicesSetPtr response;
- EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _))
+ EXPECT_CALL(response_callback_, OnAccessRequestResponse(_, _, _))
.Times(2)
.WillRepeatedly([&](const blink::mojom::StreamDevicesSet& arg0,
- blink::mojom::MediaStreamRequestResult arg1) {
- response = arg0.Clone();
- });
+ blink::mojom::MediaStreamRequestResult arg1,
... (truncated)
Original Bug Report
Potential privacy bypass of camera screen lock protection in VideoCaptureManager
Flapjack, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic flaw in VideoCaptureManager allows websites with previously granted camera permissions to start video capture while the OS screen is locked. This occurs because the internal flag tracking the screen lock state is improperly managed and can become null during a lock, bypassing the privacy protection designed to prevent background surveillance.
Affected files:
content/browser/renderer_host/media/video_capture_manager.cc
Estimated timestamp from git blame: 2021-07-02
Description
Chrome implements a privacy mitigation designed to prevent websites from recording video via the camera while the OS screen is locked. On desktop platforms, when the screen locks, VideoCaptureManager::OnScreenLocked is called to enforce this by eventually releasing active devices and blocking new capture requests.
However, there is a logic flaw in how VideoCaptureManager (in content/browser/renderer_host/media/video_capture_manager.cc) tracks the screen lock state via the lock_time_ member:
- Failure to initialize state: In
VideoCaptureManager::OnScreenLocked,lock_time_is only set if there are currently active device capture sessions (!locked_sessions_.empty()). If no cameras are in use when the screen is locked,lock_time_remains null, meaningVideoCaptureManagerfails to record that the screen is locked. - Premature state clearance: In
VideoCaptureManager::Close, if a session that was active when the screen locked is subsequently closed, it is removed fromlocked_sessions_. Iflocked_sessions_becomes empty, the code immediately clearslock_time_(setting it to null), even though the physical screen remains locked.
Because VideoCaptureManager::ConnectClient relies exclusively on lock_time_.is_null() to determine if it should block a new device start request, an attacker can bypass the protection and silently activate the physical camera while the device is secured.
Note: These steps are based on static analysis of the codebase, as our tooling cannot currently execute a live Proof of Concept.
Potential Exploit Steps
- An attacker controls a website that the victim has previously granted camera permissions to.
- The website leaves a background tab or window open, running an asynchronous JavaScript timer or waiting for a WebSocket message.
- The victim locks their OS screen.
- Depending on the state, the vulnerability manifests via one of two paths:
- Path A: If the website had no active camera streams when the screen locked,
lock_time_remains null (bypassing the lock state entirely). - Path B: If the website had an active stream, it intentionally stops the track (
track.stop()) shortly after the screen locks. This triggersVideoCaptureManager::Close, which emptieslocked_sessions_and prematurely clearslock_time_to null.
- Path A: If the website had no active camera streams when the screen locked,
- While the screen is still securely locked, the background website calls
navigator.mediaDevices.getUserMedia({video: true}). - Because the site has permission, the request routes to the browser process and hits
VideoCaptureManager::ConnectClient. ConnectClientcheckslock_time_.is_null(). Because the value is null, the check passes, andQueueStartDeviceis called.- The physical camera powers on, and the attacker receives a live video feed of the victim’s environment. The 15-second timeout (
idle_close_timer_) is never started, so recording continues indefinitely.
Suggested Fix
The screen lock state should accurately and independently track the OS state, rather than being coupled to the presence of active sessions.
- Introduce a dedicated boolean (e.g.,
is_screen_locked_) that is set totrueunconditionally inOnScreenLocked()andfalseinOnScreenUnlocked(). - Update
VideoCaptureManager::ConnectClient()to checkis_screen_locked_instead oflock_time_.is_null()to block new device starts. lock_time_andlocked_sessions_should only be used to manage the 15-second grace period (idle_close_timer_) for devices that were already running when the lock occurred.
Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.