CVE-2026-7947
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcontent/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cc |
modified | |
ifcontent/browser/loader/subresource_proxying_url_loader.cc |
modified | |
forcontent/browser/loader/subresource_proxying_url_loader.cc |
modified |
Files Changed
content/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cccontent/browser/loader/subresource_proxying_url_loader.cccontent/browser/loader/subresource_proxying_url_loader.h
Patch
From 5ee8fb48bc29eb8659b816fb3ac585eb674365ae Mon Sep 17 00:00:00 2001 From: Yao Xiao <[email protected]> Date: Thu, 26 Mar 2026 10:24:57 -0700 Subject: [PATCH] SubresourceProxyingURLLoader: Add state validation for FollowRedirect This adds a `redirect_pending_` flag to SubresourceProxyingURLLoader to ensure that FollowRedirect() can only be called after a legitimate redirect has been received via OnReceiveRedirect(). Previously, a compromised renderer could send an unsolicited FollowRedirect IPC at any time. This allowed it to: 1. Trigger a CHECK failure in AdAuctionURLLoaderInterceptor::WillFollowRedirect, crashing the browser process. 2. Poison the origin in BrowsingTopicsURLLoaderInterceptor by racing FollowRedirect with OnReceiveResponse, potentially attributing Topics observations to a victim origin. Fixed by reporting a bad message if FollowRedirect is called when no redirect is pending. Bug: 496169594 Change-Id: Iae71c56cec8ce7919d728f247778906a0cff761c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7701154 Reviewed-by: mmenke <[email protected]> Commit-Queue: Yao Xiao <[email protected]> Cr-Commit-Position: refs/heads/main@{#1605638} --- diff --git a/content/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cc b/content/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cc index f56d977cf..f7871827 100644 --- a/content/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cc +++ b/content/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cc @@ -1013,4 +1013,42 @@ ::testing::IsEmpty()); } +TEST_F(AdAuctionURLLoaderInterceptorTest, UnsolicitedFollowRedirect) { + NavigatePage(GURL("https://google.com")); + + mojo::Remote<network::mojom::URLLoaderFactory> remote_url_loader_factory; + network::TestURLLoaderFactory proxied_url_loader_factory; + mojo::Remote<network::mojom::URLLoader> remote_loader; + mojo::PendingReceiver<network::mojom::URLLoaderClient> client; + + base::WeakPtr<SubresourceProxyingURLLoaderService::BindContext> bind_context = + CreateFactory(proxied_url_loader_factory, remote_url_loader_factory); + bind_context->OnDidCommitNavigation( + web_contents()->GetPrimaryMainFrame()->GetWeakDocumentPtr()); + + remote_url_loader_factory->CreateLoaderAndStart( + remote_loader.BindNewPipeAndPassReceiver(), + /*request_id=*/0, /*options=*/0, + CreateResourceRequest(GURL("https://foo1.com")), + client.InitWithNewPipeAndPassRemote(), + net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS)); + remote_url_loader_factory.FlushForTesting(); + + std::string received_error; + mojo::SetDefaultProcessErrorHandler(base::BindLambdaForTesting( + [&](const std::string& error) { received_error = error; })); + + // This should trigger ReportBadMessage in + // SubresourceProxyingURLLoader::FollowRedirect + remote_loader->FollowRedirect(/*removed_headers=*/{}, + /*modified_headers=*/{}, + /*modified_cors_exempt_headers=*/{}, + /*new_url=*/std::nullopt); + remote_loader.FlushForTesting(); + base::RunLoop().RunUntilIdle(); + + EXPECT_EQ(received_error, "Unexpected FollowRedirect"); + mojo::SetDefaultProcessErrorHandler(base::NullCallback()); +} + } // namespace content diff --git a/content/browser/loader/subresource_proxying_url_loader.cc b/content/browser/loader/subresource_proxying_url_loader.cc index 4de4b610..d2a87af0 100644 --- a/content/browser/loader/subresource_proxying_url_loader.cc +++ b/content/browser/loader/subresource_proxying_url_loader.cc @@ -6,6 +6,7 @@ #include "content/browser/browsing_topics/browsing_topics_url_loader_interceptor.h" #include "content/browser/interest_group/ad_auction_url_loader_interceptor.h" +#include "mojo/public/cpp/bindings/message.h" #include "services/network/public/cpp/shared_url_loader_factory.h" #include "services/network/public/mojom/early_hints.mojom.h" @@ -61,6 +62,12 @@ const net::HttpRequestHeaders& modified_headers, const net::HttpRequestHeaders& modified_cors_exempt_headers, const std::optional<GURL>& new_url) { + if (!redirect_pending_) { + mojo::ReportBadMessage("Unexpected FollowRedirect"); + return; + } + redirect_pending_ = false; + std::vector<std::string> new_removed_headers = removed_headers; net::HttpRequestHeaders new_modified_headers = modified_headers; @@ -87,6 +94,11 @@ network::mojom::URLResponseHeadPtr head, mojo::ScopedDataPipeConsumerHandle body, std::optional<mojo_base::BigBuffer> cached_metadata) { + // Reset the redirect state. While it's unclear if a redirect can genuinely + // be pending at this point, we clear it to be robust against variations + // in URLLoader behavior (e.g., notifications of failures during redirects). + redirect_pending_ = false; + for (auto& interceptor : interceptors_) { interceptor->OnReceiveResponse(head); } @@ -98,6 +110,8 @@ void SubresourceProxyingURLLoader::OnReceiveRedirect( const net::RedirectInfo& redirect_info, network::mojom::URLResponseHeadPtr head) { + redirect_pending_ = true; + for (auto& interceptor : interceptors_) { interceptor->OnReceiveRedirect(redirect_info, head); } @@ -120,6 +134,11 @@ void SubresourceProxyingURLLoader::OnComplete( const network::URLLoaderCompletionStatus& status) { + // Reset the redirect state. While it's unclear if a redirect can genuinely + // be pending at this point, we clear it to be robust against variations + // in URLLoader behavior (e.g., notifications of failures during redirects). + redirect_pending_ = false; + forwarding_client_->OnComplete(status); } diff --git a/content/browser/loader/subresource_proxying_url_loader.h b/content/browser/loader/subresource_proxying_url_loader.h index 8b8c543bc..c2b6ac22 100644 --- a/content/browser/loader/subresource_proxying_url_loader.h +++ b/content/browser/loader/subresource_proxying_url_loader.h @@ -113,6 +113,10 @@ std::vector<std::unique_ptr<Interceptor>> interceptors_; mojo::Receiver<network::mojom::URLLoaderClient> client_receiver_{this}; + + // Whether a redirect is currently pending. If true, the next call from the + // renderer should be FollowRedirect(). + bool redirect_pending_ = false; }; } // namespace content
Regression Test / PoC
diff --git a/content/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cc b/content/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cc
index f56d977cf..f7871827 100644
--- a/content/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cc
+++ b/content/browser/interest_group/ad_auction_url_loader_interceptor_unittest.cc
@@ -1013,4 +1013,42 @@
::testing::IsEmpty());
}
+TEST_F(AdAuctionURLLoaderInterceptorTest, UnsolicitedFollowRedirect) {
+ NavigatePage(GURL("https://google.com"));
+
+ mojo::Remote<network::mojom::URLLoaderFactory> remote_url_loader_factory;
+ network::TestURLLoaderFactory proxied_url_loader_factory;
+ mojo::Remote<network::mojom::URLLoader> remote_loader;
+ mojo::PendingReceiver<network::mojom::URLLoaderClient> client;
+
+ base::WeakPtr<SubresourceProxyingURLLoaderService::BindContext> bind_context =
+ CreateFactory(proxied_url_loader_factory, remote_url_loader_factory);
+ bind_context->OnDidCommitNavigation(
+ web_contents()->GetPrimaryMainFrame()->GetWeakDocumentPtr());
+
+ remote_url_loader_factory->CreateLoaderAndStart(
+ remote_loader.BindNewPipeAndPassReceiver(),
+ /*request_id=*/0, /*options=*/0,
+ CreateResourceRequest(GURL("https://foo1.com")),
+ client.InitWithNewPipeAndPassRemote(),
+ net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+ remote_url_loader_factory.FlushForTesting();
+
+ std::string received_error;
+ mojo::SetDefaultProcessErrorHandler(base::BindLambdaForTesting(
+ [&](const std::string& error) { received_error = error; }));
+
+ // This should trigger ReportBadMessage in
+ // SubresourceProxyingURLLoader::FollowRedirect
+ remote_loader->FollowRedirect(/*removed_headers=*/{},
+ /*modified_headers=*/{},
+ /*modified_cors_exempt_headers=*/{},
+ /*new_url=*/std::nullopt);
+ remote_loader.FlushForTesting();
+ base::RunLoop().RunUntilIdle();
+
+ EXPECT_EQ(received_error, "Unexpected FollowRedirect");
+ mojo::SetDefaultProcessErrorHandler(base::NullCallback());
+}
+
} // namespace content
Original Bug Report
Potential origin spoofing and DoS via unsolicited FollowRedirect
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised renderer can send an unsolicited FollowRedirect IPC to SubresourceProxyingURLLoader before any network redirect actually occurs. This lack of state validation allows the renderer to trigger a deterministic CHECK failure in AdAuctionURLLoaderInterceptor, crashing the browser. Additionally, by racing this IPC with a legitimate network response, an attacker can poison the origin in BrowsingTopicsURLLoaderInterceptor to record fraudulent Topics API observations.
Affected files:
content/browser/loader/subresource_proxying_url_loader.cccontent/browser/browsing_topics/browsing_topics_url_loader_interceptor.cccontent/browser/interest_group/ad_auction_url_loader_interceptor.cc
Estimated timestamp from git blame: 2023-05-17
Note: This vulnerability was discovered via static analysis by an AI agent. The steps below are theoretical and suggest how an attacker might exploit the issue, as a working Proof of Concept has not yet been executed.
Root Cause
The SubresourceProxyingURLLoader acts as a proxy between the renderer and the Network Service to intercept subresource requests for features like Ad Auctions and Browsing Topics.
When a renderer calls FollowRedirect on its URLLoader Mojo remote, SubresourceProxyingURLLoader::FollowRedirect unconditionally iterates over its interceptors and calls interceptor->WillFollowRedirect(...). Crucially, it never validates whether the underlying network request actually issued a redirect (e.g., by tracking if OnReceiveRedirect was called first). This allows a compromised renderer to arbitrarily invoke WillFollowRedirect at any point in the loader’s lifecycle.
Impact 1: Deterministic Browser Process Crash (DoS)
In AdAuctionURLLoaderInterceptor, the ad_auction_headers_eligible_ flag is set to true in WillStartRequest. The code assumes OnReceiveRedirect will safely clear this flag before WillFollowRedirect is ever called.
Because the renderer can invoke FollowRedirect out-of-order, it reaches AdAuctionURLLoaderInterceptor::WillFollowRedirect while the flag is still true. This hits CHECK(!ad_auction_headers_eligible_) at content/browser/interest_group/ad_auction_url_loader_interceptor.cc:73, immediately crashing the browser process.
Impact 2: Browsing Topics Origin Poisoning
In BrowsingTopicsURLLoaderInterceptor, WillFollowRedirect updates its internal url_ state to the attacker-supplied new_url.
Because the renderer’s URLLoader Mojo pipe and the Network Service’s URLLoaderClient Mojo pipe are independent, an attacker can race an unsolicited FollowRedirect IPC against a legitimate OnReceiveResponse IPC from their own server. If the browser processes the FollowRedirect (poisoning url_ to a victim’s origin) just before the OnReceiveResponse (containing Observe-Browsing-Topics: ?1), the browser will erroneously attribute the Topics observation to the victim’s origin instead of the attacker’s.
Potential Attacker Steps
To trigger the DoS:
- Compromise a renderer process.
- Initiate a subresource fetch with
adAuctionHeaders: true. - Immediately send a
FollowRedirectMojo IPC on the returnedURLLoaderremote. - The browser process hits the
CHECKand crashes.
To trigger Origin Poisoning:
- Compromise a renderer process.
- Initiate a subresource fetch with
browsing_topics: trueto an attacker-controlled server. - The attacker’s server responds with
Observe-Browsing-Topics: ?1. - The compromised renderer sends an unsolicited
FollowRedirectIPC withnew_urlset tohttps://victim.com, timed to arrive at the browser process just before the Network Service’sOnReceiveResponseIPC. - The browser processes
FollowRedirect, updating the interceptor’surl_tohttps://victim.com. - The browser processes
OnReceiveResponseand records the Topics observation forhttps://victim.com.
Suggested Fix
SubresourceProxyingURLLoader should track whether a redirect is currently pending.
- Add a
bool redirect_pending_ = false;member toSubresourceProxyingURLLoader. - Set
redirect_pending_ = true;insideSubresourceProxyingURLLoader::OnReceiveRedirect. - Inside
SubresourceProxyingURLLoader::FollowRedirect, verify thatredirect_pending_istrue. If it isfalse, terminate the request, do not forward the call to interceptors, and optionally callmojo::ReportBadMessage("Unexpected FollowRedirect")to kill the misbehaving renderer. - Reset
redirect_pending_ = false;after successfully processing a validFollowRedirect.
Evaluated with Chrome root at commit: bb48272cafb7e24c93f55ef40da398cd206ee651
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. Please feel free to reach out to me if you have concerns or feedback.