Overview

Medium
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in SiteIsolation
DescriptionMissing authorization in SiteIsolation
ComponentSiteIsolation
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker495933780
Fix commitde1ee185eaac (chromium/src) +377/-11
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-25 (per vendor advisory)
Fix landed2026-08-06 (public git)
Disclosed2026-09-08
Patch gap33 days — fix public in source before the advisory

Background

BrowsingInstance / SiteInstanceGroup
A BrowsingInstance is the security container of related pages that may script each other; a SiteInstanceGroup is a per-BrowsingInstance grouping of same-process frames, and IsRelatedSiteInstanceGroup() reports whether two groups live in the same BrowsingInstance.
`RenderFrameProxyHost`
The browser-side stand-in for a frame that is hosted in a different SiteInstanceGroup/process, which receives RemoteFrameHost and RemoteMainFrameHost IPCs from that remote renderer and forwards them to the frame’s real RenderFrameHostImpl.
Speculative/stale proxy
During a cross-BrowsingInstance navigation the FrameTreeNode’s current_frame_host() can move to a new BrowsingInstance while an old main-frame proxy still exists in the previous one, so the proxy and the current document can temporarily belong to unrelated BrowsingInstances (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 “same BrowsingInstance,” 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).

Key insight
The core mistake was that these 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

  1. Establish a stale main-frame proxy A compromised renderer for site A opens a window, which is then navigated across a BrowsingInstance boundary (e.g. B → C via a browser-initiated navigation), leaving A’s process holding an old RenderFrameProxyHost for that window in a now-unrelated BrowsingInstance.
  2. 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-BrowsingInstance window.
  3. Send an unauthorized `RemoteFrameHost` IPC The renderer invokes a handler such as DidChangeOpener (or FocusPage, TakeFocus, UpdateTargetURL, CheckCompleted) through that stale proxy; pre-patch the browser forwards it to the victim’s current_frame_host(), e.g. clearing or reassigning the victim window’s opener.
  4. 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-BrowsingInstance boundary (inferred beyond opener manipulation, which the shipped test demonstrates directly).

Impact Assessment

A compromised or malicious renderer gains the ability to act on the active document of an unrelated 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

FunctionChangeNotes
switch
content/browser/renderer_host/render_frame_proxy_host.cc
modified
frame_host_receiver_for_testing
content/browser/renderer_host/render_frame_proxy_host.h
modified

Files Changed

  • content/browser/renderer_host/render_frame_proxy_host.cc
  • content/browser/renderer_host/render_frame_proxy_host.h
  • content/browser/renderer_host/render_frame_host_manager_unittest.cc
  • content/browser/renderer_host/render_frame_host_manager_browsertest.cc

Audit Directions

  • Proxy IPC handlers touching `current_frame_host()`
    Audit every RemoteFrameHost/RemoteMainFrameHost handler in RenderFrameProxyHost that reaches through frame_tree_node()->current_frame_host() to confirm it either calls IsRelatedToCurrentFrameHost() or is genuinely exempt (visual-only paths via CrossProcessFrameConnector, or Detach() which excludes main frames).
  • Stale-object trust after BrowsingInstance swaps
    Look for other browser-side objects (proxies, openers, focus/opener bookkeeping) whose lifetime can span a cross-BrowsingInstance navigation and that assume the remote renderer is still in the current document’s BrowsingInstance.
  • Process-sharing vs. security-boundary conflation
    Flag any authorization logic that reasons about processes (GetProcess()) rather than IsRelatedSiteInstanceGroup()/BrowsingInstance identity, since subframe process reuse makes same-process an unsafe proxy for same-BrowsingInstance.
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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:
Loading diff…

Original Bug Report

reported by [email protected]

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)

  1. Initial compromised renderer setup and cross-BrowsingInstance proxy identification are performed.
  2. Standard processing applied for local frame token extraction and user activation acquisition.
  3. The Leap: The attacker directly invokes the AdvanceFocus Mojo IPC. This instantly transfers FocusSourceHasTransientUserActivation to the isolated target frame in the unrelated BrowsingInstance, which directly bypasses the VerifyFencedFrameFocusChange boundary checks at render_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.

View on issue tracker