Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in GetUserMedia
DescriptionIncorrect authorization in GetUserMedia
ComponentGetUserMedia
Bug ClassLogic Error
Tracker522351802
Fix commitbae48a9a83c1 (chromium/src) +466/-77
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
chrome/browser/media/chromeos_login_and_lock_media_access_handler.cc
modified
if
content/browser/renderer_host/media/media_stream_manager.cc
modified

Files Changed

  • chrome/browser/chrome_content_browser_client.cc
  • chrome/browser/chrome_content_browser_client.h
  • chrome/browser/media/chromeos_login_and_lock_media_access_handler.cc
  • chrome/browser/media/chromeos_login_and_lock_media_access_handler.h
  • content/browser/renderer_host/media/media_stream_manager.cc
  • content/browser/renderer_host/media/media_stream_manager.h
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,
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by rj...@google.com

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:

  1. 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, meaning VideoCaptureManager fails to record that the screen is locked.
  2. Premature state clearance: In VideoCaptureManager::Close, if a session that was active when the screen locked is subsequently closed, it is removed from locked_sessions_. If locked_sessions_ becomes empty, the code immediately clears lock_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

  1. An attacker controls a website that the victim has previously granted camera permissions to.
  2. The website leaves a background tab or window open, running an asynchronous JavaScript timer or waiting for a WebSocket message.
  3. The victim locks their OS screen.
  4. 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 triggers VideoCaptureManager::Close, which empties locked_sessions_ and prematurely clears lock_time_ to null.
  5. While the screen is still securely locked, the background website calls navigator.mediaDevices.getUserMedia({video: true}).
  6. Because the site has permission, the request routes to the browser process and hits VideoCaptureManager::ConnectClient.
  7. ConnectClient checks lock_time_.is_null(). Because the value is null, the check passes, and QueueStartDevice is called.
  8. 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.

  1. Introduce a dedicated boolean (e.g., is_screen_locked_) that is set to true unconditionally in OnScreenLocked() and false in OnScreenUnlocked().
  2. Update VideoCaptureManager::ConnectClient() to check is_screen_locked_ instead of lock_time_.is_null() to block new device starts.
  3. lock_time_ and locked_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.

View on issue tracker