CVE-2026-7967
Overview
Background
- `BeginNavigation` IPC
- the renderer-to-browser message (
blink::mojom::BeginNavigationParams) that initiates a navigation; its fields are untrusted renderer input processed byRenderFrameHostImpl::BeginNavigation. - `client_side_redirect_url`
- a field in
BeginNavigationParamsrecording a client-side redirect source; it can be persisted in theNavigationEntry(as the original request URL). - `RenderProcessHost::FilterURL`
- a browser-side sanitizer that neutralizes URLs a given renderer process must not be allowed to load (e.g. privileged
chrome://WebUI URLs), rewriting them to a blocked URL. - `LoadOriginalRequestURL`
- a
NavigationControlleroperation (e.g. “Request Desktop Site”/reload) that reloads the entry’s stored original request URL.
Root Cause Analysis
In RenderFrameHostImpl::BeginNavigation, other renderer-supplied URLs such as common_params.url and begin_params->searchable_form_url were passed through GetProcess()->FilterURL, but begin_params->client_side_redirect_url was not filtered at all. A compromised renderer could therefore inject a privileged URL (the test uses the chrome://gpu WebUI URL via GetWebUIURL(kChromeUIGpuHost)) into client_side_redirect_url, and the browser would store it unfiltered as the NavigationEntry’s original request URL. The invariant that a renderer must never be able to introduce a URL its process is not authorized to load was violated for this one field. Later, a benign-looking operation like LoadOriginalRequestURL (triggered by “Request Desktop Site” or a reload) would attempt to load that poisoned URL, potentially navigating to a privileged WebUI page the renderer could not reach directly.
The fix adds GetProcess()->FilterURL(true, &begin_params->client_side_redirect_url); alongside the existing searchable_form_url filtering, so the injected URL is sanitized (rewritten to kBlockedURL) before it can be persisted. The regression test confirms the committed URL after reload is kBlockedURL, not the WebUI URL.
client_side_redirect_url was the one BeginNavigationParams URL not run through FilterURL; the fix adds the missing FilterURL call so a renderer cannot smuggle a privileged URL into the stored navigation entry.Attack Path
- Compromise a renderer
Gain control of a renderer able to shape the
BeginNavigationParamsit sends (the test simulates this withClientSideRedirectUrlReplacerinjecting the URL). - Inject a privileged URL
Set
begin_params->client_side_redirect_urlto a URL the process cannot normally load, e.g. achrome://WebUI URL. - Trigger a normal navigation
Start an ordinary renderer-initiated navigation to a benign page; the poisoned
client_side_redirect_urlrides along and, unfiltered, is saved as the entry’s original request URL. - Replay the original request URL
Invoke an operation that reloads the original request URL (e.g.
LoadOriginalRequestURLvia “Request Desktop Site”), causing the browser to navigate toward the injected privileged URL (inferred: loading a WebUI page the renderer should not reach).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ClientSideRedirectUrlReplacercontent/browser/security_exploit_browsertest.cc |
modified | |
ifcontent/browser/security_exploit_browsertest.cc |
modified | |
RemoteFrameHostInterceptorcontent/browser/security_exploit_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/security_exploit_browsertest.cc
Audit Directions
- Unfiltered URL fields in navigation IPCsEnumerate every URL/
GURLfield inCommonNavigationParams/BeginNavigationParamsand confirm each is passed throughFilterURL(or otherwise validated) before use or persistence. - Persisted renderer URLsAudit places where renderer-supplied URLs are stored in
NavigationEntry(original request URL, redirect chains) and later replayed by controller operations likeLoadOriginalRequestURL. - Field-by-field parityWhen several sibling fields are sanitized together, check that newly added fields inherit the same filtering rather than being silently exempt.
Patch
From 59cf8131947d105468cfb47814a8c1ead94f72c3 Mon Sep 17 00:00:00 2001 From: Alex Moshchuk <[email protected]> Date: Tue, 31 Mar 2026 00:14:48 -0700 Subject: [PATCH] Filter client_side_redirect_url in BeginNavigation IPC Unlike other navigation parameters (e.g., common_params.url, searchable_form_url), the client_side_redirect_url in blink::mojom::BeginNavigationParams was passed through RenderFrameHostImpl::BeginNavigation without being validated or filtered (e.g., by RenderProcessHost::FilterURL). This CL fixes that. Fixed: 497365545 Change-Id: Ia0c6ad1bb493789657c4a4ec734f90cfb43805da Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7713438 Commit-Queue: Alex Moshchuk <[email protected]> Reviewed-by: Charlie Reis <[email protected]> Cr-Commit-Position: refs/heads/main@{#1607681} --- diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc index c5646213..c402f90 100644 --- a/content/browser/renderer_host/render_frame_host_impl.cc +++ b/content/browser/renderer_host/render_frame_host_impl.cc @@ -11565,7 +11565,9 @@ } } + // TODO(crbug.com/40066983): Consider converting these into renderer kills. GetProcess()->FilterURL(true, &begin_params->searchable_form_url); + GetProcess()->FilterURL(true, &begin_params->client_side_redirect_url); // If the request was for a blob URL, but the validated URL is no longer a // blob URL, reset the blob_url_token to prevent hitting the ReportBadMessage diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc index b500c32..a280237d 100644 --- a/content/browser/security_exploit_browsertest.cc +++ b/content/browser/security_exploit_browsertest.cc @@ -1030,6 +1030,85 @@ child->current_frame_host()->GetLastCommittedURL()); } +// Helper class to replace a particular URL as the client_side_redirect_url +// in BeginNavigationParams. +class ClientSideRedirectUrlReplacer : public FrameHostInterceptor { + public: + ClientSideRedirectUrlReplacer(WebContents* web_contents, + const GURL& url_to_inject) + : FrameHostInterceptor(web_contents), url_to_inject_(url_to_inject) {} + + ClientSideRedirectUrlReplacer(const ClientSideRedirectUrlReplacer&) = delete; + ClientSideRedirectUrlReplacer& operator=( + const ClientSideRedirectUrlReplacer&) = delete; + + bool WillDispatchBeginNavigation( + RenderFrameHost* render_frame_host, + blink::mojom::CommonNavigationParamsPtr* common_params, + blink::mojom::BeginNavigationParamsPtr* begin_params, + mojo::PendingRemote<blink::mojom::BlobURLToken>* blob_url_token, + mojo::PendingAssociatedRemote<mojom::NavigationClient>* navigation_client) + override { + if (is_activated_) { + (*begin_params)->client_side_redirect_url = url_to_inject_; + is_activated_ = false; + } + return true; + } + + void Activate() { is_activated_ = true; } + + private: + GURL url_to_inject_; + bool is_activated_ = false; +}; + +// Verify that a compromised renderer can't poison client_side_redirect_url +// with a privileged URL, which could then be loaded via a subsequent +// "request desktop site" operation. +IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest, + BlockIllegalClientSideRedirectUrl) { + GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html")); + EXPECT_TRUE(NavigateToURL(shell(), start_url)); + + WebContentsImpl* web_contents = + static_cast<WebContentsImpl*>(shell()->web_contents()); + + GURL webui_url(GetWebUIURL(kChromeUIGpuHost)); + ClientSideRedirectUrlReplacer injector(web_contents, webui_url); + + // Setup the interceptor to inject the WebUI URL into the next + // BeginNavigation's client_side_redirect_url. + injector.Activate(); + + // Trigger a normal renderer-initiated navigation to a benign URL. The + // injector will poison the client_side_redirect_url in that IPC's + // BeginNavigationParams. + GURL next_url(embedded_test_server()->GetURL("b.com", "/title2.html")); + TestNavigationManager nav_manager(web_contents, next_url); + EXPECT_TRUE(ExecJs(web_contents, JsReplace("location.href = $1;", next_url))); + + // Wait for the navigation to finish. + ASSERT_TRUE(nav_manager.WaitForNavigationFinished()); + EXPECT_TRUE(nav_manager.was_successful()); + + // At this point, before the fix, the NavigationEntry has saved + // the WebUI URL as the OriginalRequestURL. + + // Simulate the user clicking "Request Desktop Site" or similar, + // triggering a reload of the original request URL. + TestNavigationObserver reload_observer(web_contents); + web_contents->GetController().LoadOriginalRequestURL(); + reload_observer.Wait(); + + // Ensure that the browser doesn't navigate to the WebUI URL, which should've + // been filtered out when processing the corresponding BeginNavigation IPC. + // TODO(crbug.com/40066983): Consider terminating the renderer process + // instead. + EXPECT_NE(webui_url, web_contents->GetLastCommittedURL()); + EXPECT_EQ(GURL(kBlockedURL), web_contents->GetLastCommittedURL()); +} + class RemoteFrameHostInterceptor : public blink::mojom::RemoteFrameHostInterceptorForTesting { public:
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index b500c32..a280237d 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1030,6 +1030,85 @@
child->current_frame_host()->GetLastCommittedURL());
}
+// Helper class to replace a particular URL as the client_side_redirect_url
+// in BeginNavigationParams.
+class ClientSideRedirectUrlReplacer : public FrameHostInterceptor {
+ public:
+ ClientSideRedirectUrlReplacer(WebContents* web_contents,
+ const GURL& url_to_inject)
+ : FrameHostInterceptor(web_contents), url_to_inject_(url_to_inject) {}
+
+ ClientSideRedirectUrlReplacer(const ClientSideRedirectUrlReplacer&) = delete;
+ ClientSideRedirectUrlReplacer& operator=(
+ const ClientSideRedirectUrlReplacer&) = delete;
+
+ bool WillDispatchBeginNavigation(
+ RenderFrameHost* render_frame_host,
+ blink::mojom::CommonNavigationParamsPtr* common_params,
+ blink::mojom::BeginNavigationParamsPtr* begin_params,
+ mojo::PendingRemote<blink::mojom::BlobURLToken>* blob_url_token,
+ mojo::PendingAssociatedRemote<mojom::NavigationClient>* navigation_client)
+ override {
+ if (is_activated_) {
+ (*begin_params)->client_side_redirect_url = url_to_inject_;
+ is_activated_ = false;
+ }
+ return true;
+ }
+
+ void Activate() { is_activated_ = true; }
+
+ private:
+ GURL url_to_inject_;
+ bool is_activated_ = false;
+};
+
+// Verify that a compromised renderer can't poison client_side_redirect_url
+// with a privileged URL, which could then be loaded via a subsequent
+// "request desktop site" operation.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ BlockIllegalClientSideRedirectUrl) {
+ GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+
+ GURL webui_url(GetWebUIURL(kChromeUIGpuHost));
+ ClientSideRedirectUrlReplacer injector(web_contents, webui_url);
+
+ // Setup the interceptor to inject the WebUI URL into the next
+ // BeginNavigation's client_side_redirect_url.
+ injector.Activate();
+
+ // Trigger a normal renderer-initiated navigation to a benign URL. The
+ // injector will poison the client_side_redirect_url in that IPC's
+ // BeginNavigationParams.
+ GURL next_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+ TestNavigationManager nav_manager(web_contents, next_url);
+ EXPECT_TRUE(ExecJs(web_contents, JsReplace("location.href = $1;", next_url)));
+
+ // Wait for the navigation to finish.
+ ASSERT_TRUE(nav_manager.WaitForNavigationFinished());
+ EXPECT_TRUE(nav_manager.was_successful());
+
+ // At this point, before the fix, the NavigationEntry has saved
+ // the WebUI URL as the OriginalRequestURL.
+
+ // Simulate the user clicking "Request Desktop Site" or similar,
+ // triggering a reload of the original request URL.
+ TestNavigationObserver reload_observer(web_contents);
+ web_contents->GetController().LoadOriginalRequestURL();
+ reload_observer.Wait();
+
+ // Ensure that the browser doesn't navigate to the WebUI URL, which should've
+ // been filtered out when processing the corresponding BeginNavigation IPC.
+ // TODO(crbug.com/40066983): Consider terminating the renderer process
+ // instead.
+ EXPECT_NE(webui_url, web_contents->GetLastCommittedURL());
+ EXPECT_EQ(GURL(kBlockedURL), web_contents->GetLastCommittedURL());
+}
+
class RemoteFrameHostInterceptor
: public blink::mojom::RemoteFrameHostInterceptorForTesting {
public:
Original Bug Report
Renderer navigation bypass to privileged schemes via client_side_redirect_url
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised renderer can bypass security restrictions and navigate to privileged schemes like chrome:// or file:// by supplying a malicious client_side_redirect_url during navigation. When triggered by common browser UI actions like toggling “Request Desktop Site”, the browser reloads this poisoned URL as a fully trusted, browser-initiated navigation.
Affected files:
content/browser/renderer_host/navigation_controller_impl.cccontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/ipc_utils.cccontent/browser/renderer_host/navigation_request.cc
Estimated timestamp from git blame: 2024-01-31
Description
There is a potential vulnerability in Chrome’s navigation handling where a compromised renderer can force the browser to navigate to privileged schemes (e.g., chrome://, file://, devtools://). This bypasses ChildProcessSecurityPolicy restrictions that normally prevent renderer-initiated navigations to these sensitive schemes.
The core issue is a missing security check in the browser process for the client_side_redirect_url field within blink::mojom::BeginNavigationParams. While other navigation parameters (like common_params.url and begin_params.searchable_form_url) are properly validated and filtered via RenderProcessHost::FilterURL or ChildProcessSecurityPolicy checks, client_side_redirect_url is passed through completely unfiltered.
Once a navigation completes with this poisoned parameter, the browser stores the malicious URL as the original_request_url in the active NavigationEntry. If the user performs a common UI action that reloads the original URL—such as toggling “Request Desktop Site” on mobile or switching device postures on ChromeOS—the browser executes a new navigation to the poisoned URL. Because this new navigation is initiated by browser UI code (NavigationControllerImpl::LoadOriginalRequestURL), it defaults to is_renderer_initiated = false, treating the navigation as highly trusted and bypassing all renderer sandbox restrictions.
Potential Steps to Reproduce
(Note: These are suggested steps based on static code analysis; our tooling cannot currently execute a live proof-of-concept.)
- Initiate Malicious Navigation: A compromised renderer sends a
BeginNavigationIPC with:common_params.url = 'https://attacker.com/landing'begin_params.client_side_redirect_url = 'chrome://settings/'(or another privileged scheme).
- Bypass Filtering: In the browser process,
RenderFrameHostImpl::BeginNavigationreceives the IPC. It fails to filter or validatebegin_params->client_side_redirect_url. - Poison Redirect Chain: During
NavigationRequest::StartNavigation(), the browser checks ifclient_side_redirect_urlis not empty and pushes it as the first element (redirect_chain_[0]) of the navigation’s redirect chain. - Commit Poisoned State: When the navigation to
https://attacker.com/landingcommits,NavigationControllerImplsaves the original request URL to theNavigationEntry. It callsNavigationRequest::GetOriginalRequestURL(), which returnsredirect_chain_[0](chrome://settings/). - Wait for User Interaction: The attacker’s page is now loaded. The exploit waits for a standard browser UI action that triggers a reload of the original URL (e.g., the user tapping “Request Desktop Site” on Android, or physically exiting tablet mode on a ChromeOS device).
- Trigger Privilege Escalation: The UI action triggers
NavigationControllerImpl::LoadOriginalRequestURL(). This method reads the poisonedoriginal_request_url(chrome://settings/) from theNavigationEntryand constructs aLoadURLParamsobject. - Bypass CPSP:
LoadURLParamsexplicitly defaultsis_renderer_initiatedtofalse.NavigationControllerImplcallsLoadURLWithParams, initiating a fully browser-trusted navigation tochrome://settings/.ChildProcessSecurityPolicychecks are bypassed, and the privileged WebUI commits successfully, breaking the web sandbox.
Suggested Fix
- Add Filtering/Validation: In
RenderFrameHostImpl::BeginNavigation, explicitly validate and filterbegin_params->client_side_redirect_urlusingGetProcess()->FilterURL(true, &begin_params->client_side_redirect_url)or via explicitChildProcessSecurityPolicy::CanRequestURLchecks, ensuring a compromised renderer cannot claim a client-side redirect from a scheme it is not allowed to access. - Defense in Depth: Consider dropping
client_side_redirect_urlentirely if it specifies a privileged scheme that the originating renderer process does not have access to.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.