Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Chromecast
DescriptionUse after free in Chromecast
ComponentChromecast
Sub-componentChromecast
Bug ClassUAF
Tracker497577345
Fix commit220a8b150484 (chromium/src) +168/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
chromecast/browser/cast_browser_interface_binders.cc
modified
for
chromecast/browser/cast_web_contents_browsertest.cc
modified

Files Changed

  • chromecast/browser/cast_web_contents_impl.h
  • chromecast/browser/cast_web_contents_impl.cc
  • chromecast/browser/test/mock_cast_web_view.h
  • chromecast/browser/cast_browser_interface_binders.cc
  • chromecast/browser/cast_web_contents_browsertest.cc
  • chromecast/browser/cast_web_contents.h
  • chromecast/browser/BUILD.gn
From 220a8b15048440395136da6de7d8c26f7d085c3e Mon Sep 17 00:00:00 2001
From: Deepak Ravichandran <deepakr@google.com>
Date: Fri, 25 Sep 2026 12:39:23 -0700
Subject: [PATCH] Gate feature-scoped Cast interface bindings on renderer features

Several interfaces registered in PopulateCastFrameBinders() are only
meant to be reachable by applications that were configured with the
corresponding renderer feature. The renderer installs the matching
bindings conditionally, but the browser process registered the binders
unconditionally, so a Cast web page could request them regardless of how
its application session was configured.

CastWebContents now exposes HasRendererFeature(), and the affected
interfaces are bound through BindFromCastWebContentsWithFeature(), which
drops the receiver when the required feature is absent:

  ActivityWindow    kEnableWindowControls
  GestureSource     kEnableSystemGestures
  SettingsPlatform  kEnableSettingsUiMojo
  CastDemo          kEnableDemoStandaloneMode

Each gate mirrors an existing renderer-side check in
CastWindowManagerBindings or FeatureManager, so correctly configured
applications are unaffected.

Adds CastWebContentsBrowserTest.FeatureGatedInterfacesRequireRendererFeature,
which exercises all four interfaces through the frame binder map via Mojo
JS in both the feature-absent and feature-present cases.

Bug: 497577345
Change-Id: I412bf2dfa43e314fa1d5a9c6d295afde7525517c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8422163
Commit-Queue: Deepak Ravichandran <deepakr@google.com>
Reviewed-by: Matthew Denton <mpdenton@chromium.org>
Reviewed-by: Sandeep Vijayasekar <sandv@google.com>
Cr-Commit-Position: refs/heads/main@{#1705064}
---
diff --git a/chromecast/browser/cast_browser_interface_binders.cc b/chromecast/browser/cast_browser_interface_binders.cc
index 16db902..0cef111 100644
--- a/chromecast/browser/cast_browser_interface_binders.cc
+++ b/chromecast/browser/cast_browser_interface_binders.cc
@@ -4,12 +4,15 @@
 
 #include "chromecast/browser/cast_browser_interface_binders.h"
 
+#include <string_view>
+
 #include "base/functional/bind.h"
 #include "chromecast/browser/application_media_capabilities.h"
 #include "chromecast/browser/application_media_info_manager.h"
 #include "chromecast/browser/audio_socket_broker.h"
 #include "chromecast/browser/cast_navigation_ui_data.h"
 #include "chromecast/browser/cast_web_contents.h"
+#include "chromecast/common/feature_constants.h"
 #include "chromecast/common/mojom/activity_window.mojom.h"
 #include "chromecast/common/mojom/application_media_capabilities.mojom.h"
 #include "chromecast/common/mojom/assistant_messenger.mojom.h"
@@ -36,15 +39,42 @@
                                                        std::move(receiver));
 }
 
+CastWebContents* CastWebContentsFromFrameHost(
+    content::RenderFrameHost* frame_host) {
+  auto* web_contents = content::WebContents::FromRenderFrameHost(frame_host);
+  if (!web_contents) {
+    return nullptr;
+  }
+  return CastWebContents::FromWebContents(web_contents);
+}
+
 template <typename Interface>
 void BindFromCastWebContents(content::RenderFrameHost* frame_host,
                              mojo::PendingReceiver<Interface> receiver) {
-  auto* web_contents = content::WebContents::FromRenderFrameHost(frame_host);
-  if (!web_contents)
+  auto* cast_web_contents = CastWebContentsFromFrameHost(frame_host);
+  if (!cast_web_contents) {
     return;
-  auto* cast_web_contents = CastWebContents::FromWebContents(web_contents);
-  if (!cast_web_contents)
+  }
+  mojo::GenericPendingReceiver generic_receiver(std::move(receiver));
+  cast_web_contents->TryBindReceiver(generic_receiver);
+}
+
+// Similar to BindFromCastWebContents() above, but only routes the receiver if
+// |feature_name| is enabled in the CastWebContents' renderer features.
+template <typename Interface>
+void BindFromCastWebContentsWithFeature(
+    std::string_view feature_name,
+    content::RenderFrameHost* frame_host,
+    mojo::PendingReceiver<Interface> receiver) {
+  auto* cast_web_contents = CastWebContentsFromFrameHost(frame_host);
+  if (!cast_web_contents) {
     return;
+  }
+  if (!cast_web_contents->HasRendererFeature(feature_name)) {
+    DVLOG(1) << "Dropping receiver for " << Interface::Name_
+             << "; required feature " << feature_name << " is not enabled.";
+    return;
+  }
   mojo::GenericPendingReceiver generic_receiver(std::move(receiver));
   cast_web_contents->TryBindReceiver(generic_receiver);
 }
@@ -120,16 +150,21 @@
   binder_map->Add<::media::mojom::Remotee>(
       base::BindRepeating(&BindFromCastWebContents<::media::mojom::Remotee>));
   binder_map->Add<::chromecast::mojom::ActivityWindow>(base::BindRepeating(
-      &BindFromCastWebContents<::chromecast::mojom::ActivityWindow>));
+      &BindFromCastWebContentsWithFeature<::chromecast::mojom::ActivityWindow>,
+      feature::kEnableWindowControls));
   binder_map->Add<
       ::chromecast::mojom::AssistantMessageService>(base::BindRepeating(
       &BindFromCastWebContents<::chromecast::mojom::AssistantMessageService>));
   binder_map->Add<::chromecast::mojom::GestureSource>(base::BindRepeating(
-      &BindFromCastWebContents<::chromecast::mojom::GestureSource>));
-  binder_map->Add<::chromecast::mojom::SettingsPlatform>(base::BindRepeating(
-      &BindFromCastWebContents<::chromecast::mojom::SettingsPlatform>));
+      &BindFromCastWebContentsWithFeature<::chromecast::mojom::GestureSource>,
+      feature::kEnableSystemGestures));
+  binder_map->Add<::chromecast::mojom::SettingsPlatform>(
+      base::BindRepeating(&BindFromCastWebContentsWithFeature<
+                              ::chromecast::mojom::SettingsPlatform>,
+                          feature::kEnableSettingsUiMojo));
   binder_map->Add<mojom::CastDemo>(
-      base::BindRepeating(&BindFromCastWebContents<mojom::CastDemo>));
+      base::BindRepeating(&BindFromCastWebContentsWithFeature<mojom::CastDemo>,
+                          feature::kEnableDemoStandaloneMode));
 
   binder_map->SetDefaultBinderDeprecated(
       base::BindRepeating(&HandleGenericReceiver));
diff --git a/chromecast/browser/cast_web_contents.h b/chromecast/browser/cast_web_contents.h
index 95548e8..bf5da1e85 100644
--- a/chromecast/browser/cast_web_contents.h
+++ b/chromecast/browser/cast_web_contents.h
@@ -268,6 +268,10 @@
   // Utility Methods
   // ===========================================================================
 
+  // Returns whether |feature_name| is enabled in the set of renderer features
+  // configured for this CastWebContents via AddRendererFeatures().
+  virtual bool HasRendererFeature(std::string_view feature_name) const = 0;
+
   // Asks the CastWebContents to bind an interface receiver using either its
   // registry or any registered InterfaceProvider.
   virtual bool TryBindReceiver(mojo::GenericPendingReceiver& receiver) = 0;
diff --git a/chromecast/browser/cast_web_contents_impl.cc b/chromecast/browser/cast_web_contents_impl.cc
index 15186005..95ce71e 100644
--- a/chromecast/browser/cast_web_contents_impl.cc
+++ b/chromecast/browser/cast_web_contents_impl.cc
@@ -236,6 +236,12 @@
   renderer_features_ = std::move(features);
 }
 
+bool CastWebContentsImpl::HasRendererFeature(
+    std::string_view feature_name) const {
+  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+  return renderer_features_.contains(feature_name);
+}
+
 void CastWebContentsImpl::SetInterfacesForRenderer(
     mojo::PendingRemote<mojom::RemoteInterfaces> remote_interfaces) {
   remote_interfaces_.SetProvider(std::move(remote_interfaces));
diff --git a/chromecast/browser/cast_web_contents_impl.h b/chromecast/browser/cast_web_contents_impl.h
index 9b72828..0b3c28a 100644
--- a/chromecast/browser/cast_web_contents_impl.h
+++ b/chromecast/browser/cast_web_contents_impl.h
@@ -83,6 +83,7 @@
   void ClosePage() override;
   void Stop(int error_code) override;
   void SetWebVisibilityAndPaint(bool visible) override;
+  bool HasRendererFeature(std::string_view feature_name) const override;
   bool TryBindReceiver(mojo::GenericPendingReceiver& receiver) override;
   InterfaceBundle* local_interfaces() override;
   void BlockMediaLoading(bool blocked) override;
diff --git a/chromecast/browser/cast_web_contents_browsertest.cc b/chromecast/browser/cast_web_contents_browsertest.cc
index fadc2fe..b27bff3f 100644
--- a/chromecast/browser/cast_web_contents_browsertest.cc
+++ b/chromecast/browser/cast_web_contents_browsertest.cc
@@ -26,7 +26,10 @@
 #include "chromecast/browser/test/cast_browser_test.h"
 #include "chromecast/browser/test_interfaces.test-mojom.h"
 #include "chromecast/common/feature_constants.h"
+#include "chromecast/common/mojom/activity_window.mojom.h"
+#include "chromecast/common/mojom/cast_demo.mojom.h"
 #include "chromecast/common/mojom/gesture.mojom.h"
+#include "chromecast/common/mojom/settings_ui.mojom.h"
 #include "chromecast/mojo/interface_bundle.h"
 #include "content/public/browser/browser_thread.h"
 #include "content/public/browser/web_contents.h"
@@ -1269,4 +1272,112 @@
                                   "document.querySelector('iframe') === null"));
 }
 
+IN_PROC_BROWSER_TEST_F(CastWebContentsBrowserTest,
+                       FeatureGatedInterfacesRequireRendererFeature) {
+  // ===========================================================================
+  // Test: PopulateCastFrameBinders() registers several interfaces behind a
+  // renderer feature. The browser must drop incoming receivers for those
+  // interfaces unless the corresponding feature was configured via
+  // AddRendererFeatures(), mirroring the renderer-side checks in
+  // FeatureManager and CastWindowManagerBindings.
+  //
+  // Requests are issued from the page through Mojo JS so that they travel the
+  // real frame binder map. Calling TryBindReceiver() directly would bypass the
+  // feature gate entirely and pass regardless of this CL.
+  // ===========================================================================
+  int num_activity_window = 0;
+  int num_gesture_source = 0;
+  int num_settings_platform = 0;
+  int num_cast_demo = 0;
+
+  auto* bundle = cast_web_contents_->local_interfaces();
+  bundle->AddBinder<::chromecast::mojom::ActivityWindow>(
+      base::BindLambdaForTesting(
+          [&](mojo::PendingReceiver<::chromecast::mojom::ActivityWindow>) {
+            ++num_activity_window;
+          }));
+  bundle->AddBinder<::chromecast::mojom::GestureSource>(
+      base::BindLambdaForTesting(
+          [&](mojo::PendingReceiver<::chromecast::mojom::GestureSource>) {
+            ++num_gesture_source;
+          }));
+  bundle->AddBinder<::chromecast::mojom::SettingsPlatform>(
+      base::BindLambdaForTesting(
+          [&](mojo::PendingReceiver<::chromecast::mojom::SettingsPlatform>) {
+            ++num_settings_platform;
+          }));
+  bundle->AddBinder<::chromecast::shell::mojom::CastDemo>(
+      base::BindLambdaForTesting(
+          [&](mojo::PendingReceiver<::chromecast::shell::mojom::CastDemo>) {
+            ++num_cast_demo;
+          }));
+
+  // TestAdder is not in PopulateCastFrameBinders(), so it reaches the bundle
+  // through the ungated default binder. It is requested last and serves as a
+  // barrier: frame interface requests share one pipe and are dispatched in
+  // order, so once it arrives every gated request before it has been handled.
+  base::RunLoop canary_loop;
+  bundle->AddBinder<mojom::TestAdder>(base::BindLambdaForTesting(
+      [&](mojo::PendingReceiver<mojom::TestAdder>) { canary_loop.Quit(); }));
+
+  run_loop_ = std::make_unique<base::RunLoop>();
+  {
+    InSequence seq;
+    EXPECT_CALL(mock_cast_wc_observer_, PageStateChanged(PageState::LOADING));
+    EXPECT_CALL(mock_cast_wc_observer_, PageStateChanged(PageState::LOADED))
+        .WillOnce(InvokeWithoutArgs([&]() { QuitRunLoop(); }));
+  }
+  cast_web_contents_->LoadUrl(GURL(url::kAboutBlankURL));
+  run_loop_->Run();
+
+  constexpr const char* kGatedInterfaces[] = {
+      "chromecast.mojom.ActivityWindow",
+      "chromecast.mojom.GestureSource",
+      "chromecast.mojom.SettingsPlatform",
+      "chromecast.shell.mojom.CastDemo",
+  };
+
+  // No renderer features are configured yet, so every receiver must be
+  // dropped by BindFromCastWebContentsWithFeature().
+  for (const char* interface_name : kGatedInterfaces) {
+    ASSERT_TRUE(
+        ExecJs(web_contents_.get(),
+               content::JsReplace(
+                   "Mojo.bindInterface($1, Mojo.createMessagePipe().handle0);",
+                   interface_name)));
+  }
+
+  // Enable all four features, then request each interface again.
+  base::DictValue features;
+  features.Set(feature::kEnableWindowControls, base::DictValue());
+  features.Set(feature::kEnableSystemGestures, base::DictValue());
+  features.Set(feature::kEnableSettingsUiMojo, base::DictValue());
+  features.Set(feature::kEnableDemoStandaloneMode, base::DictValue());
+  cast_web_contents_->AddRendererFeatures(std::move(features));
+
+  for (const char* interface_name : kGatedInterfaces) {
+    ASSERT_TRUE(
+        ExecJs(web_contents_.get(),
+               content::JsReplace(
+                   "Mojo.bindInterface($1, Mojo.createMessagePipe().handle0);",
+                   interface_name)));
+  }
+
+  // Wait for the canary rather than for the gated binds themselves. Waiting
+  // for four binds could be satisfied by the first batch alone if the gate were
+  // broken, letting the assertions below run before the second batch arrives.
+  ASSERT_TRUE(ExecJs(
+      web_contents_.get(),
+      "Mojo.bindInterface('chromecast.mojom.TestAdder', "
+      "Mojo.createMessagePipe().handle0);"));
+  canary_loop.Run();
+
+  // Exactly one bind per interface: the gated request was dropped, the enabled
+  // one was routed. A broken gate yields 2, an over-eager one yields 0.
+  EXPECT_EQ(1, num_activity_window);
+  EXPECT_EQ(1, num_gesture_source);
+  EXPECT_EQ(1, num_settings_platform);
+  EXPECT_EQ(1, num_cast_demo);
+}
+
 }  // namespace chromecast
diff --git a/chromecast/browser/test/mock_cast_web_view.h b/chromecast/browser/test/mock_cast_web_view.h
index 11e5693b..b2960fa 100644
--- a/chromecast/browser/test/mock_cast_web_view.h
+++ b/chromecast/browser/test/mock_cast_web_view.h
@@ -30,6 +30,7 @@
               (),
               (const override));
   MOCK_METHOD(void, AddRendererFeatures, (base::DictValue), (override));
+  MOCK_METHOD(bool, HasRendererFeature, (std::string_view), (const, override));
   MOCK_METHOD(void,
               SetInterfacesForRenderer,
               (mojo::PendingRemote<mojom::RemoteInterfaces>),
diff --git a/chromecast/browser/BUILD.gn b/chromecast/browser/BUILD.gn
index 767e826..41e89250 100644
--- a/chromecast/browser/BUILD.gn
+++ b/chromecast/browser/BUILD.gn
@@ -212,6 +212,7 @@
     "//chromecast/common",
     "//chromecast/common:activity_url_filter",
     "//chromecast/common:cors_exempt_headers",
+    "//chromecast/common:feature_constants",
     "//chromecast/common:queryable_data",
     "//chromecast/common/media",
     "//chromecast/external_mojo/broker_service",
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chromecast/browser/cast_web_contents_browsertest.cc b/chromecast/browser/cast_web_contents_browsertest.cc
index fadc2fe..b27bff3f 100644
--- a/chromecast/browser/cast_web_contents_browsertest.cc
+++ b/chromecast/browser/cast_web_contents_browsertest.cc
@@ -26,7 +26,10 @@
 #include "chromecast/browser/test/cast_browser_test.h"
 #include "chromecast/browser/test_interfaces.test-mojom.h"
 #include "chromecast/common/feature_constants.h"
+#include "chromecast/common/mojom/activity_window.mojom.h"
+#include "chromecast/common/mojom/cast_demo.mojom.h"
 #include "chromecast/common/mojom/gesture.mojom.h"
+#include "chromecast/common/mojom/settings_ui.mojom.h"
 #include "chromecast/mojo/interface_bundle.h"
 #include "content/public/browser/browser_thread.h"
 #include "content/public/browser/web_contents.h"
@@ -1269,4 +1272,112 @@
                                   "document.querySelector('iframe') === null"));
 }
 
+IN_PROC_BROWSER_TEST_F(CastWebContentsBrowserTest,
+                       FeatureGatedInterfacesRequireRendererFeature) {
+  // ===========================================================================
+  // Test: PopulateCastFrameBinders() registers several interfaces behind a
+  // renderer feature. The browser must drop incoming receivers for those
+  // interfaces unless the corresponding feature was configured via
+  // AddRendererFeatures(), mirroring the renderer-side checks in
+  // FeatureManager and CastWindowManagerBindings.
+  //
+  // Requests are issued from the page through Mojo JS so that they travel the
+  // real frame binder map. Calling TryBindReceiver() directly would bypass the
+  // feature gate entirely and pass regardless of this CL.
+  // ===========================================================================
+  int num_activity_window = 0;
+  int num_gesture_source = 0;
+  int num_settings_platform = 0;
+  int num_cast_demo = 0;
+
+  auto* bundle = cast_web_contents_->local_interfaces();
+  bundle->AddBinder<::chromecast::mojom::ActivityWindow>(
+      base::BindLambdaForTesting(
+          [&](mojo::PendingReceiver<::chromecast::mojom::ActivityWindow>) {
+            ++num_activity_window;
+          }));
+  bundle->AddBinder<::chromecast::mojom::GestureSource>(
+      base::BindLambdaForTesting(
+          [&](mojo::PendingReceiver<::chromecast::mojom::GestureSource>) {
+            ++num_gesture_source;
+          }));
+  bundle->AddBinder<::chromecast::mojom::SettingsPlatform>(
+      base::BindLambdaForTesting(
+          [&](mojo::PendingReceiver<::chromecast::mojom::SettingsPlatform>) {
+            ++num_settings_platform;
+          }));
+  bundle->AddBinder<::chromecast::shell::mojom::CastDemo>(
+      base::BindLambdaForTesting(
+          [&](mojo::PendingReceiver<::chromecast::shell::mojom::CastDemo>) {
+            ++num_cast_demo;
+          }));
+
+  // TestAdder is not in PopulateCastFrameBinders(), so it reaches the bundle
+  // through the ungated default binder. It is requested last and serves as a
+  // barrier: frame interface requests share one pipe and are dispatched in
+  // order, so once it arrives every gated request before it has been handled.
+  base::RunLoop canary_loop;
+  bundle->AddBinder<mojom::TestAdder>(base::BindLambdaForTesting(
+      [&](mojo::PendingReceiver<mojom::TestAdder>) { canary_loop.Quit(); }));
+
+  run_loop_ = std::make_unique<base::RunLoop>();
+  {
+    InSequence seq;
+    EXPECT_CALL(mock_cast_wc_observer_, PageStateChanged(PageState::LOADING));
+    EXPECT_CALL(mock_cast_wc_observer_, PageStateChanged(PageState::LOADED))
+        .WillOnce(InvokeWithoutArgs([&]() { QuitRunLoop(); }));
+  }
+  cast_web_contents_->LoadUrl(GURL(url::kAboutBlankURL));
+  run_loop_->Run();
+
+  constexpr const char* kGatedInterfaces[] = {
+      "chromecast.mojom.ActivityWindow",
+      "chromecast.mojom.GestureSource",
+      "chromecast.mojom.SettingsPlatform",
+      "chromecast.shell.mojom.CastDemo",
+  };
+
+  // No renderer features are configured yet, so every receiver must be
+  // dropped by BindFromCastWebContentsWithFeature().
+  for (const char* interface_name : kGatedInterfaces) {
+    ASSERT_TRUE(
+        ExecJs(web_contents_.get(),
+               content::JsReplace(
+                   "Mojo.bindInterface($1, Mojo.createMessagePipe().handle0);",
+                   interface_name)));
+  }
+
+  // Enable all four features, then request each interface again.
+  base::DictValue features;
+  features.Set(feature::kEnableWindowControls, base::DictValue());
+  features.Set(feature::kEnableSystemGestures, base::DictValue());
+  features.Set(feature::kEnableSettingsUiMojo, base::DictValue());
+  features.Set(feature::kEnableDemoStandaloneMode, base::DictValue());
+  cast_web_contents_->AddRendererFeatures(std::move(features));
+
+  for (const char* interface_name : kGatedInterfaces) {
+    ASSERT_TRUE(
+        ExecJs(web_contents_.get(),
+               content::JsReplace(
+                   "Mojo.bindInterface($1, Mojo.createMessagePipe().handle0);",
+                   interface_name)));
+  }
+
+  // Wait for the canary rather than for the gated binds themselves. Waiting
+  // for four binds could be satisfied by the first batch alone if the gate were
+  // broken, letting the assertions below run before the second batch arrives.
+  ASSERT_TRUE(ExecJs(
+      web_contents_.get(),
+      "Mojo.bindInterface('chromecast.mojom.TestAdder', "
+      "Mojo.createMessagePipe().handle0);"));
+  canary_loop.Run();
+
+  // Exactly one bind per interface: the gated request was dropped, the enabled
+  // one was routed. A broken gate yields 2, an over-eager one yields 0.
+  EXPECT_EQ(1, num_activity_window);
+  EXPECT_EQ(1, num_gesture_source);
+  EXPECT_EQ(1, num_settings_platform);
+  EXPECT_EQ(1, num_cast_demo);
+}
+
 }  // namespace chromecast
diff --git a/chromecast/browser/test/mock_cast_web_view.h b/chromecast/browser/test/mock_cast_web_view.h
index 11e5693b..b2960fa 100644
--- a/chromecast/browser/test/mock_cast_web_view.h
+++ b/chromecast/browser/test/mock_cast_web_view.h
@@ -30,6 +30,7 @@
               (),
               (const override));
   MOCK_METHOD(void, AddRendererFeatures, (base::DictValue), (override));
+  MOCK_METHOD(bool, HasRendererFeature, (std::string_view), (const, override));
   MOCK_METHOD(void,
               SetInterfacesForRenderer,
               (mojo::PendingRemote<mojom::RemoteInterfaces>),
Loading diff…

Original Bug Report

reported by vm...@google.com

Sandbox Escape: Missing authorization for CastDemo Mojo interface allows MITM

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The chromecast.shell.mojom.CastDemo interface is unconditionally exposed to all Cast renderers by the browser process. A compromised renderer can bypass renderer-side feature flag checks and directly request this sensitive interface, which is then forwarded without authorization to the privileged Cast Service. This potentially allows an attacker to reconfigure the device’s Wi-Fi network and execute a persistent Man-in-the-Middle (MITM) attack.

Affected files:

  • chromecast/browser/cast_browser_interface_binders.cc
  • chromecast/browser/cast_web_contents_impl.cc

Estimated timestamp from git blame: 2022-07-18

Summary

On Chromecast, the browser process registers a binder for the chromecast.shell.mojom.CastDemo interface unconditionally for every render frame. This interface provides sensitive methods, such as SetWifiCredentials, intended only for retail store demonstration mode. While legitimate access is controlled by a renderer-side feature flag check (kEnableDemoStandaloneMode in feature_manager.cc), a compromised renderer with native code execution can bypass this check by directly requesting the interface via the BrowserInterfaceBroker. The browser process fails to authorize the request before forwarding it to the highly-privileged Cast Service, potentially enabling a full sandbox escape and persistent device compromise.

Technical Details

  1. Unconditional Binder Registration: In chromecast/browser/cast_content_browser_client_receiver_bindings.cc:113-117, RegisterBrowserInterfaceBindersForFrame calls PopulateCastFrameBinders for every newly created render frame. Inside chromecast/browser/cast_browser_interface_binders.cc:131-132, a binder for mojom::CastDemo is added unconditionally, mapping it to the BindFromCastWebContents helper.

  2. Missing Browser-Side Authorization: When a compromised renderer directly requests the CastDemo interface, the request reaches BindFromCastWebContents (cast_browser_interface_binders.cc:39-50). This function retrieves the CastWebContents object for the app session but crucially fails to verify if the session is authorized (e.g., by checking cast_web_contents->renderer_features_ for the kEnableDemoStandaloneMode flag). Instead, it unconditionally passes the request to cast_web_contents->TryBindReceiver().

  3. Forwarding to the Cast Service: In CastWebContentsImpl::TryBindReceiver (chromecast/browser/cast_web_contents_impl.cc:453-471), the method first attempts to bind the receiver locally. Since CastDemo is not a browser-side local interface, it falls back to forwarding the request to remote_interfaces_.Bind(std::move(receiver)).

  4. Security Contract Violation: The remote_interfaces_ object proxies requests to the closed-source, highly-privileged Cast Service. Forwarding the untrusted renderer’s receiver directly violates the documented security contract in cast_web_contents.mojom:103-105: “Sensitive APIs should never be exposed through |remote_interfaces|.”

Impact

An attacker who has compromised a standard Chromecast renderer process (e.g., via a V8 or Blink vulnerability) can establish a direct Mojo connection to the privileged Cast Service. They can then invoke sensitive methods like SetWifiCredentials("attacker-ssid", "attacker-psk") (chromecast/common/mojom/cast_demo.mojom:62). This forces the device onto an attacker-controlled network, resulting in a persistent Man-in-the-Middle (MITM) attack over all subsequent device traffic. Other sensitive methods, such as GetAvailableWifiNetworks (location reconnaissance) and GetConnectionStatus (information leakage), are also exposed.

Suggested Reproduction Steps

Note: These are potential steps based on code analysis; our tooling agent cannot execute arbitrary code to produce a working exploit.

  1. Exploit a memory safety vulnerability to gain arbitrary native code execution within a standard Cast app renderer process.
  2. Construct a payload that interacts with the Mojo IPC system. Create a mojo::Remote<chromecast::shell::mojom::CastDemo>.
  3. Request the interface from the browser process: render_frame_host->GetBrowserInterfaceBroker().GetInterface(remote.BindNewPipeAndPassReceiver()).
  4. Once the remote is connected to the Cast Service, invoke the sensitive API: remote->SetWifiCredentials("attacker-ssid", "attacker-psk").
  5. Observe the Chromecast device disconnecting from its legitimate network and associating with the attacker’s access point.

Suggested Fix

The browser process must validate that the requesting renderer is authorized before binding or forwarding the CastDemo interface.

In BindFromCastWebContents (or a dedicated binder function for CastDemo), verify that the CastWebContents object possesses the kEnableDemoStandaloneMode feature flag in its renderer_features_ dictionary before passing the receiver to TryBindReceiver. Alternatively, do not unconditionally register the CastDemo binder in PopulateCastFrameBinders; instead, register it conditionally based on the app session’s properties.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker