CVE-2026-17756
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fchrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc |
modified | |
forchrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc |
modified | |
ifchrome/browser/ui/media_router/presentation_receiver_window_controller.cc |
modified |
Files Changed
chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.ccchrome/browser/ui/media_router/presentation_receiver_window_controller.ccchrome/browser/ui/media_router/presentation_receiver_window_controller.h
Patch
From 16cbed6b75c8bcdd7beafddecae84db7065f38eb Mon Sep 17 00:00:00 2001 From: mark a. foltz <[email protected]> Date: Mon, 08 Jun 2026 17:49:05 -0700 Subject: [PATCH] Fix three security vulnerabilities in the Presentation API. This CL resolves three distinct security issues identified in the Presentation API implementation across Blink and the browser process: 1. Allowlist Bypass (components/media_router): - Bug: IsSiteInitiatedMirroringSource() only matched the standard Cast Streaming audio+video app ID, allowing site-initiated tab-mirroring requests with alternative streaming app IDs (e.g. audio-only) or legacy URL formats to bypass the kPresentationApiAllowlist check. - Fix: Updated the predicate to match any Cast Presentation URL for which ContainsStreamingApp() is true. This ensures the allowlist is correctly applied to all mirroring sources. - Test: Added CastMediaRouteProviderTest.PresentationApiMirroringOriginAllowlist. 2. Cross-Origin Connection Hijacking (chrome/browser/ui/media_router): - Bug: When a receiver page navigated cross-origin, the navigation policy rightly disallowed it and called Terminate() which asynchronously closed the page. However, the NavigationHandle was not aborted, allowing the cross-origin page to commit. The new document could then call SetReceiver and hijack the pending PresentationConnection. - Fix: Asynchronously stop the navigation and terminate the receiver window upon detecting a disallowed navigation. Added origin validation to LocalPresentationManager to ensure subsequent registrations for a presentation ID match the origin of the first registration. This prevents cross-origin hijacking while allowing same-origin redirects and offscreen tab test configurations (such as in MediaRouterIntegrationOneUANoReceiverBrowserTest). - Test: Added PresentationReceiverNavigationBrowserTest. CrossOriginNavigationDoesNotCommit. 3. Out-of-Slice Heap Data Leak (third_party/blink): - Bug: PresentationConnection::send() for an ArrayBufferView enqueued the entire backing DOMArrayBuffer instead of the specified view slice, leaking out-of-slice heap data from the renderer's linear memory to the receiver. - Fix: Transmit only the view's slice by copying it into a new DOMArrayBuffer using DOMArrayBuffer::Create( array_buffer_view->ByteSpan()). - Test: Added PresentationConnectionTest.SendArrayBufferViewSendsOnlySlice. Fixed: 513363822,513232523,501980797 Test: CastMediaRouteProviderTest.PresentationApiMirroringOriginAllowlist, PresentationReceiverNavigationBrowserTest.CrossOriginNavigationDoesNotCommit, PresentationConnectionTest.SendArrayBufferViewSendsOnlySlice Change-Id: If0da3e414aeccd7de916453b96a4e8635951a825 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7908443 Commit-Queue: Mark Foltz <[email protected]> Reviewed-by: Muyao Xu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1643569} --- diff --git a/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc b/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc index efbe710..b208ee7 100644 --- a/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc +++ b/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc @@ -34,6 +34,7 @@ using ::testing::_; using testing::Mock; using ::testing::NiceMock; +using ::testing::SaveArg; using testing::WithArg; namespace media_router { @@ -153,6 +154,26 @@ base::RunLoop().RunUntilIdle(); } + // POC helper: invokes the private OnSinkQueryUpdated() + // (CastMediaRouteProvider friends this fixture class) and returns the + // |origins| that were forwarded to MediaRouter::OnSinksReceived -- i.e. the + // result of GetOrigins(). + std::vector<url::Origin> GetOnSinksReceivedOrigins( + const MediaSource::Id& source_id, + const std::vector<MediaSinkInternal>& sinks) { + std::vector<url::Origin> captured_origins; + base::RunLoop run_loop; + EXPECT_CALL(mock_router_, OnSinksReceived(mojom::MediaRouteProviderId::CAST, + source_id, sinks, _)) + .WillOnce( + testing::DoAll(SaveArg<3>(&captured_origins), + base::test::RunOnceClosure(run_loop.QuitClosure()))); + provider_->OnSinkQueryUpdated(source_id, sinks); + run_loop.Run(); + Mock::VerifyAndClearExpectations(&mock_router_); + return captured_origins; + } + void UpdateSinkQueryAndExpectSinkReceived( const std::vector<MediaSinkInternal>& expected_received_sinks, const MediaSource::Id& source_id, @@ -201,6 +222,43 @@ EXPECT_TRUE(app_discovery_service_.callbacks().empty()); } +TEST_F(CastMediaRouteProviderTest, PresentationApiMirroringOriginAllowlist) { + struct Case { + const char* name; + const char* source_id; + } const cases[] = { + {"video", "cast:0F5096E8?clientId=1"}, + {"audio-only", "cast:85CDB22F?clientId=1"}, + {"legacy-url", + "https://google.com/cast#__castAppId__=0F5096E8/__castClientId__=1"}, + }; + + const MediaSinkInternal sink = CreateCastSink(1); + const std::vector<MediaSinkInternal> sinks = {sink}; + + for (const auto& c : cases) { + // The IsCastPresentationUrl + ContainsStreamingApp combination is exactly + // what GetMirroringType() uses to select MirroringType::kTab. + EXPECT_TRUE(MediaSource(c.source_id).IsCastPresentationUrl()); + + // All three sources are routed as tab-mirroring by CastActivityManager + // (DoLaunchSession -> ContainsStreamingApp() -> AddMirroringActivity). + auto cast_source = CastMediaSource::FromMediaSourceId(c.source_id); + ASSERT_TRUE(cast_source); + EXPECT_TRUE(cast_source->ContainsStreamingApp()) + << c.source_id << " is treated as a Cast Streaming (mirroring) app"; + + // OnSinkQueryUpdated computes GetOrigins(source_id) and forwards it to + // MediaRouter::OnSinksReceived. + std::vector<url::Origin> captured_origins = + GetOnSinksReceivedOrigins(c.source_id, sinks); + + // kPresentationApiAllowlist is applied, restricting these sources to + // trusted origins. + EXPECT_EQ(captured_origins.size(), 3u); + } +} + TEST_F(CastMediaRouteProviderTest, CreateRouteFailsInvalidSink) { // Sink does not exist. provider_->CreateRoute( diff --git a/chrome/browser/ui/media_router/presentation_receiver_window_controller.cc b/chrome/browser/ui/media_router/presentation_receiver_window_controller.cc index a05510ea..febdb4d 100644 --- a/chrome/browser/ui/media_router/presentation_receiver_window_controller.cc +++ b/chrome/browser/ui/media_router/presentation_receiver_window_controller.cc @@ -10,6 +10,7 @@ #include "base/functional/bind.h" #include "base/memory/ptr_util.h" #include "base/strings/utf_string_conversions.h" +#include "base/task/single_thread_task_runner.h" #include "chrome/browser/profiles/profile.h" #include "chrome/browser/profiles/profile_destroyer.h" #include "chrome/browser/ui/media_router/presentation_receiver_window.h" @@ -144,10 +145,20 @@ void PresentationReceiverWindowController::DidStartNavigation( content::NavigationHandle* handle) { if (!navigation_policy_.AllowNavigation(handle)) { - Terminate(); + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, + base::BindOnce(&PresentationReceiverWindowController::StopAndTerminate, + weak_factory_.GetWeakPtr())); } } +void PresentationReceiverWindowController::StopAndTerminate() { + if (web_contents_) { + web_contents_->Stop(); + } + Terminate(); +} + void PresentationReceiverWindowController::TitleWasSet( content::NavigationEntry* entry) { window_->UpdateWindowTitle(); diff --git a/chrome/browser/ui/media_router/presentation_receiver_window_controller.h b/chrome/browser/ui/media_router/presentation_receiver_window_controller.h index 3fc06be..5e440426 100644 --- a/chrome/browser/ui/media_router/presentation_receiver_window_controller.h +++ b/chrome/browser/ui/media_router/presentation_receiver_window_controller.h @@ -87,6 +87,8 @@ // PresentationReceiverWindowDelegate overrides. void WindowClosed() final; + void StopAndTerminate(); + // content::WebContentsObserver overrides. void DidStartNavigation(content::NavigationHandle* handle) final; void TitleWasSet(content::NavigationEntry* entry) final; @@ -126,6 +128,9 @@ TitleChangeCallback title_change_callback_; media_router::PresentationNavigationPolicy navigation_policy_; + + base::WeakPtrFactory<PresentationReceiverWindowController> weak_factory_{ + this}; }; #endif // CHROME_BROWSER_UI_MEDIA_ROUTER_PRESENTATION_RECEIVER_WINDOW_CONTROLLER_H_ diff --git a/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc b/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc index dbdf12e..d9c5e9a9 100644
Regression Test / PoC
diff --git a/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc b/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
index efbe710..b208ee7 100644
--- a/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
+++ b/chrome/browser/media/router/providers/cast/cast_media_route_provider_unittest.cc
@@ -34,6 +34,7 @@
using ::testing::_;
using testing::Mock;
using ::testing::NiceMock;
+using ::testing::SaveArg;
using testing::WithArg;
namespace media_router {
@@ -153,6 +154,26 @@
base::RunLoop().RunUntilIdle();
}
+ // POC helper: invokes the private OnSinkQueryUpdated()
+ // (CastMediaRouteProvider friends this fixture class) and returns the
+ // |origins| that were forwarded to MediaRouter::OnSinksReceived -- i.e. the
+ // result of GetOrigins().
+ std::vector<url::Origin> GetOnSinksReceivedOrigins(
+ const MediaSource::Id& source_id,
+ const std::vector<MediaSinkInternal>& sinks) {
+ std::vector<url::Origin> captured_origins;
+ base::RunLoop run_loop;
+ EXPECT_CALL(mock_router_, OnSinksReceived(mojom::MediaRouteProviderId::CAST,
+ source_id, sinks, _))
+ .WillOnce(
+ testing::DoAll(SaveArg<3>(&captured_origins),
+ base::test::RunOnceClosure(run_loop.QuitClosure())));
+ provider_->OnSinkQueryUpdated(source_id, sinks);
+ run_loop.Run();
+ Mock::VerifyAndClearExpectations(&mock_router_);
+ return captured_origins;
+ }
+
void UpdateSinkQueryAndExpectSinkReceived(
const std::vector<MediaSinkInternal>& expected_received_sinks,
const MediaSource::Id& source_id,
@@ -201,6 +222,43 @@
EXPECT_TRUE(app_discovery_service_.callbacks().empty());
}
+TEST_F(CastMediaRouteProviderTest, PresentationApiMirroringOriginAllowlist) {
+ struct Case {
+ const char* name;
+ const char* source_id;
+ } const cases[] = {
+ {"video", "cast:0F5096E8?clientId=1"},
+ {"audio-only", "cast:85CDB22F?clientId=1"},
+ {"legacy-url",
+ "https://google.com/cast#__castAppId__=0F5096E8/__castClientId__=1"},
+ };
+
+ const MediaSinkInternal sink = CreateCastSink(1);
+ const std::vector<MediaSinkInternal> sinks = {sink};
+
+ for (const auto& c : cases) {
+ // The IsCastPresentationUrl + ContainsStreamingApp combination is exactly
+ // what GetMirroringType() uses to select MirroringType::kTab.
+ EXPECT_TRUE(MediaSource(c.source_id).IsCastPresentationUrl());
+
+ // All three sources are routed as tab-mirroring by CastActivityManager
+ // (DoLaunchSession -> ContainsStreamingApp() -> AddMirroringActivity).
+ auto cast_source = CastMediaSource::FromMediaSourceId(c.source_id);
+ ASSERT_TRUE(cast_source);
+ EXPECT_TRUE(cast_source->ContainsStreamingApp())
+ << c.source_id << " is treated as a Cast Streaming (mirroring) app";
+
+ // OnSinkQueryUpdated computes GetOrigins(source_id) and forwards it to
+ // MediaRouter::OnSinksReceived.
+ std::vector<url::Origin> captured_origins =
+ GetOnSinksReceivedOrigins(c.source_id, sinks);
+
+ // kPresentationApiAllowlist is applied, restricting these sources to
+ // trusted origins.
+ EXPECT_EQ(captured_origins.size(), 3u);
+ }
+}
+
TEST_F(CastMediaRouteProviderTest, CreateRouteFailsInvalidSink) {
// Sink does not exist.
provider_->CreateRoute(
diff --git a/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc b/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc
index dbdf12e..d9c5e9a9 100644
--- a/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc
+++ b/chrome/browser/ui/media_router/presentation_receiver_window_controller_browsertest.cc
@@ -11,6 +11,8 @@
#include "base/functional/bind.h"
#include "base/path_service.h"
#include "base/run_loop.h"
+#include "base/strings/escape.h"
+#include "base/task/single_thread_task_runner.h"
#include "base/test/run_until.h"
#include "base/threading/thread_restrictions.h"
#include "base/timer/elapsed_timer.h"
@@ -34,12 +36,15 @@
#include "mojo/public/cpp/bindings/receiver.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "net/base/filename_util.h"
+#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/embedded_test_server.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "third_party/blink/public/mojom/presentation/presentation.mojom.h"
#include "ui/display/display.h"
#include "ui/display/screen.h"
#include "ui/views/widget/widget.h"
#include "url/gurl.h"
+#include "url/origin.h"
using testing::_;
@@ -310,6 +315,127 @@
destroyer.AwaitTerminate(std::move(receiver_window));
}
+class PresentationReceiverNavigationBrowserTest
+ : public PresentationReceiverWindowControllerBrowserTest {
+ protected:
+ PresentationReceiverNavigationBrowserTest()
+ : https_server_(net::EmbeddedTestServer::TYPE_HTTPS) {}
+
+ void SetUpOnMainThread() override {
+ PresentationReceiverWindowControllerBrowserTest::SetUpOnMainThread();
+ host_resolver()->AddRule("*", "127.0.0.1");
+ // navigator.presentation is [SecureContext]; serve over HTTPS so the
+ // hijacker page's user JS can read the stolen connection.
+ https_server_.SetSSLConfig(net::EmbeddedTestServer::CERT_TEST_NAMES);
+ https_server_.ServeFilesFromSourceDirectory(
+ "chrome/test/data/media/router");
+ ASSERT_TRUE(https_server_.Start());
+ }
+
+ net::EmbeddedTestServer https_server_;
+};
+
+// Observes a receiver WebContents and records every committed primary
+// main-frame URL until the WebContents is destroyed.
+class CommittedUrlRecorder : public content::WebContentsObserver {
+ public:
+ explicit CommittedUrlRecorder(content::WebContents* wc)
+ : content::WebContentsObserver(wc) {}
+
+ void DidFinishNavigation(content::NavigationHandle* handle) override {
+ if (handle->IsInPrimaryMainFrame() && handle->HasCommitted()) {
+ committed_urls_.push_back(handle->GetURL());
+ LOG(ERROR) << "Main-frame navigation committed: "
+ << handle->GetURL().spec();
+ if (on_commit_cb_ && handle->GetURL() == on_commit_url_) {
+ std::move(on_commit_cb_).Run();
+ }
+ }
+ }
+ void RunOnCommit(const GURL& url, base::OnceClosure cb) {
+ on_commit_url_ = url;
+ on_commit_cb_ = std::move(cb);
+ }
+ const std::vector<GURL>& committed_urls() const { return committed_urls_; }
+
+ private:
+ std::vector<GURL> committed_urls_;
+ GURL on_commit_url_;
+ base::OnceClosure on_commit_cb_;
+};
+
+IN_PROC_BROWSER_TEST_F(PresentationReceiverNavigationBrowserTest,
+ CrossOriginNavigationDoesNotCommit) {
+ // Two distinct HTTPS origins (a.test vs b.test, both covered by
+ // CERT_TEST_NAMES) — site isolation puts them in different renderer
+ // processes and both are SecureContexts so navigator.presentation is exposed.
+ const GURL target_url = https_server_.GetURL("b.test", "/target.html");
+ const std::string receiver_path =
+ "/target_receiver.html?" +
+ base::EscapeQueryParamValue(target_url.spec(), /*use_plus=*/false);
+ const GURL start_url = https_server_.GetURL("a.test", receiver_path);
+ const url::Origin target_origin = url::Origin::Create(target_url);
+ ASSERT_NE(url::Origin::Create(start_url), target_origin);
+
+ // 1. Create the receiver window.
+ // Instead of ReceiverWindowDestroyer, we use a simple RunLoop to wait for
+ // the asynchronous termination callback.
+ base::RunLoop terminate_loop;
+ auto receiver_window =
+ PresentationReceiverWindowController::CreateFromOriginalProfile(
+ browser()->profile(), gfx::Rect(100, 100),
+ terminate_loop.QuitClosure(), GetNoopTitleChangeCallback());
+ CommittedUrlRecorder recorder(receiver_window->web_contents());
+ receiver_window->Start(kPresentationId, start_url);
+
+ // 2. start_url commits and Blink eagerly creates a PresentationReceiver.
+ // start_url then attempts to navigate to target_url.
+ // PresentationNavigationPolicy::AllowNavigation returns false for that
+ // second main-frame navigation.
+ // Our fix asynchronously stops the navigation and terminates the window,
+ // which runs the termination callback and quits the loop.
+ terminate_loop.Run();
+
+ // 3. Verify that the disallowed navigation never committed.
+ EXPECT_EQ(1u, recorder.committed_urls().size());
+ EXPECT_EQ(start_url, recorder.committed_urls()[0]);
+
+ // 4. Register a controller connection for the same presentation_id.
+ // Since the receiver window is destroyed/terminated, the connection
+ // should not be hijacked or routed to target.
+ FakeControllerConnection controller_connection;
+ media_router::LocalPresentationManagerFactory::GetOrCreateForBrowserContext(
+ browser()->profile())
+ ->RegisterLocalPresentationController(
+ blink::mojom::PresentationInfo(start_url, kPresentationId),
+ content::GlobalRenderFrameHostId(0, 0), controller_connection.Bind(),
+ controller_connection.MakeConnectionRequest(),
+ media_router::MediaRoute("route",
+ media_router::MediaSource(start_url), "sink",
+ "desc", true));
+
+ std::string received;
+ base::RunLoop loop;
+ EXPECT_CALL(controller_connection, OnMessage(_))
+ .WillRepeatedly([&](blink::mojom::PresentationConnectionMessagePtr msg) {
+ if (msg->is_message()) {
+ received = msg->get_message();
+ }
+ loop.Quit();
+ });
+
+ // Run the loop for a short time to ensure no message is received.
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostDelayedTask(
+ FROM_HERE, loop.QuitClosure(), base::Milliseconds(500));
+ loop.Run();
+
+ // Safely destroy the receiver window controller.
+ receiver_window.reset();
+
+ // 5. Verify that no message to target was received.
+ EXPECT_TRUE(received.empty());
+}
+
IN_PROC_BROWSER_TEST_F(PresentationReceiverWindowControllerBrowserTest,
WindowClosingTerminatesPresentation) {
// Start receiver window.
diff --git a/chrome/test/data/media/router/target.html b/chrome/test/data/media/router/target.html
new file mode 100644
index 0000000..0319655
--- /dev/null
+++ b/chrome/test/data/media/router/target.html
@@ -0,0 +1,33 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Target page</title>
+<script>
+ // This page is cross-origin from the start_url. Because the receiver
+ // WebContents has web_prefs.presentation_receiver=true (set on the
+ // WebContents and preserved across navigation) and
+ // ReceiverPresentationServiceDelegateImpl is a WebContentsUserData keyed by
+ // the original presentation_id, the eagerly-created PresentationReceiver
+ // for THIS document calls SetReceiver on the browser, and
+ // LocalPresentation::RegisterReceiver overwrites receiver_callback_ to
+ // point at this origin's renderer. Any controller connection registered for
+ // the presentation is then delivered here.
+ if (location.search !== '?warmup' && self === top) {
+ const recv = navigator.presentation.receiver;
+ if (recv) {
+ recv.connectionList.then(list => {
+ const grab = c => {
+ c.onconnect = () => c.send('CAPTURED-BY:' + location.origin);
+ if (c.state === 'connected') {
+ c.send('CAPTURED-BY:' + location.origin);
+ }
+ };
+ list.connections.forEach(grab);
+ list.onconnectionavailable = e => grab(e.connection);
+ });
+ }
+ }
+</script>
+</head>
+<body>target</body>
+</html>
diff --git a/chrome/test/data/media/router/target_receiver.html b/chrome/test/data/media/router/target_receiver.html
new file mode 100644
index 0000000..2e113fd
--- /dev/null
+++ b/chrome/test/data/media/router/target_receiver.html
@@ -0,0 +1,42 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Presentation receiver that navigates cross-origin</title>
+</head>
+<body>receiver
+<script>
+ // Note: Blink's ModulesInitializer eagerly creates a PresentationReceiver
+ // for every outermost document with settings.presentation_receiver=true, so
+ // this page already holds the LocalPresentation's receiver_callback_. The
+ // hijack works because the cross-origin page that commits next ALSO gets an
+ // eagerly-created PresentationReceiver, and LocalPresentation::
+ // RegisterReceiver (with DCHECKs off, as in production) silently overwrites
+ // receiver_callback_ to point at that page's renderer.
+
... (truncated)
Original Bug Report
Potential cross-origin PresentationConnection hijack in receiver window
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 without the Chrome Security team.
Overview: A logic error in Presentation API receiver windows allows a malicious receiver page to bypass navigation restrictions and hijack a pending PresentationConnection. The policy enforcement fails to cancel disallowed navigations, and the LocalPresentationManager flushes pending connections to the new origin without validating it against the requested URL.
Affected files:
chrome/browser/ui/media_router/presentation_receiver_window_controller.cccomponents/media_router/browser/presentation/local_presentation_manager.cccomponents/media_router/browser/presentation/receiver_presentation_service_delegate_impl.cccomponents/media_router/browser/presentation/presentation_navigation_policy.cc
Estimated timestamp from git blame: 2024-12-23
Summary
A potential vulnerability exists in the wired-display Presentation API receiver implementation that allows a malicious receiver page to bypass navigation restrictions and hijack a pending PresentationConnection intended for the original origin.
This occurs because the PresentationReceiverWindowController does not successfully cancel disallowed cross-origin navigations, and the LocalPresentationManager flushes pending controller connections to whatever origin eventually commits in the receiver window.
Technical Details
1. Non-cancelling Navigation Policy
PresentationReceiverWindowController implements a PresentationNavigationPolicy intended to lock the receiver window to a single navigation (the start_url). When a second navigation is detected in DidStartNavigation, the policy correctly returns false. However, the controller’s response is to call Terminate(), which invokes web_contents_->ClosePage().
ClosePage() is an asynchronous operation that sends an IPC to the renderer to run unload handlers and starts a 500ms timeout (kUnloadTimeout). Crucially, DidStartNavigation does not cancel the current NavigationHandle (e.g., via web_contents_->Stop()). Consequently, the disallowed navigation proceeds to fetch and commit while the page closure is pending.
2. Connection Hijacking Logic
The hijacking occurs through the following sequence:
- A controller initiates a presentation.
WiredDisplayMediaRouteProviderstarts the receiver window and asynchronously fires a success callback to the controller. - Because the receiver window has not finished loading the initial
start_url, the controller’sPresentationConnectionpipes are stored inLocalPresentationManager::pending_controllers_, keyed by a uniquepresentation_id. - A malicious
start_url(attacker.test) loads but does not accessnavigator.presentation(which avoids creating aPresentationServiceImpl). It then immediately triggers a client-side navigation toevil.test. DidStartNavigationfires,Terminate()is called, but the navigation toevil.testcontinues and commits in a new renderer process. The maliciousattacker.testrenderer can intentionally delay acknowledging theunloadIPC to maximize the 500ms timeout.- The
web_prefs.presentation_receiverpreference and thepresentation_id(stored inReceiverPresentationServiceDelegateImpl) are tied to theWebContentsand are inherited by the new origin (evil.test). evil.testaccessesnavigator.presentation.receiver. This creates a newPresentationServiceImpland triggersRegisterReceiverConnectionAvailableCallback.LocalPresentationManager::RegisterReceiveris called with the survivingpresentation_id. It finds thepending_controllers_and flushes the Mojo pipes for the connection to theevil.testrenderer. The implementation entirely ignores the fact that the calling origin (evil.test) does not match the URL originally requested by the controller (attacker.test).
At this point, evil.test has full access to the PresentationConnection that was intended for the original start_url origin. The attacker has a window of approximately 500ms to exfiltrate data or send malicious messages before the window is forcefully closed.
Suggested Steps to Reproduce
Note: These are potential steps based on static analysis, as our tooling does not currently run code.
- From a controller page, start a presentation to a wired display using a URL controlled by the attacker (
https://attacker.test/receiver.html). receiver.htmlshould immediately navigate to a different origin:location.href='https://evil.test/hijack.html'. It must not accessnavigator.presentationbefore navigating.attacker.testshould artificially delay itsunloadhandler to ensure the 500ms timeout is utilized.hijack.htmlaccessesnavigator.presentation.receiver.connectionListand observes the incoming connection.- Observe that
hijack.html(onevil.test) can now send and receive messages from the controller, even though the connection was associated withattacker.test.
Suggested Fix
- Synchronous Abortion:
PresentationReceiverWindowController::DidStartNavigationshould explicitly abort the navigation before callingTerminate(). Callingweb_contents_->Stop()or discarding pending navigation entries would prevent the disallowed page from ever committing. - Origin Validation:
LocalPresentationManager::OnLocalPresentationReceiverCreatedshould verify that thepresentation_infoURL matches the URL that was originally requested by the controller, rather than relying solely on thepresentation_id.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results 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.