CVE-2026-87606
Overview
Files Changed
content/browser/renderer_host/render_frame_host_manager_browsertest.cc
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_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.
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.