Medium watch CVSS 3.1 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in COOP
DescriptionInsufficient validation of untrusted input in COOP
ComponentCOOP
Bug ClassInput Validation
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.
Tracker495802788
Fix commit2b5169f2383c (chromium/src) +202/-1
CWECWE-20 (Improper input validation)
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-03-24 (per vendor advisory)
Fix landed2026-04-01 (public git)
Disclosed2026-05-05
Patch gap34 days — fix public in source before the advisory

Background

`DidChangeOpener`
the browser-side RenderFrameHostManager handler that updates a frame’s opener relationship in response to a renderer-supplied opener frame token.
`BrowsingInstance` / `SiteInstanceGroup`
the isolation boundary that keeps unrelated windows from scripting each other; frames in different BrowsingInstances must not share an opener relationship.
Inactive `RenderFrameHostImpl`
an RFH that is in the back/forward cache (BFCache) or pending deletion and is therefore no longer the current, active document of its FrameTreeNode.
`IsInactiveAndDisallowActivation`
a helper that reports whether an RFH is inactive and, as a side effect, forbids it from being reactivated for the given reason.

Root Cause Analysis

The vulnerable path is in RenderFrameHostManager::DidChangeOpener (content/browser/renderer_host/render_frame_host_manager.cc), reached when a renderer sends a DidChangeOpener IPC naming an opener frame token.

Pre-patch, once the token resolved to a non-null opener_rfhi, the code immediately took opener = opener_rfhi->frame_tree_node() without checking the RFH’s lifecycle state or whether it belonged to a related SiteInstanceGroup. The violated invariant is that an opener relationship may only be established toward an active frame in the same BrowsingInstance as the caller; a compromised renderer could instead supply the token of a BFCache/pending-deletion RFH whose FrameTreeNode’s current RFH had since swapped into a different BrowsingInstance (e.g., a COOP, cross-origin-isolated, or WebUI page).

The fix tightens the path by returning early when opener_rfhi->IsInactiveAndDisallowActivation(DisallowActivationReasonId::kDidChangeOpener) is true, or when the caller’s SiteInstanceGroup is not related to source_site_instance_group via IsRelatedSiteInstanceGroup, so the cross-BrowsingInstance opener edge is never created.

Key insight
The handler trusted the renderer-supplied opener token without validating the resolved RFH’s activity state or BrowsingInstance relationship; the fix adds exactly those two guards before adopting the frame as the new opener.

Attack Path

  1. Set up an inactive opener A compromised renderer navigates a frame (A1) such that its original RFH is placed in BFCache while the FrameTreeNode’s current RFH swaps into a different BrowsingInstance (e.g., a WebUI page), per the security_exploit_browsertest.cc setup.
  2. Send a spoofed IPC From a proxy in a related renderer, the attacker issues a DidChangeOpener IPC supplying the still-valid frame token of the inactive A1 RFH as the opener for a target frame.
  3. Browser resolves without validation Pre-patch, DidChangeOpener resolves the token to the inactive opener_rfhi and takes its frame_tree_node(), whose current RFH is in a different BrowsingInstance.
  4. Cross-BrowsingInstance opener established This creates an opener edge across the isolation boundary, breaking BrowsingInstance isolation guarantees for the COOP/cross-origin-isolated/WebUI page (further exploitation beyond the broken isolation is not demonstrated in the diff) (inferred).

Impact Assessment

A compromised renderer could induce the browser process to establish an opener relationship across BrowsingInstance boundaries, undermining isolation protecting COOP, cross-origin-isolated, or WebUI pages; the advisory rates this medium severity and describes it as insufficient validation of untrusted input in COOP. The precondition is a compromised/malicious renderer able to send IPCs and arrange an inactive (BFCache/pending-deletion) RFH whose FrameTreeNode has swapped BrowsingInstance. The advisory does not report in-the-wild exploitation, and no memory-corruption or escalation primitive is demonstrated by the patch.

Changed Functions

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

Files Changed

  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/renderer_host/render_frame_host_manager.cc
  • content/browser/renderer_host/render_frame_host_manager_browsertest.cc
  • content/browser/renderer_host/render_frame_proxy_host.cc
  • content/browser/security_exploit_browsertest.cc

Audit Directions

  • Renderer-supplied frame/document tokens
    Audit every browser-side handler that resolves a renderer-provided frame token to an RFH, and verify it checks both activity state and BrowsingInstance/SiteInstanceGroup relationship before acting on it.
  • Inactive RFH lifecycle assumptions
    Look for code that dereferences an RFH obtained via frame_tree_node() from a token without accounting for BFCache/pending-deletion RFHs whose current RFH may have swapped BrowsingInstance.
  • Opener and named-window plumbing
    Review other opener/named-window reconnection paths (window.open('', name)) for the same missing same-BrowsingInstance constraint.
From 2b5169f2383cf64a28ea5928c4d5ab756436cbbc Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <alexmos@chromium.org>
Date: Wed, 01 Apr 2026 13:30:28 -0700
Subject: [PATCH] Block inactive frames from updating frame opener via DidChangeOpener

When processing DidChangeOpener IPCs from a renderer, the browser
process resolves the provided opener frame token to a
RenderFrameHostImpl. Previously, this logic did not verify the lifecycle
state of the resolved RenderFrameHost or check whether it is in a
different BrowsingInstance.

A compromised renderer could exploit this by supplying the frame token
of a RenderFrameHost that is in BFCache or pending deletion. This can be
used to trick the browser to establish a cross-BrowsingInstance opener
relationship, if the current RFH of the inactive RenderFrameHost's
FrameTreeNode is in a different BrowsingInstance. This is particularly
bad if the current RFH is hosting a COOP, cross-origin isolated, or
WebUI page, breaking the BrowsingInstance isolation guarantees.

This CL fixes this by updating RenderFrameHostManager::DidChangeOpener
to explicitly ignore the opener change request if the opener's RFH is
not active or if it's in a different BrowsingInstance.

Fixed: 495802788
Change-Id: I6931db403fe559af10a46ef6efb6106c589b9448
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7708607
Reviewed-by: Charlie Reis <creis@chromium.org>
Commit-Queue: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1608745}
---

diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 2ff622f..da96f61e 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -9706,6 +9706,8 @@
     return;
   }
 
+  // Note that this call internally protects against `opener_frame_token`
+  // referring to an inactive frame.
   owner_->GetRenderFrameHostManager().DidChangeOpener(
       opener_frame_token, GetSiteInstance()->group());
 }
diff --git a/content/browser/renderer_host/render_frame_host_manager.cc b/content/browser/renderer_host/render_frame_host_manager.cc
index 79b772e..1e76d25a 100644
--- a/content/browser/renderer_host/render_frame_host_manager.cc
+++ b/content/browser/renderer_host/render_frame_host_manager.cc
@@ -1110,8 +1110,20 @@
         *opener_frame_token);
     // If |opener_rfhi| is null, the opener RFH has already disappeared.  In
     // this case, clear the opener rather than keeping the old opener around.
-    if (opener_rfhi)
+    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.
+      if (opener_rfhi->IsInactiveAndDisallowActivation(
+              DisallowActivationReasonId::kDidChangeOpener) ||
+          !render_frame_host_->GetSiteInstance()
+               ->group()
+               ->IsRelatedSiteInstanceGroup(source_site_instance_group)) {
+        return;
+      }
       opener = opener_rfhi->frame_tree_node();
+    }
   }
 
   if (frame_tree_node_->opener() == opener)
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 506bd7f4..95e196f6 100644
--- a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
@@ -5957,6 +5957,72 @@
   }
 }
 
+// Tests that a renderer cannot change the opener of a frame across different
+// BrowsingInstances (e.g., when trying to reconnect to a named window that
+// has navigated to a WebUI URL).
+IN_PROC_BROWSER_TEST_P(RenderFrameHostManagerTest,
+                       CannotChangeOpenerAcrossBrowsingInstances) {
+  StartEmbeddedServer();
+
+  GURL url_a1(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_a2(embedded_test_server()->GetURL("a.com", "/title2.html"));
+  GURL url_a3(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  GURL webui_url = GetWebUIURL(kChromeUIGpuHost);
+
+  EXPECT_TRUE(NavigateToURL(shell(), url_a1));
+  WebContentsImpl* web_contents_1 =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  FrameTreeNode* ftn_1 = web_contents_1->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* rfh_1 = ftn_1->current_frame_host();
+
+  // Use window.open() to open a popup from A1 to A2 named "foo", and another
+  // unnamed popup from A1 to A3.
+  Shell* tab_2 = OpenPopup(rfh_1, url_a2, "foo");
+  Shell* tab_3 = OpenPopup(rfh_1, url_a3, "");
+
+  WebContentsImpl* web_contents_2 =
+      static_cast<WebContentsImpl*>(tab_2->web_contents());
+  FrameTreeNode* ftn_2 = web_contents_2->GetPrimaryFrameTree().root();
+  WebContentsImpl* web_contents_3 =
+      static_cast<WebContentsImpl*>(tab_3->web_contents());
+  FrameTreeNode* ftn_3 = web_contents_3->GetPrimaryFrameTree().root();
+
+  // Verify the initial opener relationship.
+  EXPECT_EQ(ftn_1, ftn_2->opener());
+
+  // Verify all tabs are in the same BrowsingInstance.
+  EXPECT_TRUE(rfh_1->GetSiteInstance()->IsRelatedSiteInstance(
+      web_contents_2->GetPrimaryMainFrame()->GetSiteInstance()));
+  EXPECT_TRUE(rfh_1->GetSiteInstance()->IsRelatedSiteInstance(
+      web_contents_3->GetPrimaryMainFrame()->GetSiteInstance()));
+
+  // From Tab 3, discover Tab 2 by name and change its opener to Tab 3. This
+  // should work, as they're in the same BrowsingInstance.
+  EXPECT_TRUE(ExecJs(tab_3, "!!window.open('', 'foo');"));
+  EXPECT_EQ(ftn_3, ftn_2->opener());
+
+  // Navigate Tab 2 to a WebUI URL. This forces a BrowsingInstance swap.
+  EXPECT_TRUE(NavigateToURL(tab_2, webui_url));
+  EXPECT_FALSE(rfh_1->GetSiteInstance()->IsRelatedSiteInstance(
+      web_contents_2->GetPrimaryMainFrame()->GetSiteInstance()));
+
+  // Currently, the WebUI page's opener in the browser process is not severed,
+  // unlike COOP cases. (This allows the opener to still function if we were to
+  // go back to the previous page.) However, window.opener visible to JS should
+  // be null.
+  EXPECT_EQ(ftn_3, ftn_2->opener());
+  EXPECT_TRUE(ExecJs(tab_2, "window.opener == null"));
+
+  // From Tab 1, attempt to discover Tab 2 by name and change its opener to
+  // Tab 1.
+  EXPECT_TRUE(ExecJs(ftn_1, "!!window.open('', 'foo');"));
+
+  // Because those two tabs are now in different BrowsingInstances, this attempt
+  // to change the opener should be rejected. Verify the opener did not change.
+  EXPECT_EQ(ftn_3, ftn_2->opener());
+  EXPECT_TRUE(ExecJs(tab_2, "window.opener == null"));
+}
+
 namespace {
 
 // A helper to post a recurring check that a renderer process is foregrounded.
diff --git a/content/browser/renderer_host/render_frame_proxy_host.cc b/content/browser/renderer_host/render_frame_proxy_host.cc
index bce2b4c..eb03d035 100644
--- a/content/browser/renderer_host/render_frame_proxy_host.cc
+++ b/content/browser/renderer_host/render_frame_proxy_host.cc
@@ -853,6 +853,8 @@
 
 void RenderFrameProxyHost::DidChangeOpener(
     const std::optional<blink::LocalFrameToken>& opener_frame_token) {
+  // Note that this call internally protects against `opener_frame_token`
+  // referring to an inactive frame.
   frame_tree_node_->render_manager()->DidChangeOpener(opener_frame_token,
                                                       site_instance_group());
 }
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index a280237d..b5f0e66 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1606,6 +1606,121 @@
             kill_waiter.Wait());
 }
 
+// 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.
+//
+// Setup:
+//   Tab 1: A1 in BCG1 -> navigates to WebUI in BCG3, A1 gets bfcached
+//   Tab 2: A2 in BCG2
+//   Tab 3: B in BCG2
+// Attacker controls both A and B, and executes DidChangeOpener on a proxy for
+// tab 3 in A2's SiteInstance, asking the browser process to update tab3's
+// 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) {
+  // This test assumes that back-forward cache is enabled.
+  if (!IsBackForwardCacheEnabled()) {
+    GTEST_SKIP();
+  }
+
+  // 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")));
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 506bd7f4..95e196f6 100644
--- a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
@@ -5957,6 +5957,72 @@
   }
 }
 
+// Tests that a renderer cannot change the opener of a frame across different
+// BrowsingInstances (e.g., when trying to reconnect to a named window that
+// has navigated to a WebUI URL).
+IN_PROC_BROWSER_TEST_P(RenderFrameHostManagerTest,
+                       CannotChangeOpenerAcrossBrowsingInstances) {
+  StartEmbeddedServer();
+
+  GURL url_a1(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_a2(embedded_test_server()->GetURL("a.com", "/title2.html"));
+  GURL url_a3(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  GURL webui_url = GetWebUIURL(kChromeUIGpuHost);
+
+  EXPECT_TRUE(NavigateToURL(shell(), url_a1));
+  WebContentsImpl* web_contents_1 =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  FrameTreeNode* ftn_1 = web_contents_1->GetPrimaryFrameTree().root();
+  RenderFrameHostImpl* rfh_1 = ftn_1->current_frame_host();
+
+  // Use window.open() to open a popup from A1 to A2 named "foo", and another
+  // unnamed popup from A1 to A3.
+  Shell* tab_2 = OpenPopup(rfh_1, url_a2, "foo");
+  Shell* tab_3 = OpenPopup(rfh_1, url_a3, "");
+
+  WebContentsImpl* web_contents_2 =
+      static_cast<WebContentsImpl*>(tab_2->web_contents());
+  FrameTreeNode* ftn_2 = web_contents_2->GetPrimaryFrameTree().root();
+  WebContentsImpl* web_contents_3 =
+      static_cast<WebContentsImpl*>(tab_3->web_contents());
+  FrameTreeNode* ftn_3 = web_contents_3->GetPrimaryFrameTree().root();
+
+  // Verify the initial opener relationship.
+  EXPECT_EQ(ftn_1, ftn_2->opener());
+
+  // Verify all tabs are in the same BrowsingInstance.
+  EXPECT_TRUE(rfh_1->GetSiteInstance()->IsRelatedSiteInstance(
+      web_contents_2->GetPrimaryMainFrame()->GetSiteInstance()));
+  EXPECT_TRUE(rfh_1->GetSiteInstance()->IsRelatedSiteInstance(
+      web_contents_3->GetPrimaryMainFrame()->GetSiteInstance()));
+
+  // From Tab 3, discover Tab 2 by name and change its opener to Tab 3. This
+  // should work, as they're in the same BrowsingInstance.
+  EXPECT_TRUE(ExecJs(tab_3, "!!window.open('', 'foo');"));
+  EXPECT_EQ(ftn_3, ftn_2->opener());
+
+  // Navigate Tab 2 to a WebUI URL. This forces a BrowsingInstance swap.
+  EXPECT_TRUE(NavigateToURL(tab_2, webui_url));
+  EXPECT_FALSE(rfh_1->GetSiteInstance()->IsRelatedSiteInstance(
+      web_contents_2->GetPrimaryMainFrame()->GetSiteInstance()));
+
+  // Currently, the WebUI page's opener in the browser process is not severed,
+  // unlike COOP cases. (This allows the opener to still function if we were to
+  // go back to the previous page.) However, window.opener visible to JS should
+  // be null.
+  EXPECT_EQ(ftn_3, ftn_2->opener());
+  EXPECT_TRUE(ExecJs(tab_2, "window.opener == null"));
+
+  // From Tab 1, attempt to discover Tab 2 by name and change its opener to
+  // Tab 1.
+  EXPECT_TRUE(ExecJs(ftn_1, "!!window.open('', 'foo');"));
+
+  // Because those two tabs are now in different BrowsingInstances, this attempt
+  // to change the opener should be rejected. Verify the opener did not change.
+  EXPECT_EQ(ftn_3, ftn_2->opener());
+  EXPECT_TRUE(ExecJs(tab_2, "window.opener == null"));
+}
+
 namespace {
 
 // A helper to post a recurring check that a renderer process is foregrounded.
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index a280237d..b5f0e66 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1606,6 +1606,121 @@
             kill_waiter.Wait());
 }
 
+// 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.
+//
+// Setup:
+//   Tab 1: A1 in BCG1 -> navigates to WebUI in BCG3, A1 gets bfcached
+//   Tab 2: A2 in BCG2
+//   Tab 3: B in BCG2
+// Attacker controls both A and B, and executes DidChangeOpener on a proxy for
+// tab 3 in A2's SiteInstance, asking the browser process to update tab3's
+// 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) {
+  // This test assumes that back-forward cache is enabled.
+  if (!IsBackForwardCacheEnabled()) {
+    GTEST_SKIP();
+  }
+
+  // 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 (still controlled by the attacker) in the
+  // same BCG as A2.
+  GURL attacker_cross_site_url(
+      embedded_test_server()->GetURL("b.test", "/title3.html"));
+  Shell* tab3 = OpenPopup(tab2, attacker_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();
+
+  // Navigate A1 to a WebUI URL. This should force a BrowsingInstance swap. The
+  // original RenderFrameHost is placed into BFCache. Note that the popups
+  // created so far don't stop BFCache from working because they're in a
+  // separate BCG.
+  GURL chrome_url(std::string(kChromeUIScheme) + "://" +
+                  std::string(kChromeUIGpuHost));
+  EXPECT_TRUE(NavigateToURL(shell(), chrome_url));
+  RenderFrameHostImpl* tab1_webui_rfh = tab1_root->current_frame_host();
+  EXPECT_NE(tab1_rfh, tab1_webui_rfh);
+  EXPECT_TRUE(tab1_rfh->IsInBackForwardCache());
+  EXPECT_FALSE(tab1_webui_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab2_rfh->GetSiteInstance()));
+  EXPECT_FALSE(tab1_webui_rfh->GetSiteInstance()->IsRelatedSiteInstance(
+      tab3_rfh->GetSiteInstance()));
+
+  // Look up the proxy that represents B in A2's SiteInstance (both
+  // controlled by the attacker). 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 BFCached frame. This goes to the RenderFrameHostManager of
+  // tab3, trying to set its opener to the FTN that's currently showing the
+  // WebUI page.
+  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);
+
+  // Normally, opener updates trigger proxy creation to ensure that the frame
+  // that had its opener updated has access to the new opener chain. Ensure we
+  // didn't create a RenderFrameProxyHost for the WebUI page in tab3's
+  // SiteInstanceGroup, connecting the WebUI page to the attacker-controlled
+  // tab3.
+  RenderFrameProxyHost* webui_to_non_webui_proxy =
+      tab1_webui_rfh->browsing_context_state()->GetRenderFrameProxyHost(
+          tab3_rfh->GetSiteInstance()->group());
+  EXPECT_FALSE(webui_to_non_webui_proxy);
+}
+
 namespace {
 
 // An interceptor class that allows replacing the URL of the commit IPC from
Loading diff…

Original Bug Report

reported by vm...@google.com

COOP bypass via stale BFCached RFH token in RenderFrameHostManager::DidChangeOpener

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: RenderFrameHostManager::DidChangeOpener fails to verify the lifecycle state of the RenderFrameHost corresponding to a renderer-supplied opener token. A compromised renderer can supply the token of a BFCached frame to establish a cross-BrowsingInstance opener relationship to a COOP-isolated page. This triggers cross-BrowsingInstance proxy creation, bypassing Cross-Origin-Opener-Policy and Site Isolation.

Affected files:

  • content/browser/renderer_host/render_frame_host_manager.cc
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/renderer_host/render_frame_proxy_host.cc
  • content/browser/renderer_host/frame_tree_node.cc
  • content/browser/renderer_host/browsing_context_state.cc

Estimated timestamp from git blame: 2025-12-11

Summary

A potential security vulnerability exists in RenderFrameHostManager::DidChangeOpener where renderer-supplied frame tokens are resolved to FrameTreeNode objects without verifying the lifecycle state of the associated RenderFrameHost.

A compromised renderer can provide a token for a RenderFrameHost that is currently in the BackForward Cache (BFCache). Because BFCached RenderFrameHost instances retain a pointer to their original FrameTreeNode, and because that FrameTreeNode now hosts a new page in a potentially different BrowsingInstance, this allows the renderer to establish a window.opener relationship across BrowsingInstance boundaries. This bypasses Cross-Origin-Opener-Policy (COOP) and Site Isolation guarantees.

Technical Details

The vulnerability is located in the RenderFrameHostManager::DidChangeOpener method in content/browser/renderer_host/render_frame_host_manager.cc.

When a renderer sends a DidChangeOpener message (e.g., via the RemoteFrameHost Mojo interface), the browser resolves the provided opener_frame_token using RenderFrameHostImpl::FromFrameToken:

// content/browser/renderer_host/render_frame_host_manager.cc:1093
void RenderFrameHostManager::DidChangeOpener(
    const std::optional<blink::LocalFrameToken>& opener_frame_token,
    SiteInstanceGroup* source_site_instance_group) {
  FrameTreeNode* opener = nullptr;
  if (opener_frame_token) {
    RenderFrameHostImpl* opener_rfhi = RenderFrameHostImpl::FromFrameToken(
        source_site_instance_group->process()->GetDeprecatedID(),
        *opener_frame_token);
    if (opener_rfhi)
      opener = opener_rfhi->frame_tree_node();
  }
  // ...

RenderFrameHostImpl::FromFrameToken (in render_frame_host_impl.cc:2327) only validates that the requesting process ID matches the owner of the RenderFrameHost by looking it up in GetTokenFrameMap(). It does not filter out RenderFrameHost instances in the kInBackForwardCache or pending-deletion lifecycle states.

While in the BFCache, the RenderFrameHost remains in the global token map and its frame_tree_node_ member continues to point to the active FrameTreeNode that originally hosted it. However, that FrameTreeNode may now be hosting a new page in a different BrowsingInstance (e.g., due to a COOP-enforced navigation).

By providing the token of a BFCached RenderFrameHost, a compromised renderer causes the browser to set an attacker-controlled frame’s opener to a FrameTreeNode belonging to a different BrowsingInstance. The subsequent proxy-creation logic in RenderFrameHostManager::CreateOpenerProxies explicitly lacks IsRelatedSiteInstanceGroup checks (acknowledged by TODO(crbug.com/40205442) at line 5707 of render_frame_host_manager.cc), which allows cross-BrowsingInstance proxies to be created.

Suggested Potential Steps to Trigger

(Note: These steps represent a theoretical exploit path based on code analysis, as an automated working PoC has not yet been executed.)

  1. Setup Setup: An attacker controls a compromised renderer process (Process A) hosting a malicious webpage in a main frame (FrameTreeNode 1 / FTN 1). The active RenderFrameHostImpl is RFH 1. Process A records the LocalFrameToken of RFH 1.
  2. Open Popup: The attacker’s page opens a popup window, creating FrameTreeNode 2 (FTN 2) with an active RenderFrameHostImpl (RFH 2) also hosted in Process A.
  3. Navigate to Victim (COOP): The attacker navigates FTN 1 to a cross-origin victim site (e.g., victim.com) that employs a strict Cross-Origin-Opener-Policy (COOP).
  4. BFCache Entry: Due to the COOP header, the browser enforces a strict BrowsingInstance swap. The victim page is loaded into a new RenderFrameHostImpl (RFH 3) in an isolated process (Process B). The attacker’s original RFH 1 is preserved and placed into the BackForward Cache, retaining its pointer to FTN 1.
  5. Navigate Popup: The attacker navigates the popup (FTN 2) to another attacker-controlled site (e.g., attacker2.com), which requires a SiteInstance swap to a new Process C (RFH 4). Process A is granted a RenderFrameProxyHost (Proxy A) for FTN 2.
  6. Send Malicious IPC: The compromised Process A constructs and sends a DidChangeOpener Mojo message on the RemoteFrameHost interface associated with Proxy A, providing the LocalFrameToken of the BFCached RFH 1.
  7. Token Resolution & Opener Hijack: The browser receives this in RenderFrameProxyHost::DidChangeOpener and routes it to RenderFrameHostManager::DidChangeOpener. FromFrameToken successfully returns the BFCached RFH 1. The browser extracts its frame_tree_node_ (which points to the COOP-isolated FTN 1) and forcibly sets it as the opener of FTN 2.
  8. Cross-BrowsingInstance Proxy Creation: Because the active RenderFrameHost for FTN 2 (Process C) is in a different SiteInstanceGroup than the IPC sender (Process A), the browser calls UpdateOpener. This invokes CreateOpenerProxies, which blindly creates RenderFrameProxyHost objects in Process C’s SiteInstanceGroup pointing to the victim in Process B.
  9. Exploitation: Process C now possesses a fully functional window.opener handle to the COOP-isolated victim page.

Impact

This flaw allows a compromised renderer to bypass COOP isolation and obtain a window.opener handle to a page in a different BrowsingInstance. This can be exploited to:

  1. Perform Cross-Site Leaks (XS-Leaks), such as detecting navigations or counting frames in the victim page.
  2. Defeat Spectre BrowsingInstance-level isolation by forcing the browser to create cross-process frame proxies that shouldn’t exist.
  3. Cause browser-process state corruption by populating a BrowsingContextState’s proxy map with entries from unrelated SiteInstanceGroups. This will eventually trigger security assertions and CHECK_EQ failures (e.g., at content/browser/renderer_host/frame_tree.cc:803), leading to a Denial of Service of the browser process.

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