CVE-2026-87506
Overview
Background
- `chrome-untrusted://`
- a WebUI scheme (
kChromeUIUntrustedScheme) for browser-hosted UI that may process untrustworthy content, and which is meant to run with no WebUI bindings and no elevated URL-fetch privileges. - Requestable schemes
- the per-WebUI list (
requestable_schemes_, exposed viaweb_ui_->GetRequestableSchemes()) of URL schemes that the WebUI’s renderer process is allowed to fetch. - `ChildProcessSecurityPolicyImpl::GrantRequestScheme` / `CanRequestURL`
- the browser-side security policy that records which schemes a renderer process may request and later answers whether that process is permitted to load a given URL.
- Privilege elevation in WebUI
- a lower-trust WebUI context gaining a capability reserved for higher-trust
chrome://UI, here the ability to requestchrome://andfile://URLs.
Root Cause Analysis
Every WebUIImpl was constructed with requestable_schemes_ pre-populated to {kChromeUIScheme, url::kFileScheme}, so the browser unconditionally treated chrome:// and file:// as requestable for any WebUI. When a frame’s WebUI type was set in content/browser/renderer_host/render_frame_host_impl.cc, the code iterated web_ui_->GetRequestableSchemes() and called ChildProcessSecurityPolicyImpl::GrantRequestScheme for each entry, thereby granting those schemes to the renderer process — including processes hosting chrome-untrusted:// content. The violated invariant is that a chrome-untrusted:// WebUI must not receive chrome:// or file:// request privileges.
The fix removes the default scheme initialization from the WebUIImpl constructor and, at the grant site, only calls web_ui_->AddRequestableScheme(kChromeUIScheme) and AddRequestableScheme(url::kFileScheme) when the frame’s GetSiteInstance()->GetSiteInfo().site_url() is not the kChromeUIUntrustedScheme, so untrusted WebUIs end up with an empty requestable-scheme set. The diff is purely a scope-tightening of who gets the grant; it adds a guard around an existing grant rather than changing any downstream URL-loading mechanism.
chrome:// and file:// requestable schemes by default to all WebUIs regardless of trust level, which leaked those privileges to chrome-untrusted:// contexts; the fix conditions the grant on the site not being kChromeUIUntrustedScheme and drops the default from the constructor.Attack Path
- Load an untrusted WebUI
An attacker gets a
chrome-untrusted://document loaded (e.g. via a feature/UI that embeds untrusted WebUI content), which per the commit message retains this defect “throughout the lifetime,” not only at startup. - Inherit the default grant
On WebUI-type assignment the pre-patch
render_frame_host_impl.ccloop reads the constructor-seededrequestable_schemes_({kChromeUIScheme, url::kFileScheme}) and callsChildProcessSecurityPolicyImpl::GrantRequestSchemefor the untrusted process, soCanRequestURLnow returns true forchrome://andfile://. - Request privileged URLs
From the untrusted renderer the attacker requests
file://(e.g.file:///etc/passwd) andchrome://(e.g.chrome://version) URLs that the untrusted context should never be able to fetch, as the new negative test assertions onCanRequestURLconfirm. - Escalate using the leaked reach
Access to local files and internal
chrome://surfaces could be leveraged for information disclosure or further compromise(inferred); the diff itself only demonstrates the improper scheme grant, not any specific downstream read primitive or sandbox escape.
Impact Assessment
chrome-untrusted:// WebUI gains the ability to request chrome:// and file:// URLs from that renderer process, i.e. a privilege elevation within the WebUI/browser context beyond what the untrusted scheme is supposed to hold. The precondition is that untrusted WebUI content is loaded in a frame that undergoes WebUI-type assignment. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.3. The advisory states only “Privilege elevation in WebUI” and reports no exploited-in-the-wild status or severity, and the patch demonstrates only the scheme grant.Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Fcontent/browser/webui/web_ui_security_browsertest.cc |
modified |
Files Changed
content/browser/webui/web_ui_impl.cccontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/webui/web_ui_security_browsertest.cc
Audit Directions
- Default-granted capabilities in constructorsLook for security-relevant privilege sets (schemes, bindings, permissions) initialized to a permissive default in a base constructor and later applied without re-checking the actual trust level of the context.
- Trust-level checks at grant sitesAudit every
ChildProcessSecurityPolicyImpl::Grant*/AddRequestableSchemecall path to confirm it distinguisheschrome://fromchrome-untrusted://(kChromeUIUntrustedScheme) rather than treating all WebUIs uniformly. - Lifetime vs. startup assumptionsRe-examine WebUI privilege assignments that were reasoned about only for initial navigation, since, as the commit notes, the grant “persists throughout the lifetime” and can apply on later WebUI-type transitions.
Patch
From d074777b13325b2108b4c0c2565c7c239cafa526 Mon Sep 17 00:00:00 2001 From: Fred Shih <[email protected]> Date: Tue, 04 Aug 2026 13:21:57 -0700 Subject: [PATCH] Fix sandbox escape for chrome-untrusted:// The bug only mentioned startup, but I guess this issue persists throughout the lifetime. This change ensures that chrome-untrusted:// does not get access to chrome:// and file:// URLs by default. I hope this doesn't break anything... Bug: 497551905 Change-Id: Ibb48b22505a5603cb235f76e6ff56a23bd6d7e25 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8178737 Commit-Queue: Fred Shih <[email protected]> Reviewed-by: Rakina Zata Amni <[email protected]> Cr-Commit-Position: refs/heads/main@{#1673628} --- diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc index 78ce44e..0ba81f0 100644 --- a/content/browser/renderer_host/render_frame_host_impl.cc +++ b/content/browser/renderer_host/render_frame_host_impl.cc @@ -13740,6 +13740,11 @@ web_ui_type_ = new_web_ui_type; // WebUIs need the ability to request certain schemes. + if (!GetSiteInstance()->GetSiteInfo().site_url().SchemeIs( + kChromeUIUntrustedScheme)) { + web_ui_->AddRequestableScheme(kChromeUIScheme); + web_ui_->AddRequestableScheme(url::kFileScheme); + } for (const auto& scheme : web_ui_->GetRequestableSchemes()) { ChildProcessSecurityPolicyImpl::GetInstance()->GrantRequestScheme( GetProcess()->GetDeprecatedID(), scheme); diff --git a/content/browser/webui/web_ui_impl.cc b/content/browser/webui/web_ui_impl.cc index aa277dde..0fd5d93 100644 --- a/content/browser/webui/web_ui_impl.cc +++ b/content/browser/webui/web_ui_impl.cc @@ -215,8 +215,7 @@ } WebUIImpl::WebUIImpl(WebContents* web_contents) - : requestable_schemes_({kChromeUIScheme, url::kFileScheme}), - web_contents_(web_contents), + : web_contents_(web_contents), web_contents_observer_( std::make_unique<WebUIMainFrameObserver>(this, web_contents_)) { DCHECK(web_contents_); diff --git a/content/browser/webui/web_ui_security_browsertest.cc b/content/browser/webui/web_ui_security_browsertest.cc index 87a9e46..f4ba4f57 100644 --- a/content/browser/webui/web_ui_security_browsertest.cc +++ b/content/browser/webui/web_ui_security_browsertest.cc @@ -58,7 +58,8 @@ ScopedWebUIControllerFactoryRegistration factory_registration_{&factory_}; }; -// Verify chrome-untrusted:// have no bindings. +// Verify chrome-untrusted:// have no bindings and cannot request chrome or file +// URLs. IN_PROC_BROWSER_TEST_F(WebUISecurityTest, UntrustedNoBindings) { auto* web_contents = shell()->web_contents(); WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig( @@ -67,17 +68,19 @@ const GURL untrusted_url(GetChromeUntrustedUIURL("test-host/title1.html")); EXPECT_TRUE(NavigateToURL(web_contents, untrusted_url)); + auto process_id = + *shell()->web_contents()->GetPrimaryMainFrame()->GetProcess()->GetID(); EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()->HasWebUIBindings( - shell() - ->web_contents() - ->GetPrimaryMainFrame() - ->GetProcess() - ->GetDeprecatedID())); + process_id)); EXPECT_TRUE(shell() ->web_contents() ->GetPrimaryMainFrame() ->GetEnabledBindings() .empty()); + EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()->CanRequestURL( + process_id, GURL("file:///etc/passwd"))); + EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()->CanRequestURL( + process_id, GURL("chrome://version"))); } // Loads a WebUI which does not have any bindings.
Regression Test / PoC
diff --git a/content/browser/webui/web_ui_security_browsertest.cc b/content/browser/webui/web_ui_security_browsertest.cc
index 87a9e46..f4ba4f57 100644
--- a/content/browser/webui/web_ui_security_browsertest.cc
+++ b/content/browser/webui/web_ui_security_browsertest.cc
@@ -58,7 +58,8 @@
ScopedWebUIControllerFactoryRegistration factory_registration_{&factory_};
};
-// Verify chrome-untrusted:// have no bindings.
+// Verify chrome-untrusted:// have no bindings and cannot request chrome or file
+// URLs.
IN_PROC_BROWSER_TEST_F(WebUISecurityTest, UntrustedNoBindings) {
auto* web_contents = shell()->web_contents();
WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig(
@@ -67,17 +68,19 @@
const GURL untrusted_url(GetChromeUntrustedUIURL("test-host/title1.html"));
EXPECT_TRUE(NavigateToURL(web_contents, untrusted_url));
+ auto process_id =
+ *shell()->web_contents()->GetPrimaryMainFrame()->GetProcess()->GetID();
EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()->HasWebUIBindings(
- shell()
- ->web_contents()
- ->GetPrimaryMainFrame()
- ->GetProcess()
- ->GetDeprecatedID()));
+ process_id));
EXPECT_TRUE(shell()
->web_contents()
->GetPrimaryMainFrame()
->GetEnabledBindings()
.empty());
+ EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()->CanRequestURL(
+ process_id, GURL("file:///etc/passwd")));
+ EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()->CanRequestURL(
+ process_id, GURL("chrome://version")));
}
// Loads a WebUI which does not have any bindings.