CVE-2026-79202
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TestGestureSourcechromecast/browser/cast_web_contents_browsertest.cc |
modified | |
ifchromecast/browser/cast_web_contents_browsertest.cc |
modified | |
ifchromecast/external_mojo/public/cpp/external_mojo_broker.cc |
modified |
Files Changed
chromecast/browser/BUILD.gnchromecast/browser/cast_web_contents_browsertest.ccchromecast/external_mojo/public/cpp/external_mojo_broker.ccchromecast/renderer/cast_demo_bindings.cc
Patch
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/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", 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/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();
Regression Test / PoC
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
Original Bug Report
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.ccchromecast/renderer/settings_ui_bindings.ccchromecast/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
-
CastWindowManagerBindings::OnBackGestureInchromecast/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 -
CastWindowManagerBindings::InvokeV8Callback(Both overloads) Inchromecast/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 -
CastDemoBindings::VolumeChangedInchromecast/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 -
SettingsUiBindings::HandleSideSwipeInchromecast/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 -
SettingsUiBindings::SendPlatformInfoInchromecast/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 writeAdditionally, 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.
- Load a same-origin subframe in a Cast application environment that has feature flags (like system gestures or settings UI Mojo) enabled.
- 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 }); - Trigger the corresponding event (e.g., simulating a tap gesture or calling a Gin-bound method that leads to the callback execution).
- During the synchronous callback execution,
frameElement.remove()will triggerRenderFrameImpl::FrameDetached()->delete this, which in turn triggersCastBinding::OnDestruct()->delete thison the binding subclass instance. - When V8 returns control to C++, the subclass code attempts to update its member variables (e.g., moving
v8::UniquePersistentor callingstd::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.