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

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncomplete cleanup in SiteIsolation
DescriptionIncomplete cleanup in SiteIsolation
ComponentSiteIsolation
Bug ClassLogic 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.
Tracker518078552
Fix commitccc0ccf66880 (chromium/src) +96/-1
CWECWE-459
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-30 (per vendor advisory)
Fix landed2026-07-17 (public git)
Disclosed2026-08-25
Patch gap39 days — fix public in source before the advisory

Background

Inner delegate / placeholder frame
When an inner WebContents or GuestView (e.g. a MimeHandlerView guest, as the regression test simulates) is attached into a page, an existing subframe’s RenderFrameHostImpl becomes a placeholder whose inner_tree_main_frame_tree_node_id_ points at the inner frame tree’s main frame.
`SwapOuterDelegateFrame()`
The RenderFrameHostImpl method that runs at attach time, swaps the placeholder for a proxy and sends the renderer an Unload IPC for the placeholder’s local frame.
`OnUnloadACK()` / `DidUnloadRenderFrame`
The renderer’s acknowledgement that the unload finished; before this patch, the placeholder’s child-frame cleanup (ResetChildren()) ran only here, so it depended on the renderer replying.
`FrameTree::NodeIterator`
Iterates the frame tree and, for a placeholder, treats the inner tree’s main frame as the only child, so any local children_ the placeholder still holds are skipped during cross-tree iteration.

Root Cause Analysis

Before the patch, RenderFrameHostImpl::SwapOuterDelegateFrame() set up the inner-delegate attachment and sent an Unload IPC, but it left the placeholder’s existing child frames in place until OnUnloadACK() called ResetChildren(). That only happens after the renderer sends DidUnloadRenderFrame. Until then, which is indefinitely if a misbehaving renderer withholds the ACK, the placeholder could hold a non-null inner_tree_main_frame_tree_node_id_ and a non-empty children_ at the same time. The commit message says FrameTree::NodeIterator does not handle that state: those children are skipped by cross-tree iteration. In the same window, OnCreateChildFrame() would still accept new children for the placeholder because its render frame was still considered live.

The fix moves the cleanup into the browser. SwapOuterDelegateFrame() now calls ResetChildren() before sending the Unload IPC, and OnCreateChildFrame() returns early, dropping the request, when inner_tree_main_frame_tree_node_id_ is set.

As a result, the invariant “a delegate placeholder has no local children” no longer depends on the renderer.

Key insight
The browser enforced the “placeholder has no local children” invariant only lazily, through a renderer-driven unload ACK. The fix enforces it eagerly in the browser at attach time and refuses later child creation on a delegate placeholder.

Attack Path

  1. Child frame under the future placeholder A subframe that will become the inner-delegate placeholder gets a child iframe. The regression test does this with script and notes that “a compromised renderer could create this child.”
  2. Inner delegate attaches The embedder attaches an inner WebContents/GuestView to that subframe (e.g. MimeHandlerView), which calls SwapOuterDelegateFrame() and sends Unload. Whether the attacker can trigger this depends on embedder features such as PDF/MimeHandlerView or guest views.
  3. Renderer withholds the unload ACK A compromised renderer never sends DidUnloadRenderFrame, so before the fix ResetChildren() in OnUnloadACK() never runs and the stale children remain. It can also send OnCreateChildFrame messages to add new children to the placeholder.
  4. Inconsistent frame tree The placeholder now has both an inner-tree main frame and hidden local children. Browser-side code that walks frames via FrameTree::NodeIterator skips those children, so per-frame bookkeeping (e.g. site-isolation, lifecycle or cleanup logic) can miss frames that are still live (inferred).

Impact Assessment

Reaching this state needs a compromised renderer that withholds the unload ACK or injects OnCreateChildFrame IPCs, plus an embedder flow that attaches an inner delegate (GuestView, MimeHandlerView, or the fenced frame mentioned in the code comment). The advisory classifies it as “Incomplete cleanup in SiteIsolation”. The likely consequence is that live child frames escape browser-side frame-tree iteration, which could weaken site-isolation or lifecycle enforcement or lead to stale-object bugs (inferred). The patch does not show a specific memory-safety sink. 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_impl.cc
modified
BindRepeating
content/browser/security_exploit_browsertest.cc
modified
BeginNavigationTransitionReplacer
content/browser/security_exploit_browsertest.cc
modified

Files Changed

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

Audit Directions

  • Browser invariants gated on renderer ACKs
    Look for browser-side cleanup that runs only in handlers for renderer replies (unload/detach/swap ACKs). A compromised renderer can withhold these indefinitely, so security-relevant state should be fixed up synchronously in the browser.
  • Renderer IPCs accepted in transitional states
    Check whether OnCreateChildFrame, navigation, and other frame-creating IPCs are rejected when the target RFH is a placeholder, pending deletion, or otherwise in a special state, not only when IsRenderFrameLive() is false.
  • Iterators with special-cased topology
    Review FrameTree::NodeIterator and similar walkers that substitute or skip nodes (inner trees, fenced frames, portals/guests) for states where “hidden” nodes are still live and so escape policy checks or teardown.
From ccc0ccf66880d0306d268da01f2d88a1f0aa1f00 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <alexmos@chromium.org>
Date: Fri, 17 Jul 2026 09:12:18 -0700
Subject: [PATCH] Clear inner-delegate placeholder children at attach time

When attaching an inner WebContents/GuestView to a placeholder
RenderFrameHost, SwapOuterDelegateFrame() sends an Unload IPC but defers
the placeholder's child-frame cleanup to OnUnloadACK(), which only runs
once the renderer sends DidUnloadRenderFrame. Until then the placeholder
can hold both inner_tree_main_frame_tree_node_id_ and non-empty
children_, which FrameTree::NodeIterator does not handle (the children
are skipped by cross-tree iteration).

Make the cleanup browser-side:
- SwapOuterDelegateFrame() now calls ResetChildren() before
  sending the Unload IPC, so existing children are cleared at
  attach time without waiting for the renderer.
- OnCreateChildFrame() now drops the message if
  inner_tree_main_frame_tree_node_id_ is set, so the placeholder
  cannot acquire new children once it has become a delegate node
  for an inner frame tree.

The existing ResetChildren() in OnUnloadACK() is retained as a no-op
safety net.

This is a followup to a similar issue in crbug.com/517241992.

Most of this CL has been adapted from the AI-generated fix at
https://crbug.com/518078552#comment4.

Bug: 518078552, 517241992
Change-Id: I66871ede1d5b8312c68393d09d7ff4ac2e6c0d37
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8112019
Commit-Queue: Alex Moshchuk <alexmos@chromium.org>
Reviewed-by: Kevin McNee <mcnee@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1663944}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 27e1428..cf8354a 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -5188,6 +5188,16 @@
     }
   }
 
+  // The placeholder frame for an inner frame tree (e.g., an attached
+  // GuestView, fenced frame) must never have its own local children, since
+  // the inner tree's main frame is treated as this frame's only child during
+  // FrameTree iteration. This message could only have been sent before the
+  // renderer processed the corresponding Unload IPC, so just drop it. See
+  // https://crbug.com/518078552.
+  if (inner_tree_main_frame_tree_node_id_) {
+    return;
+  }
+
   // `new_routing_id`, `frame_token`, `devtools_frame_token` and
   // `document_token` were generated on the browser's IO thread and not taken
   // from the renderer process.
@@ -6879,6 +6889,12 @@
 void RenderFrameHostImpl::SwapOuterDelegateFrame(
     RenderFrameProxyHost* proxy,
     const base::UnguessableToken& devtools_frame_token) {
+  // The placeholder frame for an inner frame tree must never have its own
+  // local children. Clear them here, before sending the Unload IPC, so that
+  // the cleanup does not depend on the renderer sending a DidUnloadRenderFrame
+  // ACK. See https://crbug.com/518078552.
+  ResetChildren();
+
   // Note: At this point the placeholder iframe for embedding the guest has
   // been initialized with a devtools_frame_token that is different from the
   // guest's main frame (that is about to be attached to it). When we swap
@@ -7182,7 +7198,8 @@
     // stay around but it will no longer be associated with a RenderFrame.
     // Ensure there are no lingering child frames before marking the frame
     // deleted - this matters for compromised renderers (see
-    // https://crbug.com/517241992).
+    // https://crbug.com/517241992). This is a defense-in-depth call that
+    // mirrors a similar call in SwapOuterDelegateFrame().
     ResetChildren();
     RenderFrameDeleted();
     return;
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 7a4372a9..1cafdb7 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -80,6 +80,8 @@
 #include "device/gamepad/public/mojom/gamepad.mojom.h"
 #include "ipc/constants.mojom.h"
 #include "mojo/core/embedder/embedder.h"
+#include "mojo/public/cpp/bindings/associated_receiver.h"
+#include "mojo/public/cpp/bindings/associated_remote.h"
 #include "mojo/public/cpp/bindings/pending_associated_remote.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
@@ -2963,6 +2965,82 @@
   EXPECT_EQ(0U, subframe->child_count());
 }
 
+// Test that the placeholder frame for an inner delegate has its children
+// cleared synchronously at attach time, without depending on the renderer's
+// DidUnloadRenderFrame ACK (which a misbehaving renderer may withhold), and
+// that the placeholder cannot acquire new children after the inner delegate is
+// attached. See https://crbug.com/518078552.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       NoChildrenOnInnerDelegatePlaceholderAfterAttach) {
+  // Start on a page with a blank iframe, simulating the normal starting point
+  // of attaching an inner delegate (e.g., for MimeHandlerView) to a placeholder
+  // subframe.
+  GURL main_url(
+      embedded_test_server()->GetURL("a.com", "/page_with_blank_iframe.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  RenderFrameHostImpl* main_frame = web_contents->GetPrimaryMainFrame();
+  RenderFrameHostImpl* subframe = main_frame->child_at(0)->current_frame_host();
+
+  // Add a child frame to the subframe so that we can test if it gets properly
+  // detached. A compromised renderer could create this child.
+  EXPECT_TRUE(ExecJs(subframe,
+                     "let f = document.createElement('iframe'); "
+                     "document.body.appendChild(f);"));
+  ASSERT_EQ(1U, subframe->child_count());
+  RenderFrameHostImpl* grandchild = subframe->child_at(0)->current_frame_host();
+  RenderFrameHostWrapper grandchild_observer(grandchild);
+
+  // Swallow the unload ACK so that any cleanup observed below is solely due to
+  // browser-side attach logic and not the renderer's DidUnloadRenderFrame ACK.
+  subframe->SetUnloadACKCallbackForTesting(
+      base::BindRepeating([]() { return true; }));
+
+  // Attach an inner delegate. This synchronously calls SwapOuterDelegateFrame()
+  // which sends the Unload IPC, and it sets is_inner_delegate_attached().
+  EXPECT_TRUE(CreateAndAttachInnerContents(subframe));
+  EXPECT_TRUE(subframe->frame_tree_node()
+                  ->render_manager()
+                  ->is_inner_delegate_attached());
+  EXPECT_TRUE(subframe->inner_tree_main_frame_tree_node_id());
+
+  // The placeholder frame must have no children immediately after attach,
+  // independently of whether the renderer ACKs the unload.
+  EXPECT_TRUE(grandchild_observer.IsRenderFrameDeleted());
+  EXPECT_EQ(0U, subframe->child_count());
+
+  // Simulate the renderer attempting to create a new child on the placeholder
+  // frame after the inner delegate has been attached. The placeholder's render
+  // frame is still considered created in the browser (since the unload ACK was
+  // swallowed above), so OnCreateChildFrame()'s lifecycle checks alone would
+  // not reject this; the dedicated inner-delegate check should drop it.
+  ASSERT_TRUE(subframe->IsRenderFrameLive());
+  mojo::AssociatedRemote<mojom::Frame> frame_remote;
+  std::ignore = frame_remote.BindNewEndpointAndPassDedicatedReceiver();
+  mojo::PendingRemote<blink::mojom::BrowserInterfaceBroker> bib_remote;
+  mojo::AssociatedReceiver<blink::mojom::AssociatedInterfaceProvider>
+      associated_interface_provider_receiver(nullptr);
+  std::ignore = associated_interface_provider_receiver
+                    .BindNewEndpointAndPassDedicatedRemote();
+  subframe->OnCreateChildFrame(
+      subframe->GetProcess()->GetNextRoutingID(), frame_remote.Unbind(),
+      bib_remote.InitWithNewPipeAndPassReceiver(),
+      blink::mojom::PolicyContainerBindParams::New(
+          mojo::PendingAssociatedRemote<blink::mojom::PolicyContainerHost>()
+              .InitWithNewEndpointAndPassReceiver()),
+      associated_interface_provider_receiver.Unbind(),
+      blink::mojom::TreeScopeType::kDocument, "", "uniqueName1",
+      /*is_created_by_script=*/false, blink::LocalFrameToken(),
+      base::UnguessableToken::Create(), blink::DocumentToken(),
+      blink::FramePolicy(), blink::mojom::FrameOwnerProperties(),
+      blink::FrameOwnerElementType::kIframe, ukm::kInvalidSourceId);
+
+  // The placeholder frame must still have no children.
+  EXPECT_EQ(0U, subframe->child_count());
+}
+
 class BeginNavigationTransitionReplacer : public FrameHostInterceptor {
  public:
   BeginNavigationTransitionReplacer(WebContents* web_contents,
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 7a4372a9..1cafdb7 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -80,6 +80,8 @@
 #include "device/gamepad/public/mojom/gamepad.mojom.h"
 #include "ipc/constants.mojom.h"
 #include "mojo/core/embedder/embedder.h"
+#include "mojo/public/cpp/bindings/associated_receiver.h"
+#include "mojo/public/cpp/bindings/associated_remote.h"
 #include "mojo/public/cpp/bindings/pending_associated_remote.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
@@ -2963,6 +2965,82 @@
   EXPECT_EQ(0U, subframe->child_count());
 }
 
+// Test that the placeholder frame for an inner delegate has its children
+// cleared synchronously at attach time, without depending on the renderer's
+// DidUnloadRenderFrame ACK (which a misbehaving renderer may withhold), and
+// that the placeholder cannot acquire new children after the inner delegate is
+// attached. See https://crbug.com/518078552.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       NoChildrenOnInnerDelegatePlaceholderAfterAttach) {
+  // Start on a page with a blank iframe, simulating the normal starting point
+  // of attaching an inner delegate (e.g., for MimeHandlerView) to a placeholder
+  // subframe.
+  GURL main_url(
+      embedded_test_server()->GetURL("a.com", "/page_with_blank_iframe.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+  WebContentsImpl* web_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  RenderFrameHostImpl* main_frame = web_contents->GetPrimaryMainFrame();
+  RenderFrameHostImpl* subframe = main_frame->child_at(0)->current_frame_host();
+
+  // Add a child frame to the subframe so that we can test if it gets properly
+  // detached. A compromised renderer could create this child.
+  EXPECT_TRUE(ExecJs(subframe,
+                     "let f = document.createElement('iframe'); "
+                     "document.body.appendChild(f);"));
+  ASSERT_EQ(1U, subframe->child_count());
+  RenderFrameHostImpl* grandchild = subframe->child_at(0)->current_frame_host();
+  RenderFrameHostWrapper grandchild_observer(grandchild);
+
+  // Swallow the unload ACK so that any cleanup observed below is solely due to
+  // browser-side attach logic and not the renderer's DidUnloadRenderFrame ACK.
+  subframe->SetUnloadACKCallbackForTesting(
+      base::BindRepeating([]() { return true; }));
+
+  // Attach an inner delegate. This synchronously calls SwapOuterDelegateFrame()
+  // which sends the Unload IPC, and it sets is_inner_delegate_attached().
+  EXPECT_TRUE(CreateAndAttachInnerContents(subframe));
+  EXPECT_TRUE(subframe->frame_tree_node()
+                  ->render_manager()
+                  ->is_inner_delegate_attached());
+  EXPECT_TRUE(subframe->inner_tree_main_frame_tree_node_id());
+
+  // The placeholder frame must have no children immediately after attach,
+  // independently of whether the renderer ACKs the unload.
+  EXPECT_TRUE(grandchild_observer.IsRenderFrameDeleted());
+  EXPECT_EQ(0U, subframe->child_count());
+
+  // Simulate the renderer attempting to create a new child on the placeholder
+  // frame after the inner delegate has been attached. The placeholder's render
+  // frame is still considered created in the browser (since the unload ACK was
+  // swallowed above), so OnCreateChildFrame()'s lifecycle checks alone would
+  // not reject this; the dedicated inner-delegate check should drop it.
+  ASSERT_TRUE(subframe->IsRenderFrameLive());
+  mojo::AssociatedRemote<mojom::Frame> frame_remote;
+  std::ignore = frame_remote.BindNewEndpointAndPassDedicatedReceiver();
+  mojo::PendingRemote<blink::mojom::BrowserInterfaceBroker> bib_remote;
+  mojo::AssociatedReceiver<blink::mojom::AssociatedInterfaceProvider>
+      associated_interface_provider_receiver(nullptr);
+  std::ignore = associated_interface_provider_receiver
+                    .BindNewEndpointAndPassDedicatedRemote();
+  subframe->OnCreateChildFrame(
+      subframe->GetProcess()->GetNextRoutingID(), frame_remote.Unbind(),
+      bib_remote.InitWithNewPipeAndPassReceiver(),
+      blink::mojom::PolicyContainerBindParams::New(
+          mojo::PendingAssociatedRemote<blink::mojom::PolicyContainerHost>()
+              .InitWithNewEndpointAndPassReceiver()),
+      associated_interface_provider_receiver.Unbind(),
+      blink::mojom::TreeScopeType::kDocument, "", "uniqueName1",
+      /*is_created_by_script=*/false, blink::LocalFrameToken(),
+      base::UnguessableToken::Create(), blink::DocumentToken(),
+      blink::FramePolicy(), blink::mojom::FrameOwnerProperties(),
+      blink::FrameOwnerElementType::kIframe, ukm::kInvalidSourceId);
+
+  // The placeholder frame must still have no children.
+  EXPECT_EQ(0U, subframe->child_count());
+}
+
 class BeginNavigationTransitionReplacer : public FrameHostInterceptor {
  public:
   BeginNavigationTransitionReplacer(WebContents* web_contents,
Loading diff…

Original Bug Report

reported by vm...@google.com

FrameTree invariant bypass in SwapOuterDelegateFrame allows creation of hidden subtrees

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 logic issue in the outer delegate frame swapping mechanism allows a compromised renderer to bypass the child frame cleanup mechanism. By withholding the Unload ACK, a placeholder frame’s Mojo endpoints remain active and bound while the frame is in an inconsistent state. An attacker can then spawn child frames under this placeholder, creating a hidden subtree that is skipped during standard frame tree walks.

Affected files:

  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/web_contents/web_contents_impl.cc
  • content/browser/renderer_host/agent_scheduling_group_host.cc
  • content/browser/renderer_host/frame_tree.cc

Estimated timestamp from git blame: 2019-02-26

Root Cause Analysis

The cleanup logic designed to prevent lingering child frames during delegate attachment is normally triggered upon receiving the unload acknowledgement. Specifically, ResetChildren() is invoked within the inner-delegate branch of RenderFrameHostImpl::OnUnloadACK():

// content/browser/renderer_host/render_frame_host_impl.cc
RenderFrameHostOwner* owner =
    IsPendingDeletion() ? GetFrameTreeNodeForUnload() : owner_;
if (!is_main_frame() &&
    owner->GetRenderFrameHostManager().is_inner_delegate_attached()) {
  ResetChildren();
  RenderFrameDeleted();
  return;
}

However, when a placeholder frame is swapped via RenderFrameHostImpl::SwapOuterDelegateFrame(), the browser sends an unload request to the renderer but does not configure the standard browser-side bookkeeping to enforce a timeout or transition the frame state:

// content/browser/renderer_host/render_frame_host_impl.cc
void RenderFrameHostImpl::SwapOuterDelegateFrame(...) {
  ...
  GetMojomFrameInRenderer()->Unload(/*is_loading=*/false, ...); // Fire-and-forget
  // Does NOT set is_waiting_for_unload_ack_ = true
  // Does NOT start unload_event_monitor_timeout_
  // Does NOT set the lifecycle state to kRunningUnloadHandlers
  ...
}

Because the unload ACK is expected exclusively via the renderer-controlled IPC DidUnloadRenderFrame without any expected-state or timeout enforcement, a compromised renderer can simply withhold the ACK indefinitely. This leaves the placeholder RenderFrameHost with active Mojo endpoints bound while its state remains LifecycleStateImpl::kActive and RenderFrameState::kCreated.


Potential Trigger Path

Note: The following steps are potential/suggested steps to trigger this behavior, as our current evaluation is based on static analysis of the codebase.

  1. A compromised renderer creates a placeholder iframe (e.g., <embed type="application/pdf" src="..."> during MimeHandlerView flow), triggering PrepareForInnerWebContentsAttach -> AttachToOuterWebContentsFrame -> AttachInnerWebContentsImpl in the browser.
  2. AttachInnerWebContentsImpl sets inner_tree_main_frame_tree_node_id_ on the placeholder frame, calls SwapOuterDelegateFrame (which sends the Unload IPC to the renderer without setting up a timeout or changing the lifecycle state), and completes attachment.
  3. The compromised renderer intentionally ignores the Unload IPC and never sends AgentSchedulingGroupHost::DidUnloadRenderFrame.
  4. Since the Mojo interfaces (mojom::FrameHost and blink::mojom::LocalFrameHost) on the placeholder frame remain bound, the compromised renderer calls CreateChildFrame on the placeholder’s pipe.
  5. In the browser, RenderFrameHostImpl::OnCreateChildFrame checks is_render_frame_created() (which is still kCreated) and IsInactiveAndDisallowActivation (which returns false because lifecycle_state_ remains kActive). The browser accepts the request and adds a child frame (gc1) to the placeholder’s children list.

Resulting Inconsistent State and Impact

This sequence puts the browser into a state where:

  • placeholder->lifecycle_state_ == kActive
  • placeholder->children_ is non-empty (containing gc1).
  • is_inner_delegate_attached() evaluates to true.

This violates the core design invariant: child_count() > 0 => inner_tree_main_frame_tree_node_id().is_null() (which is explicitly asserted in test environments, such as in content/browser/renderer_host/scroll_into_view_browsertest.cc:460-462).

Security Consequences:

Standard frame walks (such as ForEachRenderFrameHost and NodesIncludingInnerTreeNodes) walk the tree using include_delegate_nodes_for_inner_frame_trees = false. When the FrameTree::NodeIterator encounters the placeholder frame node, it skips the placeholder entirely and descends directly into the inner tree’s main frame:

// content/browser/renderer_host/frame_tree.cc:95-98
if (should_descend_into_inner_trees_ && inner_tree_main_ftn) {
  if (include_delegate_nodes_for_inner_frame_trees_)
    queue_.push_back(child); // Skipped
  queue_.push_back(inner_tree_main_ftn);
}

Because the placeholder node is never queued, its children (the newly created frame gc1) are never visited. This creates a hidden active frame subtree that is invisible to security policy enforcements, origin checks, and lifecycle audits, yet remains fully active with live, renderer-controlled Mojo connections.


Suggested Remediation

To prevent the creation of children under placeholder frames, the browser should ensure that once SwapOuterDelegateFrame is invoked, the placeholder frame is immediately restricted from creating child frames or processing further state-modifying IPCs.

  1. Disable Mojo Receivers Early: In SwapOuterDelegateFrame, immediately close or unbind the frame-creation and frame-navigation related Mojo receivers (such as local_frame_host_receiver_), rather than waiting for the asynchronous unload ACK.
  2. Explicit Validation: Modify RenderFrameHostImpl::OnCreateChildFrame to explicitly reject child frame creation if an inner delegate is attached or in the process of being attached (e.g., is_inner_delegate_attached() is true or is_attaching_inner_delegate() is true).

Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040


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