High watch CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Chromecast
DescriptionUse after free in Chromecast
ComponentChromecast
Sub-componentChromecast
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker521285077
Fix commit2991d533dd22 (chromium/src) +141/-42
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-08 (per vendor advisory)
Fix landed2026-07-27 (public git)
Disclosed2026-08-25
Patch gap29 days — fix public in source before the advisory

Background

CastBinding classes (`CastWindowManagerBindings`, `SettingsUiBindings`, `CastDemoBindings`)
Chromecast renderer objects, each a RenderFrameObserver, that expose native callbacks to page JS and self-delete in OnDestruct() when their frame is torn down.
`v8::UniquePersistent<v8::Function>`
a persistent V8 handle stored as a member (e.g. on_back_gesture_callback_, side_swipe_handler_, platform_info_handler_, volume_change_handler_, *callback_function) that holds a page-supplied JS handler.
`handler->Call(...)`
synchronously invokes the page’s JS handler, which can run arbitrary script including detaching the handler’s own frame.
`v8::Local<T>::New(isolate, persistent)`
constructs a local from a persistent by const reference, so a std::move() of the persistent into it does not actually transfer ownership.

Root Cause Analysis

In each affected binding, the gesture/volume/platform-info dispatch method obtained a local handler via v8::Local<v8::Function>::New(isolate, std::move(member_)), called the page JS with handler->Call(...), and then wrote the persistent member back with member_ = v8::UniquePersistent<v8::Function>(isolate, handler). Because the page callback can synchronously detach its own frame, the owning RenderFrameObserver-derived CastBinding is destroyed (via OnDestruct()) during Call(), so the post-Call() member write executes on a freed object — a use-after-free. Compounding this, v8::Local<T>::New() takes the persistent by const reference, so the std::move()/restore was a no-op that served no purpose.

The fix removes the std::move() and drops the post-Call() member write entirely (using std::ignore = handler->Call(...)), so no member of this is touched after script runs; in SettingsUiBindings::Install(), the pending payload is swapped onto a local (std::swap(pending, pending_platform_info_json_)) before SendPlatformInfo(pending) so the member is not accessed after the reentrant call. It additionally adds v8::MicrotasksScope (kDoNotRunMicrotasks) to the affected callbacks to avoid V8 debug-check failures when invoked outside the Blink task runner.

Key insight
The core mistake was writing back a persistent handler member after handler->Call() ran page JS that could destroy this; the fix deletes those post-Call() member writes (and the pointless std::move()), and stages the pending platform-info string on the stack, so no freed-object access occurs.

Attack Path

  1. Register a handler Page JS in a Cast frame registers a gesture/volume/settings callback (e.g. cast.__platform__.windowManager.onTapGesture(...)), stored in a v8::UniquePersistent member of a CastBinding.
  2. Callback detaches its own frame The handler body removes its own frameElement (as in the regression test), which tears down the frame during handler execution.
  3. Binding self-deletes mid-call The RenderFrameObserver-derived CastBinding runs OnDestruct() and is freed while handler->Call(...) is still on the stack.
  4. Use-after-free on member write Pre-patch, control returns and writes member_ = v8::UniquePersistent<...>(isolate, handler) (and, for SettingsUiBindings, pending_platform_info_json_.clear()) on the destroyed CastBinding, corrupting freed memory.
  5. Exploitation The freed-object write could be steered toward controlled corruption in the renderer. (inferred)

Impact Assessment

An attacker controlling a Cast page’s JS gains a renderer-process use-after-free by having a Cast gesture/volume/settings callback detach its own frame, freeing the CastBinding before the method writes back its handler member; the precondition is a Cast context that installs these bindings and dispatches such a callback. The advisory classifies this as use-after-free in Chromecast; no exploited-in-the-wild status is stated, and escalation beyond the freed-memory write (e.g. controlled read/write or code execution) is (inferred).

Changed Functions

FunctionChangeNotes
if
chromecast/external_mojo/public/cpp/external_mojo_broker.cc
modified
TestGestureSource
chromecast/browser/cast_web_contents_browsertest.cc
modified
if
chromecast/browser/cast_web_contents_browsertest.cc
modified

Files Changed

  • chromecast/renderer/settings_ui_bindings.cc
  • chromecast/external_mojo/public/cpp/external_mojo_broker.cc
  • chromecast/browser/cast_web_contents_browsertest.cc
  • chromecast/renderer/cast_demo_bindings.cc
  • chromecast/renderer/cast_window_manager_bindings.cc
  • chromecast/browser/BUILD.gn

Audit Directions

  • State writes after JS callbacks
    Audit renderer bindings that call page-supplied JS and then read or write this members; each such write is a UAF candidate because the callback may detach the frame and self-delete the observer.
  • `RenderFrameObserver` self-deletion
    Review classes that self-delete in OnDestruct() for methods that keep touching members across a reentrant call that can trigger frame teardown.
  • No-op `std::move()` into `v8::Local::New`
    Flag v8::Local<T>::New(isolate, std::move(persistent_)) patterns followed by a restore; the move is meaningless and the restore is exactly the dangerous post-call access.
From 2991d533dd224fa6297a16859a775777d88bd129 Mon Sep 17 00:00:00 2001
From: Simeon Anfinrud <[email protected]>
Date: Mon, 27 Jul 2026 12:46:53 -0700
Subject: [PATCH] [chromecast] Don't access CastBinding members after JS callbacks

CastWindowManagerBindings, SettingsUiBindings and CastDemoBindings
invoke page-supplied JS callbacks and then write back the
v8::UniquePersistent handler member. The callback can detach its own
frame, which deletes the CastBinding (RenderFrameObserver self-deletes
in OnDestruct()), so the post-Call() member write runs on a destroyed
object.

v8::Local<T>::New() takes the persistent by const reference, so the
std::move() / restore was a no-op anyway. Drop both so the handlers
don't touch |this| after running script. Also swap the pending
platform-info payload onto the stack before delivering it in
SetPlatformInfoHandler().

This also means a page that re-registers a handler from inside the
callback now keeps the new handler instead of having it clobbered by the
restore.

Also add v8::MicrotasksScope to InvokeV8Callback overloads and VolumeChanged
callbacks to prevent V8 debug check failures when called outside the Blink
task runner.

Add a cast_shell_browsertests case that dispatches a tap gesture to a
subframe whose handler removes its own frameElement and checks that the
renderer is still responsive afterwards.

Bug: 521285077
Test: cast_shell_browsertests
Change-Id: Iaa8a0d0fbd0a21464f73b9897882310f16411138
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8009010
Auto-Submit: Simeon Anfinrud <[email protected]>
Commit-Queue: Simeon Anfinrud <[email protected]>
Reviewed-by: Shawn Quereshi <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1668893}
---
diff --git a/chromecast/external_mojo/public/cpp/external_mojo_broker.cc b/chromecast/external_mojo/public/cpp/external_mojo_broker.cc
index 58f9f96e..1acedce1 100644
--- a/chromecast/external_mojo/public/cpp/external_mojo_broker.cc
+++ b/chromecast/external_mojo/public/cpp/external_mojo_broker.cc
@@ -17,6 +17,8 @@
 
 #include <optional>
 
+#include "base/files/file_path.h"
+#include "base/files/file_util.h"
 #include "base/functional/bind.h"
 #include "base/functional/callback_helpers.h"
 #include "base/location.h"
@@ -436,6 +438,10 @@
 
   LOG(INFO) << "Initializing external mojo broker at: " << broker_path;
 
+  if (!use_abstract_namespace) {
+    base::DeleteFile(base::FilePath(broker_path));
+  }
+
   mojo::NamedPlatformChannel::Options channel_options;
   channel_options.server_name = broker_path;
   channel_options.use_abstract_namespace = use_abstract_namespace;
diff --git a/chromecast/renderer/cast_demo_bindings.cc b/chromecast/renderer/cast_demo_bindings.cc
index 3e4d999..f5e13c505 100644
--- a/chromecast/renderer/cast_demo_bindings.cc
+++ b/chromecast/renderer/cast_demo_bindings.cc
@@ -332,20 +332,18 @@
   v8::Isolate* isolate = web_frame->GetAgentGroupScheduler()->Isolate();
   v8::HandleScope handle_scope(isolate);
   v8::Local<v8::Context> context = web_frame->MainWorldScriptContext();
+  v8::MicrotasksScope microtasks_scope(
+      context, v8::MicrotasksScope::kDoNotRunMicrotasks);
   v8::Context::Scope context_scope(context);
   v8::Local<v8::Function> handler =
-      v8::Local<v8::Function>::New(isolate, std::move(volume_change_handler_));
+      v8::Local<v8::Function>::New(isolate, volume_change_handler_);
 
   auto args =
       std::to_array<v8::Local<v8::Value>>({gin::ConvertToV8(isolate, level)});
 
-  v8::MaybeLocal<v8::Value> maybe_result =
+  // Running |handler| may delete |this|; do not touch any members afterwards.
+  std::ignore =
       handler->Call(context, context->Global(), args.size(), args.data());
-
-  volume_change_handler_ = v8::UniquePersistent<v8::Function>(isolate, handler);
-
-  v8::Local<v8::Value> result;
-  std::ignore = maybe_result.ToLocal(&result);
 }
 
 void CastDemoBindings::PersistLocalStorage() {
diff --git a/chromecast/renderer/cast_window_manager_bindings.cc b/chromecast/renderer/cast_window_manager_bindings.cc
index 96d6e9c..6e01c00 100644
--- a/chromecast/renderer/cast_window_manager_bindings.cc
+++ b/chromecast/renderer/cast_window_manager_bindings.cc
@@ -229,17 +229,14 @@
   v8::Isolate* isolate = web_frame->GetAgentGroupScheduler()->Isolate();
   v8::HandleScope handle_scope(isolate);
   v8::Local<v8::Context> context = web_frame->MainWorldScriptContext();
+  v8::MicrotasksScope microtasks_scope(
+      context, v8::MicrotasksScope::kDoNotRunMicrotasks);
   v8::Context::Scope context_scope(context);
   v8::Local<v8::Function> handler =
-      v8::Local<v8::Function>::New(isolate, std::move(*callback_function));
+      v8::Local<v8::Function>::New(isolate, *callback_function);
 
-  v8::MaybeLocal<v8::Value> maybe_result =
-      handler->Call(context, context->Global(), 0, nullptr);
-
-  *callback_function = v8::UniquePersistent<v8::Function>(isolate, handler);
-
-  v8::Local<v8::Value> result;
-  std::ignore = maybe_result.ToLocal(&result);
+  // Running |handler| may delete |this|; do not touch any members afterwards.
+  std::ignore = handler->Call(context, context->Global(), 0, nullptr);
 }
 
 void CastWindowManagerBindings::InvokeV8Callback(
@@ -253,22 +250,20 @@
   v8::Isolate* isolate = web_frame->GetAgentGroupScheduler()->Isolate();
   v8::HandleScope handle_scope(isolate);
   v8::Local<v8::Context> context = web_frame->MainWorldScriptContext();
+  v8::MicrotasksScope microtasks_scope(
+      context, v8::MicrotasksScope::kDoNotRunMicrotasks);
   v8::Context::Scope context_scope(context);
   v8::Local<v8::Function> handler =
-      v8::Local<v8::Function>::New(isolate, std::move(*callback_function));
+      v8::Local<v8::Function>::New(isolate, *callback_function);
 
   v8::Local<v8::Number> touch_x = v8::Integer::New(isolate, touch_location.x());
   v8::Local<v8::Number> touch_y = v8::Integer::New(isolate, touch_location.y());
 
   auto args = std::to_array<v8::Local<v8::Value>>({touch_x, touch_y});
 
-  v8::MaybeLocal<v8::Value> maybe_result =
+  // Running |handler| may delete |this|; do not touch any members afterwards.
+  std::ignore =
       handler->Call(context, context->Global(), args.size(), args.data());
-
-  *callback_function = v8::UniquePersistent<v8::Function>(isolate, handler);
-
-  v8::Local<v8::Value> result;
-  std::ignore = maybe_result.ToLocal(&result);
 }
 
 void CastWindowManagerBindings::OnBackGesture(
@@ -285,13 +280,14 @@
   v8::Isolate* isolate = web_frame->GetAgentGroupScheduler()->Isolate();
   v8::HandleScope handle_scope(isolate);
   v8::Local<v8::Context> context = web_frame->MainWorldScriptContext();
+  v8::MicrotasksScope microtasks_scope(
+      context, v8::MicrotasksScope::kDoNotRunMicrotasks);
   v8::Context::Scope context_scope(context);
-  v8::Local<v8::Function> handler = v8::Local<v8::Function>::New(
-      isolate, std::move(on_back_gesture_callback_));
+  v8::Local<v8::Function> handler =
+      v8::Local<v8::Function>::New(isolate, on_back_gesture_callback_);
+  // Running |handler| may delete |this|; do not touch any members afterwards.
   auto result = handler->Call(context, context->Global(), 0, nullptr);
 
-  on_back_gesture_callback_ =
-      v8::UniquePersistent<v8::Function>(isolate, handler);
   if (result.IsEmpty()) {
     LOG(ERROR) << "No value from callback execution; ";
     std::move(callback).Run(false);
diff --git a/chromecast/renderer/settings_ui_bindings.cc b/chromecast/renderer/settings_ui_bindings.cc
index 77e5a60..4c1666c 100644
--- a/chromecast/renderer/settings_ui_bindings.cc
+++ b/chromecast/renderer/settings_ui_bindings.cc
@@ -6,6 +6,7 @@
 
 #include <array>
 #include <tuple>
+#include <utility>
 
 #include "base/check.h"
 #include "base/functional/bind.h"
@@ -52,7 +53,7 @@
       context, v8::MicrotasksScope::kDoNotRunMicrotasks);
   v8::Context::Scope context_scope(context);
   v8::Local<v8::Function> handler =
-      v8::Local<v8::Function>::New(isolate, std::move(side_swipe_handler_));
+      v8::Local<v8::Function>::New(isolate, side_swipe_handler_);
 
   v8::Local<v8::Number> touch_event =
       v8::Integer::New(isolate, static_cast<int>(event));
@@ -64,13 +65,9 @@
   auto args = std::to_array<v8::Local<v8::Value>>(
       {touch_event, touch_origin, touch_x, touch_y});
 
-  v8::MaybeLocal<v8::Value> maybe_result =
+  // Running |handler| may delete |this|; do not touch any members afterwards.
+  std::ignore =
       handler->Call(context, context->Global(), args.size(), args.data());
-
-  side_swipe_handler_ = v8::UniquePersistent<v8::Function>(isolate, handler);
-
-  v8::Local<v8::Value> result;
-  std::ignore = maybe_result.ToLocal(&result);
 }
 
 void SettingsUiBindings::SendPlatformInfo(
@@ -88,7 +85,7 @@
       context, v8::MicrotasksScope::kDoNotRunMicrotasks);
   v8::Context::Scope context_scope(context);
   v8::Local<v8::Function> handler =
-      v8::Local<v8::Function>::New(isolate, std::move(platform_info_handler_));
+      v8::Local<v8::Function>::New(isolate, platform_info_handler_);
 
   v8::Local<v8::String> platform_info =
       v8::String::NewFromUtf8(isolate, platform_info_json.data(),
@@ -97,13 +94,9 @@
 
   auto args = std::to_array<v8::Local<v8::Value>>({platform_info});
 
-  v8::MaybeLocal<v8::Value> maybe_result =
+  // Running |handler| may delete |this|; do not touch any members afterwards.
+  std::ignore =
       handler->Call(context, context->Global(), args.size(), args.data());
-
-  platform_info_handler_ = v8::UniquePersistent<v8::Function>(isolate, handler);
-
-  v8::Local<v8::Value> result;
-  std::ignore = maybe_result.ToLocal(&result);
 }
 
 void SettingsUiBindings::Install(v8::Local<v8::Object> cast_platform,
@@ -136,8 +129,11 @@
   platform_info_handler_ =
       v8::UniquePersistent<v8::Function>(isolate, platform_info_handler);
   if (!pending_platform_info_json_.empty()) {
-    SendPlatformInfo(pending_platform_info_json_);
-    pending_platform_info_json_.clear();
+    std::string pending;
+    std::swap(pending, pending_platform_info_json_);
+    // SendPlatformInfo() runs script and may delete |this|; do not touch any
+    // members afterwards.
+    SendPlatformInfo(pending);
   }
 }
 
diff --git a/chromecast/browser/cast_web_contents_browsertest.cc b/chromecast/browser/cast_web_contents_browsertest.cc
index 7350e44..fadc2fe 100644
--- a/chromecast/browser/cast_web_contents_browsertest.cc
+++ b/chromecast/browser/cast_web_contents_browsertest.cc
@@ -25,6 +25,8 @@
 #include "chromecast/browser/mojom/cast_web_service.mojom.h"
 #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/gesture.mojom.h"
 #include "chromecast/mojo/interface_bundle.h"
 #include "content/public/browser/browser_thread.h"
 #include "content/public/browser/web_contents.h"
@@ -1167,4 +1169,104 @@
   EXPECT_EQ(2u, provider.num_doublers());
 }
 
+// Browser-side mojom::GestureSource which captures the renderer's
+// GestureHandler so the test can dispatch gesture events directly.
+class TestGestureSource : public mojom::GestureSource {
+ public:
+  TestGestureSource() = default;
+  ~TestGestureSource() override = default;
+
+  void Bind(mojo::PendingReceiver<mojom::GestureSource> receiver) {
+    receivers_.Add(this, std::move(receiver));
+  }
+
+  void WaitForSubscribe() {
+    if (handler_) {
+      return;
+    }
+    base::RunLoop run_loop;
+    subscribed_closure_ = run_loop.QuitClosure();
+    run_loop.Run();
+  }
+
+  mojo::Remote<mojom::GestureHandler>& handler() { return handler_; }
+
+  // mojom::GestureSource:
+  void Subscribe(mojo::PendingRemote<mojom::GestureHandler> handler) override {
+    handler_.reset();
+    handler_.Bind(std::move(handler));
+    if (subscribed_closure_) {
+      std::move(subscribed_closure_).Run();
+    }
+  }
+  void SetCanGoBack(bool can_go_back) override {}
+  void SetCanTopDrag(bool can_top_drag) override {}
+  void SetCanRightDrag(bool can_right_drag) override {}
+
+ private:
+  mojo::ReceiverSet<mojom::GestureSource> receivers_;
+  mojo::Remote<mojom::GestureHandler> handler_;
+  base::OnceClosure subscribed_closure_;
+};
+
+IN_PROC_BROWSER_TEST_F(CastWebContentsBrowserTest,
+                       WindowManagerGestureCallbackDetachesFrame) {
+  // ===========================================================================
+  // Test: A page-supplied gesture callback may detach its own frame while it is
+  // running. The renderer must remain in a consistent state once the callback
+  // returns.
+  // ===========================================================================
+  TestGestureSource gesture_source;
+  cast_web_contents_->local_interfaces()->AddBinder(base::BindRepeating(
+      &TestGestureSource::Bind, base::Unretained(&gesture_source)));
+
+  base::DictValue features;
+  features.Set(feature::kEnableSystemGestures, base::DictValue());
+  cast_web_contents_->AddRendererFeatures(std::move(features));
+
+  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();
+
+  ASSERT_TRUE(ExecJs(web_contents_.get(),
+                     "var ifr = document.createElement('iframe');"
+                     "document.body.appendChild(ifr);"));
+  content::RenderFrameHost* child =
+      content::ChildFrameAt(web_contents_.get(), 0);
+  ASSERT_TRUE(child);
+
+  // Wait for the cast.__platform__.windowManager bindings to be installed in
+  // the subframe, register a tap callback that removes the frame and bind the
+  // GestureHandler to the browser-side source.
+  ASSERT_TRUE(ExecJs(child,
+                     "(async () => {"
+                     "  while (!self.cast || !cast.__platform__ ||"
+                     "         !cast.__platform__.windowManager) {"
+                     "    await new Promise(r => setTimeout(r, 0));"
+                     "  }"
+                     "  cast.__platform__.windowManager.onTapGesture("
+                     "      () => { frameElement.remove(); });"
+                     "  cast.__platform__.windowManager.canGoBack(true);"
+                     "})();"));
+  gesture_source.WaitForSubscribe();
+  ASSERT_TRUE(gesture_source.handler().is_bound());
+
+  // Dispatch the gesture; the JS callback synchronously detaches the frame.
+  base::RunLoop disconnect_loop;
+  gesture_source.handler().set_disconnect_handler(
+      disconnect_loop.QuitClosure());
+  gesture_source.handler()->OnTapGesture();
+  disconnect_loop.Run();
+
+  // The main frame's renderer must still be responsive.
+  EXPECT_EQ(true, content::EvalJs(web_contents_.get(),
+                                  "document.querySelector('iframe') === null"));
+}
+
 }  // namespace chromecast
diff --git a/chromecast/browser/BUILD.gn b/chromecast/browser/BUILD.gn
index 0f1a647..3d2e0ea 100644
--- a/chromecast/browser/BUILD.gn
+++ b/chromecast/browser/BUILD.gn
@@ -531,6 +531,7 @@
     "//chromecast/base",
     "//chromecast/base:chromecast_switches",
     "//chromecast/base/metrics",
+    "//chromecast/common:feature_constants",
     "//chromecast/graphics",
     "//chromecast/mojo",
     "//components/keyed_service/content",
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 7350e44..fadc2fe 100644
--- a/chromecast/browser/cast_web_contents_browsertest.cc
+++ b/chromecast/browser/cast_web_contents_browsertest.cc
@@ -25,6 +25,8 @@
 #include "chromecast/browser/mojom/cast_web_service.mojom.h"
 #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/gesture.mojom.h"
 #include "chromecast/mojo/interface_bundle.h"
 #include "content/public/browser/browser_thread.h"
 #include "content/public/browser/web_contents.h"
@@ -1167,4 +1169,104 @@
   EXPECT_EQ(2u, provider.num_doublers());
 }
 
+// Browser-side mojom::GestureSource which captures the renderer's
+// GestureHandler so the test can dispatch gesture events directly.
+class TestGestureSource : public mojom::GestureSource {
+ public:
+  TestGestureSource() = default;
+  ~TestGestureSource() override = default;
+
+  void Bind(mojo::PendingReceiver<mojom::GestureSource> receiver) {
+    receivers_.Add(this, std::move(receiver));
+  }
+
+  void WaitForSubscribe() {
+    if (handler_) {
+      return;
+    }
+    base::RunLoop run_loop;
+    subscribed_closure_ = run_loop.QuitClosure();
+    run_loop.Run();
+  }
+
+  mojo::Remote<mojom::GestureHandler>& handler() { return handler_; }
+
+  // mojom::GestureSource:
+  void Subscribe(mojo::PendingRemote<mojom::GestureHandler> handler) override {
+    handler_.reset();
+    handler_.Bind(std::move(handler));
+    if (subscribed_closure_) {
+      std::move(subscribed_closure_).Run();
+    }
+  }
+  void SetCanGoBack(bool can_go_back) override {}
+  void SetCanTopDrag(bool can_top_drag) override {}
+  void SetCanRightDrag(bool can_right_drag) override {}
+
+ private:
+  mojo::ReceiverSet<mojom::GestureSource> receivers_;
+  mojo::Remote<mojom::GestureHandler> handler_;
+  base::OnceClosure subscribed_closure_;
+};
+
+IN_PROC_BROWSER_TEST_F(CastWebContentsBrowserTest,
+                       WindowManagerGestureCallbackDetachesFrame) {
+  // ===========================================================================
+  // Test: A page-supplied gesture callback may detach its own frame while it is
+  // running. The renderer must remain in a consistent state once the callback
+  // returns.
+  // ===========================================================================
+  TestGestureSource gesture_source;
+  cast_web_contents_->local_interfaces()->AddBinder(base::BindRepeating(
+      &TestGestureSource::Bind, base::Unretained(&gesture_source)));
+
+  base::DictValue features;
+  features.Set(feature::kEnableSystemGestures, base::DictValue());
+  cast_web_contents_->AddRendererFeatures(std::move(features));
+
+  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();
+
+  ASSERT_TRUE(ExecJs(web_contents_.get(),
+                     "var ifr = document.createElement('iframe');"
+                     "document.body.appendChild(ifr);"));
+  content::RenderFrameHost* child =
+      content::ChildFrameAt(web_contents_.get(), 0);
+  ASSERT_TRUE(child);
+
+  // Wait for the cast.__platform__.windowManager bindings to be installed in
+  // the subframe, register a tap callback that removes the frame and bind the
+  // GestureHandler to the browser-side source.
+  ASSERT_TRUE(ExecJs(child,
+                     "(async () => {"
+                     "  while (!self.cast || !cast.__platform__ ||"
+                     "         !cast.__platform__.windowManager) {"
+                     "    await new Promise(r => setTimeout(r, 0));"
+                     "  }"
+                     "  cast.__platform__.windowManager.onTapGesture("
+                     "      () => { frameElement.remove(); });"
+                     "  cast.__platform__.windowManager.canGoBack(true);"
+                     "})();"));
+  gesture_source.WaitForSubscribe();
+  ASSERT_TRUE(gesture_source.handler().is_bound());
+
+  // Dispatch the gesture; the JS callback synchronously detaches the frame.
+  base::RunLoop disconnect_loop;
+  gesture_source.handler().set_disconnect_handler(
+      disconnect_loop.QuitClosure());
+  gesture_source.handler()->OnTapGesture();
+  disconnect_loop.Run();
+
+  // The main frame's renderer must still be responsive.
+  EXPECT_EQ(true, content::EvalJs(web_contents_.get(),
+                                  "document.querySelector('iframe') === null"));
+}
+
 }  // namespace chromecast
Loading diff…

Original Bug Report

reported by [email protected]

Potential Use-After-Free Write in Chromecast CastBinding Subclasses via Synchronous Subframe Detach

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: Multiple Chromecast CastBinding renderer subclasses synchronously invoke page-supplied JavaScript callbacks and subsequently write to their member variables without checking if the instance is still alive. If the callback synchronously detaches its own subframe, the CastBinding instance self-deletes, leading to a heap use-after-free (UAF) write.

Affected files:

  • chromecast/renderer/cast_window_manager_bindings.cc
  • chromecast/renderer/settings_ui_bindings.cc
  • chromecast/renderer/cast_demo_bindings.cc

Estimated timestamp from git blame: 2021-10-12

Detailed Description

There is a potential Use-After-Free (UAF) write vulnerability in several CastBinding subclasses (CastWindowManagerBindings, SettingsUiBindings, and CastDemoBindings) in the Chromecast renderer process.

All CastBinding subclasses are content::RenderFrameObservers that manage their own lifetime. They synchronously self-delete when their associated RenderFrame is destroyed:

// chromecast/renderer/native_bindings_helper.cc
void CastBinding::OnDestruct() {
  delete this;
}

When page-supplied JavaScript registers a callback via these native bindings, the binding saves it as a v8::UniquePersistent<v8::Function>. Upon certain browser events or Mojo triggers, the binding retrieves this persistent handle and synchronously invokes the callback via v8::Function::Call().

However, there are at least five locations in these subclasses where a member of this is modified after the synchronous Call() execution completes without a liveness check. If the running JavaScript callback synchronously detaches its own subframe (e.g., via frameElement.remove()), the RenderFrame is destroyed, which synchronously calls OnDestruct() and deletes the CastBinding instance. When the execution returns to the C++ caller, the subsequent member write results in a heap UAF write.

Vulnerable Instances

  1. CastWindowManagerBindings::OnBackGesture In chromecast/renderer/cast_window_manager_bindings.cc:

    auto result = handler->Call(context, context->Global(), 0, nullptr);
    on_back_gesture_callback_ =
        v8::UniquePersistent<v8::Function>(isolate, handler); // UAF write
    
  2. CastWindowManagerBindings::InvokeV8Callback (Both overloads) In chromecast/renderer/cast_window_manager_bindings.cc:

    v8::MaybeLocal<v8::Value> maybe_result =
        handler->Call(context, context->Global(), args.size(), args.data());
    *callback_function = v8::UniquePersistent<v8::Function>(isolate, handler); // UAF write
    
  3. CastDemoBindings::VolumeChanged In chromecast/renderer/cast_demo_bindings.cc:

    v8::MaybeLocal<v8::Value> maybe_result =
        handler->Call(context, context->Global(), args.size(), args.data());
    volume_change_handler_ = v8::UniquePersistent<v8::Function>(isolate, handler); // UAF write
    
  4. SettingsUiBindings::HandleSideSwipe In chromecast/renderer/settings_ui_bindings.cc:

    v8::MaybeLocal<v8::Value> maybe_result =
        handler->Call(context, context->Global(), args.size(), args.data());
    side_swipe_handler_ = v8::UniquePersistent<v8::Function>(isolate, handler); // UAF write
    
  5. SettingsUiBindings::SendPlatformInfo In chromecast/renderer/settings_ui_bindings.cc:

    v8::MaybeLocal<v8::Value> maybe_result =
        handler->Call(context, context->Global(), args.size(), args.data());
    platform_info_handler_ = v8::UniquePersistent<v8::Function>(isolate, handler); // UAF write
    

    Additionally, if triggered from JS via SetPlatformInfoHandler():

    if (!pending_platform_info_json_.empty()) {
      SendPlatformInfo(pending_platform_info_json_);
      pending_platform_info_json_.clear(); // Second UAF write on freed std::string
    }
    

Potential Trigger Steps

Note: These are suggested steps from manual code review. Our tooling does not currently have the capability to run code to confirm a working Proof of Concept.

  1. Load a same-origin subframe in a Cast application environment that has feature flags (like system gestures or settings UI Mojo) enabled.
  2. In the subframe’s JavaScript context, register a callback with the binding interface, such as:
    cast.__platform__.windowManager.onTapGesture(() => {
      frameElement.remove(); // Synchronously detaches subframe and deletes the binding
    });
    
  3. Trigger the corresponding event (e.g., simulating a tap gesture or calling a Gin-bound method that leads to the callback execution).
  4. During the synchronous callback execution, frameElement.remove() will trigger RenderFrameImpl::FrameDetached() -> delete this, which in turn triggers CastBinding::OnDestruct() -> delete this on the binding subclass instance.
  5. When V8 returns control to C++, the subclass code attempts to update its member variables (e.g., moving v8::UniquePersistent or calling std::string::clear()), corrupting the freed memory space.

Suggested Fix

To safely resolve this, use a weak pointer liveness check before accessing or modifying any member variables of the CastBinding subclass after returning from synchronous JavaScript execution.

Add a base::WeakPtrFactory to the binding subclasses and modify the callbacks to check the liveness of this:

base::WeakPtr<CastWindowManagerBindings> weak_this = weak_factory_.GetWeakPtr();

v8::MaybeLocal<v8::Value> maybe_result =
    handler->Call(context, context->Global(), 0, nullptr);

if (!weak_this) {
  return; // The frame and binding have been deleted
}

*callback_function = v8::UniquePersistent<v8::Function>(isolate, handler);

Evaluated with Chrome root at commit: 3947e01999a53d4e2382e39736cb79d79c7dffcf


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