Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Navigation
DescriptionInappropriate implementation in Navigation
ComponentNavigation
Bug ClassLogic Error
Tracker517241992
Fix commitccc0ccf66880 (chromium/src) +96/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

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 <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

Frame-tree state confusion as OnUnloadACK inner-delegate branch omits ResetChildren

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 bug in RenderFrameHostImpl::OnUnloadACK allows a compromised renderer to retain active child frames under a placeholder frame that is attaching an inner WebContents. This occurs because the inner-delegate early-return path invokes RenderFrameDeleted() but omits the necessary ResetChildren() subtree cleanup. This leaves the frame tree in an inconsistent state where active child frames are invisible to cross-tree walks.

Affected files:

  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/web_contents/web_contents_impl.cc
  • extensions/browser/guest_view/mime_handler_view/mime_handler_view_embedder.cc
  • content/browser/renderer_host/frame_tree.cc

Estimated timestamp from git blame: 2019-02-26

Root Cause Analysis

In content/browser/renderer_host/render_frame_host_impl.cc (around lines 7150-7158), the inner-delegate early-return path inside OnUnloadACK handles the scenario where a frame is unloaded during inner-delegate attachment:

RenderFrameHostOwner* owner =
    IsPendingDeletion() ? GetFrameTreeNodeForUnload() : owner_;
if (!is_main_frame() &&
    owner->GetRenderFrameHostManager().is_inner_delegate_attached()) {
  // This RFH was unloaded while attaching an inner delegate. The RFH
  // will stay around but it will no longer be associated with a RenderFrame.
  RenderFrameDeleted();
  return;
}

Crucially, this path invokes RenderFrameDeleted() but completely omits the ResetChildren() subtree-teardown that every other standard frame-teardown path performs (such as the renderer-process-death path at line 4464 or the destructor at line 2867).

Additionally, there is no guard in OnCreateChildFrame or AddChild that prevents a renderer from successfully requesting and creating child frames under a placeholder RenderFrameHost during the active or early-attachment phases.

Potential Trigger Scenario

(Note: These are potential steps based on static analysis of the codebase, as our tooling does not have the capability to run code or execute a proof of concept.)

  1. A compromised renderer process (Process A) hosts a frame that navigates to a GuestView-eligible resource (such as a PDF handled via MimeHandlerView).
  2. The browser creates a MimeHandlerViewEmbedder in extensions/browser/guest_view/mime_handler_view/mime_handler_view_embedder.cc and commits a template HTML page inside the subframe in Process A.
  3. The template HTML creates a child <embed> element with a unique name, resulting in the creation of a placeholder RenderFrameHostImpl in the browser which enters the kActive state.
  4. During the asynchronous preparation phase for attaching the inner WebContents (e.g., during Mojo round-trips for CreateBeforeUnloadControl at mime_handler_view_embedder.cc line 162), the placeholder RFH remains active in the browser.
  5. The compromised renderer takes advantage of this window to call FrameHost::CreateChildFrame on the placeholder frame’s Mojo interface. This successfully adds a child gc1 FrameTreeNode to placeholder->children_ because the placeholder is active and its render frame is created.
  6. The browser proceeds with the guest attachment inside WebContentsImpl::AttachInnerWebContentsImpl (defined in content/browser/web_contents/web_contents_impl.cc at line 3273), updating inner_tree_main_frame_tree_node_id_ and calling SwapOuterDelegateFrame(), which sends Unload to the placeholder frame in Process A.
  7. The compromised renderer ignores the unload command for the child gc1 but responds with an unload ACK for the placeholder.
  8. The browser receives the unload ACK inside RenderFrameHostImpl::OnUnloadACK. It matches the early-return condition and invokes RenderFrameDeleted() on the placeholder, but skips ResetChildren() cleanup.
  9. This leaves the child frame gc1 alive and active inside placeholder->children_ with its Mojo endpoints fully bound to Process A.

Security Implications

This behavior leads to a severe desynchronization of the browser-process frame tree:

  • Ghost Frames: The child gc1 remains alive and fully bound in the background even though the placeholder has been marked “deleted”. A compromised renderer can continue to run scripts, make requests, and execute IPCs through gc1 without any visible UI or active frame presence.
  • Inconsistent Iteration: As defined in content/browser/renderer_host/frame_tree.cc (at line 337), SubtreeAndInnerTreeNodes descends directly into the inner tree, skipping the placeholder because include_delegate_nodes_for_inner_frame_trees is false by default. Thus, gc1 is skipped during normal cross-tree walks (such as ForEachRenderFrameHost), making it invisible to security or policy checks that rely on global frame traversal.

Suggested Remediation

Ensure that when a placeholder RFH is unloaded or detached during inner-delegate attachment, any children in its subtree are properly torn down. This can be accomplished by calling ResetChildren() before RenderFrameDeleted() inside the inner-delegate early-return branch of RenderFrameHostImpl::OnUnloadACK:

if (!is_main_frame() &&
    owner->GetRenderFrameHostManager().is_inner_delegate_attached()) {
  // Properly tear down child subtrees before marking the frame deleted.
  ResetChildren();
  RenderFrameDeleted();
  return;
}

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