Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in GetUserMedia
DescriptionInsufficient validation of untrusted input in GetUserMedia
ComponentGetUserMedia
Bug ClassLogic Error
Tracker514242889
Fix commite05978f4fa65 (chromium/src) +223/-47
CISA KEVNot listed
CreditedMihnea Nicolau
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
for
content/browser/renderer_host/media/media_stream_manager.cc
modified
if
content/browser/renderer_host/media/media_stream_manager.cc
modified

Files Changed

  • content/browser/renderer_host/media/audio_output_authorization_handler.cc
  • content/browser/renderer_host/media/media_stream_manager.cc
  • content/browser/renderer_host/media/media_stream_manager.h
From e05978f4fa65bdf419397b6b37fa05d2f2b4b3a9 Mon Sep 17 00:00:00 2001
From: Dale Curtis <[email protected]>
Date: Tue, 02 Jun 2026 18:38:04 -0700
Subject: [PATCH] Ensure session_id is validated for media authorizations

Previously, `MediaStreamDevice.session_id` was treated as a global bearer
token. This CL introduces validation of session IDs before authorizing:
- Added `ValidateAudioSession` and `ValidateVideoSession` to `MediaStreamManager`
  to verify that the supplied session ID belongs to the requesting
  `RenderFrameHost` / origin.
- Added validation checks to `VideoCaptureHost::Start`,
  `RenderFrameAudioInputStreamFactory::Core::CreateStream`, and
  `AudioOutputAuthorizationHandler::RequestDeviceAuthorization`.
- Merged and optimized the internal session lookup in `MediaStreamManager` to
  strictly enforce type-safety (audio vs. video) during validation.
- Invalid validation attempts now trigger `mojo::ReportBadMessage` and
  terminate the compromised renderer.

Fixed: 514242889
Change-Id: I295ddcc3a05ff8ccf656eee646dab33a60626444
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7861584
Reviewed-by: Guido Urdaneta <[email protected]>
Commit-Queue: Dale Curtis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1640629}
---

diff --git a/content/browser/renderer_host/media/audio_output_authorization_handler.cc b/content/browser/renderer_host/media/audio_output_authorization_handler.cc
index 7b675488..794d4c7 100644
--- a/content/browser/renderer_host/media/audio_output_authorization_handler.cc
+++ b/content/browser/renderer_host/media/audio_output_authorization_handler.cc
@@ -179,6 +179,16 @@
   // output device is found, reuse the input device permissions.
   if (media::AudioDeviceDescription::UseSessionIdToSelectDevice(session_id,
                                                                 device_id)) {
+    if (!media_stream_manager_->ValidateAudioSession(
+            session_id,
+            GlobalRenderFrameHostId(render_process_id_, render_frame_id))) {
+      trace_scope->SimpleEvent("Unauthorized session");
+      std::move(cb).Run(media::OUTPUT_DEVICE_STATUS_ERROR_NOT_AUTHORIZED,
+                        media::AudioParameters::UnavailableDeviceParams(),
+                        std::string(), std::string());
+      return;
+    }
+
     const blink::MediaStreamDevice* device =
         media_stream_manager_->audio_input_device_manager()
             ->GetOpenedDeviceById(session_id);
diff --git a/content/browser/renderer_host/media/media_stream_manager.cc b/content/browser/renderer_host/media/media_stream_manager.cc
index 2babe01b..941761e 100644
--- a/content/browser/renderer_host/media/media_stream_manager.cc
+++ b/content/browser/renderer_host/media/media_stream_manager.cc
@@ -2339,11 +2339,15 @@
   return (it != requests_.end()) ? it->second.get() : nullptr;
 }
 
-MediaStreamManager::DeviceRequest*
-MediaStreamManager::FindRequestByVideoSessionId(
-    const base::UnguessableToken& session_id) const {
+MediaStreamManager::DeviceRequest* MediaStreamManager::FindRequestBySessionId(
+    const base::UnguessableToken& session_id,
+    SessionType* out_type) const {
   DCHECK_CURRENTLY_ON(BrowserThread::IO);
 
+  if (session_id.is_empty()) {
+    return nullptr;
+  }
+
   for (const LabeledDeviceRequest& labeled_request : requests_) {
     DeviceRequest* const request = labeled_request.second.get();
     if (!request) {
@@ -2351,10 +2355,18 @@
     }
     for (const blink::mojom::StreamDevicesPtr& stream_devices_ptr :
          request->stream_devices_set.stream_devices) {
-      const std::optional<blink::MediaStreamDevice>& video_device =
-          stream_devices_ptr->video_device;
-      if (video_device && video_device->serializable_session_id().has_value() &&
-          video_device->serializable_session_id().value() == session_id) {
+      if (stream_devices_ptr->audio_device.has_value() &&
+          stream_devices_ptr->audio_device->session_id() == session_id) {
+        if (out_type) {
+          *out_type = SessionType::kAudio;
+        }
+        return request;
+      }
+      if (stream_devices_ptr->video_device.has_value() &&
+          stream_devices_ptr->video_device->session_id() == session_id) {
+        if (out_type) {
+          *out_type = SessionType::kVideo;
+        }
         return request;
       }
     }
@@ -2363,14 +2375,43 @@
   return nullptr;
 }
 
+bool MediaStreamManager::ValidateSession(
+    const base::UnguessableToken& session_id,
+    const GlobalRenderFrameHostId& render_frame_host_id,
+    SessionType expected_type) const {
+  DCHECK_CURRENTLY_ON(BrowserThread::IO);
+  SessionType actual_type;
+  DeviceRequest* const request =
+      FindRequestBySessionId(session_id, &actual_type);
+  if (!request) {
+    return true;  // Safe: session not active.
+  }
+  return request->requesting_render_frame_host_id == render_frame_host_id &&
+         actual_type == expected_type;
+}
+
+bool MediaStreamManager::ValidateAudioSession(
+    const base::UnguessableToken& session_id,
+    const GlobalRenderFrameHostId& render_frame_host_id) const {
+  return ValidateSession(session_id, render_frame_host_id, SessionType::kAudio);
+}
+
+bool MediaStreamManager::ValidateVideoSession(
+    const base::UnguessableToken& session_id,
+    const GlobalRenderFrameHostId& render_frame_host_id) const {
+  return ValidateSession(session_id, render_frame_host_id, SessionType::kVideo);
+}
+
 #if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
 
 CapturedSurfaceController* MediaStreamManager::GetCapturedSurfaceController(
     GlobalRenderFrameHostId capturer_rfh_id,
     const base::UnguessableToken& session_id,
     blink::mojom::CapturedSurfaceControlResult& result) {
-  DeviceRequest* const request = FindRequestByVideoSessionId(session_id);
-  if (!request) {
+  SessionType actual_type;
+  DeviceRequest* const request =
+      FindRequestBySessionId(session_id, &actual_type);
+  if (!request || actual_type != SessionType::kVideo) {
     result = CapturedSurfaceControlResult::kCapturedSurfaceNotFoundError;
     return nullptr;
   }
@@ -3499,9 +3540,10 @@
   // If the video for a screen capture is aborted, the corresponding
   // audio must also be stopped.
   if (blink::IsVideoScreenCaptureMediaType(stream_type)) {
+    SessionType actual_type;
     DeviceRequest* const request =
-        FindRequestByVideoSessionId(capture_session_id);
-    if (request) {
+        FindRequestBySessionId(capture_session_id, &actual_type);
+    if (request && actual_type == SessionType::kVideo) {
       for (const auto& stream_devices_ptr :
            request->stream_devices_set.stream_devices) {
         if (stream_devices_ptr->audio_device.has_value()) {
@@ -4390,8 +4432,10 @@
         callback) {
   DCHECK_CURRENTLY_ON(BrowserThread::IO);
 
-  DeviceRequest* const request = FindRequestByVideoSessionId(session_id);
-  if (!request) {
+  SessionType actual_type;
+  DeviceRequest* const request =
+      FindRequestBySessionId(session_id, &actual_type);
+  if (!request || actual_type != SessionType::kVideo) {
     std::move(callback).Run(blink::mojom::CapturedSurfaceControlResult::
                                 kCapturedSurfaceNotFoundError);
     return;
@@ -4455,8 +4499,9 @@
 
 std::optional<url::Origin> MediaStreamManager::GetOriginByVideoSessionId(
     const base::UnguessableToken& session_id) {
-  DeviceRequest* request = FindRequestByVideoSessionId(session_id);
-  if (request == nullptr) {
+  SessionType actual_type;
+  DeviceRequest* request = FindRequestBySessionId(session_id, &actual_type);
+  if (request == nullptr || actual_type != SessionType::kVideo) {
     return std::nullopt;
   }
   return request->salt_and_origin.origin();
diff --git a/content/browser/renderer_host/media/media_stream_manager.h b/content/browser/renderer_host/media/media_stream_manager.h
index b98ae39c..a5af92dc 100644
--- a/content/browser/renderer_host/media/media_stream_manager.h
+++ b/content/browser/renderer_host/media/media_stream_manager.h
@@ -487,6 +487,31 @@
   std::optional<url::Origin> GetOriginByVideoSessionId(
       const base::UnguessableToken& session_id);
 
+  // Validates that the renderer-supplied `session_id` is authorized for use by
+  // the calling `render_frame_host_id`.
+  //
+  // Returns `true` if:
+  // - The session is active and owned by `render_frame_host_id` (valid usage).
+  // - The session is not active/not found in MediaStreamManager. This is
+  //   considered safe as a non-existent session cannot be hijacked; it will
+  //   fail gracefully downstream (e.g., returning null device). This allows
+  //   legitimate asynchronous races (such as teardown races) to fail gracefully
+  //   instead of causing false-positive renderer terminations.
+  //
+  // Returns `false` if:
+  // - The session is active but owned by a different `RenderFrameHost`/origin.
+  // - The session is active but is of the wrong type (e.g., passing a video
+  //   session ID to an audio endpoint).
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/media/render_frame_audio_input_stream_factory_unittest.cc b/content/browser/renderer_host/media/render_frame_audio_input_stream_factory_unittest.cc
index 902e6496..a665b61 100644
--- a/content/browser/renderer_host/media/render_frame_audio_input_stream_factory_unittest.cc
+++ b/content/browser/renderer_host/media/render_frame_audio_input_stream_factory_unittest.cc
@@ -15,8 +15,11 @@
 #include "content/browser/media/forwarding_audio_stream_factory.h"
 #include "content/browser/renderer_host/media/audio_input_device_manager.h"
 #include "content/browser/renderer_host/media/media_stream_manager.h"
+#include "content/browser/renderer_host/media/media_stream_ui_proxy.h"
 #include "content/public/browser/browser_task_traits.h"
 #include "content/public/browser/browser_thread.h"
+#include "content/public/browser/desktop_media_id.h"
+#include "content/public/browser/desktop_streams_registry.h"
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/render_process_host.h"
 #include "content/public/browser/web_contents.h"
@@ -174,6 +177,62 @@
         kDeviceName));
   }
 
+  base::UnguessableToken GenerateAudioStream(
+      blink::mojom::MediaStreamType stream_type,
+      const std::string& device_id,
+      const url::Origin& origin) {
+    base::RunLoop run_loop;
+    base::UnguessableToken session_id;
+
+    blink::StreamControls controls(true /* request_audio */,
+                                   false /* request_video */);
+    controls.audio.stream_type = stream_type;
+
+    std::string resolved_device_id =
+        device_id.empty() ? "fake_default_mic_id" : device_id;
+    blink::MediaStreamDevice fake_device(stream_type, resolved_device_id,
+                                         "Fake Device");
+
+    if (!device_id.empty()) {
+      controls.audio.device_ids = {device_id};
+    }
+
+    media_stream_manager_->UseFakeUIFactoryForTests(base::BindRepeating(
+        [](blink::MediaStreamDevice fake_device) {
+          auto fake_ui = std::make_unique<FakeMediaStreamUIProxy>(
+              /*tests_use_fake_render_frame_hosts=*/true);
+          fake_ui->AddAvailableDevices({fake_device});
+          return fake_ui;
+        },
+        fake_device));
+
+    media_stream_manager_->GenerateStreams(
+        main_rfh()->GetGlobalId(), /*requester_id=*/1, /*page_request_id=*/1,
+        controls, MediaDeviceSaltAndOrigin("salt", origin),
+        /*user_gesture=*/true,
+        blink::mojom::StreamSelectionInfo::NewSearchOnlyByDeviceId({}),
+        base::BindOnce(
+            [](base::RunLoop* run_loop, base::UnguessableToken* session_id,
+               blink::mojom::MediaStreamRequestResult result,
+               const std::string& label,
+               blink::mojom::StreamDevicesSetPtr stream_devices_set,
+               bool pan_tilt_zoom_allowed) {
+              DCHECK_EQ(result, blink::mojom::MediaStreamRequestResult::OK);
+              DCHECK_EQ(stream_devices_set->stream_devices.size(), 1u);
+              DCHECK(stream_devices_set->stream_devices[0]
+                         ->audio_device.has_value());
+              *session_id = stream_devices_set->stream_devices[0]
+                                ->audio_device->session_id();
+              run_loop->Quit();
+            },
+            &run_loop, &session_id),
+        base::DoNothing(), base::DoNothing(), base::DoNothing(),
+        base::DoNothing(), base::DoNothing(), base::DoNothing());
+
+    run_loop.Run();
+    return session_id;
+  }
+
   const media::AudioParameters kParams =
       media::AudioParameters::UnavailableDeviceParams();
   const std::string kDeviceId = "test id";
@@ -201,11 +260,9 @@
       factory_remote.BindNewPipeAndPassReceiver(), media_stream_manager_.get(),
       main_rfh());
 
-  base::UnguessableToken session_id =
-      audio_input_device_manager()->Open(blink::MediaStreamDevice(
-          blink::mojom::MediaStreamType::DEVICE_AUDIO_CAPTURE, kDeviceId,
-          kDeviceName));
-  base::RunLoop().RunUntilIdle();
+  url::Origin origin = url::Origin::Create(GURL("https://test.com"));
+  base::UnguessableToken session_id = GenerateAudioStream(
+      blink::mojom::MediaStreamType::DEVICE_AUDIO_CAPTURE, "", origin);
 
   mojo::PendingRemote<blink::mojom::RendererAudioInputStreamFactoryClient>
       client;
@@ -229,11 +286,16 @@
   RenderFrameHost* main_frame = source_contents->GetPrimaryMainFrame();
   WebContentsMediaCaptureId capture_id(
       main_frame->GetProcess()->GetDeprecatedID(), main_frame->GetRoutingID());
-  base::UnguessableToken session_id =
-      audio_input_device_manager()->Open(blink::MediaStreamDevice(
-          blink::mojom::MediaStreamType::GUM_TAB_AUDIO_CAPTURE,
-          capture_id.ToString(), kDeviceName));
-  base::RunLoop().RunUntilIdle();
+
+  url::Origin origin = url::Origin::Create(GURL("https://test.com"));
+  DesktopMediaID media_id(DesktopMediaID::TYPE_WEB_CONTENTS,
+                          DesktopMediaID::kNullId, capture_id);
+  std::string stream_id = DesktopStreamsRegistry::GetInstance()->RegisterStream(
+      main_rfh()->GetProcess()->GetDeprecatedID(), main_rfh()->GetRoutingID(),
+      origin, media_id, kRegistryStreamTypeTab);
+
+  base::UnguessableToken session_id = GenerateAudioStream(
+      blink::mojom::MediaStreamType::GUM_TAB_AUDIO_CAPTURE, stream_id, origin);
 
   mojo::PendingRemote<blink::mojom::RendererAudioInputStreamFactoryClient>
       client;
@@ -257,11 +319,16 @@
   RenderFrameHost* main_frame = source_contents->GetPrimaryMainFrame();
   WebContentsMediaCaptureId capture_id(
       main_frame->GetProcess()->GetDeprecatedID(), main_frame->GetRoutingID());
-  base::UnguessableToken session_id =
-      audio_input_device_manager()->Open(blink::MediaStreamDevice(
-          blink::mojom::MediaStreamType::GUM_TAB_AUDIO_CAPTURE,
-          capture_id.ToString(), kDeviceName));
-  base::RunLoop().RunUntilIdle();
+
+  url::Origin origin = url::Origin::Create(GURL("https://test.com"));
+  DesktopMediaID media_id(DesktopMediaID::TYPE_WEB_CONTENTS,
+                          DesktopMediaID::kNullId, capture_id);
+  std::string stream_id = DesktopStreamsRegistry::GetInstance()->RegisterStream(
+      main_rfh()->GetProcess()->GetDeprecatedID(), main_rfh()->GetRoutingID(),
+      origin, media_id, kRegistryStreamTypeTab);
+
+  base::UnguessableToken session_id = GenerateAudioStream(
+      blink::mojom::MediaStreamType::GUM_TAB_AUDIO_CAPTURE, stream_id, origin);
 
   source_contents.reset();
   mojo::PendingRemote<blink::mojom::RendererAudioInputStreamFactoryClient>
diff --git a/content/browser/renderer_host/media/video_capture_unittest.cc b/content/browser/renderer_host/media/video_capture_unittest.cc
index 5d907811..b634973 100644
--- a/content/browser/renderer_host/media/video_capture_unittest.cc
+++ b/content/browser/renderer_host/media/video_capture_unittest.cc
@@ -116,8 +116,11 @@
 
     // Create a Host and connect it to a simulated IPC channel.
     host_ = std::make_unique<VideoCaptureHost>(
-        GlobalRenderFrameHostId() /* render_frame_host_id */,
+        GlobalRenderFrameHostId(1, 1) /* render_frame_host_id */,
         media_stream_manager_.get());
+    host_receiver_ =
+        std::make_unique<mojo::Receiver<media::mojom::VideoCaptureHost>>(
+            host_.get(), host_remote_.BindNewPipeAndPassReceiver());
 
     OpenSession();
   }
@@ -128,6 +131,7 @@
 
     CloseSession();
 
+    host_receiver_.reset();
     host_.reset();
   }
 
@@ -224,11 +228,12 @@
         .Times(AnyNumber())
         .WillRepeatedly(ExitMessageLoop(task_runner_, run_loop.QuitClosure()));
 
-    host_->Start(DeviceId(), opened_session_id_, params,
-                 observer_receiver_.BindNewPipeAndPassRemote());
+    host_remote_->Start(DeviceId(), opened_session_id_, params,
+                        observer_receiver_.BindNewPipeAndPassRemote());
 
     // Ensure that the browser context has been retrevied and the observer is
     // connected.
+    host_remote_.FlushForTesting();
     observer_receiver_.FlushForTesting();
 
     run_loop.Run();
@@ -243,11 +248,12 @@
                 DoOnVideoCaptureError(
                     media::VideoCaptureError::kVideoCaptureControllerInvalid))
         .Times(1);
-    host_->Start(DeviceId(), base::UnguessableToken(), params,
-                 observer_receiver_.BindNewPipeAndPassRemote());
+    host_remote_->Start(DeviceId(), base::UnguessableToken::Create(), params,
+                        observer_receiver_.BindNewPipeAndPassRemote());
 
     // Ensure that the browser context has been retrevied and the observer is
     // connected.
+    host_remote_.FlushForTesting();
     observer_receiver_.FlushForTesting();
   }
 
@@ -266,16 +272,18 @@
     EXPECT_CALL(*this,
                 DoOnStateChanged(media::mojom::VideoCaptureState::STARTED))
         .Times(AtMost(1));
-    host_->Start(DeviceId(), opened_session_id_, params,
-                 observer_receiver_.BindNewPipeAndPassRemote());
+    host_remote_->Start(DeviceId(), opened_session_id_, params,
+                        observer_receiver_.BindNewPipeAndPassRemote());
 
     // Ensure that the browser context has been retrevied and the observer is
     // connected.
+    host_remote_.FlushForTesting();
     observer_receiver_.FlushForTesting();
 
     EXPECT_CALL(*this,
                 DoOnStateChanged(media::mojom::VideoCaptureState::STOPPED));
-    host_->Stop(DeviceId());
+    host_remote_->Stop(DeviceId());
+    host_remote_.FlushForTesting();
     run_loop.RunUntilIdle();
   }
 
@@ -285,7 +293,8 @@
 
     EXPECT_CALL(*this,
                 DoOnStateChanged(media::mojom::VideoCaptureState::PAUSED));
-    host_->Pause(DeviceId());
+    host_remote_->Pause(DeviceId());
+    host_remote_.FlushForTesting();
 
     media::VideoCaptureParams params;
     params.requested_format = media::VideoCaptureFormat(
@@ -293,7 +302,8 @@
 
     EXPECT_CALL(*this,
                 DoOnStateChanged(media::mojom::VideoCaptureState::RESUMED));
-    host_->Resume(DeviceId(), opened_session_id_, params);
+    host_remote_->Resume(DeviceId(), opened_session_id_, params);
+    host_remote_.FlushForTesting();
     run_loop.RunUntilIdle();
   }
 
@@ -303,7 +313,8 @@
     EXPECT_CALL(*this,
                 DoOnStateChanged(media::mojom::VideoCaptureState::STOPPED))
         .WillOnce(ExitMessageLoop(task_runner_, run_loop.QuitClosure()));
-    host_->Stop(DeviceId());
+    host_remote_->Stop(DeviceId());
+    host_remote_.FlushForTesting();
 
     run_loop.Run();
 
@@ -365,6 +376,9 @@
   std::string opened_device_label_;
 
   std::unique_ptr<VideoCaptureHost> host_;
+  mojo::Remote<media::mojom::VideoCaptureHost> host_remote_;
+  std::unique_ptr<mojo::Receiver<media::mojom::VideoCaptureHost>>
+      host_receiver_;
   mojo::Receiver<media::mojom::VideoCaptureObserver> observer_receiver_{this};
 };
Loading diff…

Original Bug Report

reported by [email protected]

Media capture endpoints accept cross-origin MediaStream session IDs

Media capture endpoints accept cross-origin MediaStream session IDs

Summary

A compromised renderer for one origin can attach attacker-origin audio/video consumers to a different origin’s already-granted microphone or camera capture session.

The browser process returns MediaStreamDevice.session_id values to the renderer when a media stream is opened. Normal Blink code uses those session IDs only for tracks owned by the current document. However, the browser-side live capture endpoints treat the renderer-supplied session ID as a global bearer token:

  • media.mojom.VideoCaptureHost.Start() accepts a victim-origin camera session_id and starts delivering video frames to an attacker-origin VideoCaptureObserver.
  • blink.mojom.RendererAudioInputStreamFactory.CreateStream() accepts a victim-origin microphone session_id and creates an attacker-origin audio input stream backed by the victim microphone device.

The attacker origin does not need camera or microphone permission. The victim origin has the permission and owns the active session. The violation is that the browser process does not validate that the supplied session ID belongs to the bound caller frame / origin before connecting the live media consumer.

This report uses MojoJS only to model the compromised-renderer condition and send raw Mojo messages. No browser patch is required.

Impact

This is a browser-side media permission and origin-ownership bypass for already-active capture sessions.

Expected security property:

MediaStreamDevice.session_id should only authorize stream consumers created by the frame / document / origin that owns the corresponding media stream. A renderer-supplied session ID should not let another origin attach a live media consumer to a victim-origin camera or microphone session.

Actual behavior:

The browser process accepts the victim-origin session ID from the attacker-origin Mojo caller and connects the attacker-controlled observer/client to the live capture pipeline.

For video, the attacker receives OnBufferReady() callbacks and shared-memory video buffers for the victim camera session, even though the attacker origin has not been granted camera permission. For audio, the attacker receives a browser-created AudioInputStream and a ReadWriteAudioDataPipe; after record(), the shared-memory audio data changes, proving the attacker-origin stream is backed by the victim-origin microphone session.

The PoC handoff of the victim session_id is intentional: it models a compromised renderer with access to a live victim MediaStreamDevice. The vulnerability is that the browser process fails to validate session ownership before returning live camera/microphone data to the caller.

Affected Code

MediaStreamDevice exposes the session token over Mojo:

171 struct MediaStreamDevice {
...
180   mojo_base.mojom.UnguessableToken? session_id;
181   media.mojom.DisplayMediaInformation? display_media_info;
182 };

media.mojom.VideoCaptureHost.Start() accepts a renderer-supplied session ID and attacker-supplied observer:

94 interface VideoCaptureHost {
95   // Start the |session_id| session with |params|. The video capture will be
96   // identified as |device_id|, a new id picked by the renderer process.
97   // |observer| will be used for notifications.
98   Start(mojo_base.mojom.UnguessableToken device_id,
99         mojo_base.mojom.UnguessableToken session_id,
100        VideoCaptureParams params,
101        pending_remote<VideoCaptureObserver> observer);

VideoCaptureHost::Start() binds the attacker observer and connects the client using the supplied session_id:

274 void VideoCaptureHost::Start(
275     const base::UnguessableToken& device_id,
276     const base::UnguessableToken& session_id,
277     const media::VideoCaptureParams& params,
278     mojo::PendingRemote<media::mojom::VideoCaptureObserver> observer) {
...
291   DCHECK(!device_id_to_observer_map_.contains(device_id));
292   auto& observer_in_map = device_id_to_observer_map_[device_id];
293   observer_in_map.Bind(std::move(observer));
...
303   controllers_[controller_id] = base::WeakPtr<VideoCaptureController>();
304   ConnectClient(session_id, params, controller_id,
305                 render_frame_host_delegate_->render_frame_host_id(),
306                 base::BindOnce(&VideoCaptureHost::OnControllerAdded,
307                                weak_factory_.GetWeakPtr(), device_id));
308 }

The later ConnectClient() lookup obtains the origin for the supplied session but does not reject if the session belongs to another frame or origin:

590   std::optional<url::Origin> origin =
591       media_stream_manager_->GetOriginByVideoSessionId(session_id);
592   media_stream_manager_->video_capture_manager()->ConnectClient(
593       session_id, params, controller_id, render_frame_host_id, this,
594       std::move(origin), std::move(done_cb));

By contrast, nearby captured-surface-control methods demonstrate the expected owner check pattern: they look up the request by session ID and then compare the stored requesting RFH with the bound caller RFH before proceeding:

2352   DeviceRequest* const request = FindRequestByVideoSessionId(session_id);
...
2358   if (request->requesting_render_frame_host_id != capturer_rfh_id) {
2359     result = CapturedSurfaceControlResult::kUnknownError;
2360     return nullptr;
2361   }

The audio stream endpoint has the same bearer-token shape. RendererAudioInputStreamFactory.CreateStream() accepts a renderer-supplied microphone session ID:

18 interface RendererAudioInputStreamFactory {
...
21   CreateStream(
22       pending_remote<RendererAudioInputStreamFactoryClient> client,
23       mojo_base.mojom.UnguessableToken session_id,
24       media.mojom.AudioParameters params,
25       bool automatic_gain_control,
26       uint32 shared_memory_count,
27       media.mojom.AudioProcessingConfig? processing_config);

RenderFrameAudioInputStreamFactory::Core::CreateStream() resolves that session ID through the global audio input device manager and then creates an input stream for the bound caller frame. There is no ownership comparison between the session owner and process_id_ / frame_id_:

238 void RenderFrameAudioInputStreamFactory::Core::CreateStream(
...
241     const base::UnguessableToken& session_id,
...
253   const blink::MediaStreamDevice* device =
254       media_stream_manager_->audio_input_device_manager()->GetOpenedDeviceById(
255           session_id);
...
285   } else {
286     forwarding_factory_->CreateInputStream(
287         process_id_, frame_id_, device->id, audio_params, shared_memory_count,
288         automatic_gain_control, std::move(processing_config),
289         std::move(client));

AudioInputDeviceManager::GetOpenedDeviceById() is a session-ID lookup over opened devices:

83 const blink::MediaStreamDevice* AudioInputDeviceManager::GetOpenedDeviceById(
84     const base::UnguessableToken& session_id) {
85   DCHECK_CURRENTLY_ON(BrowserThread::IO);
86   auto device = GetDevice(session_id);
87   if (device == devices_.end())
88     return nullptr;
90   return &(*device);
91 }

Proof

The attached PoC serves two local origins:

attacker origin: http://127.0.0.1:8820/index.html
victim origin:   http://localhost:8821/victim.html

The victim top-level window first obtains camera or microphone permission through the normal Web API. Its renderer then opens the granted device through MediaStreamDispatcherHost.OpenDevice() and sends the resulting raw MediaStreamDevice.session_id to the attacker page to model a compromised renderer with access to a live victim MediaStreamDevice.

The attacker page then binds the live media consumer endpoints directly:

VideoCaptureHost.Start(attackerDeviceId, victimVideoSessionId, params, attackerObserver)
RendererAudioInputStreamFactory.CreateStream(client, victimAudioSessionId, params, ...)

The attached evidence-video-real-device.log is a manual real-device video run. The victim origin received camera permission and opened the camera session. The attacker origin still had camera permission state prompt, and enumerateDevices() exposed no camera label or device id to the attacker origin. Despite that, Chrome delivered camera frames to the attacker-origin observer:

[victim http://localhost:8821] normal getUserMedia returned video track label="Elgato Facecam Neo (0fd9:0081)" readyState="live"
[victim http://localhost:8821] OpenDevice success=true label="05d5d38a-1234-4cd7-a356-8d91b20cfc6b"
[victim http://localhost:8821] victim video device ... sessionId={"high":"0x94611bfb48a654a","low":"0x2f3f4ef26ce61644"}
[attacker http://127.0.0.1:8820] attacker camera permission=prompt
[attacker http://127.0.0.1:8820] attacker enumerateDevices videoinput[0] label="" deviceId=""
[attacker http://127.0.0.1:8820] calling VideoCaptureHost.Start(... victimSessionId={"high":"0x94611bfb48a654a","low":"0x2f3f4ef26ce61644"})
[attacker http://127.0.0.1:8820] video observer state=STARTED
[attacker http://127.0.0.1:8820] video observer newBuffer id=0 handleKeys=gpuMemoryBufferHandle
[attacker http://127.0.0.1:8820] video observer bufferReady count=1 bufferId=0 pixelFormat=6 coded={"width":640,"height":480}
[attacker http://127.0.0.1:8820] SUCCESS: attacker-origin VideoCaptureObserver received a frame from the victim-origin camera session.

The attached evidence-audio-real-device.log is a manual real-device microphone run. The victim origin received microphone permission and opened the microphone session. The attacker origin still had microphone permission state prompt, and enumerateDevices() exposed no microphone label or device id to the attacker origin. Despite that, Chrome created and started an attacker-origin audio input stream backed by the victim microphone session:

[victim http://localhost:8821] normal getUserMedia returned audio track label="Microphone (AT2020USB-X) (0909:0052)" readyState="live"
[victim http://localhost:8821] OpenDevice success=true label="945cace1-a51a-4a3b-835f-c77916a6640e"
[victim http://localhost:8821] victim audio device ... sessionId={"high":"0x5044e1e651aebd0d","low":"0xb2c2a2c7c0c013e9"}
[attacker http://127.0.0.1:8820] attacker microphone permission=prompt
[attacker http://127.0.0.1:8820] attacker enumerateDevices audioinput[0] label="" deviceId=""
[attacker http://127.0.0.1:8820] calling RendererAudioInputStreamFactory.CreateStream(victimSessionId={"high":"0x5044e1e651aebd0d","low":"0xb2c2a2c7c0c013e9"})
[attacker http://127.0.0.1:8820] audio streamCreated initiallyMuted=false streamId={"high":"0xed33d162ce5399da","low":"0xcbaca4731fb0560e"}
[attacker http://127.0.0.1:8820] audio sharedMemory.mapBuffer(0, 4096) => result=0 hasBuffer=true
[attacker http://127.0.0.1:8820] SUCCESS: attacker-origin renderer created and started an audio input stream using the victim-origin microphone session id.
[attacker http://127.0.0.1:8820] audio sharedMemory snapshot 1 checksum=106098 prefix=0,0,0,0,0,0,0,0,161,213,106,177,10,0,0,0

For manual real-device verification, grant camera/microphone permission only to the victim origin and do not grant those permissions to the attacker origin. The attacker page logs its own permission state before invoking the raw Mojo endpoints.

Tested Versions

Source review:

Chromium checkout: 39ce5ba88b12b39e7a45c7ea603043cecff53d9b
Commit: Add hovered scrollbar arrow color id

Manual real-device reproduction:

Windows Chromium ASAN 149.0.7805.0
MojoJS enabled
Real Elgato Facecam Neo camera
Real AT2020USB-X microphone

Steps to Reproduce

Place these PoC files in one directory:

server.py
index.html
attacker.js
victim.html
victim.js

Start the PoC server:

MOJO_GEN_DIR=/path/to/chromium/src/out/<build>/gen python3 server.py

Launch Chrome or Chromium with MojoJS enabled:

/path/to/chrome --user-data-dir=/tmp/mediastream-session-profile --no-first-run --enable-blink-features=MojoJS http://127.0.0.1:8820/index.html

Manual video flow:

  1. On the attacker page, click 1. Open victim camera window.
  2. In the new http://localhost:8821/ victim window, click Start victim camera session.
  3. Grant camera permission when Chrome prompts for the victim origin.
  4. Do not grant camera permission to the attacker origin.
  5. Click 2. Attach attacker video consumer.
  6. Observe that the victim origin logs OpenDevice success=true and a victim sessionId.
  7. Observe that the attacker origin logs attacker camera permission=prompt and blank camera device labels, then a raw VideoCaptureHost.Start() call with the victim session ID, followed by video observer state=STARTED, video observer newBuffer, video observer bufferReady, and the video success line.

Manual audio flow:

  1. On the attacker page, click 3. Open victim microphone window.
  2. In the new http://localhost:8821/ victim window, click Start victim microphone session.
  3. Grant microphone permission when Chrome prompts for the victim origin.
  4. Do not grant microphone permission to the attacker origin.
  5. Click 4. Attach attacker audio consumer.
  6. Observe that the victim origin logs OpenDevice success=true and a victim sessionId.
  7. Observe that the attacker origin logs attacker microphone permission=prompt and blank microphone device labels, then a raw RendererAudioInputStreamFactory.CreateStream() call with the victim session ID, followed by audio streamCreated, successful shared-memory mapping, the audio success line, and a changed audio shared-memory checksum.

Suggested Fix Direction

Do not treat MediaStreamDevice.session_id as a global bearer token for live capture consumers.

Before VideoCaptureHost::Start() connects a client for a renderer-supplied session ID, validate that the resolved media request belongs to the same bound RenderFrameHost / requester context as the VideoCaptureHost. The captured-surface-control helper already shows the expected pattern: resolve the request by session ID, then compare request->requesting_render_frame_host_id against the bound caller RFH before allowing the operation.

Similarly, before RenderFrameAudioInputStreamFactory::Core::CreateStream() creates an input stream for a renderer-supplied session ID, validate that the opened audio device/session belongs to the factory’s bound frame. If the session was opened by another frame, process, storage key, or origin, fail before calling CreateInputStream().

This is related to issue 514080030, which reported media.mojom.ImageCapture.takePhoto() accepting a cross-origin active camera source id. This report covers a separate set of live capture endpoints and a different browser-side token: MediaStreamDevice.session_id. Here, VideoCaptureHost.Start() accepts a victim camera session ID and delivers live video frames to the attacker origin, and RendererAudioInputStreamFactory.CreateStream() accepts a victim microphone session ID and creates an attacker-origin audio input stream. The common security boundary is media-session ownership validation in the browser process, but the affected endpoints and demonstrated impact are distinct.

Attached Files

server.py
index.html
attacker.js
victim.html
victim.js
evidence-video-real-device.log
evidence-audio-real-device.log

Credit: Mihnea Nicolau

View on issue tracker