CVE-2026-14017
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/render_frame_host_impl.cc |
modified | |
BindRepeatingcontent/browser/security_exploit_browsertest.cc |
modified | |
BeginNavigationTransitionReplacercontent/browser/security_exploit_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/security_exploit_browsertest.cc
Patch
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,
Regression Test / PoC
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,
Original Bug Report
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.cccontent/browser/web_contents/web_contents_impl.ccextensions/browser/guest_view/mime_handler_view/mime_handler_view_embedder.cccontent/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.)
- A compromised renderer process (Process A) hosts a frame that navigates to a GuestView-eligible resource (such as a PDF handled via
MimeHandlerView). - The browser creates a
MimeHandlerViewEmbedderinextensions/browser/guest_view/mime_handler_view/mime_handler_view_embedder.ccand commits a template HTML page inside the subframe in Process A. - The template HTML creates a child
<embed>element with a unique name, resulting in the creation of a placeholderRenderFrameHostImplin the browser which enters thekActivestate. - During the asynchronous preparation phase for attaching the inner WebContents (e.g., during Mojo round-trips for
CreateBeforeUnloadControlatmime_handler_view_embedder.ccline 162), the placeholder RFH remains active in the browser. - The compromised renderer takes advantage of this window to call
FrameHost::CreateChildFrameon the placeholder frame’s Mojo interface. This successfully adds a childgc1FrameTreeNodetoplaceholder->children_because the placeholder is active and its render frame is created. - The browser proceeds with the guest attachment inside
WebContentsImpl::AttachInnerWebContentsImpl(defined incontent/browser/web_contents/web_contents_impl.ccat line 3273), updatinginner_tree_main_frame_tree_node_id_and callingSwapOuterDelegateFrame(), which sendsUnloadto the placeholder frame in Process A. - The compromised renderer ignores the unload command for the child
gc1but responds with an unload ACK for the placeholder. - The browser receives the unload ACK inside
RenderFrameHostImpl::OnUnloadACK. It matches the early-return condition and invokesRenderFrameDeleted()on the placeholder, but skipsResetChildren()cleanup. - This leaves the child frame
gc1alive and active insideplaceholder->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
gc1remains 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 throughgc1without any visible UI or active frame presence. - Inconsistent Iteration: As defined in
content/browser/renderer_host/frame_tree.cc(at line 337),SubtreeAndInnerTreeNodesdescends directly into the inner tree, skipping the placeholder becauseinclude_delegate_nodes_for_inner_frame_treesis false by default. Thus,gc1is skipped during normal cross-tree walks (such asForEachRenderFrameHost), 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.