CVE-2026-87606
Overview
Background
- BrowsingInstance / SiteInstanceGroup
- A
BrowsingInstanceis the security container of related pages that may script each other; aSiteInstanceGroupis a per-BrowsingInstance grouping of same-process frames, andIsRelatedSiteInstanceGroup()reports whether two groups live in the sameBrowsingInstance. - `RenderFrameProxyHost`
- The browser-side stand-in for a frame that is hosted in a different
SiteInstanceGroup/process, which receivesRemoteFrameHostandRemoteMainFrameHostIPCs from that remote renderer and forwards them to the frame’s realRenderFrameHostImpl. - Speculative/stale proxy
- During a cross-
BrowsingInstancenavigation theFrameTreeNode’scurrent_frame_host()can move to a newBrowsingInstancewhile an old main-frame proxy still exists in the previous one, so the proxy and the current document can temporarily belong to unrelatedBrowsingInstances (and, via subframe process reuse, even share a renderer process). - Subframe process reuse
- Chrome may place same-site frames from different
BrowsingInstances in the same OS process, so “same process” does not imply “sameBrowsingInstance,” which is the crux this bug exploits.
Root Cause Analysis
Several RenderFrameProxyHost IPC handlers that act on the node’s current_frame_host() — CheckCompleted, UpdateTargetURL, DidChangeOpener, TakeFocus, FocusPage, DidFocusFrame, AdvanceFocus, PrintCrossProcessSubframe, and CapturePaintPreviewOfCrossProcessSubframe — forwarded the request to the active RenderFrameHostImpl without first verifying that the proxy belonged to the same BrowsingInstance as that current document. Because a stale main-frame proxy can survive a cross-BrowsingInstance navigation, a compromised renderer holding such a proxy could drive these operations against a document in an unrelated BrowsingInstance, violating the Site Isolation invariant that a renderer may only affect frames within its own BrowsingInstance. Only RouteCloseEvent and OpenURL had an ad-hoc IsRelatedSiteInstanceGroup() check; the remaining handlers had none.
The fix adds IsRelatedToCurrentFrameHost(), which returns true only when the proxy’s site_instance_group_ is related to current_frame_host()->GetSiteInstance()->group(), and every listed handler now early-returns when it returns false. This works because the check runs before any forwarding, so an IPC from an unrelated BrowsingInstance is silently dropped rather than applied to the victim document; an kEmbedderToInnerTree exemption preserves legitimate embedder-to-inner-FrameTree paths (<webview>, fenced frames).
RenderFrameProxyHost handlers trusted that a proxy’s remote renderer was always in the target document’s BrowsingInstance, which is false for a stale main-frame proxy mid-navigation; the fix centralizes an explicit IsRelatedSiteInstanceGroup() authorization check (IsRelatedToCurrentFrameHost()) that every such handler must pass before touching current_frame_host().Attack Path
- Establish a stale main-frame proxy
A compromised renderer for site A opens a window, which is then navigated across a
BrowsingInstanceboundary (e.g. B → C via a browser-initiated navigation), leaving A’s process holding an oldRenderFrameProxyHostfor that window in a now-unrelatedBrowsingInstance. - Land in the same process as the victim
Via subframe process reuse the attacker arranges for its frame and the victim window’s current document to occupy the same OS process while remaining in different
BrowsingInstances, so the attacker legitimately controls a proxy object that points at the cross-BrowsingInstancewindow. - Send an unauthorized `RemoteFrameHost` IPC
The renderer invokes a handler such as
DidChangeOpener(orFocusPage,TakeFocus,UpdateTargetURL,CheckCompleted) through that stale proxy; pre-patch the browser forwards it to the victim’scurrent_frame_host(), e.g. clearing or reassigning the victim window’sopener. - Cross-BrowsingInstance manipulation
The attacker thereby mutates state (opener linkage, focus, status-bar target URL, unload/close, print) of a document in an unrelated
BrowsingInstance, breaking Site Isolation’s cross-BrowsingInstanceboundary (inferred beyond opener manipulation, which the shipped test demonstrates directly).
Impact Assessment
BrowsingInstance — changing window.opener, stealing or shifting focus, spoofing the hovered target URL, triggering close/unload, or initiating print/paint-preview — from within its own renderer process, i.e. a Site Isolation authorization bypass rather than memory corruption. Preconditions are a renderer capable of sending crafted IPCs (typically already compromised) plus a cross-BrowsingInstance navigation of a window it can reach through a stale proxy. The advisory classifies this as missing authorization in Site Isolation; any further escalation to information disclosure or code execution is not demonstrated by the patch and would be (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
switchcontent/browser/renderer_host/render_frame_proxy_host.cc |
modified | |
frame_host_receiver_for_testingcontent/browser/renderer_host/render_frame_proxy_host.h |
modified |
Files Changed
content/browser/renderer_host/render_frame_proxy_host.cccontent/browser/renderer_host/render_frame_proxy_host.hcontent/browser/renderer_host/render_frame_host_manager_unittest.cccontent/browser/renderer_host/render_frame_host_manager_browsertest.cc
Audit Directions
- Proxy IPC handlers touching `current_frame_host()`Audit every
RemoteFrameHost/RemoteMainFrameHosthandler inRenderFrameProxyHostthat reaches throughframe_tree_node()->current_frame_host()to confirm it either callsIsRelatedToCurrentFrameHost()or is genuinely exempt (visual-only paths viaCrossProcessFrameConnector, orDetach()which excludes main frames). - Stale-object trust after BrowsingInstance swapsLook for other browser-side objects (proxies, openers, focus/opener bookkeeping) whose lifetime can span a cross-
BrowsingInstancenavigation and that assume the remote renderer is still in the current document’sBrowsingInstance. - Process-sharing vs. security-boundary conflationFlag any authorization logic that reasons about processes (
GetProcess()) rather thanIsRelatedSiteInstanceGroup()/BrowsingInstanceidentity, since subframe process reuse makes same-process an unsafe proxy for same-BrowsingInstance.
Patch
From de1ee185eaac569d38f61c9fd5a0e5546cf7425d Mon Sep 17 00:00:00 2001 From: Alex Moshchuk <[email protected]> Date: Thu, 06 Aug 2026 16:57:24 -0700 Subject: [PATCH] Add BrowsingInstance checks to RenderFrameProxyHost IPC handlers A main-frame RenderFrameProxyHost can briefly belong to a different BrowsingInstance than its FrameTreeNode's current RenderFrameHost while a cross-BrowsingInstance navigation is in progress (the proxy lives in the speculative SiteInstanceGroup). Several RemoteFrameHost and RemoteMainFrameHost handlers previously lacked checks to prevent a compromised renderer in that speculative process from acting on the currently active document in an unrelated BrowsingInstance. This CL consolidates these BrowsingInstance checks into a new helper, IsRelatedToCurrentFrameHost(), which verifies that the proxy's SiteInstanceGroup is related to the current document's group. The helper supports two modes of enforcement, depending on whether an exemption is needed for inner FrameTrees: 1. Strict enforcement (no exemptions): Dropping IPCs that should never cross a BrowsingInstance boundary. The IPCs that were updated to use this are: - CheckCompleted (for tracking unload handler status) - UpdateTargetURL (updating status bar when hovering over a link) - DidChangeOpener (modifying window.opener) - TakeFocus / FocusPage (ask to shift focus up to the frame's embedder or browser UI) - RouteCloseEvent (window.close, had existing BI checks that were updated to use new helper) - OpenURL (had existing BI checks that were updated to use new helper) 2. Embedder-to-Inner-FrameTree exemption: Same as above, but exempts IPCs needed for an embedder to manage an inner FrameTree. The IPCs that use this exemption are: - DidFocusFrame (for focusing an element backed by an inner FrameTree) - AdvanceFocus (for tab traversal) - PrintCrossProcessSubframe (for printing) - CapturePaintPreviewOfCrossProcessSubframe (for paint preview) These currently need to support both <webview> tags and fenced frames (and there is existing test coverage for both of these cases). Note that not all IPCs received through RenderFrameProxyHost need the new check. The remaining IPCs don't act on current_frame_host() and instead update visual state via CrossProcessFrameConnector. These include VisibilityChanged, UpdateViewportIntersection, SetIsInert, etc. Additionally, Detach() is not updated to use the new helper because it already excludes main frame cases, which are the only cases where the BrowsingInstance mismatch could occur. Note that the new check intentionally does not kill the renderer, since the condition could be triggered legitimately in a race where the IPC arrives while a cross-BrowsingInstance navigation is committing. Bug: 495933780 Change-Id: I3797b7aa85f11c6126378a1271fd7aff709e2786 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8161280 Reviewed-by: Charlie Reis <[email protected]> Commit-Queue: Alex Moshchuk <[email protected]> Cr-Commit-Position: refs/heads/main@{#1675396} --- diff --git a/content/browser/renderer_host/render_frame_proxy_host.cc b/content/browser/renderer_host/render_frame_proxy_host.cc index fe6fe765..66a6a157 100644 --- a/content/browser/renderer_host/render_frame_proxy_host.cc +++ b/content/browser/renderer_host/render_frame_proxy_host.cc @@ -10,6 +10,7 @@ #include <vector> #include "base/containers/circular_deque.h" +#include "base/feature_list.h" #include "base/functional/callback.h" #include "base/hash/hash.h" #include "base/lazy_instance.h" @@ -77,6 +78,11 @@ return *token_frame_proxy_map; } +// A kill switch for the enforcement of cross-BrowsingInstance checks in +// RenderFrameProxyHost IPC handlers. See https://crbug.com/495933780. +BASE_FEATURE(kEnforceCrossBrowsingInstanceChecks, + base::FEATURE_ENABLED_BY_DEFAULT); + } // namespace // static @@ -332,6 +338,50 @@ return site_instance_group_->agent_scheduling_group(); } +bool RenderFrameProxyHost::IsRelatedToCurrentFrameHost( + CrossBrowsingInstanceExemption exemption) const { + if (!base::FeatureList::IsEnabled(kEnforceCrossBrowsingInstanceChecks)) { + return true; + } + + // This check ensures that an IPC sent via a specific proxy (in the sender's + // process) can only affect a target frame if the proxy and the target belong + // to the same BrowsingInstance. Normally the active sender frame in this + // proxy's process will be in the same SiteInstanceGroup and BCG as this + // proxy, but even in compromised renderers we can guarantee that this proxy + // is legitimately in the BCG and under the renderer process's control. + // + // In some cases, it may also be useful to check the BCG of the sender frame + // when it will be used in response, such as DidChangeOpener or + // RouteMessageEvent. + RenderFrameHostImpl* current_rfh = frame_tree_node_->current_frame_host(); + if (site_instance_group_->IsRelatedSiteInstanceGroup( + current_rfh->GetSiteInstance()->group())) { + return true; + } + + switch (exemption) { + case CrossBrowsingInstanceExemption::kNone: + break; + case CrossBrowsingInstanceExemption::kEmbedderToInnerTree: + // An embedder could only target the inner frame tree's main frame. Note + // that using GetParent() instead of GetParentOrOuterDocument() allows any + // kind of outer-to-inner FrameTree interaction, including both <webview> + // tags and fenced frames. This is necessary because certain IPCs (such as + // AdvanceFocus) need to work with fenced frames. + if (!current_rfh->GetParent()) { + if (RenderFrameHostImpl* embedder = + current_rfh->GetParentOrOuterDocumentOrEmbedder()) { + if (site_instance_group_ == embedder->GetSiteInstance()->group()) { + return true; + } + } + } + break; + } + return false; +} + void RenderFrameProxyHost::SetRenderFrameProxyCreated(bool created) { render_frame_proxy_created_ = created; @@ -429,6 +479,9 @@ } void RenderFrameProxyHost::CheckCompleted() { + if (!IsRelatedToCurrentFrameHost()) { + return; + } RenderFrameHostImpl* target_rfh = frame_tree_node()->current_frame_host(); target_rfh->GetAssociatedLocalFrame()->CheckCompleted(); } @@ -478,12 +531,22 @@ // Do not focus inactive RenderFrameHost. if (!render_frame_host->IsActive()) return; + // Do not focus a RenderFrameHost in a different BrowsingInstance, except + // when focusing an inner FrameTree (e.g., for <webview> tags). + if (!IsRelatedToCurrentFrameHost( + CrossBrowsingInstanceExemption::kEmbedderToInnerTree)) { + return; + } frame_tree_node_->SetFocusedFrame(site_instance_group()); } void RenderFrameProxyHost::CapturePaintPreviewOfCrossProcessSubframe( const gfx::Rect& clip_rect, const base::UnguessableToken& guid) { + if (!IsRelatedToCurrentFrameHost( + CrossBrowsingInstanceExemption::kEmbedderToInnerTree)) { + return; + } RenderFrameHostImpl* rfh = frame_tree_node_->current_frame_host(); // Do not capture paint on behalf of inactive RenderFrameHost. if (rfh->IsInactiveAndDisallowActivation( @@ -747,6 +810,10 @@ void RenderFrameProxyHost::PrintCrossProcessSubframe(const gfx::Rect& rect, int32_t document_cookie) { + if (!IsRelatedToCurrentFrameHost( + CrossBrowsingInstanceExemption::kEmbedderToInnerTree)) { + return; + } RenderFrameHostImpl* rfh = frame_tree_node_->current_frame_host(); rfh->delegate()->PrintCrossProcessSubframe(rect, document_cookie, rfh); } @@ -758,16 +825,26 @@ } void RenderFrameProxyHost::FocusPage() { + if (!IsRelatedToCurrentFrameHost()) { + return; + } frame_tree_node_->current_frame_host()->FocusPage(); } void RenderFrameProxyHost::TakeFocus(bool reverse) { + if (!IsRelatedToCurrentFrameHost()) { + return; + } frame_tree_node_->current_frame_host()->TakeFocus(reverse); } void RenderFrameProxyHost::UpdateTargetURL( const GURL& url, blink::mojom::RemoteMainFrameHost::UpdateTargetURLCallback callback) { + if (!IsRelatedToCurrentFrameHost()) { + std::move(callback).Run(); + return; + } frame_tree_node_->current_frame_host()->UpdateTargetURL(url, std::move(callback)); } @@ -785,12 +862,10 @@ // Tell the active RenderFrameHost to run unload handlers and close, as long // as the request came from a RenderFrameHost in the same BrowsingInstance. - // We receive this from a WebViewImpl when it receives a request to close - // the window containing the active RenderFrameHost. Note that different - // BrowsingInstances in the same CoopRelatedGroup should not be able to close - // each other's windows, therefore checking IsRelatedSiteInstance() is enough. - if (site_instance_group()->IsRelatedSiteInstanceGroup( - rfh->GetSiteInstance()->group())) { + // We receive this from a WebViewImpl when it receives a request to close the + // window containing the active RenderFrameHost. Different BrowsingInstances + // should not be able to close each other's windows. + if (IsRelatedToCurrentFrameHost()) { rfh->ClosePage(RenderFrameHostImpl::ClosePageSource::kRenderer); } } @@ -821,11 +896,9 @@ } // Verify that we are in the same BrowsingInstance as the current - // RenderFrameHost. Note that different BrowsingInstances in the same - // CoopRelatedGroup should not be able to navigate each other's frames, - // therefore checking IsRelatedSiteInstance() is enough. - if (!site_instance_group()->IsRelatedSiteInstanceGroup( - current_rfh->GetSiteInstance()->group())) { + // RenderFrameHost. Different BrowsingInstances should not be able to navigate + // each other's frames. + if (!IsRelatedToCurrentFrameHost()) { return; } @@ -922,6 +995,9 @@ void RenderFrameProxyHost::DidChangeOpener( const std::optional<blink::LocalFrameToken>& opener_frame_token) { + if (!IsRelatedToCurrentFrameHost()) { + return; + } // Note that this call internally protects against `opener_frame_token` // referring to an inactive frame. frame_tree_node_->render_manager()->DidChangeOpener(opener_frame_token, @@ -931,6 +1007,13 @@ void RenderFrameProxyHost::AdvanceFocus( blink::mojom::FocusType focus_type, const blink::LocalFrameToken& source_frame_token) { + // Do not advance focus into a RenderFrameHost in a different + // BrowsingInstance. + if (!IsRelatedToCurrentFrameHost( + CrossBrowsingInstanceExemption::kEmbedderToInnerTree)) { + return; + } + // Translate the source RenderFrameHost in this process to its equivalent // RenderFrameProxyHost in the target SiteInstanceGroup. This is needed for // continuing the focus traversal from correct place in a parent frame after diff --git a/content/browser/renderer_host/render_frame_proxy_host.h b/content/browser/renderer_host/render_frame_proxy_host.h index cef56d1..53e44aa 100644 --- a/content/browser/renderer_host/render_frame_proxy_host.h +++ b/content/browser/renderer_host/render_frame_proxy_host.h @@ -311,6 +311,31 @@ // with. AgentSchedulingGroupHost& GetAgentSchedulingGroup(); + // Defines exemptions for `IsRelatedToCurrentFrameHost()` below. + enum class CrossBrowsingInstanceExemption { + // No exemptions. The proxy and the current frame host must be in related + // SiteInstanceGroups (i.e., in the same BrowsingInstance). + kNone, + // Exempts embedder-to-inner-tree communication. This allows an embedder + // page to send certain IPCs (e.g., focus, print) to an inner frame tree + // (e.g., <webview> tag or Fenced Frame) even though they are in different + // BrowsingInstances. + kEmbedderToInnerTree, + }; + + // Checks if the proxy's SiteInstanceGroup is related to the + // SiteInstanceGroup of the current RenderFrameHost. This prevents + // a compromised renderer in one BrowsingInstance from acting on a + // frame in another BrowsingInstance. This check is required for any + // RenderFrameProxyHost IPC-handling function that acts on main frames + // and accesses current_frame_host(). + // + // The `exemption` parameter allows specific cross-BrowsingInstance + // communication paths (like guest view focus) to bypass this check. + bool IsRelatedToCurrentFrameHost( + CrossBrowsingInstanceExemption exemption = + CrossBrowsingInstanceExemption::kNone) const; + // Needed for tests to be able to swap the implementation and intercept calls. mojo::AssociatedReceiver<blink::mojom::RemoteFrameHost>& frame_host_receiver_for_testing() { diff --git a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc index 1748d0c..15d0448 100644 --- a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc +++ b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc @@ -7542,6 +7542,142 @@ EXPECT_FALSE(instance2->GetSiteInfo().are_v8_optimizations_disabled()); } +// Tests that a renderer process cannot send IPCs through an old +// RenderFrameProxyHost to manipulate a window that has navigated to a different +// BrowsingInstance, even if the new BrowsingInstance happens to share a process +// with the old one (e.g., due to subframe process reuse). +IN_PROC_BROWSER_TEST_P( + RenderFrameHostManagerTest, + ProxyIgnoresRequestsFromOldBrowsingInstanceWithProcessReuse) { + StartEmbeddedServer(); + DisableBackForwardCache(BackForwardCacheImpl::TEST_REQUIRES_NO_CACHING); + + // Ensure that all sites in this test are isolated from each other (even on + // Android, where site-per-process is not the default). + IsolateOriginsForTesting(embedded_test_server(), shell()->web_contents(), + {"a.com", "b.com", "c.com", "d.com"}); + + // Navigate to A1. + GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html")); + EXPECT_TRUE(NavigateToURL(shell(), url_a)); + RenderFrameHostImpl* a1_rfh = + static_cast<WebContentsImpl*>(shell()->web_contents()) + ->GetPrimaryMainFrame(); + + // A1 opens A2 in a new window. + ShellAddedObserver shell2_observer; + EXPECT_TRUE(ExecJs(shell(), "window.open('/title2.html', 'window2');")); + Shell* shell2 = shell2_observer.GetShell(); + EXPECT_TRUE(WaitForLoadStop(shell2->web_contents())); + + // A2 navigates to B2 in the same BCG1. Window 2 now has a proxy in A's + // process. + GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html")); + EXPECT_TRUE(NavigateToURLFromRenderer(shell2, url_b)); + RenderFrameHostImpl* b2_rfh = + static_cast<WebContentsImpl*>(shell2->web_contents()) + ->GetPrimaryMainFrame(); + RenderFrameProxyHost* window2_proxy_in_bcg1 = + b2_rfh->browsing_context_state()->GetRenderFrameProxyHost( + a1_rfh->GetSiteInstance()->group()); + EXPECT_TRUE(window2_proxy_in_bcg1); + + // B2's opener should be A1. + EXPECT_EQ(a1_rfh->frame_tree_node(), b2_rfh->frame_tree_node()->opener()); + + // The user navigates B2 to C2 in BCG2. Use a browser-initiated navigation to + // force a BrowsingInstance swap. Be careful not to use something like a WebUI + // for this, since that would prevent a subsequent window.open() from staying + // in the same BCG2, which is needed in this test. + GURL url_c(embedded_test_server()->GetURL("c.com", "/title2.html")); + EXPECT_TRUE(NavigateToURL(shell2, url_c)); + RenderFrameHostImpl* c2_rfh = + static_cast<WebContentsImpl*>(shell2->web_contents()) + ->GetPrimaryMainFrame(); + EXPECT_FALSE(c2_rfh->GetSiteInstance()->group()->IsRelatedSiteInstanceGroup( + a1_rfh->GetSiteInstance()->group())); + + // C2's opener stays as A1. + EXPECT_EQ(a1_rfh->frame_tree_node(), c2_rfh->frame_tree_node()->opener()); + + // C2 opens another page D3 in a new window that embeds A3 as a subframe. + // This will create a proxy for C2 in BCG2 in both D3's and A3's process. + GURL url_d(embedded_test_server()->GetURL( + "d.com", "/cross_site_iframe_factory.html?d(a)")); + ShellAddedObserver shell3_observer; + EXPECT_TRUE( + ExecJs(shell2, "window.open('" + url_d.spec() + "', 'window3');")); + Shell* shell3 = shell3_observer.GetShell(); + EXPECT_TRUE(WaitForLoadStop(shell3->web_contents())); + RenderFrameHostImpl* d3_rfh = + static_cast<WebContentsImpl*>(shell3->web_contents()) + ->GetPrimaryMainFrame(); + RenderFrameHostImpl* a3_rfh = d3_rfh->child_at(0)->current_frame_host(); + + // Verify A3 shares process with A1 (due to subframe process reuse). + EXPECT_EQ(a1_rfh->GetProcess(), a3_rfh->GetProcess()); + + // A1 and A3 should be in different BCGs. + EXPECT_FALSE(a1_rfh->GetSiteInstance()->group()->IsRelatedSiteInstanceGroup( + a3_rfh->GetSiteInstance()->group())); + + // Get the proxy for Window 2 (shell2)'s main frame in A3's + // SiteInstanceGroup. + RenderFrameProxyHost* window2_proxy_in_bcg2 = + c2_rfh->browsing_context_state()->GetRenderFrameProxyHost( + a3_rfh->GetSiteInstance()->group()); + EXPECT_TRUE(window2_proxy_in_bcg2); + + // Even though they're in the same process and representing the same window 2, + // these two proxies are different, corresponding to two different ... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
index 1748d0c..15d0448 100644
--- a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
@@ -7542,6 +7542,142 @@
EXPECT_FALSE(instance2->GetSiteInfo().are_v8_optimizations_disabled());
}
+// Tests that a renderer process cannot send IPCs through an old
+// RenderFrameProxyHost to manipulate a window that has navigated to a different
+// BrowsingInstance, even if the new BrowsingInstance happens to share a process
+// with the old one (e.g., due to subframe process reuse).
+IN_PROC_BROWSER_TEST_P(
+ RenderFrameHostManagerTest,
+ ProxyIgnoresRequestsFromOldBrowsingInstanceWithProcessReuse) {
+ StartEmbeddedServer();
+ DisableBackForwardCache(BackForwardCacheImpl::TEST_REQUIRES_NO_CACHING);
+
+ // Ensure that all sites in this test are isolated from each other (even on
+ // Android, where site-per-process is not the default).
+ IsolateOriginsForTesting(embedded_test_server(), shell()->web_contents(),
+ {"a.com", "b.com", "c.com", "d.com"});
+
+ // Navigate to A1.
+ GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), url_a));
+ RenderFrameHostImpl* a1_rfh =
+ static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryMainFrame();
+
+ // A1 opens A2 in a new window.
+ ShellAddedObserver shell2_observer;
+ EXPECT_TRUE(ExecJs(shell(), "window.open('/title2.html', 'window2');"));
+ Shell* shell2 = shell2_observer.GetShell();
+ EXPECT_TRUE(WaitForLoadStop(shell2->web_contents()));
+
+ // A2 navigates to B2 in the same BCG1. Window 2 now has a proxy in A's
+ // process.
+ GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURLFromRenderer(shell2, url_b));
+ RenderFrameHostImpl* b2_rfh =
+ static_cast<WebContentsImpl*>(shell2->web_contents())
+ ->GetPrimaryMainFrame();
+ RenderFrameProxyHost* window2_proxy_in_bcg1 =
+ b2_rfh->browsing_context_state()->GetRenderFrameProxyHost(
+ a1_rfh->GetSiteInstance()->group());
+ EXPECT_TRUE(window2_proxy_in_bcg1);
+
+ // B2's opener should be A1.
+ EXPECT_EQ(a1_rfh->frame_tree_node(), b2_rfh->frame_tree_node()->opener());
+
+ // The user navigates B2 to C2 in BCG2. Use a browser-initiated navigation to
+ // force a BrowsingInstance swap. Be careful not to use something like a WebUI
+ // for this, since that would prevent a subsequent window.open() from staying
+ // in the same BCG2, which is needed in this test.
+ GURL url_c(embedded_test_server()->GetURL("c.com", "/title2.html"));
+ EXPECT_TRUE(NavigateToURL(shell2, url_c));
+ RenderFrameHostImpl* c2_rfh =
+ static_cast<WebContentsImpl*>(shell2->web_contents())
+ ->GetPrimaryMainFrame();
+ EXPECT_FALSE(c2_rfh->GetSiteInstance()->group()->IsRelatedSiteInstanceGroup(
+ a1_rfh->GetSiteInstance()->group()));
+
+ // C2's opener stays as A1.
+ EXPECT_EQ(a1_rfh->frame_tree_node(), c2_rfh->frame_tree_node()->opener());
+
+ // C2 opens another page D3 in a new window that embeds A3 as a subframe.
+ // This will create a proxy for C2 in BCG2 in both D3's and A3's process.
+ GURL url_d(embedded_test_server()->GetURL(
+ "d.com", "/cross_site_iframe_factory.html?d(a)"));
+ ShellAddedObserver shell3_observer;
+ EXPECT_TRUE(
+ ExecJs(shell2, "window.open('" + url_d.spec() + "', 'window3');"));
+ Shell* shell3 = shell3_observer.GetShell();
+ EXPECT_TRUE(WaitForLoadStop(shell3->web_contents()));
+ RenderFrameHostImpl* d3_rfh =
+ static_cast<WebContentsImpl*>(shell3->web_contents())
+ ->GetPrimaryMainFrame();
+ RenderFrameHostImpl* a3_rfh = d3_rfh->child_at(0)->current_frame_host();
+
+ // Verify A3 shares process with A1 (due to subframe process reuse).
+ EXPECT_EQ(a1_rfh->GetProcess(), a3_rfh->GetProcess());
+
+ // A1 and A3 should be in different BCGs.
+ EXPECT_FALSE(a1_rfh->GetSiteInstance()->group()->IsRelatedSiteInstanceGroup(
+ a3_rfh->GetSiteInstance()->group()));
+
+ // Get the proxy for Window 2 (shell2)'s main frame in A3's
+ // SiteInstanceGroup.
+ RenderFrameProxyHost* window2_proxy_in_bcg2 =
+ c2_rfh->browsing_context_state()->GetRenderFrameProxyHost(
+ a3_rfh->GetSiteInstance()->group());
+ EXPECT_TRUE(window2_proxy_in_bcg2);
+
+ // Even though they're in the same process and representing the same window 2,
+ // these two proxies are different, corresponding to two different
+ // SiteInstanceGroups in different BCGs.
+ EXPECT_NE(window2_proxy_in_bcg1, window2_proxy_in_bcg2);
+ EXPECT_NE(
+ window2_proxy_in_bcg1->site_instance_group()->browsing_instance_id(),
+ window2_proxy_in_bcg2->site_instance_group()->browsing_instance_id());
+ EXPECT_EQ(window2_proxy_in_bcg1->GetProcess(),
+ window2_proxy_in_bcg2->GetProcess());
+ EXPECT_EQ(window2_proxy_in_bcg1->frame_tree_node(),
+ window2_proxy_in_bcg2->frame_tree_node());
+ // Check that the window2 proxy in BCG1 can still be found. Note that this
+ // lookup will no longer be possible to do via `c2_rfh` if
+ // NewBrowsingContextStateOnBrowsingContextGroupSwap is launched (see
+ // https://crbug.com/40239885 and https://crbug.com/40205442).
+ EXPECT_EQ(window2_proxy_in_bcg1,
+ c2_rfh->browsing_context_state()->GetRenderFrameProxyHost(
+ a1_rfh->GetSiteInstance()->group()));
+
+ // Verify opener of Window 2 is still A1.
+ EXPECT_EQ(a1_rfh->frame_tree_node(), c2_rfh->frame_tree_node()->opener());
+
+ // A3 can update C2's opener to itself.
+ EXPECT_TRUE(ExecJs(a3_rfh, "window.open('', 'window2');"));
+ EXPECT_EQ(a3_rfh->frame_tree_node(), c2_rfh->frame_tree_node()->opener());
+
+ // In contrast, A1 shouldn't be able to update C2's opener to itself via
+ // window.open(), as its proxy to window 2 is in a different BrowsingInstance
+ // than C2.
+ EXPECT_TRUE(ExecJs(a1_rfh, "window.open('', 'window2');"));
+ EXPECT_EQ(a3_rfh->frame_tree_node(), c2_rfh->frame_tree_node()->opener());
+
+ // Also, try to disown openers directly through the appropriate proxies,
+ // verifying our assumptions more directly (and simulating what a compromised
+ // renderer could do).
+ //
+ // If A1 tries to clear Window 2's opener via its old proxy, this should be
+ // dropped because A1 is in BCG1 and Window 2 is now in BCG2. The opener
+ // should not be changed.
+ static_cast<blink::mojom::RemoteFrameHost*>(window2_proxy_in_bcg1)
+ ->DidChangeOpener(std::nullopt);
+ EXPECT_EQ(a3_rfh->frame_tree_node(), c2_rfh->frame_tree_node()->opener());
+
+ // If A3 clears Window 2's opener via its proxy, this should succeed because
+ // A3 and Window 2 are in the same BCG.
+ static_cast<blink::mojom::RemoteFrameHost*>(window2_proxy_in_bcg2)
+ ->DidChangeOpener(std::nullopt);
+ EXPECT_EQ(nullptr, c2_rfh->frame_tree_node()->opener());
+}
+
INSTANTIATE_TEST_SUITE_P(All,
RenderFrameHostManagerTest,
testing::ValuesIn(RenderDocumentFeatureLevelValues()));
diff --git a/content/browser/renderer_host/render_frame_host_manager_unittest.cc b/content/browser/renderer_host/render_frame_host_manager_unittest.cc
index 51d4b81..3eba274 100644
--- a/content/browser/renderer_host/render_frame_host_manager_unittest.cc
+++ b/content/browser/renderer_host/render_frame_host_manager_unittest.cc
@@ -16,12 +16,14 @@
#include "base/command_line.h"
#include "base/files/file_path.h"
#include "base/functional/bind.h"
+#include "base/functional/callback_helpers.h"
#include "base/hash/hash.h"
#include "base/memory/raw_ptr.h"
#include "base/memory/scoped_refptr.h"
#include "base/run_loop.h"
#include "base/strings/string_util.h"
#include "base/strings/utf_string_conversions.h"
+#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "base/time/time.h"
@@ -2128,6 +2130,126 @@
}
}
+// FakeLocalFrame that records which IPCs it receives. Used to verify that
+// cross-BrowsingInstance IPCs sent to a proxy are not illegitimately forwarded
+// to the current RenderFrameHost and then sent to its LocalFrame.
+class IpcTrackingFakeLocalFrame : public content::FakeLocalFrame {
+ public:
+ explicit IpcTrackingFakeLocalFrame(TestRenderFrameHost* rfh) {
+ rfh->ResetLocalFrame();
+ Init(rfh->GetRemoteAssociatedInterfaces());
+ }
+
+ bool check_completed_called() const { return check_completed_called_; }
+ bool advance_focus_called() const { return advance_focus_called_; }
+
+ // FakeLocalFrame:
+ void CheckCompleted() override { check_completed_called_ = true; }
+ void AdvanceFocusInFrame(blink::mojom::FocusType focus_type,
+ const std::optional<blink::RemoteFrameToken>&
+ source_frame_token) override {
+ advance_focus_called_ = true;
+ }
+
+ private:
+ bool check_completed_called_ = false;
+ bool advance_focus_called_ = false;
+};
+
+// Helper to track whether IPCs like TakeFocus() were called on
+// WebContentsDelegate.
+class CallTrackingWebContentsDelegate : public WebContentsDelegate {
+ public:
+ bool take_focus_called() const { return take_focus_called_; }
+ bool update_target_url_called() const { return update_target_url_called_; }
+
+ bool TakeFocus(WebContents* source, bool reverse) override {
+ take_focus_called_ = true;
+ return true;
+ }
+
+ void UpdateTargetURL(WebContents* source, const GURL& url) override {
+ update_target_url_called_ = true;
+ }
+
+ private:
+ bool take_focus_called_ = false;
+ bool update_target_url_called_ = false;
+};
+
+// Main frame RenderFrameProxyHosts can briefly belong to a different
+// BrowsingInstance than the FrameTreeNode's current RenderFrameHost while a
+// cross-BrowsingInstance navigation is in progress (the proxy lives in the
+// speculative SiteInstanceGroup). Requests received from such a proxy should
+// not be forwarded to the current RenderFrameHost.
+TEST_P(RenderFrameHostManagerTest,
+ ProxyIgnoresRequestsFromUnrelatedBrowsingInstance) {
+ const GURL kUrl1(GetWebUIURL("foo"));
+ const GURL kUrl2("http://www.google.com/");
+
+ // Navigate to a WebUI page.
+ NavigationSimulator::NavigateAndCommitFromBrowser(contents(), kUrl1);
+ TestRenderFrameHost* initial_rfh = main_test_rfh();
+ scoped_refptr<SiteInstanceImpl> initial_instance =
+ initial_rfh->GetSiteInstance();
+
+ // Intercept LocalFrame messages on the initial RenderFrameHost so that we
+ // can observe whether CheckCompleted is forwarded to it.
+ IpcTrackingFakeLocalFrame local_frame(initial_rfh);
+ CallTrackingWebContentsDelegate delegate;
+ contents()->SetDelegate(&delegate);
+
+ // Start a browser-initiated navigation that swaps BrowsingInstances. This
+ // creates a speculative RenderFrameHost in a new BrowsingInstance and a
+ // RenderFrameProxyHost for the main frame in the speculative
+ // SiteInstanceGroup, while `initial_rfh` is still current.
+ auto navigation =
+ NavigationSimulator::CreateBrowserInitiated(kUrl2, contents());
+ navigation->ReadyToCommit();
+ ASSERT_TRUE(contents()->CrossProcessNavigationPending());
+ RenderFrameHostImpl* speculative_rfh =
+ contents()->GetSpeculativePrimaryMainFrame();
+ ASSERT_TRUE(speculative_rfh);
+ ASSERT_FALSE(initial_instance->IsRelatedSiteInstance(
+ speculative_rfh->GetSiteInstance()));
+
+ // Find the main frame proxy in the speculative SiteInstanceGroup and verify
+ // that it is in a different BrowsingInstance than the current frame host.
+ FrameTreeNode* root = contents()->GetPrimaryFrameTree().root();
+ RenderFrameProxyHost* proxy =
+ speculative_rfh->browsing_context_state()->GetRenderFrameProxyHost(
+ speculative_rfh->GetSiteInstance()->group());
+ ASSERT_TRUE(proxy);
+ ASSERT_EQ(root, proxy->frame_tree_node());
+ ASSERT_EQ(initial_rfh, root->current_frame_host());
+ ASSERT_FALSE(proxy->site_instance_group()->IsRelatedSiteInstanceGroup(
+ initial_rfh->GetSiteInstance()->group()));
+
+ // Simulate the speculative renderer sending RemoteFrameHost::CheckCompleted
+ // on the proxy. This should be dropped rather than forwarded to
+ // `initial_rfh`, since the proxy is in a different BrowsingInstance.
+ static_cast<blink::mojom::RemoteFrameHost*>(proxy)->CheckCompleted();
+ initial_rfh->FlushLocalFrameMessages();
+ EXPECT_FALSE(local_frame.check_completed_called());
+
+ // Verify TakeFocus is dropped.
+ static_cast<blink::mojom::RemoteMainFrameHost*>(proxy)->TakeFocus(false);
+ EXPECT_FALSE(delegate.take_focus_called());
+
+ // Verify UpdateTargetURL is dropped.
+ static_cast<blink::mojom::RemoteMainFrameHost*>(proxy)->UpdateTargetURL(
+ GURL("http://evil.com"), base::DoNothing());
+ EXPECT_FALSE(delegate.update_target_url_called());
+
+ // Verify AdvanceFocus is dropped.
+ static_cast<blink::mojom::RemoteFrameHost*>(proxy)->AdvanceFocus(
+ blink::mojom::FocusType::kForward, blink::LocalFrameToken());
+ initial_rfh->FlushLocalFrameMessages();
+ EXPECT_FALSE(local_frame.advance_focus_called());
+
+ contents()->SetDelegate(nullptr);
+}
+
class RenderFrameHostManagerTestWithSiteIsolation
: public RenderFrameHostManagerTest {
public:
Original Bug Report
SiteInstanceGroup Check Bypass in RFPH IPC Handlers
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: Several IPC handlers in RenderFrameProxyHost lack IsRelatedSiteInstanceGroup() checks. A compromised renderer can exploit this to interact with unrelated frames in different BrowsingInstances. This can lead to unauthorized focus manipulation and security restriction bypasses.
Affected files:
content/browser/renderer_host/render_frame_proxy_host.cc
Estimated timestamp from git blame: 2025-02-11
Final Result: The missing IsRelatedSiteInstanceGroup() checks in RenderFrameProxyHost IPC handlers (AdvanceFocus, FocusPage, TakeFocus, etc.) allow a compromised renderer to inject transient user activation into unrelated frames and bypass fenced-frame focus boundaries.
Vulnerability Details
Initial logic, proxy parameters, and IPC routing are validated as established context. Proxies for unrelated frames legitimately exist within a compromised renderer (e.g., via CoopRelatedGroup or cross-BrowsingInstance navigations). The compromised renderer utilizes its Mojo connection to target these proxies, and standard IPC dispatching occurs without verifying SiteInstanceGroup relationships.
Exploitation Steps (Suggested)
- Initial compromised renderer setup and cross-BrowsingInstance proxy identification are performed.
- Standard processing applied for local frame token extraction and user activation acquisition.
- The Leap: The attacker directly invokes the
AdvanceFocusMojo IPC. This instantly transfersFocusSourceHasTransientUserActivationto the isolated target frame in the unrelated BrowsingInstance, which directly bypasses theVerifyFencedFrameFocusChangeboundary checks atrender_frame_host_impl.cc:6081.
Alternatively, direct invocation of FocusPage or TakeFocus forces immediate cross-process UI focus stealing.
(Note: These are potential steps as the agent cannot execute code to provide a working PoC).
Suggested Fix
Apply the site_instance_group()->IsRelatedSiteInstanceGroup(target_group) check to the affected handlers in RenderFrameProxyHost (FocusPage, TakeFocus, DidFocusFrame, AdvanceFocus, CheckCompleted, CapturePaintPreviewOfCrossProcessSubframe, PrintCrossProcessSubframe, DidChangeOpener, UpdateTargetURL). If the check fails, drop the message or invoke bad_message::ReceivedBadMessage.
Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8
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.