CVE-2026-79191
Overview
Background
- Initiator origin
- The origin of the document that started a navigation; the browser validates the renderer-supplied initiator so a compromised renderer cannot spoof who initiated a navigation.
- Opaque origin precursor
- An opaque origin carries a “precursor” tuple recording the origin it was derived from; validation must ensure the renderer cannot claim an arbitrary precursor it was never allowed to host.
- MHTML subframe
- A subframe within a saved MHTML archive document, which loads content from the archive and, historically, could have precursor origins not matching the MHTML document’s process lock.
- `ChildProcessSecurityPolicyImpl` committed-origin tracking
AddCommittedOrigin()records every committed frame’s origin andHostsOrigin()consults that recorded set to decide whether a process is allowed to host a given origin.
Root Cause Analysis
VerifyInitiatorOrigin() in ipc_utils.cc contained a special case: when the navigated frame was an MHTML subframe (current_rfh && current_rfh->IsMhtmlSubframe()), it return trued early for any opaque initiator origin, accepting whatever precursor tuple the renderer supplied without further checks. As the removed comment explains, this exception dated to before ChildProcessSecurityPolicyImpl tracked committed origins, when HostsOrigin() compared only against the process lock and would have wrongly rejected the legitimate archive-derived precursors seen in NavigationMhtmlBrowserTest.DataIframe (e.g. lock file:/// sandboxed vs precursor http://8.8.8.8/). Once committed-origin tracking landed, every frame in an MHTML page that could legitimately initiate such a navigation has already committed in the process, so its opaque origin is recorded by AddCommittedOrigin and the subsequent HostsOrigin() check accepts it — making the early return unnecessary and dangerous, because it let a compromised renderer claim an opaque initiator with an arbitrary, unrelated precursor.
The fix deletes the early return so MHTML-subframe initiators fall through to the normal HostsOrigin()-based validation. The added browser test injects an opaque initiator with an unrelated precursor on an MHTML subframe’s BeginNavigation and confirms the renderer is terminated with INVALID_INITIATOR_ORIGIN.
HostsOrigin() validate MHTML-subframe initiators like any other, rejecting spoofed precursors.Attack Path
- Load an MHTML archive
The victim navigates to an MHTML document containing a subframe, so an MHTML subframe (
IsMhtmlSubframe()true) exists in the MHTML document’s process. - Compromised renderer forges initiator
A compromised/malicious renderer starts a navigation from the MHTML subframe and supplies an opaque initiator origin whose precursor is unrelated to anything the archive or process legitimately hosts (the test injects one derived from
https://other-precursor.example). - Bypassed validation (pre-patch)
VerifyInitiatorOrigin()hit theIsMhtmlSubframe()early return and accepted the forged opaque initiator and its arbitrary precursor without consultingHostsOrigin(). - Spoofed initiator accepted The navigation proceeded with a renderer-chosen initiator/precursor the process was not authorized to claim, defeating the site-isolation initiator-origin check (inferred as to any concrete downstream security-decision abuse; the patch shows the acceptance/rejection, not a specific follow-on primitive).
Impact Assessment
AuthorizationError (“Incorrect authorization in SiteIsolation”) and does not report exploitation in the wild. The precondition is a renderer (already compromised, as the test simulates by overwriting the IPC’s initiator) driving navigation from an MHTML subframe; any escalation beyond initiator spoofing is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
BeginNavigationInitiatorReplacercontent/browser/navigation_mhtml_browsertest.cc |
modified | |
ifcontent/browser/navigation_mhtml_browsertest.cc |
modified |
Files Changed
content/browser/navigation_mhtml_browsertest.cccontent/browser/renderer_host/ipc_utils.cc
Audit Directions
- Obsolete validation exceptionsSearch for early
return true/ skip-check branches justified by comments describing pre-refactor limitations (e.g. process-lock-only checks) that may be stale after security infrastructure changes. - Opaque origin precursorsAudit all initiator- and origin-validation sites that special-case opaque origins to confirm the precursor tuple is still checked against committed-origin tracking rather than trusted from the renderer.
- MHTML and other special document typesReview other MHTML/sandboxed/data-URL special cases in navigation and origin validation to ensure they now rely on
AddCommittedOrigin/HostsOrigin()rather than legacy process-lock comparisons.
Patch
From f61dcd7ebcda685675065f7abf556c4f16f69ed1 Mon Sep 17 00:00:00 2001 From: Alex Moshchuk <[email protected]> Date: Mon, 20 Jul 2026 12:29:52 -0700 Subject: [PATCH] Validate opaque initiator precursors for MHTML subframes VerifyInitiatorOrigin() returned early for any opaque initiator origin when the navigated frame was an MHTML subframe, accepting whatever precursor tuple the renderer supplied. The exception was added before ChildProcessSecurityPolicyImpl tracked committed origins, when HostsOrigin() compared only against the process lock and rejected the legitimate archive-derived precursors seen in NavigationMhtmlBrowserTest.DataIframe. Now that AddCommittedOrigin() records every committed frame's origin and HostsOrigin() consults that set, any frame in an MHTML page that could legitimately initiate a navigation already passes the check. Drop the early return so the precursor is validated like any other initiator origin, and add a browser test that intercepts BeginNavigation on an MHTML subframe and injects an opaque initiator with an unrelated precursor, expecting the renderer to be terminated with INVALID_INITIATOR_ORIGIN. Bug: 517606780 Change-Id: I5d0f9b921d5df73fc4c32db8347baa0c2b7945c4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8118708 Commit-Queue: Alex Moshchuk <[email protected]> Reviewed-by: Łukasz Anforowicz <[email protected]> Cr-Commit-Position: refs/heads/main@{#1664853} --- diff --git a/content/browser/renderer_host/ipc_utils.cc b/content/browser/renderer_host/ipc_utils.cc index 94ee783..9c5c718 100644 --- a/content/browser/renderer_host/ipc_utils.cc +++ b/content/browser/renderer_host/ipc_utils.cc @@ -100,17 +100,20 @@ return true; } - // Certain (e.g., data:) navigations in subframes of MHTML documents may - // have precursor origins that do not match the process lock of the MHTML - // document. This is seen in NavigationMhtmlBrowserTest.DataIframe, where: + // Navigations in subframes of MHTML documents may have precursor origins + // that do not match the process lock of the MHTML document. This is seen + // in NavigationMhtmlBrowserTest.DataIframe, where: // - renderer origin lock = { file:/// sandboxed } // - precursor of initiator origin = http://8.8.8.8/ - // Note that RenderFrameHostImpl::CanCommitOriginAndUrl() similarly allows - // such navigations to commit, and it also ensures that they can only commit - // in the main frame MHTML document's process. - if (current_rfh && current_rfh->IsMhtmlSubframe()) { - return true; - } + // In the past, this case used to be special-cased here, but this is no + // longer needed now that ChildProcessSecurityPolicy's enforcements have + // been switched to use committed origin tracking. Any frame in the MHTML + // page that could legitimately initiate such a navigation has already + // committed in this process, so its (opaque) origin has been recorded by + // ChildProcessSecurityPolicyImpl::AddCommittedOrigin and the HostsOrigin() + // check below will accept it. There is therefore no need to skip the check + // for MHTML subframes, and doing so would allow the renderer to claim an + // opaque initiator with an arbitrary precursor. } auto* policy = ChildProcessSecurityPolicyImpl::GetInstance(); diff --git a/content/browser/navigation_mhtml_browsertest.cc b/content/browser/navigation_mhtml_browsertest.cc index df1989b..7126186 100644 --- a/content/browser/navigation_mhtml_browsertest.cc +++ b/content/browser/navigation_mhtml_browsertest.cc @@ -30,6 +30,7 @@ #include "content/public/test/test_utils.h" #include "content/shell/browser/shell.h" #include "content/test/content_browser_test_utils_internal.h" +#include "content/test/frame_host_interceptor.h" #include "mojo/public/c/system/trap.h" #include "mojo/public/c/system/types.h" #include "mojo/public/cpp/system/data_pipe.h" @@ -41,6 +42,7 @@ #include "third_party/blink/public/common/features.h" #include "third_party/blink/public/common/page_state/page_state.h" #include "url/gurl.h" +#include "url/origin.h" #include "url/url_constants.h" namespace content { @@ -1022,4 +1024,71 @@ kill_waiter.Wait()); } +// Verifies that an MHTML subframe cannot start a navigation with an opaque +// initiator origin whose precursor doesn't correspond to anything that has +// committed in the MHTML document's process. +IN_PROC_BROWSER_TEST_F(NavigationMhtmlImprovementsBrowserTest, + MhtmlSubframeBeginNavigationOpaqueInitiatorPrecursor) { + // Intercepts BeginNavigation to overwrite the initiator origin once + // activated. This simulates a renderer that lies about the initiator. + class BeginNavigationInitiatorReplacer : public FrameHostInterceptor { + public: + BeginNavigationInitiatorReplacer(WebContents* web_contents, + url::Origin initiator_to_inject) + : FrameHostInterceptor(web_contents), + initiator_to_inject_(std::move(initiator_to_inject)) {} + + 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_) { + (*common_params)->initiator_origin = initiator_to_inject_; + is_activated_ = false; + } + return true; + } + + void Activate() { is_activated_ = true; } + + private: + url::Origin initiator_to_inject_; + bool is_activated_ = false; + }; + + // The interceptor must be created before the frames whose IPCs it will + // intercept. Use an opaque origin whose precursor is unrelated to anything + // the MHTML archive contains. + url::Origin injected_origin = + url::Origin::Create(GURL("https://other-precursor.example")) + .DeriveNewOpaqueOrigin(); + BeginNavigationInitiatorReplacer injector(web_contents(), injected_origin); + + MhtmlArchive mhtml_archive; + mhtml_archive.AddHtmlDocument( + GURL("http://example.com"), + "<iframe src=\"http://example.com/subframe.html\"></iframe>"); + mhtml_archive.AddHtmlDocument(GURL("http://example.com/subframe.html"), + "subframe content"); + GURL mhtml_url = mhtml_archive.Write("index.mhtml"); + EXPECT_TRUE(NavigateToURL(shell(), mhtml_url)); + + RenderFrameHostImpl* main_document = main_frame_host(); + ASSERT_EQ(1u, main_document->child_count()); + RenderFrameHostImpl* sub_document = + main_document->child_at(0)->current_frame_host(); + ASSERT_TRUE(sub_document->IsMhtmlSubframe()); + + // Start a renderer-initiated navigation in the subframe and overwrite its + // initiator origin. The renderer should be terminated since the precursor + // doesn't match anything the process is allowed to host. + RenderProcessHostBadIpcMessageWaiter kill_waiter(sub_document->GetProcess()); + injector.Activate(); + ExecuteScriptAsync(sub_document, "window.location = 'about:blank';"); + EXPECT_EQ(bad_message::INVALID_INITIATOR_ORIGIN, kill_waiter.Wait()); +} + } // namespace content
Regression Test / PoC
diff --git a/content/browser/navigation_mhtml_browsertest.cc b/content/browser/navigation_mhtml_browsertest.cc
index df1989b..7126186 100644
--- a/content/browser/navigation_mhtml_browsertest.cc
+++ b/content/browser/navigation_mhtml_browsertest.cc
@@ -30,6 +30,7 @@
#include "content/public/test/test_utils.h"
#include "content/shell/browser/shell.h"
#include "content/test/content_browser_test_utils_internal.h"
+#include "content/test/frame_host_interceptor.h"
#include "mojo/public/c/system/trap.h"
#include "mojo/public/c/system/types.h"
#include "mojo/public/cpp/system/data_pipe.h"
@@ -41,6 +42,7 @@
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/page_state/page_state.h"
#include "url/gurl.h"
+#include "url/origin.h"
#include "url/url_constants.h"
namespace content {
@@ -1022,4 +1024,71 @@
kill_waiter.Wait());
}
+// Verifies that an MHTML subframe cannot start a navigation with an opaque
+// initiator origin whose precursor doesn't correspond to anything that has
+// committed in the MHTML document's process.
+IN_PROC_BROWSER_TEST_F(NavigationMhtmlImprovementsBrowserTest,
+ MhtmlSubframeBeginNavigationOpaqueInitiatorPrecursor) {
+ // Intercepts BeginNavigation to overwrite the initiator origin once
+ // activated. This simulates a renderer that lies about the initiator.
+ class BeginNavigationInitiatorReplacer : public FrameHostInterceptor {
+ public:
+ BeginNavigationInitiatorReplacer(WebContents* web_contents,
+ url::Origin initiator_to_inject)
+ : FrameHostInterceptor(web_contents),
+ initiator_to_inject_(std::move(initiator_to_inject)) {}
+
+ 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_) {
+ (*common_params)->initiator_origin = initiator_to_inject_;
+ is_activated_ = false;
+ }
+ return true;
+ }
+
+ void Activate() { is_activated_ = true; }
+
+ private:
+ url::Origin initiator_to_inject_;
+ bool is_activated_ = false;
+ };
+
+ // The interceptor must be created before the frames whose IPCs it will
+ // intercept. Use an opaque origin whose precursor is unrelated to anything
+ // the MHTML archive contains.
+ url::Origin injected_origin =
+ url::Origin::Create(GURL("https://other-precursor.example"))
+ .DeriveNewOpaqueOrigin();
+ BeginNavigationInitiatorReplacer injector(web_contents(), injected_origin);
+
+ MhtmlArchive mhtml_archive;
+ mhtml_archive.AddHtmlDocument(
+ GURL("http://example.com"),
+ "<iframe src=\"http://example.com/subframe.html\"></iframe>");
+ mhtml_archive.AddHtmlDocument(GURL("http://example.com/subframe.html"),
+ "subframe content");
+ GURL mhtml_url = mhtml_archive.Write("index.mhtml");
+ EXPECT_TRUE(NavigateToURL(shell(), mhtml_url));
+
+ RenderFrameHostImpl* main_document = main_frame_host();
+ ASSERT_EQ(1u, main_document->child_count());
+ RenderFrameHostImpl* sub_document =
+ main_document->child_at(0)->current_frame_host();
+ ASSERT_TRUE(sub_document->IsMhtmlSubframe());
+
+ // Start a renderer-initiated navigation in the subframe and overwrite its
+ // initiator origin. The renderer should be terminated since the precursor
+ // doesn't match anything the process is allowed to host.
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(sub_document->GetProcess());
+ injector.Activate();
+ ExecuteScriptAsync(sub_document, "window.location = 'about:blank';");
+ EXPECT_EQ(bad_message::INVALID_INITIATOR_ORIGIN, kill_waiter.Wait());
+}
+
} // namespace content
Original Bug Report
VerifyInitiatorOrigin bypass via MHTML subframe opaque initiator origin
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A security bypass in VerifyInitiatorOrigin potentially allows a compromised MHTML renderer process to skip process-lock validation checks on opaque initiator origins for MHTML subframes. By supplying an opaque origin containing an arbitrary, attacker-forged precursor origin, the renderer can successfully commit a navigation with the spoofed precursor. This weakness allows the compromised process to register the forged precursor origin within the ChildProcessSecurityPolicy’s committed origins list, bypassing subsequent Site Isolation defenses.
Affected files:
content/browser/renderer_host/ipc_utils.cccontent/browser/renderer_host/render_frame_host_impl.cc
Estimated timestamp from git blame: 2024-06-14
Root Cause Analysis
In content/browser/renderer_host/ipc_utils.cc, VerifyInitiatorOrigin is responsible for ensuring that a renderer process is authorized to host the initiator origin of a navigation. However, the function contains an early return that short-circuits the check if the initiator origin is opaque and the target frame is an MHTML subframe:
// content/browser/renderer_host/ipc_utils.cc
if (initiator_origin.opaque()) {
...
if (current_rfh && current_rfh->IsMhtmlSubframe()) {
return true;
}
}
auto* policy = ChildProcessSecurityPolicyImpl::GetInstance();
if (!policy->HostsOrigin(process_id, initiator_origin)) {
...
}
Because the browser returns true early on line 112, it skips the subsequent HostsOrigin check. This allows a compromised MHTML process to supply an opaque origin wrapping an arbitrary, forged precursor origin (such as https://victim.example) without triggering a process termination.
During navigation commit, RenderFrameHostImpl::CanCommitOriginAndUrl similarly allows opaque origins to commit in an MHTML subframe if it resides in the same SiteInstance as the main frame, which also skips standard process-lock checks on the precursor tuple.
Upon commit, RenderFrameHostImpl::UpdatePermissionsForNavigation registers the committed origin via ChildProcessSecurityPolicyImpl::AddCommittedOrigin, adding opaque{precursor=https://victim.example} to the process’s committed_origins_ list. This enables the compromised process to pass subsequent HostsOrigin checks for the spoofed precursor origin, allowing it to bypass cross-origin boundaries (such as spoofing the source origin in postMessage calls).
Potential Steps to Trigger the Vulnerability
Note: These steps are based on static code analysis; our analysis does not currently include a running proof of concept.
- A user loads a local or downloaded MHTML archive containing an
<iframe>subframe S. - The compromised renderer process hosting the MHTML document sends a
FrameHost::BeginNavigationIPC targeting subframe S with:common_params.url = "about:blank"common_params.initiator_originset to an opaque origin with a forged precursor ofhttps://victim.example.
VerifyInitiatorOrigindetectsIsMhtmlSubframe()is true and accepts the forged initiator origin without validation.- Since the URL is
about:blank, the browser derives the commit origin directly from the initiator origin, preserving the forged precursor. - The navigation completes.
ChildProcessSecurityPolicyregisters the originopaque{precursor=https://victim.example}within the process’s committed origins. - The compromised renderer then calls
RouteMessageEvent(postMessage) targetinghttps://victim.examplewith the spoofed source origin, which successfully passes theHostsOriginchecks inRenderFrameProxyHost.
Suggested Remediation
To remediate this issue, the browser should ensure that the precursor of any opaque initiator origin supplied by an MHTML subframe is validated against the process lock of the MHTML renderer process instead of being skipped. VerifyInitiatorOrigin should check that the precursor of the initiator origin is compatible with the process lock, or enforce that the precursor matches the main frame’s origin (or is otherwise authorized), rather than returning true unconditionally.
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.