CVE-2026-102330
Overview
Background
- Synchronous about:blank commit
- A commit the renderer performs locally (without a browser-side
NavigationRequest) when a frame first loadsabout:blank, reporting its result to the browser viaDidCommitProvisionalLoadParams. - `last_committed_origin_`
- The browser-derived origin for the frame, set in
SetOriginDependentStateOfNewFrame()before the first navigation and treated as the authoritative expected origin for the synchronous commit. - Opaque origin / precursor
- An origin with no tuple (e.g. a sandboxed document), carrying a
precursortuple recording the origin it was derived from, retrievable viaGetTupleOrPrecursorTupleIfOpaque(). - `Origin::CanBeDerivedFrom()`
- A
url::Originhelper that checks whether an origin could legitimately have been produced from a givenGURL, and whichDCHECKs (crashes/undefined in release) when handed an invalid/emptyGURL.
Root Cause Analysis
The origin-validation branch in DidCommitNavigationInternal() (in render_frame_host_impl.cc) guarded synchronous about:blank commits with !params->origin.opaque() && !params->origin.CanBeDerivedFrom(last_committed_origin_.GetURL()). This had two defects: it only ran the CanBeDerivedFrom() consistency check when the renderer-reported origin was non-opaque, so a compromised renderer could report any opaque origin and have it silently accepted, replacing the browser-derived last_committed_origin_; and when last_committed_origin_ was itself opaque, GetURL() yielded an empty/invalid GURL that was passed into CanBeDerivedFrom(), tripping its DCHECK.
The fix branches on whether the browser-derived origin is opaque: if opaque, the reported origin must also be opaque with a matching precursor (GetTupleOrPrecursorTupleIfOpaque() equality); if non-opaque, the existing CanBeDerivedFrom() check is applied but now always with a valid GURL; only on failure does it call ReceivedBadMessage() and return false. This restores the invariant that the renderer-reported origin for a synchronous about:blank commit must agree with the browser-derived origin, closing the path that let the renderer substitute an unrelated origin.
GURL into CanBeDerivedFrom().Attack Path
- Compromise a renderer
An attacker with a compromised or malicious renderer process (the standard SiteIsolation threat model) controls the contents of
DidCommitProvisionalLoadParams, includingparams->origin. - Trigger a synchronous about:blank commit
The renderer creates a frame that loads
about:blanksynchronously (e.g. a sandboxed iframe withsrc="about:blank"), which commits without a browser-sideNavigationRequest, exercising the vulnerable branch. - Report a forged opaque origin
Because the old check skipped validation for opaque origins, the renderer reports an arbitrary opaque origin (any precursor, or none); the browser accepts it and lets it replace the browser-derived
last_committed_origin_(demonstrated by theSyncAboutBlankCommitOriginReplacertest, which the post-patch code now rejects withRFHI_SYNCHRONOUS_COMMIT_ORIGIN_MISMATCH). - Obtain an incorrect authorization state
With the frame’s committed origin now under renderer control, subsequent origin-based authorization decisions in the browser can be made against an attacker-chosen origin (incorrect authorization in SiteIsolation)
(inferred).
Impact Assessment
about:blank commit, defeating SiteIsolation’s origin-based authorization; the advisory classifies this as “Incorrect authorization in SiteIsolation” (AuthorizationError). The precondition is renderer control over the commit params plus the ability to trigger a synchronous about:blank commit. The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5. Any further escalation (cross-origin data access, sandbox bypass) beyond the origin confusion is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/render_frame_host_impl.cc |
modified | |
SyncAboutBlankCommitOriginReplacercontent/browser/security_exploit_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fcontent/browser/security_exploit_browsertest.cc |
modified |
Files Changed
content/browser/bad_message.htools/metrics/histograms/metadata/stability/enums.xmlcontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/security_exploit_browsertest.cc
Audit Directions
- Opaque-origin short-circuits in commit validationLook for origin/URL consistency checks that special-case
opaque()by skipping validation entirely; an opaque origin still carries a precursor that must be compared, not trusted blindly. - Invalid GURL into origin helpersAudit callers that pass
Origin::GetURL()(empty for opaque origins) into functions likeCanBeDerivedFrom()thatDCHECKon invalid URLs, especially on paths reachable from renderer-supplied data. - Synchronous / NavigationRequest-less commit pathsReview all
DidCommitProvisionalLoadParamshandling wherenavigation_requestis null (synchronousabout:blank, initial empty document), since browser-side invariants normally enforced via theNavigationRequestmust be re-derived and validated from browser state instead.
Patch
From 2ddf8c2004f3fe43573fb900ce5cccd2fc660c52 Mon Sep 17 00:00:00 2001
From: Rakina Zata Amni <rakina@chromium.org>
Date: Mon, 14 Sep 2026 17:37:30 -0700
Subject: [PATCH] Validate opaque origins on synchronous about:blank commits
Before the first navigation in a frame, the browser sets the last
committed origin in SetOriginDependentStateOfNewFrame(). For synchronous
about:blank commits, the origin reported by the renderer in
DidCommitProvisionalLoadParams should agree with this browser-derived
value.
The existing check in DidCommitNavigationInternal() only covered the
case where the renderer reports a non-opaque origin, and when the
browser-derived origin was opaque it passed an empty GURL to
Origin::CanBeDerivedFrom(), which DCHECKs on invalid URLs. It also
accepted any opaque origin from the renderer, allowing the
browser-derived origin to be silently replaced.
This change branches on whether the browser-derived origin is opaque.
When it is, the renderer-reported origin must also be opaque with a
matching precursor; the nonce is not yet compared because the browser
and renderer can still derive different nonces for popups opened from
CSP-sandboxed documents with allow-popups-to-escape-sandbox. When it is
not opaque, the existing CanBeDerivedFrom() check is applied (now always
with a valid URL), and the "became opaque" case remains allowed for the
same popup scenario.
TAG=agy
CONV=5c1f3f18-89f3-4682-8400-f86fd31c0142
Bug: 498793976
Change-Id: I8db9b9f842660126984682f6340b417741e50e44
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8181529
Reviewed-by: Will Harris <wfh@chromium.org>
Reviewed-by: Charlie Reis <creis@chromium.org>
Commit-Queue: Rakina Zata Amni <rakina@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1697688}
---
diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index af993c2..568abf9 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -374,7 +374,7 @@
RFHI_WEBMCP_DUPLICATE_BIND = 346,
RFHI_WEBMCP_DUPLICATE_SET_RECEIVER = 347,
RFHI_WEBMCP_EXPOSED_UNTRUSTWORTHY_ORIGIN = 348,
- RFHI_SYNCHONOUS_COMMIT_ORIGIN_MISMATCH = 349,
+ RFHI_SYNCHRONOUS_COMMIT_ORIGIN_MISMATCH = 349,
RFHI_WEBMCP_INVALID_TOOL_OWNER = 350,
NR_BAD_ORIGIN_HEADER = 351,
RFH_DID_COMMIT_NAVIGATION_WHILE_BFCACHED = 352,
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 13a2167..d63fe6f3 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -16298,8 +16298,16 @@
navigation_request
? navigation_request->GetUrlInfo().embedder_isolation_info
: EmbedderIsolationInfo::CreateNone();
+ // Use `is_sandboxed` from `navigation_request` (if provided) to represent the
+ // newly committing document's sandbox state (which can differ from this
+ // RFH's active document sandbox state prior to commit, e.g. due to CSP
+ // sandbox response headers). When there is no `navigation_request` (e.g.,
+ // synchronous about:blank commits), fall back to the frame's active sandbox
+ // flags.
bool is_sandboxed =
- navigation_request && navigation_request->GetUrlInfo().is_sandboxed;
+ navigation_request
+ ? navigation_request->GetUrlInfo().is_sandboxed
+ : IsSandboxed(network::mojom::WebSandboxFlags::kOrigin);
// Attempts to commit certain off-limits URL should be caught more strictly
// than our FilterURL checks. If a renderer violates this policy, it
@@ -16686,12 +16694,38 @@
if (is_synchronous_about_blank_commit &&
params->origin.scheme() != url::kFileScheme &&
(!frame_tree_->is_guest() ||
- !base::FeatureList::IsEnabled(features::kGuestViewMPArch)) &&
- !params->origin.opaque() &&
- !params->origin.CanBeDerivedFrom(last_committed_origin_.GetURL())) {
- bad_message::ReceivedBadMessage(
- GetProcess(), bad_message::RFHI_SYNCHONOUS_COMMIT_ORIGIN_MISMATCH);
- return false;
+ !base::FeatureList::IsEnabled(features::kGuestViewMPArch))) {
+ bool origin_matches = false;
+ if (last_committed_origin_.opaque()) {
+ // When the browser-derived origin is opaque, the renderer-reported
+ // origin must also be opaque with a matching precursor.
+ // TODO(crbug.com/486082219): Compare origin nonces as well once nonces
+ // are synchronized for popups opened from CSP-sandboxed documents with
+ // allow-popups-to-escape-sandbox.
+ origin_matches =
+ params->origin.opaque() &&
+ params->origin.GetTupleOrPrecursorTupleIfOpaque() ==
+ last_committed_origin_.GetTupleOrPrecursorTupleIfOpaque();
+ } else if (params->origin.opaque()) {
+ // The browser-derived origin is not opaque but the renderer-reported
+ // one is. This can legitimately (but incorrectly) happen for popups
+ // opened from CSP-sandboxed documents with
+ // allow-popups-to-escape-sandbox, where the browser sees no sandbox
+ // flags on the frame but the renderer still inherits CSP sandbox flags
+ // for the document.
+ // TODO(crbug.com/486082219): Remove this handling once the incorrect
+ // case above can no longer happen and browser & renderer origins are
+ // always consistent.
+ origin_matches = true;
+ } else {
+ origin_matches =
+ params->origin.CanBeDerivedFrom(last_committed_origin_.GetURL());
+ }
+ if (!origin_matches) {
+ bad_message::ReceivedBadMessage(
+ GetProcess(), bad_message::RFHI_SYNCHRONOUS_COMMIT_ORIGIN_MISMATCH);
+ return false;
+ }
}
// Fill the redirect chain for the NavigationRequest. Since this is only for
diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml
index f3ce4145..e954265b 100644
--- a/tools/metrics/histograms/metadata/stability/enums.xml
+++ b/tools/metrics/histograms/metadata/stability/enums.xml
@@ -524,7 +524,7 @@
<int value="346" label="RFHI_WEBMCP_DUPLICATE_BIND"/>
<int value="347" label="RFHI_WEBMCP_DUPLICATE_SET_RECEIVER"/>
<int value="348" label="RFHI_WEBMCP_EXPOSED_UNTRUSTWORTHY_ORIGIN"/>
- <int value="349" label="RFHI_SYNCHONOUS_COMMIT_ORIGIN_MISMATCH"/>
+ <int value="349" label="RFHI_SYNCHRONOUS_COMMIT_ORIGIN_MISMATCH"/>
<int value="350" label="RFHI_WEBMCP_INVALID_TOOL_OWNER"/>
<int value="351" label="NR_BAD_ORIGIN_HEADER"/>
<int value="352" label="RFH_DID_COMMIT_NAVIGATION_WHILE_BFCACHED"/>
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index c029f4f..734f718 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -4828,6 +4828,40 @@
url::Origin origin_override_;
};
+// Interceptor that replaces the origin in DidCommitProvisionalLoadParams with
+// the specified value for synchronous about:blank commits, which arrive without
+// a NavigationRequest.
+class SyncAboutBlankCommitOriginReplacer
+ : public DidCommitNavigationInterceptor {
+ public:
+ SyncAboutBlankCommitOriginReplacer(WebContents* web_contents,
+ url::Origin origin_override)
+ : DidCommitNavigationInterceptor(web_contents),
+ origin_override_(std::move(origin_override)) {}
+
+ SyncAboutBlankCommitOriginReplacer(
+ const SyncAboutBlankCommitOriginReplacer&) = delete;
+ SyncAboutBlankCommitOriginReplacer& operator=(
+ const SyncAboutBlankCommitOriginReplacer&) = delete;
+
+ ~SyncAboutBlankCommitOriginReplacer() override = default;
+
+ protected:
+ bool WillProcessDidCommitNavigation(
+ RenderFrameHost* render_frame_host,
+ NavigationRequest* navigation_request,
+ mojom::DidCommitProvisionalLoadParamsPtr* params,
+ mojom::DidCommitProvisionalLoadInterfaceParamsPtr*) override {
+ if (!navigation_request && (*params)->url.IsAboutBlank()) {
+ (*params)->origin = origin_override_;
+ }
+ return true;
+ }
+
+ private:
+ url::Origin origin_override_;
+};
+
} // namespace
IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest, MismatchedOriginToCommit) {
@@ -4847,6 +4881,70 @@
EXPECT_EQ(bad_message::RFH_ORIGIN_TO_COMMIT_MISMATCH, kill_waiter.Wait());
}
+// Verify that a sandboxed iframe's synchronous about:blank commit rejects a
+// non-opaque origin when an opaque origin is expected.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ SandboxedIframeSyncCommitWithNonOpaqueOrigin) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryFrameTree()
+ .root();
+ url::Origin parent_origin =
+ root->current_frame_host()->GetLastCommittedOrigin();
+ EXPECT_FALSE(parent_origin.opaque());
+
+ // Set up an interceptor that modifies the about:blank commit's origin to
+ // test robustness against invalid origin reporting from the renderer.
+ SyncAboutBlankCommitOriginReplacer replacer(shell()->web_contents(),
+ parent_origin);
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ root->current_frame_host()->GetProcess());
+
+ // Create a sandboxed iframe with src="about:blank". The renderer performs a
+ // synchronous about:blank commit whose origin is modified above.
+ ExecuteScriptAsync(root,
+ "var iframe = document.createElement('iframe');"
+ "iframe.sandbox = '';"
+ "iframe.src = 'about:blank';"
+ "document.body.appendChild(iframe);");
+
+ EXPECT_EQ(bad_message::RFHI_SYNCHRONOUS_COMMIT_ORIGIN_MISMATCH,
+ kill_waiter.Wait());
+}
+
+// Verify that a sandboxed iframe's synchronous about:blank commit rejects an
+// opaque origin whose precursor does not match the expected precursor.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ SandboxedIframeSyncCommitWithMismatchedOpaqueOrigin) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryFrameTree()
+ .root();
+
+ // Set up an interceptor that modifies the about:blank commit's origin to an
+ // opaque origin with no precursor, testing error handling when precursors do
+ // not match.
+ SyncAboutBlankCommitOriginReplacer replacer(shell()->web_contents(),
+ url::Origin());
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ root->current_frame_host()->GetProcess());
+
+ // Create a sandboxed iframe with src="about:blank". The renderer performs a
+ // synchronous about:blank commit whose origin is modified above.
+ ExecuteScriptAsync(root,
+ "var iframe = document.createElement('iframe');"
+ "iframe.sandbox = '';"
+ "iframe.src = 'about:blank';"
+ "document.body.appendChild(iframe);");
+
+ EXPECT_EQ(bad_message::RFHI_SYNCHRONOUS_COMMIT_ORIGIN_MISMATCH,
+ kill_waiter.Wait());
+}
+
// Ensure that the renderer is terminated if it sends an OpenURL IPC with an
// invalid SWITCH_TO_TAB disposition.
IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest, OpenURLInvalidDisposition) {
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index c029f4f..734f718 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -4828,6 +4828,40 @@
url::Origin origin_override_;
};
+// Interceptor that replaces the origin in DidCommitProvisionalLoadParams with
+// the specified value for synchronous about:blank commits, which arrive without
+// a NavigationRequest.
+class SyncAboutBlankCommitOriginReplacer
+ : public DidCommitNavigationInterceptor {
+ public:
+ SyncAboutBlankCommitOriginReplacer(WebContents* web_contents,
+ url::Origin origin_override)
+ : DidCommitNavigationInterceptor(web_contents),
+ origin_override_(std::move(origin_override)) {}
+
+ SyncAboutBlankCommitOriginReplacer(
+ const SyncAboutBlankCommitOriginReplacer&) = delete;
+ SyncAboutBlankCommitOriginReplacer& operator=(
+ const SyncAboutBlankCommitOriginReplacer&) = delete;
+
+ ~SyncAboutBlankCommitOriginReplacer() override = default;
+
+ protected:
+ bool WillProcessDidCommitNavigation(
+ RenderFrameHost* render_frame_host,
+ NavigationRequest* navigation_request,
+ mojom::DidCommitProvisionalLoadParamsPtr* params,
+ mojom::DidCommitProvisionalLoadInterfaceParamsPtr*) override {
+ if (!navigation_request && (*params)->url.IsAboutBlank()) {
+ (*params)->origin = origin_override_;
+ }
+ return true;
+ }
+
+ private:
+ url::Origin origin_override_;
+};
+
} // namespace
IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest, MismatchedOriginToCommit) {
@@ -4847,6 +4881,70 @@
EXPECT_EQ(bad_message::RFH_ORIGIN_TO_COMMIT_MISMATCH, kill_waiter.Wait());
}
+// Verify that a sandboxed iframe's synchronous about:blank commit rejects a
+// non-opaque origin when an opaque origin is expected.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ SandboxedIframeSyncCommitWithNonOpaqueOrigin) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryFrameTree()
+ .root();
+ url::Origin parent_origin =
+ root->current_frame_host()->GetLastCommittedOrigin();
+ EXPECT_FALSE(parent_origin.opaque());
+
+ // Set up an interceptor that modifies the about:blank commit's origin to
+ // test robustness against invalid origin reporting from the renderer.
+ SyncAboutBlankCommitOriginReplacer replacer(shell()->web_contents(),
+ parent_origin);
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ root->current_frame_host()->GetProcess());
+
+ // Create a sandboxed iframe with src="about:blank". The renderer performs a
+ // synchronous about:blank commit whose origin is modified above.
+ ExecuteScriptAsync(root,
+ "var iframe = document.createElement('iframe');"
+ "iframe.sandbox = '';"
+ "iframe.src = 'about:blank';"
+ "document.body.appendChild(iframe);");
+
+ EXPECT_EQ(bad_message::RFHI_SYNCHRONOUS_COMMIT_ORIGIN_MISMATCH,
+ kill_waiter.Wait());
+}
+
+// Verify that a sandboxed iframe's synchronous about:blank commit rejects an
+// opaque origin whose precursor does not match the expected precursor.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ SandboxedIframeSyncCommitWithMismatchedOpaqueOrigin) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryFrameTree()
+ .root();
+
+ // Set up an interceptor that modifies the about:blank commit's origin to an
+ // opaque origin with no precursor, testing error handling when precursors do
+ // not match.
+ SyncAboutBlankCommitOriginReplacer replacer(shell()->web_contents(),
+ url::Origin());
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ root->current_frame_host()->GetProcess());
+
+ // Create a sandboxed iframe with src="about:blank". The renderer performs a
+ // synchronous about:blank commit whose origin is modified above.
+ ExecuteScriptAsync(root,
+ "var iframe = document.createElement('iframe');"
+ "iframe.sandbox = '';"
+ "iframe.src = 'about:blank';"
+ "document.body.appendChild(iframe);");
+
+ EXPECT_EQ(bad_message::RFHI_SYNCHRONOUS_COMMIT_ORIGIN_MISMATCH,
+ kill_waiter.Wait());
+}
+
// Ensure that the renderer is terminated if it sends an OpenURL IPC with an
// invalid SWITCH_TO_TAB disposition.
IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest, OpenURLInvalidDisposition) {