Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncomplete cleanup in SiteIsolation
DescriptionIncomplete cleanup in SiteIsolation
ComponentSiteIsolation
Bug ClassLogic Error
Tracker518078552
Fix commitccc0ccf66880 (chromium/src) +96/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

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
From ccc0ccf66880d0306d268da01f2d88a1f0aa1f00 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <[email protected]>
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 <[email protected]>
Reviewed-by: Kevin McNee <[email protected]>
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 [email protected]

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