Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ImageCapture
DescriptionInappropriate implementation in ImageCapture
ComponentImageCapture
Bug ClassLogic Error
Tracker502493950
Fix commit5b650b9f407b (chromium/src) +152/-11
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
content/browser/image_capture/image_capture_impl.cc
modified
ImageCaptureImpl
content/browser/image_capture/image_capture_impl.h
modified
CONTENT_EXPORT
content/browser/image_capture/image_capture_impl.h
modified
ImageCaptureImplTest
content/browser/image_capture/image_capture_impl_unittest.cc
modified

Files Changed

  • content/browser/browser_interface_binders.cc
  • content/browser/image_capture/image_capture_impl.cc
  • content/browser/image_capture/image_capture_impl.h
  • content/browser/image_capture/image_capture_impl_unittest.cc
From 5b650b9f407b614fd9ac97f50cc7a92c3b1012c0 Mon Sep 17 00:00:00 2001
From: Menard, Alexis <[email protected]>
Date: Thu, 30 Apr 2026 08:28:39 -0700
Subject: [PATCH] media-capture: Add visibility check in ImageCaptureImpl::SetPhotoOptions

Make sure that the page is visible before allowing pan/tilt/zoom settings
to be applied. This is to prevent abuse of these features when the page
is not visible.

Bug: 502493950
Change-Id: Id90315c680609d9247e823b918fc46b8ea6908a5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7801956
Reviewed-by: Reilly Grant <[email protected]>
Commit-Queue: Menard, Alexis <[email protected]>
Reviewed-by: Giovanni Ortuno Urquidi <[email protected]>
Auto-Submit: Menard, Alexis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1623224}
---

diff --git a/content/browser/browser_interface_binders.cc b/content/browser/browser_interface_binders.cc
index 4cd3a2c..39ecacc 100644
--- a/content/browser/browser_interface_binders.cc
+++ b/content/browser/browser_interface_binders.cc
@@ -730,6 +730,12 @@
 }
 #endif  // BUILDFLAG(IS_P2P_ENABLED)
 
+void BindImageCaptureImpl(
+    RenderFrameHost* frame_host,
+    mojo::PendingReceiver<media::mojom::ImageCapture> receiver) {
+  ImageCaptureImpl::Create(frame_host, std::move(receiver));
+}
+
 void BindDevicePostureProvider(
     RenderFrameHost* frame_host,
     mojo::PendingReceiver<blink::mojom::DevicePostureProvider> receiver) {
@@ -1038,7 +1044,7 @@
       &BindRenderFrameHostImpl<
           &RenderFrameHostImpl::CreateAudioOutputStreamFactory>);
 
-  map->Add<media::mojom::ImageCapture>(&ImageCaptureImpl::Create);
+  map->Add<media::mojom::ImageCapture>(&BindImageCaptureImpl);
 
   map->Add<media::mojom::InterfaceFactory>(
       &BindRenderFrameHostImpl<
diff --git a/content/browser/image_capture/image_capture_impl.cc b/content/browser/image_capture/image_capture_impl.cc
index 044e864c..fac1f6d2 100644
--- a/content/browser/image_capture/image_capture_impl.cc
+++ b/content/browser/image_capture/image_capture_impl.cc
@@ -20,6 +20,7 @@
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/render_process_host.h"
 #include "content/public/common/content_features.h"
+#include "content/public/common/page_visibility_state.h"
 #include "media/capture/mojom/image_capture_types.h"
 #include "media/capture/video/video_capture_device.h"
 #include "mojo/public/cpp/bindings/callback_helpers.h"
@@ -80,12 +81,14 @@
 // static
 void ImageCaptureImpl::Create(
     RenderFrameHost* render_frame_host,
-    mojo::PendingReceiver<media::mojom::ImageCapture> receiver) {
+    mojo::PendingReceiver<media::mojom::ImageCapture> receiver,
+    bool skip_connecting_to_media_stream_manager_for_testing) {
   CHECK(render_frame_host);
   // ImageCaptureImpl owns itself. It will self-destruct when a Mojo interface
   // error occurs, the RenderFrameHost is deleted, or the RenderFrameHost
   // navigates to a new document.
-  new ImageCaptureImpl(*render_frame_host, std::move(receiver));
+  new ImageCaptureImpl(*render_frame_host, std::move(receiver),
+                       skip_connecting_to_media_stream_manager_for_testing);
 }
 
 void ImageCaptureImpl::GetPhotoState(const std::string& source_id,
@@ -116,12 +119,19 @@
                        "ImageCaptureImpl::SetPhotoOptions",
                        TRACE_EVENT_SCOPE_PROCESS);
 
-  if ((settings->has_pan || settings->has_tilt || settings->has_zoom) &&
-      !HasPanTiltZoomPermissionGranted()) {
+  if (render_frame_host().GetVisibilityState() !=
+          content::PageVisibilityState::kVisible ||
+      ((settings->has_pan || settings->has_tilt || settings->has_zoom) &&
+       !HasPanTiltZoomPermissionGranted())) {
     std::move(callback).Run(false);
     return;
   }
 
+  if (skip_connecting_to_media_stream_manager_for_testing_) {
+    std::move(callback).Run(true);
+    return;
+  }
+
   SetPhotoOptionsCallback scoped_callback =
       mojo::WrapCallbackWithDefaultInvokeIfNotRun(
           base::BindPostTaskToCurrentDefault(std::move(callback)), false);
@@ -152,8 +162,11 @@
 
 ImageCaptureImpl::ImageCaptureImpl(
     RenderFrameHost& render_frame_host,
-    mojo::PendingReceiver<media::mojom::ImageCapture> receiver)
-    : DocumentService(render_frame_host, std::move(receiver)) {}
+    mojo::PendingReceiver<media::mojom::ImageCapture> receiver,
+    bool skip_connecting_to_media_stream_manager_for_testing)
+    : DocumentService(render_frame_host, std::move(receiver)),
+      skip_connecting_to_media_stream_manager_for_testing_(
+          skip_connecting_to_media_stream_manager_for_testing) {}
 
 ImageCaptureImpl::~ImageCaptureImpl() = default;
 
diff --git a/content/browser/image_capture/image_capture_impl.h b/content/browser/image_capture/image_capture_impl.h
index be048c2c..9bb0645 100644
--- a/content/browser/image_capture/image_capture_impl.h
+++ b/content/browser/image_capture/image_capture_impl.h
@@ -6,18 +6,20 @@
 #define CONTENT_BROWSER_IMAGE_CAPTURE_IMAGE_CAPTURE_IMPL_H_
 
 #include "base/memory/weak_ptr.h"
+#include "content/common/content_export.h"
 #include "content/public/browser/document_service.h"
 #include "media/capture/mojom/image_capture.mojom.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 
 namespace content {
 
-class ImageCaptureImpl final
+class CONTENT_EXPORT ImageCaptureImpl final
     : public content::DocumentService<media::mojom::ImageCapture> {
  public:
   static void Create(
       RenderFrameHost* render_frame_host,
-      mojo::PendingReceiver<media::mojom::ImageCapture> receiver);
+      mojo::PendingReceiver<media::mojom::ImageCapture> receiver,
+      bool skip_connecting_to_media_stream_manager_for_testing = false);
 
   ImageCaptureImpl(const ImageCaptureImpl&) = delete;
   ImageCaptureImpl& operator=(const ImageCaptureImpl&) = delete;
@@ -33,8 +35,10 @@
                  TakePhotoCallback callback) override;
 
  private:
-  ImageCaptureImpl(RenderFrameHost& render_frame_host,
-                   mojo::PendingReceiver<media::mojom::ImageCapture> receiver);
+  ImageCaptureImpl(
+      RenderFrameHost& render_frame_host,
+      mojo::PendingReceiver<media::mojom::ImageCapture> receiver,
+      bool skip_connecting_to_media_stream_manager_for_testing = false);
   ~ImageCaptureImpl() override;
 
   void OnGetPhotoState(GetPhotoStateCallback callback,
@@ -42,6 +46,8 @@
 
   bool HasPanTiltZoomPermissionGranted();
 
+  bool skip_connecting_to_media_stream_manager_for_testing_ = false;
+
   base::WeakPtrFactory<ImageCaptureImpl> weak_factory_{this};
 };
 
diff --git a/content/browser/image_capture/image_capture_impl_unittest.cc b/content/browser/image_capture/image_capture_impl_unittest.cc
new file mode 100644
index 0000000..a55cd813
--- /dev/null
+++ b/content/browser/image_capture/image_capture_impl_unittest.cc
@@ -0,0 +1,115 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "content/browser/image_capture/image_capture_impl.h"
+
+#include "base/test/test_future.h"
+#include "content/public/browser/browser_context.h"
+#include "content/public/browser/permission_controller.h"
+#include "content/public/browser/permission_descriptor_util.h"
+#include "content/public/test/permissions_test_utils.h"
+#include "content/test/test_render_frame_host.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace content {
+
+namespace {
+constexpr char kTestUrl[] = "https://google.com";
+}  // namespace
+
+class ImageCaptureImplTest : public RenderViewHostTestHarness {
+ public:
+ public:
+  ImageCaptureImplTest() = default;
+  ImageCaptureImplTest(const ImageCaptureImplTest&) = delete;
+  ImageCaptureImplTest& operator=(const ImageCaptureImplTest&) = delete;
+  ~ImageCaptureImplTest() override = default;
+
+  void SetUp() override {
+    RenderViewHostTestHarness::SetUp();
+    origin_ = url::Origin::Create(GURL(kTestUrl));
+    NavigateAndCommit(origin_.GetURL());
+    auto receiver = image_capture_remote_.BindNewPipeAndPassReceiver();
+    content::ImageCaptureImpl::Create(
+        main_rfh(), std::move(receiver),
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/image_capture/image_capture_impl_unittest.cc b/content/browser/image_capture/image_capture_impl_unittest.cc
new file mode 100644
index 0000000..a55cd813
--- /dev/null
+++ b/content/browser/image_capture/image_capture_impl_unittest.cc
@@ -0,0 +1,115 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "content/browser/image_capture/image_capture_impl.h"
+
+#include "base/test/test_future.h"
+#include "content/public/browser/browser_context.h"
+#include "content/public/browser/permission_controller.h"
+#include "content/public/browser/permission_descriptor_util.h"
+#include "content/public/test/permissions_test_utils.h"
+#include "content/test/test_render_frame_host.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace content {
+
+namespace {
+constexpr char kTestUrl[] = "https://google.com";
+}  // namespace
+
+class ImageCaptureImplTest : public RenderViewHostTestHarness {
+ public:
+ public:
+  ImageCaptureImplTest() = default;
+  ImageCaptureImplTest(const ImageCaptureImplTest&) = delete;
+  ImageCaptureImplTest& operator=(const ImageCaptureImplTest&) = delete;
+  ~ImageCaptureImplTest() override = default;
+
+  void SetUp() override {
+    RenderViewHostTestHarness::SetUp();
+    origin_ = url::Origin::Create(GURL(kTestUrl));
+    NavigateAndCommit(origin_.GetURL());
+    auto receiver = image_capture_remote_.BindNewPipeAndPassReceiver();
+    content::ImageCaptureImpl::Create(
+        main_rfh(), std::move(receiver),
+        /*skip_connecting_to_media_stream_manager_for_testing=*/true);
+  }
+
+  void HideView() { main_rfh()->GetView()->Hide(); }
+
+  void SetPermissionForPTZ(blink::mojom::PermissionStatus status) {
+    content::PermissionController* permission_controller =
+        GetBrowserContext()->GetPermissionController();
+    DCHECK(permission_controller);
+    SetPermissionControllerOverride(permission_controller, origin_, origin_,
+                                    blink::PermissionType::CAMERA_PAN_TILT_ZOOM,
+                                    status);
+  }
+
+  void SetPhotoOptions(media::mojom::PhotoSettingsPtr photo_settings,
+                       base::OnceCallback<void(bool)> callback) {
+    CHECK_CURRENTLY_ON(BrowserThread::UI);
+    image_capture_remote_->SetPhotoOptions("", std::move(photo_settings),
+                                           std::move(callback));
+  }
+
+ private:
+  mojo::Remote<media::mojom::ImageCapture> image_capture_remote_;
+  url::Origin origin_;
+};
+
+TEST_F(ImageCaptureImplTest, SetPhotoOptionsDefault) {
+  base::test::TestFuture<bool> future;
+  SetPhotoOptions(media::mojom::PhotoSettings::New(), future.GetCallback());
+  EXPECT_TRUE(future.Get());
+}
+
+TEST_F(ImageCaptureImplTest, SetPhotoOptionsWithHiddenVisibility) {
+  SetPermissionForPTZ(blink::mojom::PermissionStatus::GRANTED);
+
+  HideView();
+
+  base::test::TestFuture<bool> future;
+  auto photo_settings = media::mojom::PhotoSettings::New();
+  photo_settings->has_pan = true;
+  photo_settings->has_tilt = true;
+  photo_settings->has_zoom = true;
+  SetPhotoOptions(std::move(photo_settings), future.GetCallback());
+  EXPECT_FALSE(future.Get());
+
+  future.Clear();
+  SetPhotoOptions(media::mojom::PhotoSettings::New(), future.GetCallback());
+  EXPECT_FALSE(future.Get());
+}
+
+// PTZ permission is always granted on Android (see
+// MediaDevicesPermissionChecker::HasPanTiltZoomPermissionGrantedOnUIThread),
+// overriding it will have no effect.
+#if !BUILDFLAG(IS_ANDROID)
+TEST_F(ImageCaptureImplTest, SetPhotoOptionsWithPTZNoPermission) {
+  SetPermissionForPTZ(blink::mojom::PermissionStatus::DENIED);
+
+  base::test::TestFuture<bool> future;
+  auto photo_settings = media::mojom::PhotoSettings::New();
+  photo_settings->has_pan = true;
+  photo_settings->has_tilt = true;
+  photo_settings->has_zoom = true;
+  SetPhotoOptions(std::move(photo_settings), future.GetCallback());
+  EXPECT_FALSE(future.Get());
+}
+#endif
+
+TEST_F(ImageCaptureImplTest, SetPhotoOptionsWithPTZWithPermission) {
+  SetPermissionForPTZ(blink::mojom::PermissionStatus::GRANTED);
+
+  base::test::TestFuture<bool> future;
+  auto photo_settings = media::mojom::PhotoSettings::New();
+  photo_settings->has_pan = true;
+  photo_settings->has_tilt = true;
+  photo_settings->has_zoom = true;
+  SetPhotoOptions(std::move(photo_settings), future.GetCallback());
+  EXPECT_TRUE(future.Get());
+}
+
+}  // namespace content
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index 9202c686..9cf3dd2 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -2707,6 +2707,7 @@
     "../browser/gpu/gpu_data_manager_impl_private_unittest.cc",
     "../browser/handwriting/handwriting_recognition_service_impl_unittest.cc",
     "../browser/idle/idle_manager_unittest.cc",
+    "../browser/image_capture/image_capture_impl_unittest.cc",
     "../browser/installedapp/installed_app_provider_impl_unittest.cc",
     "../browser/interest_group/ad_auction_headers_util_unittest.cc",
     "../browser/interest_group/ad_auction_service_impl_unittest.cc",
Loading diff…

Original Bug Report

reported by [email protected]

Missing browser-side visibility check for Pan/Tilt/Zoom in ImageCaptureImpl

Project Fortify, 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 without the Chrome Security team.

Overview: The browser-side implementation of Image Capture lacks a page visibility check when processing Pan/Tilt/Zoom (PTZ) commands. This allows a compromised renderer to physically manipulate camera hardware while the page is hidden or backgrounded.

Affected files:

  • content/browser/image_capture/image_capture_impl.cc
  • third_party/blink/renderer/modules/imagecapture/image_capture.cc
  • content/browser/media/media_devices_permission_checker.cc

Estimated timestamp from git blame: 2023-02-15

Description

The W3C Image Capture API specification requires that Pan/Tilt/Zoom (PTZ) constraints be rejected when the requesting document is not fully active or visible. Currently, Chromium enforces this visibility restriction primarily in the unprivileged renderer process. Specifically, in third_party/blink/renderer/modules/imagecapture/image_capture.cc (CheckAndApplyMediaTrackConstraintsToSettings), PTZ requests are rejected with a SecurityError if !IsPageVisible().

However, the browser-process Mojo handler ImageCaptureImpl::SetPhotoOptions (located in content/browser/image_capture/image_capture_impl.cc) fails to perform a corresponding visibility check. While it verifies that the CAMERA_PAN_TILT_ZOOM permission is granted before forwarding settings to the IO thread, it does not check the visibility state of the RenderFrameHost.

Because this security boundary is only enforced in the untrusted renderer, a compromised renderer with an active capture session and previously granted PTZ permissions can bypass the Blink-side check. By crafting and sending a media.mojom.ImageCapture::SetPhotoOptions IPC directly to the browser, an attacker can physically actuate camera motors or adjust optical zoom from a hidden or backgrounded tab.

This bypass is particularly notable on Android, where MediaDevicesPermissionChecker::HasPanTiltZoomPermissionGrantedOnUIThread unconditionally returns true for PTZ if the base camera permission is granted. On Android, the renderer-side visibility check is the only mechanism preventing background PTZ manipulation.

Potential Reproduction Steps

Note: These are suggested steps based on source code analysis.

  1. A user visits an attacker-controlled page which requests and is granted camera and PTZ permissions, initiating an active capture session.
  2. The attacker gains arbitrary code execution (RCE) within the sandboxed renderer process.
  3. The user navigates away or backgrounds the tab, transitioning its visibility state to hidden.
  4. From the compromised renderer, the attacker bypasses the Blink bindings and constructs a media::mojom::ImageCapture::SetPhotoOptions Mojo IPC.
  5. The payload specifies has_pan = true, has_tilt = true, or has_zoom = true with a target value.
  6. The renderer sends this IPC to the browser process over the established ImageCapture interface.
  7. The browser process receives the request in ImageCaptureImpl::SetPhotoOptions, verifies the permission, but fails to check visibility. It forwards the request to the physical capture device.
  8. The physical camera pans, tilts, or zooms despite the tab being hidden.

Suggested Fix

In content/browser/image_capture/image_capture_impl.cc, ImageCaptureImpl::SetPhotoOptions should explicitly verify that the requesting frame is visible before proceeding. Because ImageCaptureImpl inherits from DocumentService, it can access the RenderFrameHost directly.

if (render_frame_host().GetVisibilityState() != content::PageVisibilityState::kVisible) {
  // Reject the request or ignore it.
  std::move(callback).Run(false);
  return;
}

This check should likely be restricted only to requests that modify PTZ, to remain consistent with the W3C specification and renderer-side behavior.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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