Overview

Medium
Severity
3.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Video Capture
DescriptionInsufficient validation of untrusted input in Video Capture
ComponentVideo Capture
Sub-componentCapture
Bug ClassInput Validation
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker513186670
Fix commitf5f020cf8f3e (chromium/src) +580/-41
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-14 (per vendor advisory)
Fix landed2026-05-19 (public git)
Disclosed2026-06-30
Patch gap42 days — fix public in source before the advisory

Background

Video capture service
the services/video_capture utility service that enumerates cameras and hands out capture devices to consumers through VideoSourceProvider/DeviceFactory Mojo interfaces.
Virtual device
a client-registered capture device (AddSharedMemoryVirtualDevice, AddTextureVirtualDevice, AddGpuMemoryBufferVirtualDevice) whose frames are pushed by a mojom::Producer, tracked in virtual_devices_by_id_ keyed by the client-supplied device_info.descriptor.device_id.
`VirtualDeviceEnabledDeviceFactory`
a wrapper over the real (hardware) device_factory_ that merges registered virtual devices into enumeration and routes CreateDevice(device_id, ...) to either a virtual or a physical device.
Device-ID collision
a virtual device registered with the same device_id string as a physical camera, so a lookup by ID can resolve to the wrong backing device.

Root Cause Analysis

Before the patch, VirtualDeviceEnabledDeviceFactory::CreateDevice first searched virtual_devices_by_id_ and, on a hit, immediately returned the virtual device’s DeviceInfo with kNone, only calling device_factory_->CreateDevice when no virtual device matched; the Add*VirtualDevice methods accepted any client-supplied device_id without checking it against the hardware camera list. A client able to register a virtual device could therefore choose a physical camera’s device_id and shadow it, so every consumer that opened that camera ID would be wired to the attacker’s producer instead of the real hardware. The violated invariant is that a virtual device must not be able to impersonate an existing physical device ID.

The fix enforces it in two places: each Add*VirtualDevice now first calls device_factory_->GetDeviceInfos and OnGetDeviceInfosForVirtualDevice returns early (dropping the pending receiver/remote, so the producer’s pipe disconnects) if any hardware descriptor has the same ID; and CreateDevice now asks the physical factory first, falling back to a virtual device only when the result is kVideoCaptureSystemDeviceIdNotFound, and otherwise erasing any same-ID virtual entry before returning the physical result.

Key insight
Virtual device registration trusted a client-chosen device_id and device lookup gave virtual entries priority over hardware, letting a virtual device hijack a real camera’s identity; the patch rejects registrations that collide with hardware IDs and flips CreateDevice to physical-first with virtual fallback only on “not found”.

Attack Path

  1. Obtain virtual-device registration access a client of the video capture service’s virtual-device API (which client/process holds this access is not shown in the patch and is (inferred) to be a less-trusted or compromised service client) enumerates devices to learn a physical camera’s device_id.
  2. Register a colliding virtual device the client calls AddSharedMemoryVirtualDevice (or the texture/GMB variants) with device_info.descriptor.device_id equal to the physical camera’s ID; pre-patch this was inserted into virtual_devices_by_id_ unconditionally.
  3. Consumer opens the "real" camera when a consumer later requests that ID, pre-patch CreateDevice found the virtual entry first and returned it with kNone, never consulting the hardware factory.
  4. Spoofed frames delivered the consumer receives attacker-pushed frames under the identity of the physical camera, i.e. camera spoofing/impersonation (downstream use of spoofed video, e.g. for UI or verification flows, is (inferred)).

Impact Assessment

A party able to register virtual capture devices could impersonate a physical camera and feed arbitrary frames to consumers that believed they were reading hardware, within the video capture service’s device routing. The advisory rates this Medium (“Insufficient validation of untrusted input in Video Capture”) and does not report in-the-wild exploitation. The patch demonstrates a logic/identity-spoofing flaw, not memory corruption; any broader escalation is (inferred).

Changed Functions

FunctionChangeNotes
if
services/video_capture/virtual_device_enabled_device_factory.cc
modified
VirtualDeviceEntry
services/video_capture/virtual_device_enabled_device_factory.h
modified
for
services/video_capture/test/video_capture_service_unittest.cc
modified
if
services/video_capture/test/video_capture_service_unittest.cc
modified
TEST_F
services/video_capture/test/video_capture_service_unittest.cc
modified

Files Changed

  • services/video_capture/test/video_capture_service_unittest.cc
  • services/video_capture/test/virtual_device_unittest.cc
  • services/video_capture/virtual_device_enabled_device_factory.cc
  • services/video_capture/virtual_device_enabled_device_factory.h

Audit Directions

  • Client-chosen identifiers in shared namespaces
    look for other registries (audio virtual devices, media-source IDs, Bluetooth/HID virtual devices) where a client-supplied ID is inserted into a map that also resolves trusted/hardware entries, and check lookup priority.
  • Check-then-register races
    the new hardware check runs asynchronously before registration; review whether a camera hot-plugged afterwards is consistently handled (the new physical-first CreateDevice path covers it) and apply the same “authoritative source wins at use time” rule elsewhere.
  • Fallback ordering
    audit factories that wrap a real backend with an override layer (*EnabledDeviceFactory, test/fake device wrappers) for override-first lookups that let less-trusted entries mask real ones.
diff --git a/services/video_capture/virtual_device_enabled_device_factory.cc b/services/video_capture/virtual_device_enabled_device_factory.cc
index d077105..7db24003 100644
--- a/services/video_capture/virtual_device_enabled_device_factory.cc
+++ b/services/video_capture/virtual_device_enabled_device_factory.cc
@@ -4,9 +4,11 @@
 
 #include "services/video_capture/virtual_device_enabled_device_factory.h"
 
+#include <algorithm>
 #include <utility>
 
 #include "base/functional/bind.h"
+#include "base/functional/callback.h"
 #include "media/capture/video/video_capture_device_info.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
@@ -140,17 +142,39 @@
 void VirtualDeviceEnabledDeviceFactory::CreateDevice(
     const std::string& device_id,
     CreateDeviceCallback callback) {
-  auto virtual_device_iter = virtual_devices_by_id_.find(device_id);
-  if (virtual_device_iter != virtual_devices_by_id_.end()) {
-    // The requested virtual device is already used by another client.
-    // Revoke the access for the current client.
-    VirtualDeviceEntry& device_entry = virtual_device_iter->second;
-    DeviceInfo info{device_entry.GetDevice(), media::VideoCaptureError::kNone};
-    std::move(callback).Run(std::move(info));
+  device_factory_->CreateDevice(
+      device_id,
+      base::BindOnce(
+          &VirtualDeviceEnabledDeviceFactory::OnDeviceFactoryDeviceCreated,
+          weak_factory_.GetWeakPtr(), device_id, std::move(callback)));
+}
+
+void VirtualDeviceEnabledDeviceFactory::OnDeviceFactoryDeviceCreated(
+    std::string device_id,
+    CreateDeviceCallback outer_callback,
+    DeviceInfo info) {
+  if (info.result_code !=
+      media::VideoCaptureError::kVideoCaptureSystemDeviceIdNotFound) {
+    // Sever any old virtual device connection since underlying device creation
+    // was successful or failed with another error than not found.
+    virtual_devices_by_id_.erase(device_id);
+    std::move(outer_callback).Run(info);
     return;
   }
 
-  return device_factory_->CreateDevice(device_id, std::move(callback));
+  // The underlying factory failed to create the device.
+  // Fall back to a registered virtual device if one exists.
+  auto virtual_device_iter = virtual_devices_by_id_.find(device_id);
+  if (virtual_device_iter != virtual_devices_by_id_.end()) {
+    VirtualDeviceEntry& device_entry = virtual_device_iter->second;
+    DeviceInfo virtual_device_info{device_entry.GetDevice(),
+                                   media::VideoCaptureError::kNone};
+    std::move(outer_callback).Run(std::move(virtual_device_info));
+    return;
+  }
+
+  // No virtual device to fall back to, return the original failure.
+  std::move(outer_callback).Run(info);
 }
 
 void VirtualDeviceEnabledDeviceFactory::StopDevice(
@@ -163,12 +187,24 @@
     mojo::PendingRemote<mojom::Producer> producer_pending_remote,
     mojo::PendingReceiver<mojom::SharedMemoryVirtualDevice>
         virtual_device_receiver) {
+  device_factory_->GetDeviceInfos(base::BindOnce(
+      &VirtualDeviceEnabledDeviceFactory::OnGetDeviceInfosForVirtualDevice,
+      weak_factory_.GetWeakPtr(), device_info.descriptor.device_id,
+      base::BindOnce(&VirtualDeviceEnabledDeviceFactory::
+                         CompleteAddSharedMemoryVirtualDevice,
+                     weak_factory_.GetWeakPtr(), device_info,
+                     std::move(producer_pending_remote),
+                     std::move(virtual_device_receiver))));
+}
+
+void VirtualDeviceEnabledDeviceFactory::CompleteAddSharedMemoryVirtualDevice(
+    const media::VideoCaptureDeviceInfo& device_info,
+    mojo::PendingRemote<mojom::Producer> producer_pending_remote,
+    mojo::PendingReceiver<mojom::SharedMemoryVirtualDevice>
+        virtual_device_receiver) {
   auto device_id = device_info.descriptor.device_id;
-  auto virtual_device_iter = virtual_devices_by_id_.find(device_id);
-  if (virtual_device_iter != virtual_devices_by_id_.end()) {
-    // Revoke the access for the current producer and consumer by
-    // removing it from the list.
-    virtual_devices_by_id_.erase(virtual_device_iter);
+  if (!PrepareVirtualDeviceId(device_id)) {
+    return;
   }
 
   mojo::Remote<mojom::Producer> producer(std::move(producer_pending_remote));
@@ -185,23 +221,31 @@
       base::BindOnce(&VirtualDeviceEnabledDeviceFactory::
                          OnVirtualDeviceProducerConnectionErrorOrClose,
                      base::Unretained(this), device_id));
-  VirtualDeviceEntry device_entry(device_info, std::move(device),
-                                  std::move(producer_receiver));
-  virtual_devices_by_id_.insert(
-      std::make_pair(device_id, std::move(device_entry)));
-  EmitDevicesChangedEvent();
+  CompleteRegisteringVirtualDevice(
+      device_id, VirtualDeviceEntry(device_info, std::move(device),
+                                    std::move(producer_receiver)));
 }
 
 void VirtualDeviceEnabledDeviceFactory::AddTextureVirtualDevice(
     const media::VideoCaptureDeviceInfo& device_info,
     mojo::PendingReceiver<mojom::TextureVirtualDevice>
         virtual_device_receiver) {
+  device_factory_->GetDeviceInfos(base::BindOnce(
+      &VirtualDeviceEnabledDeviceFactory::OnGetDeviceInfosForVirtualDevice,
+      weak_factory_.GetWeakPtr(), device_info.descriptor.device_id,
+      base::BindOnce(
+          &VirtualDeviceEnabledDeviceFactory::CompleteAddTextureVirtualDevice,
+          weak_factory_.GetWeakPtr(), device_info,
+          std::move(virtual_device_receiver))));
+}
+
+void VirtualDeviceEnabledDeviceFactory::CompleteAddTextureVirtualDevice(
+    const media::VideoCaptureDeviceInfo& device_info,
+    mojo::PendingReceiver<mojom::TextureVirtualDevice>
+        virtual_device_receiver) {
   auto device_id = device_info.descriptor.device_id;
-  auto virtual_device_iter = virtual_devices_by_id_.find(device_id);
-  if (virtual_device_iter != virtual_devices_by_id_.end()) {
-    // Revoke the access for the current producer and consumer by
-    // removing it from the list.
-    virtual_devices_by_id_.erase(virtual_device_iter);
+  if (!PrepareVirtualDeviceId(device_id)) {
+    return;
   }
 
   auto device = std::make_unique<TextureVirtualDeviceMojoAdapter>();
@@ -212,23 +256,31 @@
       base::BindOnce(&VirtualDeviceEnabledDeviceFactory::
                          OnVirtualDeviceProducerConnectionErrorOrClose,
                      base::Unretained(this), device_id));
-  VirtualDeviceEntry device_entry(device_info, std::move(device),
-                                  std::move(producer_receiver));
-  virtual_devices_by_id_.insert(
-      std::make_pair(device_id, std::move(device_entry)));
-  EmitDevicesChangedEvent();
+  CompleteRegisteringVirtualDevice(
+      device_id, VirtualDeviceEntry(device_info, std::move(device),
+                                    std::move(producer_receiver)));
 }
 
 void VirtualDeviceEnabledDeviceFactory::AddGpuMemoryBufferVirtualDevice(
     const media::VideoCaptureDeviceInfo& device_info,
     mojo::PendingReceiver<mojom::GpuMemoryBufferVirtualDevice>
         virtual_device_receiver) {
+  device_factory_->GetDeviceInfos(base::BindOnce(
+      &VirtualDeviceEnabledDeviceFactory::OnGetDeviceInfosForVirtualDevice,
+      weak_factory_.GetWeakPtr(), device_info.descriptor.device_id,
+      base::BindOnce(&VirtualDeviceEnabledDeviceFactory::
+                         CompleteAddGpuMemoryBufferVirtualDevice,
+                     weak_factory_.GetWeakPtr(), device_info,
+                     std::move(virtual_device_receiver))));
+}
+
+void VirtualDeviceEnabledDeviceFactory::CompleteAddGpuMemoryBufferVirtualDevice(
+    const media::VideoCaptureDeviceInfo& device_info,
+    mojo::PendingReceiver<mojom::GpuMemoryBufferVirtualDevice>
+        virtual_device_receiver) {
   auto device_id = device_info.descriptor.device_id;
-  auto virtual_device_iter = virtual_devices_by_id_.find(device_id);
-  if (virtual_device_iter != virtual_devices_by_id_.end()) {
-    // Revoke the access for the current producer and consumer by
-    // removing it from the list.
-    virtual_devices_by_id_.erase(virtual_device_iter);
+  if (!PrepareVirtualDeviceId(device_id)) {
+    return;
   }
 
   auto device = std::make_unique<GpuMemoryBufferVirtualDeviceMojoAdapter>();
@@ -239,11 +291,9 @@
       base::BindOnce(&VirtualDeviceEnabledDeviceFactory::
                          OnVirtualDeviceProducerConnectionErrorOrClose,
                      base::Unretained(this), device_id));
-  VirtualDeviceEntry device_entry(device_info, std::move(device),
-                                  std::move(producer_receiver));
-  virtual_devices_by_id_.insert(
-      std::make_pair(device_id, std::move(device_entry)));
-  EmitDevicesChangedEvent();
+  CompleteRegisteringVirtualDevice(
+      device_id, VirtualDeviceEntry(device_info, std::move(device),
+                                    std::move(producer_receiver)));
 }
 
 void VirtualDeviceEnabledDeviceFactory::RegisterVirtualDevicesChangedObserver(
@@ -261,6 +311,39 @@
   devices_changed_observers_.push_back(std::move(observer));
 }
 
+void VirtualDeviceEnabledDeviceFactory::OnGetDeviceInfosForVirtualDevice(
+    std::string device_id,
+    base::OnceClosure registration_closure,
+    const std::vector<media::VideoCaptureDeviceInfo>& device_infos) {
+  if (std::ranges::any_of(device_infos, [&device_id](const auto& info) {
+        return info.descriptor.device_id == device_id;
+      })) {
+    // Matches a hardware camera! Reject by returning early and letting the
+    // bound Mojo receiver/remote drop.
+    return;
+  }
+  std::move(registration_closure).Run();
+}
+
+bool VirtualDeviceEnabledDeviceFactory::PrepareVirtualDeviceId(
+    const std::string& device_id) {
+  auto virtual_device_iter = virtual_devices_by_id_.find(device_id);
+  if (virtual_device_iter != virtual_devices_by_id_.end()) {
+    // Revoke the access for the current producer and consumer by
+    // removing it from the list.
+    virtual_devices_by_id_.erase(virtual_device_iter);
+  }
+  return true;
+}
+
+void VirtualDeviceEnabledDeviceFactory::CompleteRegisteringVirtualDevice(
+    const std::string& device_id,
+    VirtualDeviceEntry device_entry) {
+  virtual_devices_by_id_.insert(
+      std::make_pair(device_id, std::move(device_entry)));
+  EmitDevicesChangedEvent();
+}
+
 void VirtualDeviceEnabledDeviceFactory::OnGetDeviceInfos(
     GetDeviceInfosCallback callback,
     const std::vector<media::VideoCaptureDeviceInfo>& device_infos) {
diff --git a/services/video_capture/virtual_device_enabled_device_factory.h b/services/video_capture/virtual_device_enabled_device_factory.h
index d54253f..fc1c747 100644
--- a/services/video_capture/virtual_device_enabled_device_factory.h
+++ b/services/video_capture/virtual_device_enabled_device_factory.h
@@ -8,6 +8,7 @@
 #include <map>
 #include <utility>
 
+#include "base/functional/callback_forward.h"
 #include "build/build_config.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
@@ -59,6 +60,30 @@
 
  private:
   class VirtualDeviceEntry;
+  void OnDeviceFactoryDeviceCreated(std::string device_id,
+                                    CreateDeviceCallback outer,
+                                    DeviceInfo info);
+  void CompleteAddSharedMemoryVirtualDevice(
+      const media::VideoCaptureDeviceInfo& device_info,
+      mojo::PendingRemote<mojom::Producer> producer,
+      mojo::PendingReceiver<mojom::SharedMemoryVirtualDevice>
+          virtual_device_receiver);
+  void CompleteAddTextureVirtualDevice(
+      const media::VideoCaptureDeviceInfo& device_info,
+      mojo::PendingReceiver<mojom::TextureVirtualDevice>
+          virtual_device_receiver);
+  void CompleteAddGpuMemoryBufferVirtualDevice(
+      const media::VideoCaptureDeviceInfo& device_info,
+      mojo::PendingReceiver<mojom::GpuMemoryBufferVirtualDevice>
+          virtual_device_receiver);
+  void OnGetDeviceInfosForVirtualDevice(
+      std::string device_id,
+      base::OnceClosure registration_closure,
+      const std::vector<media::VideoCaptureDeviceInfo>& device_infos);
+
+  bool PrepareVirtualDeviceId(const std::string& device_id);
+  void CompleteRegisteringVirtualDevice(const std::string& device_id,
+                                        VirtualDeviceEntry device_entry);
 
   void OnGetDeviceInfos(
       GetDeviceInfosCallback callback,
diff --git a/services/video_capture/test/video_capture_service_unittest.cc b/services/video_capture/test/video_capture_service_unittest.cc
index 66110d17..d6d00d1 100644
--- a/services/video_capture/test/video_capture_service_unittest.cc
+++ b/services/video_capture/test/video_capture_service_unittest.cc
@@ -211,7 +211,6 @@
       create_push_subscription_remote_callback;
 
   EXPECT_CALL(create_push_subscription_remote_callback, Run)
-      .Times(1)
       .WillOnce(
           [&wait_loop](mojom::CreatePushSubscriptionResultCodePtr result_code,
                        const media::VideoCaptureParams& param) {
@@ -231,4 +230,179 @@
   wait_loop.Run();
 }
 
+// Tests that a virtual device cannot be added with the same ID as an active
+// physical device.
+TEST_F(VideoCaptureServiceTest,
+       CannotAddVirtualDeviceWhilePhysicalDeviceActive) {
+  // Retrieve the active physical devices.
+  base::RunLoop get_infos_loop;
+  std::vector<media::VideoCaptureDeviceInfo> physical_devices;
+  EXPECT_CALL(device_info_receiver_, Run)
+      .WillOnce([&get_infos_loop, &physical_devices](
+                    GetSourceInfosResult result,
+                    const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        physical_devices = infos;
+        get_infos_loop.Quit();
+      });
+  video_source_provider_->GetSourceInfos(device_info_receiver_.Get());
+  get_infos_loop.Run();
+  const std::string physical_device_id =
+      physical_devices[0].descriptor.device_id;
+
+  // Start the physical device by creating a subscription.
+  mojo::Remote<mojom::VideoSource> video_source_remote;
+  video_source_provider_->GetVideoSource(
+      physical_device_id, video_source_remote.BindNewPipeAndPassReceiver());
+
+  mojo::PendingRemote<video_capture::mojom::VideoFrameHandler> subscriber;
+  MockVideoFrameHandler mock_video_frame_handler(
+      subscriber.InitWithNewPipeAndPassReceiver());
+  mojo::Remote<video_capture::mojom::PushVideoStreamSubscription> subscription;
+
+  base::RunLoop start_device_loop;
+  base::MockCallback<mojom::VideoSource::CreatePushSubscriptionCallback>
+      create_push_subscription_callback;
+  EXPECT_CALL(create_push_subscription_callback, Run)
+      .WillOnce([&start_device_loop](
+                    mojom::CreatePushSubscriptionResultCodePtr result_code,
+                    const media::VideoCaptureParams& param) {
+        start_device_loop.Quit();
+      });
+
+  video_source_remote->CreatePushSubscription(
+      std::move(subscriber), requestable_settings_,
+      /*force_reopen_with_new_settings=*/false,
+      subscription.BindNewPipeAndPassReceiver(),
+      create_push_subscription_callback.Get());
+  start_device_loop.Run();
+
+  // Attempt to add a virtual device with the same ID.
+  auto virtual_device_context =
+      AddSharedMemoryVirtualDevice(physical_device_id);
+
+  // Verify that the virtual device is rejected and its pipe is disconnected.
+  base::RunLoop disconnect_loop;
+  virtual_device_context->device.set_disconnect_handler(
+      disconnect_loop.QuitClosure());
+  disconnect_loop.Run();
+
+  EXPECT_FALSE(virtual_device_context->device.is_connected());
+
+  // Verify that GetSourceInfos() returns ONLY 1 entry for the device ID
+  // (registration failed).
+  base::RunLoop verify_infos_loop;
+  EXPECT_CALL(device_info_receiver_, Run)
+      .WillOnce([&verify_infos_loop, physical_device_id](
+                    GetSourceInfosResult result,
+                    const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        size_t occurrences = 0;
+        for (const auto& info : infos) {
+          if (info.descriptor.device_id == physical_device_id) {
+            occurrences++;
+          }
+        }
+        EXPECT_EQ(1u, occurrences);
+        verify_infos_loop.Quit();
+      });
+  video_source_provider_->GetSourceInfos(device_info_receiver_.Get());
+  verify_infos_loop.Run();
+
+  // Cleanup
+  subscription.reset();
+  video_source_remote.reset();
+  base::RunLoop cleanup_loop;
+  video_source_provider_->GetSourceInfos(base::BindOnce(
+      [](base::RunLoop* run_loop, GetSourceInfosResult result,
+         const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        run_loop->Quit();
+      },
+      &cleanup_loop));
+  cleanup_loop.Run();
+}
+
+// Tests that a virtual device cannot be added with the same ID as an idle
+// physical device.
+TEST_F(VideoCaptureServiceTest, CannotAddVirtualDeviceWhilePhysicalDeviceIdle) {
+  // Retrieve physical device IDs.
+  base::RunLoop get_infos_loop;
+  std::vector<media::VideoCaptureDeviceInfo> physical_devices;
+  EXPECT_CALL(device_info_receiver_, Run)
+      .WillOnce([&get_infos_loop, &physical_devices](
+                    GetSourceInfosResult result,
+                    const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        physical_devices = infos;
+        get_infos_loop.Quit();
+      });
+  video_source_provider_->GetSourceInfos(device_info_receiver_.Get());
+  get_infos_loop.Run();
+  const std::string physical_device_id =
+      physical_devices[0].descriptor.device_id;
+
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/services/video_capture/test/video_capture_service_unittest.cc b/services/video_capture/test/video_capture_service_unittest.cc
index 66110d17..d6d00d1 100644
--- a/services/video_capture/test/video_capture_service_unittest.cc
+++ b/services/video_capture/test/video_capture_service_unittest.cc
@@ -211,7 +211,6 @@
       create_push_subscription_remote_callback;
 
   EXPECT_CALL(create_push_subscription_remote_callback, Run)
-      .Times(1)
       .WillOnce(
           [&wait_loop](mojom::CreatePushSubscriptionResultCodePtr result_code,
                        const media::VideoCaptureParams& param) {
@@ -231,4 +230,179 @@
   wait_loop.Run();
 }
 
+// Tests that a virtual device cannot be added with the same ID as an active
+// physical device.
+TEST_F(VideoCaptureServiceTest,
+       CannotAddVirtualDeviceWhilePhysicalDeviceActive) {
+  // Retrieve the active physical devices.
+  base::RunLoop get_infos_loop;
+  std::vector<media::VideoCaptureDeviceInfo> physical_devices;
+  EXPECT_CALL(device_info_receiver_, Run)
+      .WillOnce([&get_infos_loop, &physical_devices](
+                    GetSourceInfosResult result,
+                    const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        physical_devices = infos;
+        get_infos_loop.Quit();
+      });
+  video_source_provider_->GetSourceInfos(device_info_receiver_.Get());
+  get_infos_loop.Run();
+  const std::string physical_device_id =
+      physical_devices[0].descriptor.device_id;
+
+  // Start the physical device by creating a subscription.
+  mojo::Remote<mojom::VideoSource> video_source_remote;
+  video_source_provider_->GetVideoSource(
+      physical_device_id, video_source_remote.BindNewPipeAndPassReceiver());
+
+  mojo::PendingRemote<video_capture::mojom::VideoFrameHandler> subscriber;
+  MockVideoFrameHandler mock_video_frame_handler(
+      subscriber.InitWithNewPipeAndPassReceiver());
+  mojo::Remote<video_capture::mojom::PushVideoStreamSubscription> subscription;
+
+  base::RunLoop start_device_loop;
+  base::MockCallback<mojom::VideoSource::CreatePushSubscriptionCallback>
+      create_push_subscription_callback;
+  EXPECT_CALL(create_push_subscription_callback, Run)
+      .WillOnce([&start_device_loop](
+                    mojom::CreatePushSubscriptionResultCodePtr result_code,
+                    const media::VideoCaptureParams& param) {
+        start_device_loop.Quit();
+      });
+
+  video_source_remote->CreatePushSubscription(
+      std::move(subscriber), requestable_settings_,
+      /*force_reopen_with_new_settings=*/false,
+      subscription.BindNewPipeAndPassReceiver(),
+      create_push_subscription_callback.Get());
+  start_device_loop.Run();
+
+  // Attempt to add a virtual device with the same ID.
+  auto virtual_device_context =
+      AddSharedMemoryVirtualDevice(physical_device_id);
+
+  // Verify that the virtual device is rejected and its pipe is disconnected.
+  base::RunLoop disconnect_loop;
+  virtual_device_context->device.set_disconnect_handler(
+      disconnect_loop.QuitClosure());
+  disconnect_loop.Run();
+
+  EXPECT_FALSE(virtual_device_context->device.is_connected());
+
+  // Verify that GetSourceInfos() returns ONLY 1 entry for the device ID
+  // (registration failed).
+  base::RunLoop verify_infos_loop;
+  EXPECT_CALL(device_info_receiver_, Run)
+      .WillOnce([&verify_infos_loop, physical_device_id](
+                    GetSourceInfosResult result,
+                    const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        size_t occurrences = 0;
+        for (const auto& info : infos) {
+          if (info.descriptor.device_id == physical_device_id) {
+            occurrences++;
+          }
+        }
+        EXPECT_EQ(1u, occurrences);
+        verify_infos_loop.Quit();
+      });
+  video_source_provider_->GetSourceInfos(device_info_receiver_.Get());
+  verify_infos_loop.Run();
+
+  // Cleanup
+  subscription.reset();
+  video_source_remote.reset();
+  base::RunLoop cleanup_loop;
+  video_source_provider_->GetSourceInfos(base::BindOnce(
+      [](base::RunLoop* run_loop, GetSourceInfosResult result,
+         const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        run_loop->Quit();
+      },
+      &cleanup_loop));
+  cleanup_loop.Run();
+}
+
+// Tests that a virtual device cannot be added with the same ID as an idle
+// physical device.
+TEST_F(VideoCaptureServiceTest, CannotAddVirtualDeviceWhilePhysicalDeviceIdle) {
+  // Retrieve physical device IDs.
+  base::RunLoop get_infos_loop;
+  std::vector<media::VideoCaptureDeviceInfo> physical_devices;
+  EXPECT_CALL(device_info_receiver_, Run)
+      .WillOnce([&get_infos_loop, &physical_devices](
+                    GetSourceInfosResult result,
+                    const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        physical_devices = infos;
+        get_infos_loop.Quit();
+      });
+  video_source_provider_->GetSourceInfos(device_info_receiver_.Get());
+  get_infos_loop.Run();
+  const std::string physical_device_id =
+      physical_devices[0].descriptor.device_id;
+
+  // Add a virtual device with the same ID.
+  auto virtual_device_context =
+      AddSharedMemoryVirtualDevice(physical_device_id);
+
+  // Verify that the virtual device is rejected and its pipe is disconnected.
+  base::RunLoop disconnect_loop;
+  virtual_device_context->device.set_disconnect_handler(
+      disconnect_loop.QuitClosure());
+  disconnect_loop.Run();
+
+  EXPECT_FALSE(virtual_device_context->device.is_connected());
+
+  // Verify that GetSourceInfos() returns ONLY 1 entry for the device ID
+  // (registration failed).
+  base::RunLoop verify_infos_loop;
+  EXPECT_CALL(device_info_receiver_, Run)
+      .WillOnce([&verify_infos_loop, physical_device_id](
+                    GetSourceInfosResult result,
+                    const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        size_t occurrences = 0;
+        for (const auto& info : infos) {
+          if (info.descriptor.device_id == physical_device_id) {
+            occurrences++;
+          }
+        }
+        EXPECT_EQ(1u, occurrences);
+        verify_infos_loop.Quit();
+      });
+  video_source_provider_->GetSourceInfos(device_info_receiver_.Get());
+  verify_infos_loop.Run();
+}
+
+// Tests that a texture virtual device cannot be added with the same ID as an
+// idle physical device.
+TEST_F(VideoCaptureServiceTest,
+       CannotAddTextureVirtualDeviceWhilePhysicalDeviceIdle) {
+  // Retrieve physical device IDs.
+  base::RunLoop get_infos_loop;
+  std::vector<media::VideoCaptureDeviceInfo> physical_devices;
+  EXPECT_CALL(device_info_receiver_, Run)
+      .WillOnce([&get_infos_loop, &physical_devices](
+                    GetSourceInfosResult result,
+                    const std::vector<media::VideoCaptureDeviceInfo>& infos) {
+        physical_devices = infos;
+        get_infos_loop.Quit();
+      });
+  video_source_provider_->GetSourceInfos(device_info_receiver_.Get());
+  get_infos_loop.Run();
+
+  ASSERT_FALSE(physical_devices.empty());
+  const std::string physical_device_id =
+      physical_devices[0].descriptor.device_id;
+
+  // Add a texture virtual device with the same ID.
+  auto texture_device = AddTextureVirtualDevice(physical_device_id);
+
+  // Verify that the texture virtual device is rejected and its pipe is
+  // disconnected.
+  mojo::Remote<mojom::TextureVirtualDevice> remote_device(
+      std::move(texture_device));
+  base::RunLoop disconnect_loop;
+  remote_device.set_disconnect_handler(disconnect_loop.QuitClosure());
+  disconnect_loop.Run();
+
+  EXPECT_FALSE(remote_device.is_connected());
+}
+
 }  // namespace video_capture
diff --git a/services/video_capture/test/virtual_device_unittest.cc b/services/video_capture/test/virtual_device_unittest.cc
index de54a5c..6c46aca 100644
--- a/services/video_capture/test/virtual_device_unittest.cc
+++ b/services/video_capture/test/virtual_device_unittest.cc
@@ -5,6 +5,7 @@
 #include <algorithm>
 
 #include "base/functional/bind.h"
+#include "base/memory/raw_ptr.h"
 #include "base/memory/ref_counted.h"
 #include "base/run_loop.h"
 #include "base/test/mock_callback.h"
@@ -15,6 +16,7 @@
 #include "services/video_capture/public/cpp/mock_video_frame_handler.h"
 #include "services/video_capture/public/mojom/video_frame_handler.mojom.h"
 #include "services/video_capture/shared_memory_virtual_device_mojo_adapter.h"
+#include "services/video_capture/virtual_device_enabled_device_factory.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 using testing::_;
@@ -159,7 +161,7 @@
   base::MockCallback<
       mojom::SharedMemoryVirtualDevice::RequestFrameBufferCallback>
       request_frame_buffer_callback;
-  EXPECT_CALL(request_frame_buffer_callback, Run(_))
+  EXPECT_CALL(request_frame_buffer_callback, Run)
       .Times(1)
       .WillOnce([this](int32_t buffer_id) {
         // Verify that the returned |buffer_id| is a known buffer ID.
@@ -174,7 +176,7 @@
   base::RunLoop wait_for_stopped_loop;
   {
     testing::InSequence s;
-    EXPECT_CALL(video_frame_handler, DoOnBufferRetired(_))
+    EXPECT_CALL(video_frame_handler, DoOnBufferRetired)
         .Times(SharedMemoryVirtualDeviceMojoAdapter::
                    max_buffer_pool_buffer_count());
     EXPECT_CALL(video_frame_handler, OnStopped())
@@ -184,4 +186,259 @@
   wait_for_stopped_loop.Run();
 }
 
+class MockDeviceFactory : public DeviceFactory {
+ public:
+  MockDeviceFactory() = default;
+  ~MockDeviceFactory() override = default;
+
+  MOCK_METHOD(void,
+              GetDeviceInfos,
+              (GetDeviceInfosCallback callback),
+              (override));
+  MOCK_METHOD(void,
+              CreateDevice,
+              (const std::string& device_id, CreateDeviceCallback callback),
+              (override));
+  MOCK_METHOD(void, StopDevice, (const std::string device_id), (override));
+  MOCK_METHOD(void,
+              AddSharedMemoryVirtualDevice,
+              (const media::VideoCaptureDeviceInfo& device_info,
+               mojo::PendingRemote<mojom::Producer> producer,
+               mojo::PendingReceiver<mojom::SharedMemoryVirtualDevice>
+                   virtual_device_receiver),
+              (override));
+  MOCK_METHOD(void,
+              AddTextureVirtualDevice,
+              (const media::VideoCaptureDeviceInfo& device_info,
+               mojo::PendingReceiver<mojom::TextureVirtualDevice>
+                   virtual_device_receiver),
+              (override));
+  MOCK_METHOD(void,
+              AddGpuMemoryBufferVirtualDevice,
+              (const media::VideoCaptureDeviceInfo& device_info,
+               mojo::PendingReceiver<mojom::GpuMemoryBufferVirtualDevice>
+                   virtual_device_receiver),
+              (override));
+  MOCK_METHOD(void,
+              RegisterVirtualDevicesChangedObserver,
+              (mojo::PendingRemote<mojom::DevicesChangedObserver> observer,
+               bool raise_event_if_virtual_devices_already_present),
+              (override));
+#if BUILDFLAG(IS_WIN)
+  MOCK_METHOD(void, OnGpuInfoUpdate, (const CHROME_LUID& luid), (override));
+#endif
+};
+
+class VirtualDeviceEnabledDeviceFactoryTest : public ::testing::Test {
+ protected:
+  VirtualDeviceEnabledDeviceFactoryTest() {
+    auto mock_factory = std::make_unique<MockDeviceFactory>();
+    mock_factory_ = mock_factory.get();
+    factory_ = std::make_unique<VirtualDeviceEnabledDeviceFactory>(
+        std::move(mock_factory));
+  }
+
+  ~VirtualDeviceEnabledDeviceFactoryTest() override = default;
+
+  base::test::SingleThreadTaskEnvironment task_environment_;
+  std::unique_ptr<VirtualDeviceEnabledDeviceFactory> factory_;
+  raw_ptr<MockDeviceFactory> mock_factory_ = nullptr;
+};
+
+// Tests that when an underlying device creation succeeds, any old virtual
+// device connection with the same device ID is severed.
+TEST_F(VirtualDeviceEnabledDeviceFactoryTest,
+       CreateDeviceSucceedsOldVirtualDeviceConnectionSevered) {
+  const std::string device_id = "/test/device";
+
+  // Register the virtual device first by having GetDeviceInfos return empty
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential camera shadowing and DoS via ID collision in Video Capture Service

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The Video Capture Service allows virtual devices to register using IDs that collision with physical cameras. This enables a compromised privileged client to silently spoof camera feeds and causes a permanent hang when applications attempt to release the device.

Affected files:

  • services/video_capture/virtual_device_enabled_device_factory.cc
  • services/video_capture/video_source_impl.cc

Estimated timestamp from git blame: 2017-11-21

Summary

A logic vulnerability in VirtualDeviceEnabledDeviceFactory allows virtual video capture devices to shadow physical cameras by registering with a colliding device_id. Because the factory prioritizes virtual devices during device creation and lacks collision checks during registration, a compromised process with access to the VideoSourceProvider interface can silently intercept and spoof camera feeds. Furthermore, a failure to properly handle stop requests for virtual devices in the factory leads to a persistent Denial of Service (DoS) for the affected device ID.

Root Cause Analysis

1. Device Creation Prioritization

In VirtualDeviceEnabledDeviceFactory::CreateDevice(), the factory performs a lookup in its internal virtual device map before falling back to the physical device factory. If an entry is found in the virtual map, it is returned immediately, effectively shadowing any physical hardware with the same ID.

// services/video_capture/virtual_device_enabled_device_factory.cc
void VirtualDeviceEnabledDeviceFactory::CreateDevice(
    const std::string& device_id,
    CreateDeviceCallback callback) {
  auto virtual_device_iter = virtual_devices_by_id_.find(device_id);
  if (virtual_device_iter != virtual_devices_by_id_.end()) {
    VirtualDeviceEntry& device_entry = virtual_device_iter->second;
    DeviceInfo info{device_entry.GetDevice(), media::VideoCaptureError::kNone};
    std::move(callback).Run(std::move(info));
    return;
  }
  return device_factory_->CreateDevice(device_id, std::move(callback));
}

2. Lack of Collision Validation

Methods such as AddSharedMemoryVirtualDevice and AddTextureVirtualDevice accept a caller-supplied device_id (via VideoCaptureDeviceInfo) without validating it against the list of physical devices. This allow a client to choose a device_id that intentionally collisions with existing hardware.

3. Broken Stop Logic (Denial of Service)

The VirtualDeviceEnabledDeviceFactory::StopDevice() method only forwards stop requests to the inner physical factory. It fails to check if the ID corresponds to a virtual device:

// services/video_capture/virtual_device_enabled_device_factory.cc
void VirtualDeviceEnabledDeviceFactory::StopDevice(
    const std::string device_id) {
  device_factory_->StopDevice(device_id);
}

When a virtual device is in use, VideoSourceImpl::StopDeviceAsynchronously() calls this factory method and transitions to the kStoppingAsynchronously state. Because the virtual device is never signaled to stop, it never sends the required OnStopped notification to the BroadcastingReceiver. This causes VideoSourceImpl to hang in the stopping state indefinitely, preventing the camera ID from being reopened.

Potential Attack Scenario

  1. An attacker compromises a process with access to the video_capture.mojom.VideoSourceProvider interface. On ChromeOS, this interface is brokered to the rtanalytics daemon via the MediaPerceptionControllerClient to support media perception features.
  2. The attacker calls GetSourceInfos() to identify the device_id (typically a HAL string or system path) of a physical camera.
  3. The attacker registers a virtual device using that same device_id via AddSharedMemoryVirtualDevice().
  4. When a victim application (e.g., Google Meet) subsequently requests the camera, the Video Capture Service returns the attacker’s virtual device instead of the physical hardware.
  5. The attacker injects arbitrary video frames. Because the physical camera was never opened, its hardware activity LED remains off, enabling silent spoofing.
  6. When the victim application closes the camera, the service’s internal state hangs, leading to a persistent DoS for that device ID until the service process is restarted.

Note: These are potential steps; our current analysis is based on a review of the source code logic.

Suggested Fix

  1. Collision Check: Implement validation in VirtualDeviceEnabledDeviceFactory::Add*VirtualDevice to ensure that a virtual device cannot be registered with an ID that matches an existing physical or virtual device.
  2. Update Stop Logic: Modify VirtualDeviceEnabledDeviceFactory::StopDevice to look up the ID in virtual_devices_by_id_. If a virtual device is found, call StopDevice() on the corresponding VirtualDeviceEntry to ensure the virtual adapter correctly shuts down and notifies its consumers.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


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