Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Media UI
DescriptionInappropriate implementation in Media UI
ComponentMedia UI
Bug ClassLogic Error
Tracker514020959
Fix commit341d11c893cd (chromium/src) +150/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/global_media_controls/media_notification_service.cc
modified
CreateCastDialogControllerForSession
chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc
modified

Files Changed

  • chrome/browser/ui/global_media_controls/media_notification_service.cc
  • chrome/browser/ui/global_media_controls/media_notification_service.h
  • chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc
From 341d11c893cde0b9394ac9d345a37693583a4bd0 Mon Sep 17 00:00:00 2001
From: Yiren Wang <yrw@chromium.org>
Date: Tue, 19 May 2026 19:37:32 -0700
Subject: [PATCH] Verify web contents before passing presentation context

When creating a Cast dialog controller for a session in the Global Media
Controls, a presentation context (`context_`) might be cached.
Previously, the service passed this context to the new controller
without checking if the session's WebContents matched the WebContents
that initiated the presentation request.

This could potentially lead to UI spoofing where a presentation request
from one tab is associated with and consumed by a GMC dialog triggered
from a different tab.

This CL adds a verification check to ensure the presentation request's
initiator RenderFrameHost's WebContents matches the target session's
WebContents before passing the presentation context.

Bug: 514020959
Change-Id: Ia7ab9379fa43916ce7fb94fcc90eb81f72573767
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7858168
Commit-Queue: Yiren Wang <yrw@chromium.org>
Reviewed-by: Muyao Xu <muyaoxu@google.com>
Cr-Commit-Position: refs/heads/main@{#1633299}
---

diff --git a/chrome/browser/ui/global_media_controls/media_notification_service.cc b/chrome/browser/ui/global_media_controls/media_notification_service.cc
index 7b3a691..d7815bd5 100644
--- a/chrome/browser/ui/global_media_controls/media_notification_service.cc
+++ b/chrome/browser/ui/global_media_controls/media_notification_service.cc
@@ -37,6 +37,7 @@
 #include "content/public/browser/audio_service.h"
 #include "content/public/browser/media_session.h"
 #include "content/public/browser/media_session_service.h"
+#include "content/public/browser/render_frame_host.h"
 #include "media/base/media_switches.h"
 #include "media/remoting/device_capability_checker.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
@@ -476,11 +477,11 @@
   // `remoting_session_id` is used to construct the MediaRemotingCallback for
   // CastDeviceListHost to request Media Remoting for a MediaSession. This is
   // used for Media Remoting sessions started from the GMC dialog. However, when
-  // the dialog is opened for RemotePlayback#prompt() (when `context_` is not
-  // nullptr), the Remote Playback API on the blink side handles sending Media
-  // Remoting request and there's no need for requesting Media Remoting from
-  // MNS.
-  if (context_ == nullptr) {
+  // the dialog is opened for RemotePlayback#prompt() (when there is a matching
+  // presentation context), the Remote Playback API on the blink side handles
+  // sending the Media Remoting request and there's no need for requesting Media
+  // Remoting from MNS.
+  if (!HasPresentationContextForSession(session_id)) {
     remoting_session_id = session_id;
   }
   CreateCastDeviceListHost(CreateCastDialogControllerForSession(session_id),
@@ -511,7 +512,7 @@
     return nullptr;
   }
 
-  if (context_) {
+  if (HasPresentationContextForSession(id)) {
     return media_router::MediaRouterUI::CreateWithStartPresentationContext(
         web_contents, std::move(context_));
   }
@@ -587,6 +588,23 @@
   host_receivers_.emplace(host_id, std::move(host_receiver));
 }
 
+bool MediaNotificationService::HasPresentationContextForSession(
+    const std::string& session_id) {
+  if (!context_) {
+    return false;
+  }
+  auto* initiator_rfh = content::RenderFrameHost::FromID(
+      context_->presentation_request().render_frame_host_id);
+  if (!initiator_rfh) {
+    context_.reset();
+    return false;
+  }
+  auto* web_contents =
+      content::MediaSession::GetWebContentsFromRequestId(session_id);
+  return web_contents && content::WebContents::FromRenderFrameHost(
+                             initiator_rfh) == web_contents;
+}
+
 void MediaNotificationService::set_device_provider_for_testing(
     std::unique_ptr<MediaNotificationDeviceProvider> device_provider) {
   device_provider_ = std::move(device_provider);
diff --git a/chrome/browser/ui/global_media_controls/media_notification_service.h b/chrome/browser/ui/global_media_controls/media_notification_service.h
index 7d52895..102ceba6 100644
--- a/chrome/browser/ui/global_media_controls/media_notification_service.h
+++ b/chrome/browser/ui/global_media_controls/media_notification_service.h
@@ -151,8 +151,6 @@
   friend class MediaNotificationServiceCastTest;
   friend class MediaToolbarButtonControllerTest;
   friend class ash::GlobalMediaControlsCastStartTest;
-  FRIEND_TEST_ALL_PREFIXES(MediaNotificationServiceCastTest,
-                           CreateCastDialogControllerWithRemotePlayback);
 
   // Instantiates a MediaRouterViewsUI object associated with the Session with
   // the given |session_id|.
@@ -173,6 +171,9 @@
           client_remote,
       std::optional<std::string> remoting_session_id);
 
+  // True if there is a presentation context for the given session ID.
+  bool HasPresentationContextForSession(const std::string& session_id);
+
   // True if there are cast notifications associated with |web_contents|.
   bool HasCastNotificationsForWebContents(
       content::WebContents* web_contents) const;
diff --git a/chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc b/chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc
index 061b546..cf899d52 100644
--- a/chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc
+++ b/chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc
@@ -300,6 +300,13 @@
     return TakeReceiver(remote, /*expected_disconnect_count=*/0);
   }
 
+  bool HasPresentationContext() { return service()->context_ != nullptr; }
+
+  std::unique_ptr<media_router::CastDialogController>
+  CreateCastDialogControllerForSession(const std::string& id) {
+    return service()->CreateCastDialogControllerForSession(id);
+  }
+
  private:
   std::unique_ptr<MockWebContentsPresentationManager> presentation_manager_;
   base::MockCallback<base::OnceClosure> remote_disconnect_handler_;
@@ -593,7 +600,7 @@
   // At this point `session_item` has no RemotePlaybackMetadata and there's no
   // default MediaSource.
   std::unique_ptr<media_router::CastDialogController> controller_presentation =
-      service()->CreateCastDialogControllerForSession(id.ToString());
+      CreateCastDialogControllerForSession(id.ToString());
   std::unique_ptr<media_router::MediaRouteStarter> starter =
       controller_presentation->TakeMediaRouteStarter();
   const auto* query_result_manager = starter->GetQueryResultManagerForTesting();
@@ -609,7 +616,7 @@
 
   std::unique_ptr<media_router::CastDialogController>
       controller_remote_playback =
-          service()->CreateCastDialogControllerForSession(id.ToString());
+          CreateCastDialogControllerForSession(id.ToString());
   starter = controller_remote_playback->TakeMediaRouteStarter();
   query_result_manager = starter->GetQueryResultManagerForTesting();
   const media_router::CastModeSet mode = {
@@ -617,6 +624,120 @@
   EXPECT_EQ(mode, query_result_manager->GetSupportedCastModes());
 }
 
+TEST_F(MediaNotificationServiceCastTest,
+       CreateCastDialogControllerWithMultipleSessions) {
+  // Set up the first WebContents and session.
+  std::unique_ptr<content::WebContents> first_contents(
+      content::RenderViewHostTestHarness::CreateTestWebContents());
+  auto first_id =
+      SimulatePlayingControllableMediaForWebContents(first_contents.get());
+
+  // Set up the second WebContents and session.
+  std::unique_ptr<content::WebContents> second_contents(
+      content::RenderViewHostTestHarness::CreateTestWebContents());
+  auto second_id =
+      SimulatePlayingControllableMediaForWebContents(second_contents.get());
+
+  // The second session initiates a cast request.
+  auto second_request = content::PresentationRequest(
+      second_contents->GetPrimaryMainFrame()->GetGlobalId(), {GURL(), GURL()},
+      url::Origin::Create(GURL()));
+  auto context = CreateStartPresentationContext(second_request);
+
+  // Pass the second session's context to the service.
+  service()->OnStartPresentationContextCreated(std::move(context));
+  EXPECT_TRUE(HasPresentationContext());
+
+  // Try to create a CastDialogController for the first session.
+  // It should NOT consume the context because of origin mismatch.
+  std::unique_ptr<media_router::CastDialogController> controller_first =
+      CreateCastDialogControllerForSession(first_id.ToString());
+  EXPECT_TRUE(HasPresentationContext());
+
+  // Creating it for the second session SHOULD consume the context.
+  std::unique_ptr<media_router::CastDialogController> controller_second =
+      CreateCastDialogControllerForSession(second_id.ToString());
+  EXPECT_FALSE(HasPresentationContext());
+}
+
+TEST_F(MediaNotificationServiceCastTest,
+       GetDeviceListHostForSessionWithMultipleSessions) {
+  // Set up the first WebContents and session.
+  std::unique_ptr<content::WebContents> first_contents(
+      content::RenderViewHostTestHarness::CreateTestWebContents());
+  auto first_id =
+      SimulatePlayingControllableMediaForWebContents(first_contents.get());
+
+  // Set up the second WebContents and session.
+  std::unique_ptr<content::WebContents> second_contents(
+      content::RenderViewHostTestHarness::CreateTestWebContents());
+  auto second_id =
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc b/chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc
index 061b546..cf899d52 100644
--- a/chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc
+++ b/chrome/browser/ui/global_media_controls/media_notification_service_unittest.cc
@@ -300,6 +300,13 @@
     return TakeReceiver(remote, /*expected_disconnect_count=*/0);
   }
 
+  bool HasPresentationContext() { return service()->context_ != nullptr; }
+
+  std::unique_ptr<media_router::CastDialogController>
+  CreateCastDialogControllerForSession(const std::string& id) {
+    return service()->CreateCastDialogControllerForSession(id);
+  }
+
  private:
   std::unique_ptr<MockWebContentsPresentationManager> presentation_manager_;
   base::MockCallback<base::OnceClosure> remote_disconnect_handler_;
@@ -593,7 +600,7 @@
   // At this point `session_item` has no RemotePlaybackMetadata and there's no
   // default MediaSource.
   std::unique_ptr<media_router::CastDialogController> controller_presentation =
-      service()->CreateCastDialogControllerForSession(id.ToString());
+      CreateCastDialogControllerForSession(id.ToString());
   std::unique_ptr<media_router::MediaRouteStarter> starter =
       controller_presentation->TakeMediaRouteStarter();
   const auto* query_result_manager = starter->GetQueryResultManagerForTesting();
@@ -609,7 +616,7 @@
 
   std::unique_ptr<media_router::CastDialogController>
       controller_remote_playback =
-          service()->CreateCastDialogControllerForSession(id.ToString());
+          CreateCastDialogControllerForSession(id.ToString());
   starter = controller_remote_playback->TakeMediaRouteStarter();
   query_result_manager = starter->GetQueryResultManagerForTesting();
   const media_router::CastModeSet mode = {
@@ -617,6 +624,120 @@
   EXPECT_EQ(mode, query_result_manager->GetSupportedCastModes());
 }
 
+TEST_F(MediaNotificationServiceCastTest,
+       CreateCastDialogControllerWithMultipleSessions) {
+  // Set up the first WebContents and session.
+  std::unique_ptr<content::WebContents> first_contents(
+      content::RenderViewHostTestHarness::CreateTestWebContents());
+  auto first_id =
+      SimulatePlayingControllableMediaForWebContents(first_contents.get());
+
+  // Set up the second WebContents and session.
+  std::unique_ptr<content::WebContents> second_contents(
+      content::RenderViewHostTestHarness::CreateTestWebContents());
+  auto second_id =
+      SimulatePlayingControllableMediaForWebContents(second_contents.get());
+
+  // The second session initiates a cast request.
+  auto second_request = content::PresentationRequest(
+      second_contents->GetPrimaryMainFrame()->GetGlobalId(), {GURL(), GURL()},
+      url::Origin::Create(GURL()));
+  auto context = CreateStartPresentationContext(second_request);
+
+  // Pass the second session's context to the service.
+  service()->OnStartPresentationContextCreated(std::move(context));
+  EXPECT_TRUE(HasPresentationContext());
+
+  // Try to create a CastDialogController for the first session.
+  // It should NOT consume the context because of origin mismatch.
+  std::unique_ptr<media_router::CastDialogController> controller_first =
+      CreateCastDialogControllerForSession(first_id.ToString());
+  EXPECT_TRUE(HasPresentationContext());
+
+  // Creating it for the second session SHOULD consume the context.
+  std::unique_ptr<media_router::CastDialogController> controller_second =
+      CreateCastDialogControllerForSession(second_id.ToString());
+  EXPECT_FALSE(HasPresentationContext());
+}
+
+TEST_F(MediaNotificationServiceCastTest,
+       GetDeviceListHostForSessionWithMultipleSessions) {
+  // Set up the first WebContents and session.
+  std::unique_ptr<content::WebContents> first_contents(
+      content::RenderViewHostTestHarness::CreateTestWebContents());
+  auto first_id =
+      SimulatePlayingControllableMediaForWebContents(first_contents.get());
+
+  // Set up the second WebContents and session.
+  std::unique_ptr<content::WebContents> second_contents(
+      content::RenderViewHostTestHarness::CreateTestWebContents());
+  auto second_id =
+      SimulatePlayingControllableMediaForWebContents(second_contents.get());
+
+  // The second session initiates a cast request.
+  auto second_request = content::PresentationRequest(
+      second_contents->GetPrimaryMainFrame()->GetGlobalId(), {GURL(), GURL()},
+      url::Origin::Create(GURL()));
+  auto context = CreateStartPresentationContext(second_request);
+
+  // Pass the second session's context to the service.
+  service()->OnStartPresentationContextCreated(std::move(context));
+  EXPECT_TRUE(HasPresentationContext());
+
+  // Get DeviceListHost for the first session (mismatched presentation context).
+  // It should NOT consume the context.
+  mojo::Remote<mojom::DeviceListHost> host_remote_1;
+  MockDeviceListClient client_1;
+  service()->GetDeviceListHostForSession(
+      first_id.ToString(), TakeReceiverAndExpectNoDisconnect(host_remote_1),
+      TakeRemoteAndExpectNoDisconnect(client_1.receiver()));
+  host_remote_1.FlushForTesting();
+  client_1.receiver().FlushForTesting();
+  EXPECT_TRUE(HasPresentationContext());
+
+  // Get DeviceListHost for the second session (matched presentation context).
+  // It should consume the context.
+  mojo::Remote<mojom::DeviceListHost> host_remote_2;
+  MockDeviceListClient client_2;
+  service()->GetDeviceListHostForSession(
+      second_id.ToString(), TakeReceiverAndExpectNoDisconnect(host_remote_2),
+      TakeRemoteAndExpectNoDisconnect(client_2.receiver()));
+  host_remote_2.FlushForTesting();
+  client_2.receiver().FlushForTesting();
+  EXPECT_FALSE(HasPresentationContext());
+}
+
+TEST_F(MediaNotificationServiceCastTest,
+       OrphanedPresentationContextIsCleanedUp) {
+  std::unique_ptr<content::WebContents> contents(
+      content::RenderViewHostTestHarness::CreateTestWebContents());
+  auto id = SimulatePlayingControllableMediaForWebContents(contents.get());
+
+  auto request = content::PresentationRequest(
+      contents->GetPrimaryMainFrame()->GetGlobalId(), {GURL(), GURL()},
+      url::Origin::Create(GURL()));
+  auto context = CreateStartPresentationContext(request);
+
+  // Pass the context to the service.
+  service()->OnStartPresentationContextCreated(std::move(context));
+  EXPECT_TRUE(HasPresentationContext());
+
+  // Delete the WebContents. This destroys the RenderFrameHost as well.
+  contents.reset();
+
+  // Get DeviceListHost for the session. Since the initiator_rfh is now null
+  // (orphaned), calling GetDeviceListHostForSession should reset the context.
+  mojo::Remote<mojom::DeviceListHost> host_remote;
+  MockDeviceListClient client;
+  service()->GetDeviceListHostForSession(
+      id.ToString(), TakeReceiverAndExpectDisconnect(host_remote),
+      TakeRemoteAndExpectDisconnect(client.receiver()));
+  host_remote.FlushForTesting();
+  client.receiver().FlushForTesting();
+
+  EXPECT_FALSE(HasPresentationContext());
+}
+
 TEST_F(MediaNotificationServiceCastTest, RequestMediaRemoting) {
   service()->OnMediaRemotingRequested("invalid_item_id");
   auto id = base::UnguessableToken::Create();
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential UI spoofing and Cast session hijacking in ChromeOS Global Media Controls

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

Overview: A logic error in MediaNotificationService on ChromeOS allows a pending presentation context to be incorrectly consumed by an unrelated media session. This enables an attacker to hijack the Cast button of a trusted site, tricking the user into casting malicious content while the UI indicates they are casting from the trusted origin.

Affected files:

  • chrome/browser/ui/global_media_controls/media_notification_service.cc
  • chrome/browser/ui/global_media_controls/media_notification_service.h
  • components/global_media_controls/media_item_manager_impl.cc
  • chrome/browser/ui/ash/global_media_controls/media_notification_provider_impl.cc
  • chrome/browser/ui/media_router/media_router_ui.cc
  • chrome/browser/ui/media_router/media_route_starter.cc
  • ash/system/media/media_tray.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Description

A logic error exists in MediaNotificationService where a pending media_router::StartPresentationContext is stored and consumed without verifying that it belongs to the media session currently initializing its Cast controller.

When a website initiates a presentation request via PresentationRequest.start(), the MediaNotificationService::OnStartPresentationContextCreated method stores the resulting context in a single member variable, context_ (chrome/browser/ui/global_media_controls/media_notification_service.cc:451).

On ChromeOS, opening the Global Media Controls (GMC) triggers the creation of a list view that iterates through all active media sessions. The MediaItemManagerImpl::GetActiveItemIds method (components/global_media_controls/media_item_manager_impl.cc:133) sorts these sessions so that actively playing items are placed at the beginning of the list.

For each session in the list, the GMC UI eventually calls MediaNotificationService::CreateCastDialogControllerForSession. This function contains the following logic:

std::unique_ptr<media_router::CastDialogController>
MediaNotificationService::CreateCastDialogControllerForSession(
    const std::string& id) {
  auto* web_contents = content::MediaSession::GetWebContentsFromRequestId(id);
  // ...
  if (context_) {
    return media_router::MediaRouterUI::CreateWithStartPresentationContext(
        web_contents, std::move(context_));
  }
  // ...
}

(chrome/browser/ui/global_media_controls/media_notification_service.cc:514-517)

Because the service does not verify that the id (or the associated web_contents) matches the origin that originally created the context_, the first session processed by the GMC list will consume the pending context. Due to the sorting logic, a victim’s actively playing session (e.g., YouTube) will be processed before an attacker’s paused session, causing the victim’s Cast button to be hijacked by the attacker’s presentation URLs.

Potential Reproduction Steps

  1. Victim Activity: Open a trusted site (e.g., youtube.com) in a ChromeOS tab and start playing a video.
  2. Attacker Activity: Open an attacker-controlled site in another tab. Ensure it has an active media session (e.g., by playing and then pausing a video).
  3. Trigger Hijack: The attacker site executes new PresentationRequest(['https://attacker.com/receiver.html']).start(). This opens the Ash GMC/media tray and sets the global context_ in the browser process.
  4. UI Interaction: The GMC displays cards for both YouTube and the attacker site. The YouTube card correctly identifies its origin as youtube.com.
  5. Execution: The user clicks the Cast button on the YouTube card and selects a device. Because the YouTube card’s controller was the first to consume the attacker’s context_, the attacker’s receiver URL is launched on the device instead of the expected YouTube content.

Impact

This is a Universal Cross-Origin UI Spoofing vulnerability. An attacker can hijack the Cast functionality of any site with an active media session, leading a user to transmit potentially sensitive content or interact with a malicious receiver application while the Chrome UI misleadingly indicates the session is associated with a trusted origin.

Suggested Fix

Modify MediaNotificationService::CreateCastDialogControllerForSession to verify that the web_contents requesting the controller matches the web_contents (or RenderFrameHost) that created the context_. The StartPresentationContext object contains the render_frame_host_id of the initiator, which can be used for this verification.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


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