Medium watch CVSS 3.1 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in SiteIsolation
DescriptionIncorrect 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.
Tracker516899248
Fix commitf72d1060ae3b (chromium/src) +163/-4
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-26 (per vendor advisory)
Fix landed2026-07-24 (public git)
Disclosed2026-08-25
Patch gap32 days — fix public in source before the advisory

Background

Browsing Context Group (BCG) / BrowsingInstance
the set of windows that can script each other through references such as window.opener. Frames in unrelated BCGs must never be linked, and SiteInstanceGroup::IsRelatedSiteInstanceGroup tests whether two groups are in the same BCG.
`DidChangeOpener`
a renderer-to-browser IPC, received through a RenderFrameProxyHost, reporting that a frame’s opener changed. It carries a blink::LocalFrameToken that the browser resolves to an opener_rfhi and uses to update the FrameTreeNode’s opener.
Speculative / pending-commit `RenderFrameHost`
a RenderFrameHostImpl created for an in-progress cross-process navigation. It is not yet its FrameTreeNode’s current_frame_host() and must not be used as a live opener.
`IsInactiveAndDisallowActivation`
a RenderFrameHostImpl check that returns true for inactive frames (BFCache, pending deletion). The new comment notes that for speculative and pending-commit RFHs it kills the renderer but may return false.

Root Cause Analysis

The vulnerable code is the opener-change handling in content/browser/renderer_host/render_frame_host_manager.cc. The hunk does not show the enclosing function’s name; the commit subject places it in DidChangeOpener. After resolving the renderer-supplied token to opener_rfhi, the old check rejected the update only if opener_rfhi was inactive or if the current RFH’s group was not related to source_site_instance_group. That variable is the group of the IPC’s sender, not of the new opener. Since the sender’s proxy always belongs to the target frame’s BCG, this comparison was effectively always satisfied. A token naming a frame in a different BCG (for example one sharing the same renderer process) therefore passed the check and was set as the opener, which the new OpenerChangeDisallowedAcrossBrowsingInstances test reproduces. A second gap: per the new comment, IsInactiveAndDisallowActivation() can return false for speculative or pending-commit RFHs, so a token for such a frame was also accepted.

The fix compares against opener_rfhi->GetSiteInstance()->group() and also requires opener_rfhi == opener_rfhi->frame_tree_node()->current_frame_host(), returning early otherwise.

Key insight
The BrowsingInstance check validated the wrong party: it tested the sender’s SiteInstanceGroup instead of the proposed opener’s. Together with a liveness check that let speculative frames through, this let a renderer link frames across BCGs. The fix checks the opener’s own group and requires it to be the current frame of its FrameTreeNode.

Attack Path

  1. Compromised renderer The attacker needs control of a renderer process, since ordinary script cannot choose arbitrary frame tokens for DidChangeOpener. The regression tests simulate this by calling proxy->DidChangeOpener(...) directly.
  2. Co-locate unrelated frames The attacker arranges for a frame in another BCG, or a speculative RFH, to live in the compromised process, so its LocalFrameToken is known. The test does this with process reuse (SetMaxRendererProcessCount(1)).
  3. Spoofed opener change Through a proxy it legitimately holds for a frame in its own BCG, the renderer sends DidChangeOpener with the foreign or speculative frame’s token. Before the patch, the relatedness check used source_site_instance_group and passed.
  4. Cross-BCG opener established The browser set the target FrameTreeNode’s opener to a frame in an unrelated BrowsingInstance or to a non-current frame. This creates an opener relationship (and potentially proxies or scripting reachability) that site isolation and BCG separation should forbid (inferred beyond the opener pointer itself).

Impact Assessment

A compromised renderer could get the browser process to record opener relationships across BrowsingInstance boundaries, or to a speculative frame. This weakens the isolation boundary between unrelated windows (for example those split by COOP or noopener) and could help with cross-site data access or further confusion in the browser (inferred). It requires an already-compromised renderer and co-located frames, so it is a site-isolation bypass rather than an initial foothold. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1.

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/render_frame_host_manager.cc
modified

Files Changed

  • content/browser/renderer_host/render_frame_host_manager.cc
  • content/browser/security_exploit_browsertest.cc

Audit Directions

  • Validating the sender instead of the subject
    In browser-side IPC handlers that take frame tokens (LocalFrameToken, RemoteFrameToken) from a renderer, check that BCG, SiteInstance and process-lock checks are done on the object the token resolves to, not only on the receiving proxy or frame.
  • Non-current RFH states
    Look at callers of IsInactiveAndDisallowActivation() that rely on its return value to reject speculative or pending-commit RFHs, and add explicit current_frame_host() checks where a live, committed frame is required.
  • Opener and relationship mutations
    Audit other paths that create cross-frame relationships (window.open naming, postMessage source resolution, CreateNewWindow, proxy creation for openers) for the same “related to whom?” mistake, especially when process reuse or SetMaxRendererProcessCount puts frames from different BCGs in one process.
From f72d1060ae3b9a755cf6760dc116a42cb04e9989 Mon Sep 17 00:00:00 2001
From: Charlie Reis <creis@chromium.org>
Date: Fri, 24 Jul 2026 08:54:51 -0700
Subject: [PATCH] Prevent non-current and cross-BCG openers in DidChangeOpener.

This CL fixes the opener BCG comparison in DidChangeOpener and resolves
a corner case from IsInactiveAndDisallowActivation, with test coverage
for both.

Fixed: 516899248
Change-Id: Ied80d9ccba9eb2cafdecd405af97fb39bdf60a4b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8139403
Commit-Queue: Charlie Reis <creis@chromium.org>
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1667891}
---
diff --git a/content/browser/renderer_host/render_frame_host_manager.cc b/content/browser/renderer_host/render_frame_host_manager.cc
index 80f0533..f09e7143 100644
--- a/content/browser/renderer_host/render_frame_host_manager.cc
+++ b/content/browser/renderer_host/render_frame_host_manager.cc
@@ -1198,13 +1198,21 @@
     if (opener_rfhi) {
       // Ignore this message if |opener_rfhi| is inactive (e.g., in BFCache or
       // pending deletion), or if the FrameTreeNode's current RenderFrameHost
-      // is in a different BrowsingInstance, as it would be incorrect to
-      // establish an opener relationship in those cases.
+      // is in a different BrowsingInstance from the new opener, as it would be
+      // incorrect to establish an opener relationship in those cases.
+      //
+      // Note that `IsInactiveAndDisallowActivation` may return false for
+      // speculative and pending commit RFHs (while killing their renderer
+      // process), because this case is unexpected except for compromised
+      // renderers. As a result, also confirm that opener_rfhi is the current
+      // frame of its FTN, to catch speculative and pending commit cases.
       if (opener_rfhi->IsInactiveAndDisallowActivation(
               DisallowActivationReasonId::kDidChangeOpener) ||
+          opener_rfhi != opener_rfhi->frame_tree_node()->current_frame_host() ||
           !render_frame_host_->GetSiteInstance()
                ->group()
-               ->IsRelatedSiteInstanceGroup(source_site_instance_group)) {
+               ->IsRelatedSiteInstanceGroup(
+                   opener_rfhi->GetSiteInstance()->group())) {
         return;
       }
       opener = opener_rfhi->frame_tree_node();
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index c76fe77..36f23d8 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -2497,6 +2497,78 @@
             kill_waiter.Wait());
 }
 
+// Ensure that DidChangeOpener IPC messages do not succeed if they attempt to
+// assign an opener from a different Browsing Context Group (BCG).
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       OpenerChangeDisallowedAcrossBrowsingInstances) {
+  // Explicitly isolate a.test to ensure that this test is applicable on
+  // platforms without site-per-process.
+  IsolateOrigin("a.test");
+
+  // Force process reuse so tab 1 and tab 2 are in the same renderer process.
+  // This guarantees that the opener token (A1) is valid in the same process as
+  // the proxy in A2's SiteInstance that will be used to send the spoofed
+  // DidChangeOpener IPC later.
+  RenderProcessHost::SetMaxRendererProcessCount(1);
+
+  // Navigate to A1.
+  EXPECT_TRUE(NavigateToURL(
+      shell(), embedded_test_server()->GetURL("a.test", "/title1.html")));
+
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  RenderFrameHostImpl* tab1_rfh = web_contents->GetPrimaryMainFrame();
+  FrameTreeNode* tab1_root = tab1_rfh->frame_tree_node();
+
+  Shell* tab2 = CreateBrowser();
+  // Create an unrelated window and navigate it to A2. Workaround: navigate
+  // `tab2` to a random site before navigating to attacker's site to ensure that
+  // `tab2` shares the process with tab1, rather than staying in its initial
+  // process.
+  ASSERT_TRUE(NavigateToURL(
+      tab2, embedded_test_server()->GetURL("d.test", "/title3.html")));
+  ASSERT_TRUE(NavigateToURL(
+      tab2, embedded_test_server()->GetURL("a.test", "/title2.html")));
+  RenderFrameHostImpl* tab2_rfh = static_cast<RenderFrameHostImpl*>(
+      tab2->web_contents()->GetPrimaryMainFrame());
+  FrameTreeNode* tab2_root = tab2_rfh->frame_tree_node();
+  EXPECT_FALSE(tab1_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab2_rfh->GetSiteInstance()));
+  EXPECT_EQ(tab1_rfh->GetProcess(), tab2_rfh->GetProcess());
+
+  // Open a cross-site page B in the same BCG as A2.
+  GURL cross_site_url(embedded_test_server()->GetURL("b.test", "/title3.html"));
+  Shell* tab3 = OpenPopup(tab2, cross_site_url, "foo");
+  RenderFrameHostImpl* tab3_rfh = static_cast<RenderFrameHostImpl*>(
+      tab3->web_contents()->GetPrimaryMainFrame());
+  FrameTreeNode* tab3_root = tab3_rfh->frame_tree_node();
+  EXPECT_EQ(tab3_root->opener(), tab2_root);
+  EXPECT_TRUE(tab2_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab3_rfh->GetSiteInstance()));
+
+  // Grab the token of the A1 document that will be set as the new opener.
+  blink::LocalFrameToken opener_token = tab1_rfh->GetFrameToken();
+
+  // Look up the proxy that represents B in A2's SiteInstance. This is the
+  // target of the malicious DidChangeOpener IPC. This should exist as long as
+  // a.test and b.test are process-isolated from each other.
+  RenderFrameProxyHost* proxy =
+      tab3_rfh->browsing_context_state()->GetRenderFrameProxyHost(
+          tab2_rfh->GetSiteInstance()->group());
+  ASSERT_TRUE(proxy);
+
+  // Simulate the vulnerability by sending the DidChangeOpener IPC using the
+  // token of the frame in the wrong BCG. This goes to the
+  // RenderFrameHostManager of tab3, trying to set its opener to the FTN that's
+  // in the other BCG.
+  proxy->DidChangeOpener(opener_token);
+
+  // The opener should *not* be updated to tab1_root. It should stay the same,
+  // still pointing to tab2.
+  EXPECT_NE(tab3_root->opener(), tab1_root);
+  EXPECT_EQ(tab3_root->opener(), tab2_root);
+}
+
 // Verify that a compromised renderer can't use a BFCached page and a rogue
 // DidChangeOpener IPC to establish an opener relationship with a
 // cross-BrowsingInstance page.
@@ -2510,7 +2582,7 @@
 // opener to A1. A1 is bfcached, and the test ensures that the opener isn't set
 // to the WebUI page which is the current RFH in A1's FTN.
 IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
-                       OpenerChangeDisallowedAcrossBrowsingInstances) {
+                       OpenerChangeDisallowedAcrossBrowsingInstances_Bfcache) {
   // This test assumes that back-forward cache is enabled.
   if (!IsBackForwardCacheEnabled()) {
     GTEST_SKIP();
@@ -2612,6 +2684,85 @@
   EXPECT_FALSE(webui_to_non_webui_proxy);
 }
 
+// Similar to OpenerChangeDisallowedAcrossBrowsingInstances_Bfcache, but
+// exercises the case where the supplied opener token resolves to a speculative
+// RenderFrameHost (which happens to be in the popup tab itself). This should
+// fail even within the same BCG because the new opener is still in the
+// speculative state and not the current frame of its FrameTreeNode.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       OpenerChangeDisallowedViaSpeculativeFrame) {
+  // Explicitly isolate a.test to ensure that this test is applicable on
+  // platforms without site-per-process.
+  IsolateOrigin("a.test");
+
+  // Navigate to A1.
+  EXPECT_TRUE(NavigateToURL(
+      shell(), embedded_test_server()->GetURL("a.test", "/title1.html")));
+
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  RenderFrameHostImpl* tab1_rfh = web_contents->GetPrimaryMainFrame();
+  FrameTreeNode* tab1_root = tab1_rfh->frame_tree_node();
+
+  // Open A2 in the same BCG as A1.
+  GURL popup_url(embedded_test_server()->GetURL("a.test", "/title2.html"));
+  Shell* tab2 = OpenPopup(shell(), popup_url, "foo");
+  RenderFrameHostImpl* tab2_rfh = static_cast<RenderFrameHostImpl*>(
+      tab2->web_contents()->GetPrimaryMainFrame());
+  FrameTreeNode* tab2_root = tab2_rfh->frame_tree_node();
+  EXPECT_EQ(tab2_root->opener(), tab1_root);
+  EXPECT_TRUE(tab1_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab2_rfh->GetSiteInstance()));
+
+  // Navigate tab 2 to B in the same BCG.
+  GURL b_url(embedded_test_server()->GetURL("b.test", "/title1.html"));
+  {
+    TestNavigationObserver nav_observer(tab2->web_contents(), 1);
+    EXPECT_TRUE(ExecJs(tab2, JsReplace("location.href = $1;", b_url)));
+    nav_observer.Wait();
+  }
+  tab2_rfh = static_cast<RenderFrameHostImpl*>(
+      tab2->web_contents()->GetPrimaryMainFrame());
+  EXPECT_EQ(tab2_root->opener(), tab1_root);
+  EXPECT_TRUE(tab1_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab2_rfh->GetSiteInstance()));
+
+  // Start a cross-process navigation in tab 2 back to A, and pause it once the
+  // speculative RenderFrameHost has been created.
+  GURL dest_url(embedded_test_server()->GetURL("a.test", "/title3.html"));
+  TestNavigationManager nav_manager(tab2->web_contents(), dest_url);
+  EXPECT_TRUE(ExecJs(tab2, JsReplace("location.href = $1;", dest_url)));
+  nav_manager.WaitForSpeculativeRenderFrameHostCreation();
+  RenderFrameHostImpl* speculative_rfh =
+      tab2_root->render_manager()->speculative_frame_host();
+  ASSERT_TRUE(speculative_rfh);
+  ASSERT_EQ(speculative_rfh->GetProcess(), tab1_rfh->GetProcess());
+  ASSERT_EQ(tab2_root->current_frame_host(), tab2_rfh);
+
+  // Find SiteInstance A's proxy for tab 2, as the target for the IPC.
+  RenderFrameProxyHost* proxy =
+      tab2_rfh->browsing_context_state()->GetRenderFrameProxyHost(
+          tab1_rfh->GetSiteInstance()->group());
+  ASSERT_TRUE(proxy);
+
+  // Grab the speculative RenderFrameHost's frame token and simulate a rogue
+  // DidChangeOpener IPC that supplies it as the new opener for tab 2, which
+  // would (legally) create a cycle if it succeeds, but should fail because the
+  // token is for a speculative RFH.
+  blink::LocalFrameToken opener_token = speculative_rfh->GetFrameToken();
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(tab1_rfh->GetProcess());
+  proxy->DidChangeOpener(opener_token);
+
+  // The opener should *not* be updated to tab2_root, since the frame token is
+  // for a speculative RFH.
+  EXPECT_EQ(tab2_root->opener(), tab1_root);
+
+  // The renderer that supplied a speculative frame's token as opener should
+  // be terminated.
+  EXPECT_EQ(bad_message::RFH_INACTIVE_CHECK_FROM_SPECULATIVE_RFH,
+            kill_waiter.Wait());
+}
+
 namespace {
 
 // An interceptor class that allows replacing the URL of the commit IPC from
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index c76fe77..36f23d8 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -2497,6 +2497,78 @@
             kill_waiter.Wait());
 }
 
+// Ensure that DidChangeOpener IPC messages do not succeed if they attempt to
+// assign an opener from a different Browsing Context Group (BCG).
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       OpenerChangeDisallowedAcrossBrowsingInstances) {
+  // Explicitly isolate a.test to ensure that this test is applicable on
+  // platforms without site-per-process.
+  IsolateOrigin("a.test");
+
+  // Force process reuse so tab 1 and tab 2 are in the same renderer process.
+  // This guarantees that the opener token (A1) is valid in the same process as
+  // the proxy in A2's SiteInstance that will be used to send the spoofed
+  // DidChangeOpener IPC later.
+  RenderProcessHost::SetMaxRendererProcessCount(1);
+
+  // Navigate to A1.
+  EXPECT_TRUE(NavigateToURL(
+      shell(), embedded_test_server()->GetURL("a.test", "/title1.html")));
+
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  RenderFrameHostImpl* tab1_rfh = web_contents->GetPrimaryMainFrame();
+  FrameTreeNode* tab1_root = tab1_rfh->frame_tree_node();
+
+  Shell* tab2 = CreateBrowser();
+  // Create an unrelated window and navigate it to A2. Workaround: navigate
+  // `tab2` to a random site before navigating to attacker's site to ensure that
+  // `tab2` shares the process with tab1, rather than staying in its initial
+  // process.
+  ASSERT_TRUE(NavigateToURL(
+      tab2, embedded_test_server()->GetURL("d.test", "/title3.html")));
+  ASSERT_TRUE(NavigateToURL(
+      tab2, embedded_test_server()->GetURL("a.test", "/title2.html")));
+  RenderFrameHostImpl* tab2_rfh = static_cast<RenderFrameHostImpl*>(
+      tab2->web_contents()->GetPrimaryMainFrame());
+  FrameTreeNode* tab2_root = tab2_rfh->frame_tree_node();
+  EXPECT_FALSE(tab1_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab2_rfh->GetSiteInstance()));
+  EXPECT_EQ(tab1_rfh->GetProcess(), tab2_rfh->GetProcess());
+
+  // Open a cross-site page B in the same BCG as A2.
+  GURL cross_site_url(embedded_test_server()->GetURL("b.test", "/title3.html"));
+  Shell* tab3 = OpenPopup(tab2, cross_site_url, "foo");
+  RenderFrameHostImpl* tab3_rfh = static_cast<RenderFrameHostImpl*>(
+      tab3->web_contents()->GetPrimaryMainFrame());
+  FrameTreeNode* tab3_root = tab3_rfh->frame_tree_node();
+  EXPECT_EQ(tab3_root->opener(), tab2_root);
+  EXPECT_TRUE(tab2_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab3_rfh->GetSiteInstance()));
+
+  // Grab the token of the A1 document that will be set as the new opener.
+  blink::LocalFrameToken opener_token = tab1_rfh->GetFrameToken();
+
+  // Look up the proxy that represents B in A2's SiteInstance. This is the
+  // target of the malicious DidChangeOpener IPC. This should exist as long as
+  // a.test and b.test are process-isolated from each other.
+  RenderFrameProxyHost* proxy =
+      tab3_rfh->browsing_context_state()->GetRenderFrameProxyHost(
+          tab2_rfh->GetSiteInstance()->group());
+  ASSERT_TRUE(proxy);
+
+  // Simulate the vulnerability by sending the DidChangeOpener IPC using the
+  // token of the frame in the wrong BCG. This goes to the
+  // RenderFrameHostManager of tab3, trying to set its opener to the FTN that's
+  // in the other BCG.
+  proxy->DidChangeOpener(opener_token);
+
+  // The opener should *not* be updated to tab1_root. It should stay the same,
+  // still pointing to tab2.
+  EXPECT_NE(tab3_root->opener(), tab1_root);
+  EXPECT_EQ(tab3_root->opener(), tab2_root);
+}
+
 // Verify that a compromised renderer can't use a BFCached page and a rogue
 // DidChangeOpener IPC to establish an opener relationship with a
 // cross-BrowsingInstance page.
@@ -2510,7 +2582,7 @@
 // opener to A1. A1 is bfcached, and the test ensures that the opener isn't set
 // to the WebUI page which is the current RFH in A1's FTN.
 IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
-                       OpenerChangeDisallowedAcrossBrowsingInstances) {
+                       OpenerChangeDisallowedAcrossBrowsingInstances_Bfcache) {
   // This test assumes that back-forward cache is enabled.
   if (!IsBackForwardCacheEnabled()) {
     GTEST_SKIP();
@@ -2612,6 +2684,85 @@
   EXPECT_FALSE(webui_to_non_webui_proxy);
 }
 
+// Similar to OpenerChangeDisallowedAcrossBrowsingInstances_Bfcache, but
+// exercises the case where the supplied opener token resolves to a speculative
+// RenderFrameHost (which happens to be in the popup tab itself). This should
+// fail even within the same BCG because the new opener is still in the
+// speculative state and not the current frame of its FrameTreeNode.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       OpenerChangeDisallowedViaSpeculativeFrame) {
+  // Explicitly isolate a.test to ensure that this test is applicable on
+  // platforms without site-per-process.
+  IsolateOrigin("a.test");
+
+  // Navigate to A1.
+  EXPECT_TRUE(NavigateToURL(
+      shell(), embedded_test_server()->GetURL("a.test", "/title1.html")));
+
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  RenderFrameHostImpl* tab1_rfh = web_contents->GetPrimaryMainFrame();
+  FrameTreeNode* tab1_root = tab1_rfh->frame_tree_node();
+
+  // Open A2 in the same BCG as A1.
+  GURL popup_url(embedded_test_server()->GetURL("a.test", "/title2.html"));
+  Shell* tab2 = OpenPopup(shell(), popup_url, "foo");
+  RenderFrameHostImpl* tab2_rfh = static_cast<RenderFrameHostImpl*>(
+      tab2->web_contents()->GetPrimaryMainFrame());
+  FrameTreeNode* tab2_root = tab2_rfh->frame_tree_node();
+  EXPECT_EQ(tab2_root->opener(), tab1_root);
+  EXPECT_TRUE(tab1_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab2_rfh->GetSiteInstance()));
+
+  // Navigate tab 2 to B in the same BCG.
+  GURL b_url(embedded_test_server()->GetURL("b.test", "/title1.html"));
+  {
+    TestNavigationObserver nav_observer(tab2->web_contents(), 1);
+    EXPECT_TRUE(ExecJs(tab2, JsReplace("location.href = $1;", b_url)));
+    nav_observer.Wait();
+  }
+  tab2_rfh = static_cast<RenderFrameHostImpl*>(
+      tab2->web_contents()->GetPrimaryMainFrame());
+  EXPECT_EQ(tab2_root->opener(), tab1_root);
+  EXPECT_TRUE(tab1_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab2_rfh->GetSiteInstance()));
+
+  // Start a cross-process navigation in tab 2 back to A, and pause it once the
+  // speculative RenderFrameHost has been created.
+  GURL dest_url(embedded_test_server()->GetURL("a.test", "/title3.html"));
+  TestNavigationManager nav_manager(tab2->web_contents(), dest_url);
+  EXPECT_TRUE(ExecJs(tab2, JsReplace("location.href = $1;", dest_url)));
+  nav_manager.WaitForSpeculativeRenderFrameHostCreation();
+  RenderFrameHostImpl* speculative_rfh =
+      tab2_root->render_manager()->speculative_frame_host();
+  ASSERT_TRUE(speculative_rfh);
+  ASSERT_EQ(speculative_rfh->GetProcess(), tab1_rfh->GetProcess());
+  ASSERT_EQ(tab2_root->current_frame_host(), tab2_rfh);
+
+  // Find SiteInstance A's proxy for tab 2, as the target for the IPC.
+  RenderFrameProxyHost* proxy =
+      tab2_rfh->browsing_context_state()->GetRenderFrameProxyHost(
+          tab1_rfh->GetSiteInstance()->group());
+  ASSERT_TRUE(proxy);
+
+  // Grab the speculative RenderFrameHost's frame token and simulate a rogue
+  // DidChangeOpener IPC that supplies it as the new opener for tab 2, which
+  // would (legally) create a cycle if it succeeds, but should fail because the
+  // token is for a speculative RFH.
+  blink::LocalFrameToken opener_token = speculative_rfh->GetFrameToken();
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(tab1_rfh->GetProcess());
+  proxy->DidChangeOpener(opener_token);
+
+  // The opener should *not* be updated to tab2_root, since the frame token is
+  // for a speculative RFH.
+  EXPECT_EQ(tab2_root->opener(), tab1_root);
+
+  // The renderer that supplied a speculative frame's token as opener should
+  // be terminated.
+  EXPECT_EQ(bad_message::RFH_INACTIVE_CHECK_FROM_SPECULATIVE_RFH,
+            kill_waiter.Wait());
+}
+
 namespace {
 
 // An interceptor class that allows replacing the URL of the commit IPC from
Loading diff…

Original Bug Report

reported by vm...@google.com

Bypass of Site Isolation via speculative RFH token resolution in DidChangeOpener

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential validation gap in RenderFrameHostManager::DidChangeOpener may allow a compromised renderer to establish a cross-BrowsingInstance opener relationship. By leveraging same-site process reuse, an attacker could resolve a speculative frame token to a victim’s FrameTreeNode, bypassing COOP and Site Isolation guarantees. This could permit a compromised renderer to obtain a valid window reference to an isolated victim page.

Affected files:

  • content/browser/renderer_host/render_frame_host_manager.cc
  • content/browser/renderer_host/render_frame_host_impl.cc

Estimated timestamp from git blame: 2026-04-01

Summary of the Potential Issue

There is a potential security vulnerability in how RenderFrameHostManager::DidChangeOpener validates renderer-supplied opener frame tokens. Specifically, when a compromised renderer process provides a frame token belonging to a speculative (kSpeculative) or pending-commit (kPendingCommit) RenderFrameHost (RFH) associated with a victim tab, the browser’s safety checks can be bypassed. This allows the attacker to set the victim’s tab as its opener across BrowsingInstance boundaries, violating Site Isolation and COOP separation guarantees.

Root Cause Analysis

  1. Asynchronous Bad Message Handling in IsInactiveAndDisallowActivation: When a renderer sends a DidChangeOpener message with an invalid token, the browser calls opener_rfhi->IsInactiveAndDisallowActivation(...) (content/browser/renderer_host/render_frame_host_manager.cc;l=1200). If the resolved RFH is in a kSpeculative or kPendingCommit state, IsInactiveAndDisallowActivation invokes bad_message::ReceivedBadMessage(...) and returns false (content/browser/renderer_host/render_frame_host_impl.cc;l=9081):

    case LifecycleStateImpl::kSpeculative:
      bad_message::ReceivedBadMessage(
          GetProcess(), bad_message::RFH_INACTIVE_CHECK_FROM_SPECULATIVE_RFH);
      return false;
    

    Because ReceivedBadMessage triggers an asynchronous process shutdown, execution of the current message handler continues immediately. Since the method returned false, the check falls through, and the browser proceeds to process the message.

  2. Incorrect SiteInstance Group Validation (Reflexive Check): The secondary check verifies that the current RFH’s group is related to the sender’s group:

    !render_frame_host_->GetSiteInstance()
         ->group()
         ->IsRelatedSiteInstanceGroup(source_site_instance_group)
    

    Since the IPC is handled on the active RFH (render_frame_host_), and source_site_instance_group is passed as that same active RFH’s group (content/browser/renderer_host/render_frame_host_impl.cc;l=9828), the comparison is reflexive (comparing a group against itself). This check always evaluates to true (negated to false), failing to verify whether the target opener (opener_rfhi) is actually related to the current frame.

  3. Speculative Token Resolution: Because speculative RFHs register their frame tokens in the global map immediately upon creation, a compromised process hosting a speculative RFH can query its token. Under default process-consolidation policies, a cross-BrowsingInstance navigation to the attacker’s site can result in the speculative RFH being assigned to the attacker’s existing process. The attacker can then resolve this token to the victim’s underlying FrameTreeNode across the BrowsingInstance boundary.

Potential Attack Steps

(Note: These are theoretical steps; our testing environment has not executed a practical proof-of-concept.)

  1. An attacker compromises a renderer process P hosting an active tab (attacker.com).
  2. An unrelated sensitive tab (bank.com in a separate BrowsingInstance) navigates to attacker.com. The browser creates a speculative RFH for the navigation and assigns it to Process P due to same-site process reuse.
  3. The compromised Process P reads the speculative RFH’s LocalFrameToken via the initialization IPC.
  4. Process P sends a LocalFrameHost::DidChangeOpener message, referencing the speculative frame token.
  5. The browser processes the message, bypasses the validation checks due to the issues outlined above, and updates the active attacker tab’s opener to point to the victim’s tab.
  6. After Process P is asynchronously terminated, the user or script reloads the tab, spawning a clean renderer process with a valid cross-BrowsingInstance window.opener handle to the victim tab.

Suggested Fix

To remediate this issue, the validation check inside RenderFrameHostManager::DidChangeOpener should compare the target frame’s group against the resolved opener’s group rather than the sender’s group. Additionally, DidChangeOpener should return early if opener_rfhi is in a speculative or pending-commit state:

if (opener_rfhi) {
  if (opener_rfhi->lifecycle_state() == LifecycleStateImpl::kSpeculative ||
      opener_rfhi->lifecycle_state() == LifecycleStateImpl::kPendingCommit) {
    bad_message::ReceivedBadMessage(
        source_site_instance_group->process(),
        bad_message::RFH_INACTIVE_CHECK_FROM_SPECULATIVE_RFH);
    return;
  }

  if (opener_rfhi->IsInactiveAndDisallowActivation(
          DisallowActivationReasonId::kDidChangeOpener) ||
      !render_frame_host_->GetSiteInstance()
           ->group()
           ->IsRelatedSiteInstanceGroup(opener_rfhi->GetSiteInstance()->group())) {
    return;
  }
  opener = opener_rfhi->frame_tree_node();
}

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker