CVE-2026-87588
Overview
Background
- `CastActivityUrlFilterManager`
- a Chromecast renderer-process object that owns
activity_url_filters_, abase::flat_mapmapping eachblink::LocalFrameTokento aUrlFilterReceiver*that supplies the frame’sActivityUrlFilter. - `base::flat_map`
- an associative container backed by a single sorted contiguous vector, so an
emplace()orerase()can reallocate or shift its storage and invalidate any iterator or pointer another thread is dereferencing. - `CastURLLoaderThrottleProvider` / `CastWebSocketHandshakeThrottleProvider`
- throttle providers that are cloned onto worker threads and call
GetActivityUrlFilterForRenderFrameToken()off the main thread while the main thread creates and destroys frames. - `raw_ptr` / `GUARDED_BY`
- a checked smart pointer (MiraclePtr/BackupRefPtr) and a Clang thread-safety annotation, respectively, used to harden the map’s stored pointers and to assert the map is only touched under
filters_lock_.
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: Map holds raw UrlFilterReceiver* (diff), so erase() does not destroy the receiver. Lock only guards map access; returned pointer is used after unlock, so 'no dangling pointer' overstates the fix. Shown race is unsynchronized find vs emplace/erase.
GetActivityUrlFilterForRenderFrameToken() performed activity_url_filters_.find(frame_token) with no synchronization, and it is invoked from worker threads by the cloned throttle providers. Concurrently, the main thread mutated the same base::flat_map via the insertion path (emplace) and via OnRenderFrameRemoved() (erase), which reallocates/shifts the backing vector and destroys the removed UrlFilterReceiver. Because no lock serialized these accesses, a worker-thread lookup could observe a partially-mutated map or return a pointer into storage that had been reallocated or into a UrlFilterReceiver/ActivityUrlFilter that was being freed — a data race yielding a use-after-free.
The fix adds filters_lock_ and takes base::AutoLock in the lookup, the insertion path, and OnRenderFrameRemoved(), so every read and write of the map is mutually exclusive, and it stores entries as raw_ptr<UrlFilterReceiver> under a GUARDED_BY(filters_lock_) annotation for defense in depth and static enforcement. This works because the lookup can no longer run while the map’s storage is being resized or an entry destroyed.
base::Lock guarding all three map accesses removes the race that let a lookup return a dangling pointer.Attack Path
- Frame lifecycle churn on the main thread
The renderer creates and destroys frames, invoking the map’s insertion path and
OnRenderFrameRemoved(), whichemplace/eraseonactivity_url_filters_and free the associatedUrlFilterReceiver. - Concurrent lookup from a worker thread
A cloned
CastURLLoaderThrottleProviderorCastWebSocketHandshakeThrottleProvidercallsGetActivityUrlFilterForRenderFrameToken()off-thread, doing a lock-freefind()on the same map. - Race window hit The lookup runs while the backing vector is being reallocated/shifted or an entry destroyed, so it reads torn state or returns a pointer to freed memory.
- Use-after-free
The returned
ActivityUrlFilter*(or the destroyedUrlFilterReceiver) is dereferenced after free, corrupting renderer-process memory. (inferred: attacker control over the freed-then-reused allocation to turn this into a read/write primitive.)
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
UrlFilterReceiverchromecast/renderer/cast_activity_url_filter_manager.h |
modified | |
CastActivityUrlFilterManagerTestchromecast/renderer/cast_activity_url_filter_manager_unittest.cc |
modified | |
LookupRunnerchromecast/renderer/cast_activity_url_filter_manager_unittest.cc |
modified | |
TEST_Fchromecast/renderer/cast_activity_url_filter_manager_unittest.cc |
modified | |
forchromecast/renderer/cast_activity_url_filter_manager_unittest.cc |
modified | |
ifchromecast/BUILD.gn |
modified | |
cast_source_setchromecast/renderer/BUILD.gn |
modified | |
testchromecast/renderer/BUILD.gn |
modified |
Files Changed
chromecast/renderer/BUILD.gnchromecast/renderer/cast_activity_url_filter_manager.ccchromecast/BUILD.gnchromecast/renderer/cast_activity_url_filter_manager_unittest.ccchromecast/renderer/cast_activity_url_filter_manager.h
Audit Directions
- Lock-free reads of main-thread-owned containers from cloned throttle providersaudit other
*ThrottleProviderclones and worker-thread callbacks that read renderer-side maps/lists (e.g. token-keyed lookups) without holding a lock. - `base::flat_map`/vector-backed containers shared across threadsflag any such container mutated on one thread and read on another, since realloc/shift invalidates pointers and iterators; require a
base::LockwithGUARDED_BYor thread-affinity confinement. - Raw pointers stored in cross-thread containerslook for maps holding raw
Foo*whose pointees are destroyed on removal; preferraw_ptr/WeakPtrand verify the destruction is serialized against every reader.
Patch
From b122b4a0d4587946fd2bffdd5b7c72479d5668e5 Mon Sep 17 00:00:00 2001
From: Richard Nichols <rknichols@google.com>
Date: Tue, 28 Jul 2026 07:23:01 -0700
Subject: [PATCH] [chromecast] Make CastActivityUrlFilterManager thread-safe
CastURLLoaderThrottleProvider and CastWebSocketHandshakeThrottleProvider
are cloned to worker threads where they call
GetActivityUrlFilterForRenderFrameToken() while the main thread adds and
removes entries via OnRenderFrameCreated() and OnRenderFrameRemoved().
Guard the filter map with a lock so lookups never observe a map that is
being mutated, and hold the receivers via raw_ptr.
Adds a cast_renderer_unittests target with coverage for the concurrent
lookup path.
Bug: 495515356
Change-Id: Idedffe49e1849b521c465396611d40e3f1accca3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8108180
Reviewed-by: Shawn Quereshi <shawnq@google.com>
Reviewed-by: Simeon Anfinrud <sanfin@chromium.org>
Commit-Queue: Richard Nichols <rknichols@google.com>
Cr-Commit-Position: refs/heads/main@{#1669459}
---
diff --git a/chromecast/renderer/cast_activity_url_filter_manager.cc b/chromecast/renderer/cast_activity_url_filter_manager.cc
index ef750b8..e484b63 100644
--- a/chromecast/renderer/cast_activity_url_filter_manager.cc
+++ b/chromecast/renderer/cast_activity_url_filter_manager.cc
@@ -74,7 +74,8 @@
ActivityUrlFilter*
CastActivityUrlFilterManager::GetActivityUrlFilterForRenderFrameToken(
const blink::LocalFrameToken& frame_token) {
- const auto& it = activity_url_filters_.find(frame_token);
+ base::AutoLock lock(filters_lock_);
+ auto it = activity_url_filters_.find(frame_token);
if (it == activity_url_filters_.end())
return nullptr;
@@ -91,6 +92,7 @@
base::BindOnce(&CastActivityUrlFilterManager::OnRenderFrameRemoved,
weak_this_, frame_token));
+ base::AutoLock lock(filters_lock_);
auto result = activity_url_filters_.emplace(frame_token, filter_receiver);
if (!result.second)
@@ -101,7 +103,8 @@
void CastActivityUrlFilterManager::OnRenderFrameRemoved(
const blink::LocalFrameToken& frame_token) {
- const auto& it = activity_url_filters_.find(frame_token);
+ base::AutoLock lock(filters_lock_);
+ auto it = activity_url_filters_.find(frame_token);
if (it != activity_url_filters_.end())
activity_url_filters_.erase(it);
diff --git a/chromecast/renderer/cast_activity_url_filter_manager.h b/chromecast/renderer/cast_activity_url_filter_manager.h
index 948bb60..4ed10951 100644
--- a/chromecast/renderer/cast_activity_url_filter_manager.h
+++ b/chromecast/renderer/cast_activity_url_filter_manager.h
@@ -9,6 +9,9 @@
#include <string>
#include "base/containers/flat_map.h"
+#include "base/memory/raw_ptr.h"
+#include "base/synchronization/lock.h"
+#include "base/thread_annotations.h"
#include "chromecast/common/activity_url_filter.h"
#include "chromecast/common/mojom/activity_url_filter.mojom.h"
#include "content/public/renderer/render_frame_observer.h"
@@ -33,6 +36,7 @@
~CastActivityUrlFilterManager();
// Returns nullptr if no Activity URL filter exists for the render frame.
+ // May be called from any thread.
ActivityUrlFilter* GetActivityUrlFilterForRenderFrameToken(
const blink::LocalFrameToken& frame_token);
@@ -40,6 +44,8 @@
void OnRenderFrameRemoved(const blink::LocalFrameToken& frame_token);
private:
+ friend class CastActivityUrlFilterManagerTest;
+
class UrlFilterReceiver
: public content::RenderFrameObserver,
public chromecast::mojom::ActivityUrlFilterConfiguration {
@@ -82,8 +88,9 @@
base::WeakPtrFactory<UrlFilterReceiver> weak_factory_;
};
- base::flat_map<blink::LocalFrameToken, UrlFilterReceiver*>
- activity_url_filters_;
+ base::Lock filters_lock_;
+ base::flat_map<blink::LocalFrameToken, raw_ptr<UrlFilterReceiver>>
+ activity_url_filters_ GUARDED_BY(filters_lock_);
base::WeakPtr<CastActivityUrlFilterManager> weak_this_;
base::WeakPtrFactory<CastActivityUrlFilterManager> weak_factory_;
diff --git a/chromecast/renderer/cast_activity_url_filter_manager_unittest.cc b/chromecast/renderer/cast_activity_url_filter_manager_unittest.cc
new file mode 100644
index 0000000..4dc89b4
--- /dev/null
+++ b/chromecast/renderer/cast_activity_url_filter_manager_unittest.cc
@@ -0,0 +1,95 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "chromecast/renderer/cast_activity_url_filter_manager.h"
+
+#include <atomic>
+#include <vector>
+
+#include "base/memory/raw_ptr.h"
+#include "base/synchronization/lock.h"
+#include "base/synchronization/waitable_event.h"
+#include "base/thread_annotations.h"
+#include "base/threading/simple_thread.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/tokens/tokens.h"
+
+namespace chromecast {
+
+class CastActivityUrlFilterManagerTest : public testing::Test {
+ protected:
+ void AddFilterForToken(const blink::LocalFrameToken& token)
+ NO_THREAD_SAFETY_ANALYSIS {
+ base::AutoLock lock(manager_.filters_lock_);
+ manager_.activity_url_filters_.emplace(token, nullptr);
+ }
+
+ CastActivityUrlFilterManager manager_;
+};
+
+namespace {
+
+class LookupRunner : public base::DelegateSimpleThread::Delegate {
+ public:
+ LookupRunner(CastActivityUrlFilterManager* manager,
+ const blink::LocalFrameToken& token,
+ base::WaitableEvent* started,
+ std::atomic<bool>* stop)
+ : manager_(manager), token_(token), started_(started), stop_(stop) {}
+
+ void Run() override {
+ started_->Signal();
+ while (!stop_->load(std::memory_order_relaxed)) {
+ EXPECT_EQ(nullptr,
+ manager_->GetActivityUrlFilterForRenderFrameToken(token_));
+ }
+ }
+
+ private:
+ const raw_ptr<CastActivityUrlFilterManager> manager_;
+ const blink::LocalFrameToken token_;
+ const raw_ptr<base::WaitableEvent> started_;
+ const raw_ptr<std::atomic<bool>> stop_;
+};
+
+} // namespace
+
+TEST_F(CastActivityUrlFilterManagerTest, LookupForUnknownTokenReturnsNull) {
+ EXPECT_EQ(nullptr, manager_.GetActivityUrlFilterForRenderFrameToken(
+ blink::LocalFrameToken()));
+}
+
+// CastURLLoaderThrottleProvider clones may look up filters from worker
+// threads while frames are being created and destroyed on the main thread,
+// so the manager must allow concurrent lookups and map mutations.
+TEST_F(CastActivityUrlFilterManagerTest, ConcurrentLookupAndModification) {
+ constexpr size_t kEntryCount = 8;
+ std::vector<blink::LocalFrameToken> tokens(kEntryCount);
+ for (const auto& token : tokens) {
+ AddFilterForToken(token);
+ }
+
+ blink::LocalFrameToken missing_token;
+ base::WaitableEvent started;
+ std::atomic<bool> stop(false);
+ LookupRunner runner(&manager_, missing_token, &started, &stop);
+ base::DelegateSimpleThread thread(&runner, "FilterLookup");
+ thread.Start();
+ started.Wait();
+
+ constexpr size_t kIterations = 4096;
+ for (size_t i = 0; i < kIterations; ++i) {
+ AddFilterForToken(blink::LocalFrameToken());
+ }
+ for (const auto& token : tokens) {
+ manager_.OnRenderFrameRemoved(token);
+ }
+ EXPECT_EQ(nullptr,
+ manager_.GetActivityUrlFilterForRenderFrameToken(tokens[0]));
+
+ stop.store(true, std::memory_order_relaxed);
+ thread.Join();
+}
+
+} // namespace chromecast
diff --git a/chromecast/BUILD.gn b/chromecast/BUILD.gn
index 4627748..f3c54fa 100644
--- a/chromecast/BUILD.gn
+++ b/chromecast/BUILD.gn
@@ -101,6 +101,7 @@
if (!is_fuchsia) {
tests += [
+ "//chromecast/renderer:cast_renderer_unittests",
"//chromecast/ui/display_settings:cast_display_settings_unittests",
"//content/test:content_unittests",
]
diff --git a/chromecast/renderer/BUILD.gn b/chromecast/renderer/BUILD.gn
index 6f8da8fd..c98b375 100644
--- a/chromecast/renderer/BUILD.gn
+++ b/chromecast/renderer/BUILD.gn
@@ -3,6 +3,7 @@
# found in the LICENSE file.
import("//chromecast/chromecast.gni")
+import("//testing/test.gni")
import("//tools/grit/grit_rule.gni")
cast_source_set("renderer") {
@@ -81,6 +82,19 @@
}
}
+test("cast_renderer_unittests") {
+ sources = [ "cast_activity_url_filter_manager_unittest.cc" ]
+
+ deps = [
+ ":renderer",
+ "//base",
+ "//base/test:run_all_unittests",
+ "//base/test:test_support",
+ "//testing/gtest",
+ "//third_party/blink/public/common",
+ ]
+}
+
cast_source_set("simple_client") {
sources = [ "cast_content_renderer_client_simple.cc" ]
Regression Test / PoC
diff --git a/chromecast/renderer/cast_activity_url_filter_manager_unittest.cc b/chromecast/renderer/cast_activity_url_filter_manager_unittest.cc
new file mode 100644
index 0000000..4dc89b4
--- /dev/null
+++ b/chromecast/renderer/cast_activity_url_filter_manager_unittest.cc
@@ -0,0 +1,95 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "chromecast/renderer/cast_activity_url_filter_manager.h"
+
+#include <atomic>
+#include <vector>
+
+#include "base/memory/raw_ptr.h"
+#include "base/synchronization/lock.h"
+#include "base/synchronization/waitable_event.h"
+#include "base/thread_annotations.h"
+#include "base/threading/simple_thread.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/tokens/tokens.h"
+
+namespace chromecast {
+
+class CastActivityUrlFilterManagerTest : public testing::Test {
+ protected:
+ void AddFilterForToken(const blink::LocalFrameToken& token)
+ NO_THREAD_SAFETY_ANALYSIS {
+ base::AutoLock lock(manager_.filters_lock_);
+ manager_.activity_url_filters_.emplace(token, nullptr);
+ }
+
+ CastActivityUrlFilterManager manager_;
+};
+
+namespace {
+
+class LookupRunner : public base::DelegateSimpleThread::Delegate {
+ public:
+ LookupRunner(CastActivityUrlFilterManager* manager,
+ const blink::LocalFrameToken& token,
+ base::WaitableEvent* started,
+ std::atomic<bool>* stop)
+ : manager_(manager), token_(token), started_(started), stop_(stop) {}
+
+ void Run() override {
+ started_->Signal();
+ while (!stop_->load(std::memory_order_relaxed)) {
+ EXPECT_EQ(nullptr,
+ manager_->GetActivityUrlFilterForRenderFrameToken(token_));
+ }
+ }
+
+ private:
+ const raw_ptr<CastActivityUrlFilterManager> manager_;
+ const blink::LocalFrameToken token_;
+ const raw_ptr<base::WaitableEvent> started_;
+ const raw_ptr<std::atomic<bool>> stop_;
+};
+
+} // namespace
+
+TEST_F(CastActivityUrlFilterManagerTest, LookupForUnknownTokenReturnsNull) {
+ EXPECT_EQ(nullptr, manager_.GetActivityUrlFilterForRenderFrameToken(
+ blink::LocalFrameToken()));
+}
+
+// CastURLLoaderThrottleProvider clones may look up filters from worker
+// threads while frames are being created and destroyed on the main thread,
+// so the manager must allow concurrent lookups and map mutations.
+TEST_F(CastActivityUrlFilterManagerTest, ConcurrentLookupAndModification) {
+ constexpr size_t kEntryCount = 8;
+ std::vector<blink::LocalFrameToken> tokens(kEntryCount);
+ for (const auto& token : tokens) {
+ AddFilterForToken(token);
+ }
+
+ blink::LocalFrameToken missing_token;
+ base::WaitableEvent started;
+ std::atomic<bool> stop(false);
+ LookupRunner runner(&manager_, missing_token, &started, &stop);
+ base::DelegateSimpleThread thread(&runner, "FilterLookup");
+ thread.Start();
+ started.Wait();
+
+ constexpr size_t kIterations = 4096;
+ for (size_t i = 0; i < kIterations; ++i) {
+ AddFilterForToken(blink::LocalFrameToken());
+ }
+ for (const auto& token : tokens) {
+ manager_.OnRenderFrameRemoved(token);
+ }
+ EXPECT_EQ(nullptr,
+ manager_.GetActivityUrlFilterForRenderFrameToken(tokens[0]));
+
+ stop.store(true, std::memory_order_relaxed);
+ thread.Join();
+}
+
+} // namespace chromecast
Original Bug Report
Potential UAF and RCE in Chromecast renderer via Data Race in CastURLLoaderThrottleProvider
Flapjack (go/flapjack), an LLM-powered static analysis tool, has identified the following potential security issue.
Overview: A critical thread-safety violation exists in CastURLLoaderThrottleProvider where a background worker thread accesses CastActivityUrlFilterManager::activity_url_filters_ (a base::flat_map) without synchronization. This map is concurrently modified on the main thread when RenderFrames are destroyed, causing a Data Race and a Use-After-Free. Because the map stores raw UrlFilterReceiver* pointers instead of raw_ptr, the resulting UAF bypasses MiraclePtr (BRP) and could allow an attacker to achieve Arbitrary Code Execution in the renderer process.
Affected files:
chromecast/renderer/cast_url_loader_throttle_provider.ccchromecast/renderer/cast_activity_url_filter_manager.ccchromecast/common/activity_filtering_url_loader_throttle.cc
Estimated timestamp from git blame: 2023-11-07
Description
A critical thread-safety vulnerability exists in the Chromecast renderer implementation, specifically within CastURLLoaderThrottleProvider and CastActivityUrlFilterManager. This flaw leads to a Data Race and a potential Use-After-Free (UAF) that bypasses MiraclePtr (BRP) protections, potentially allowing an attacker to achieve Arbitrary Code Execution (RCE) in the sandboxed renderer process.
The vulnerability stems from CastURLLoaderThrottleProvider::CreateThrottles being invoked on background worker threads (e.g., when a DedicatedWorker calls fetch()). Inside this method, it accesses cast_activity_url_filter_manager_->GetActivityUrlFilterForRenderFrameToken(). This method subsequently searches activity_url_filters_, which is a base::flat_map<blink::LocalFrameToken, UrlFilterReceiver*>.
However, CastActivityUrlFilterManager is a main-thread object, and its activity_url_filters_ map is modified exclusively on the main thread when RenderFrames are created or destroyed (via OnRenderFrameCreated and OnRenderFrameRemoved). Because there is no synchronization (such as a base::Lock) protecting this flat_map, a worker thread calling find() can race with the main thread executing erase().
Impact and BRP Bypass
The activity_url_filters_ map stores raw UrlFilterReceiver* pointers. When an iframe is removed, the UrlFilterReceiver::OnDestruct() method executes delete this; on the main thread, immediately freeing the memory before the map is erased.
Because the map uses a raw C++ pointer rather than raw_ptr<UrlFilterReceiver>, MiraclePtr (BRP) does not quarantine the allocation. A worker thread racing to call find() can retrieve this dangling, freed pointer. Dereferencing it to call it->second->GetUrlFilter() retrieves an attacker-controlled ActivityUrlFilter*, which is then used by the networking stack. When ActivityFilteringURLLoaderThrottle::FilterURL is called, it executes virtual methods on the attacker-controlled object, leading to RCE.
Potential Attack Scenario
Note: These are suggested steps; a working Proof of Concept has not yet been developed to verify this sequence end-to-end.
- Setup: An attacker hosts a malicious page loaded by the Chromecast browser. The page dynamically creates an
iframeelement. - Worker Creation: The iframe executes JavaScript to spawn a DedicatedWorker (
new Worker('worker.js')). The renderer initializes the worker’sURLLoaderThrottleProvideron the main thread, passing it a raw pointer to theCastActivityUrlFilterManager. - The Race:
- On the worker thread, the JavaScript initiates a network request (
fetch()), which invokesCastURLLoaderThrottleProvider::CreateThrottles. - Concurrently, on the main thread, the attacker removes the iframe from the DOM (
iframe.remove()).
- On the worker thread, the JavaScript initiates a network request (
- UAF Triggered: The iframe removal destroys the
RenderFrame, executingUrlFilterReceiver::OnDestruct(), which callsdelete this;and frees the receiver’s memory. The worker thread’sfind()operation executes precisely at this moment, retrieving the dangling pointer from the unsynchronizedflat_map. - Exploitation: The attacker uses heap spraying on the main thread to reclaim the freed
UrlFilterReceivermemory with a forged object. The worker thread dereferences this fake object, retrieves a fakeActivityUrlFilter, and when the network request proceeds, the throttle executesurl_filter_->UrlMatchesWhitelist(url)on the attacker-controlled memory, hijacking the execution flow.
Suggested Fix
To resolve this vulnerability, the CastActivityUrlFilterManager must be made thread-safe for concurrent reads from worker threads.
- Add Synchronization: Introduce a
base::Lockorbase::ReadWriteLockwithinCastActivityUrlFilterManagerto protect all accesses (reads and writes) to theactivity_url_filters_map. - Use Smart Pointers: Change the map definition to store
std::unique_ptr<UrlFilterReceiver>orscoped_refptr<UrlFilterReceiver>to ensure the object remains alive while it is being accessed, or change it tobase::raw_ptr<UrlFilterReceiver>to enable MiraclePtr protections against the UAF.
Evaluated with Chrome root at commit: 9760e6c70cd33a320713361f17c6dcca85648c0f
Results from Flapjack so far have been promising, but it can be wrong in its deductions. At this time, it does not produce proof of concepts or fuzzer tests. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve Flapjack’s accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.