Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Navigation
DescriptionUse after free in Navigation
ComponentNavigation
Bug ClassUAF
Tracker496463315
Fix commit1ccd3deae8cc (chromium/src) +86/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
EXPECT_TRUE
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/renderer_host/render_frame_host_manager.h
  • content/browser/security_exploit_browsertest.cc
From 1ccd3deae8ccccf326c8c67f646cf3101ff114c0 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <[email protected]>
Date: Fri, 27 Mar 2026 19:47:14 -0700
Subject: [PATCH] Disallow spoofed unload ACKs during inner delegate attachment.

This CL prevents misbehaving renderers that are attaching an inner
WebContents for MimeHandlerView (such as when navigating to a PDF),
from incorrectly triggering RenderFrameDeleted from OnUnloadACK()
while not actually waiting for an unload ACK. This can happen in the
async time window where MimeHandlerView is "preparing to attach",
invoked via RenderFrameHostImpl::PrepareForInnerWebContentsAttach(),
which tries to run beforeunload handlers before proceeding with the
actual attachment via WebContents::AttachInnerWebContents().

To do this, OnUnloadACK() checks that the inner delegate has already
been attached, rather than still preparing to attach, at the time it's
received. This should always be the case in normal cases, as the
attachment process sends the Unload IPC in SwapOuterDelegateFrame()
(to swap the placeholder RenderFrame with a proxy and make it
non-live) and then calls set_attach_inner_delegate_complete() [1] in
the same task.

Another approach that was considered here was to check
`is_waiting_for_unload_ack_` in OnUnloadACK() before proceeding with
RenderFrameDeleted() for inner delegate cases, rather than after. This
didn't work out, as it also required updating
RenderFrameHostImpl::SwapOuterDelegateFrame() to set
`is_waiting_for_unload_ack_` to true, which confuses other
unload-related code (e.g., to later try to take placeholder frames
for an inner WebContents off the RFH pending delete list, which we
don't use for placeholder frames).

[1] https://source.chromium.org/chromium/chromium/src/+/main:content/browser/web_contents/web_contents_impl.cc;l=3373;drc=e28fa5b90bf167ca4f5edaa753106753e1e59a4e

Bug: 496463315
Change-Id: I0bdf4dadfc8b642af39e51a4c8fee9dd3cb8f045
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7707137
Reviewed-by: Charlie Reis <[email protected]>
Reviewed-by: Kevin McNee <[email protected]>
Commit-Queue: Alex Moshchuk <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1606603}
---

diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 8ae223b..914b061 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -7061,10 +7061,19 @@
   // it makes its renderer send this message. `owner_` is non null since this
   // attachment can only happen for subframes and pending deletion is the only
   // case where subframes may have a null `owner_`.
+  //
+  // Note that for MimeHandlerView specifically, the unload ACK can only be
+  // legitimately received after the inner delegate has already been attached by
+  // `RFH::SwapOuterDelegateFrame()`, and should be ignored if it's received
+  // during an earlier MimeHandlerView-specific preparation phase invoked via
+  // `RFH::PrepareForInnerContentsAttach()` (because not ignoring it would later
+  // disrupt the attachment, e.g. by causing the Unload IPC not to be sent).
+  // Hence, it's important to check for `is_inner_delegate_attached()` rather
+  // than `is_attaching_inner_delegate()`.
   RenderFrameHostOwner* owner =
       IsPendingDeletion() ? GetFrameTreeNodeForUnload() : owner_;
   if (!is_main_frame() &&
-      owner->GetRenderFrameHostManager().is_attaching_inner_delegate()) {
+      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();
diff --git a/content/browser/renderer_host/render_frame_host_manager.h b/content/browser/renderer_host/render_frame_host_manager.h
index aba08454..517ecfc 100644
--- a/content/browser/renderer_host/render_frame_host_manager.h
+++ b/content/browser/renderer_host/render_frame_host_manager.h
@@ -719,6 +719,12 @@
     return attach_to_inner_delegate_state_ != AttachToInnerDelegateState::NONE;
   }
 
+  // Returns true if an inner delegate has been fully attached.
+  bool is_inner_delegate_attached() const {
+    return attach_to_inner_delegate_state_ ==
+           AttachToInnerDelegateState::ATTACHED;
+  }
+
   // Called by the delegate at the end of the attaching process.
   void set_attach_inner_delegate_complete() {
     attach_to_inner_delegate_state_ = AttachToInnerDelegateState::ATTACHED;
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index fbbcfca9..b500c32 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -24,6 +24,7 @@
 #include "base/test/bind.h"
 #include "base/test/gtest_util.h"
 #include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
 #include "base/unguessable_token.h"
 #include "build/build_config.h"
 #include "content/browser/attribution_reporting/attribution_manager.h"
@@ -1859,6 +1860,75 @@
             kill_waiter.Wait());
 }
 
+// Inner delegate attachment for MimeHandlerView has an extra phase in the
+// beginning, invoked via RenderFrameHost::PrepareForInnerContentsAttach(),
+// which currently invokes beforeunload handlers prior to proceeding with
+// normal attachment in AttachInnerWebContents()/AttachGuestPage() in a later
+// task. Ensure that a misbehaving renderer doesn't trigger RenderFrameDeleted()
+// via an unload ACK in that first PrepareForInnerContentsAttach() phase, where
+// we aren't actually expecting that unload ACK.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       SpoofUnloadACKDuringInnerDelegateAttach) {
+  // 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 beforeunload handler to the blank subframe. This shouldn't ever
+  // happen during the normal inner delegate attachment flow, but a compromised
+  // renderer can still do it. This is not technically necessary for the
+  // renderer to send a spoofed unlock ACK while we're in this phase, but it
+  // makes the time window where this ACK can lead to problems larger and more
+  // practical.
+  // TODO(crbug.com/40249634): Make it impossible for placeholder
+  // frames to trigger beforeunload during inner delegate attachment.
+  subframe->DisableBeforeUnloadHangMonitorForTesting();
+  EXPECT_TRUE(ExecJs(subframe, "window.onbeforeunload = function() {};"));
+
+  // Prepare for inner web contents attach (this simulates MimeHandlerView
+  // creating the PDF inner delegate). This currently triggers the beforeunload
+  // phase.
+  base::test::TestFuture<RenderFrameHost*> future;
+  subframe->PrepareForInnerWebContentsAttach(future.GetCallback());
+
+  // Verify that the subframe is waiting for a beforeunload ACK and also
+  // attaching an inner delegate.
+  EXPECT_TRUE(subframe->is_waiting_for_beforeunload_completion());
+  EXPECT_TRUE(subframe->frame_tree_node()
+                  ->render_manager()
+                  ->is_attaching_inner_delegate());
+
+  RenderFrameHostWrapper observer(subframe);
+
+  // Spoof DidUnloadRenderFrame IPC while waiting for beforeunload ACK.
+  subframe->OnUnloadACK();
+
+  // Ensure the subframe is still live, and its RenderFrame is not deleted.
+  ASSERT_FALSE(observer.IsRenderFrameDeleted());
+
+  // Check that we're still in the inner delegate attachment phase.
+  EXPECT_TRUE(subframe->frame_tree_node()
+                  ->render_manager()
+                  ->is_attaching_inner_delegate());
+
+  // The RFH shouldn't change when inner delegate attachment is ready to
+  // proceed.
+  EXPECT_EQ(subframe, future.Get());
+
+  // Make sure proceeding with the inner WebContents attachment succeeds.
+  EXPECT_TRUE(CreateAndAttachInnerContents(subframe));
+  EXPECT_TRUE(subframe->frame_tree_node()
+                  ->render_manager()
+                  ->is_inner_delegate_attached());
+}
+
 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 fbbcfca9..b500c32 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -24,6 +24,7 @@
 #include "base/test/bind.h"
 #include "base/test/gtest_util.h"
 #include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
 #include "base/unguessable_token.h"
 #include "build/build_config.h"
 #include "content/browser/attribution_reporting/attribution_manager.h"
@@ -1859,6 +1860,75 @@
             kill_waiter.Wait());
 }
 
+// Inner delegate attachment for MimeHandlerView has an extra phase in the
+// beginning, invoked via RenderFrameHost::PrepareForInnerContentsAttach(),
+// which currently invokes beforeunload handlers prior to proceeding with
+// normal attachment in AttachInnerWebContents()/AttachGuestPage() in a later
+// task. Ensure that a misbehaving renderer doesn't trigger RenderFrameDeleted()
+// via an unload ACK in that first PrepareForInnerContentsAttach() phase, where
+// we aren't actually expecting that unload ACK.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       SpoofUnloadACKDuringInnerDelegateAttach) {
+  // 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 beforeunload handler to the blank subframe. This shouldn't ever
+  // happen during the normal inner delegate attachment flow, but a compromised
+  // renderer can still do it. This is not technically necessary for the
+  // renderer to send a spoofed unlock ACK while we're in this phase, but it
+  // makes the time window where this ACK can lead to problems larger and more
+  // practical.
+  // TODO(crbug.com/40249634): Make it impossible for placeholder
+  // frames to trigger beforeunload during inner delegate attachment.
+  subframe->DisableBeforeUnloadHangMonitorForTesting();
+  EXPECT_TRUE(ExecJs(subframe, "window.onbeforeunload = function() {};"));
+
+  // Prepare for inner web contents attach (this simulates MimeHandlerView
+  // creating the PDF inner delegate). This currently triggers the beforeunload
+  // phase.
+  base::test::TestFuture<RenderFrameHost*> future;
+  subframe->PrepareForInnerWebContentsAttach(future.GetCallback());
+
+  // Verify that the subframe is waiting for a beforeunload ACK and also
+  // attaching an inner delegate.
+  EXPECT_TRUE(subframe->is_waiting_for_beforeunload_completion());
+  EXPECT_TRUE(subframe->frame_tree_node()
+                  ->render_manager()
+                  ->is_attaching_inner_delegate());
+
+  RenderFrameHostWrapper observer(subframe);
+
+  // Spoof DidUnloadRenderFrame IPC while waiting for beforeunload ACK.
+  subframe->OnUnloadACK();
+
+  // Ensure the subframe is still live, and its RenderFrame is not deleted.
+  ASSERT_FALSE(observer.IsRenderFrameDeleted());
+
+  // Check that we're still in the inner delegate attachment phase.
+  EXPECT_TRUE(subframe->frame_tree_node()
+                  ->render_manager()
+                  ->is_attaching_inner_delegate());
+
+  // The RFH shouldn't change when inner delegate attachment is ready to
+  // proceed.
+  EXPECT_EQ(subframe, future.Get());
+
+  // Make sure proceeding with the inner WebContents attachment succeeds.
+  EXPECT_TRUE(CreateAndAttachInnerContents(subframe));
+  EXPECT_TRUE(subframe->frame_tree_node()
+                  ->render_manager()
+                  ->is_inner_delegate_attached());
+}
+
 class BeginNavigationTransitionReplacer : public FrameHostInterceptor {
  public:
   BeginNavigationTransitionReplacer(WebContents* web_contents,
Loading diff…

Original Bug Report

reported by [email protected]

Sandbox Escape: Premature RenderFrameDeleted via spoofed DidUnloadRenderFrame during inner delegate attach

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

Overview: A logic flaw in RenderFrameHostImpl::OnUnloadACK allows a compromised renderer to spoof a DidUnloadRenderFrame IPC while the browser is waiting for a BeforeUnload ACK. This bypasses the unload acknowledgment check and prematurely calls RenderFrameDeleted() on an active frame. Observers free associated state while the frame remains in the tree, leading to a potential Use-After-Free (UAF) in the browser process.

Affected files:

  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/renderer_host/agent_scheduling_group_host.cc
  • content/browser/renderer_host/render_frame_host_manager.cc

Estimated timestamp from git blame: 2025-10-27

Summary

A static analysis of content/browser/renderer_host/render_frame_host_impl.cc reveals a potential vulnerability in RenderFrameHostImpl::OnUnloadACK. The function handles the acknowledgement of a frame unloading. However, the logic contains a critical ordering flaw: it checks if the frame is attaching an inner delegate (e.g., loading a PDF via MimeHandlerView) and immediately calls RenderFrameDeleted() before verifying if the browser is actually waiting for an Unload ACK.

A compromised renderer can exploit this by sending a spoofed DidUnloadRenderFrame IPC while the browser is only waiting for a BeforeUnload ACK. This triggers RenderFrameDeleted() prematurely, causing WebContentsObservers to free per-frame state. Because the BeforeUnload timeout is still running and the frame is not detached from the FrameTree, subsequent iterations over the frame tree (e.g., via ForEachRenderFrameHost) will yield this zombie frame, potentially causing observers to access freed state, leading to a Use-After-Free (UAF) in the highly privileged Browser process.

Technical Details

In RenderFrameHostImpl::OnUnloadACK, the code evaluates whether to delete the frame during inner delegate attachment:

void RenderFrameHostImpl::OnUnloadACK() {
  // ...
  RenderFrameHostOwner* owner =
      IsPendingDeletion() ? GetFrameTreeNodeForUnload() : owner_;
  if (!is_main_frame() &&
      owner->GetRenderFrameHostManager().is_attaching_inner_delegate()) {
    // This RFH was unloaded while attaching an inner delegate.
    RenderFrameDeleted();
    return;
  }

  // Ignore spurious unload ack.
  if (!is_waiting_for_unload_ack_) {
    return;
  }
  // ...
}

The is_attaching_inner_delegate() check occurs before the is_waiting_for_unload_ack_ check.

When a subframe navigates to a resource requiring an inner delegate (like a PDF), RenderFrameHostManager::PrepareForInnerDelegateAttach sets the state to PREPARE_FRAME (making is_attaching_inner_delegate() return true). If the subframe has a beforeunload handler, the browser dispatches a BeforeUnload IPC and starts a timeout. Crucially, during this phase, the browser is waiting for a BeforeUnload ACK, so is_waiting_for_unload_ack_ is false.

A compromised renderer can send a DidUnloadRenderFrame IPC for this subframe via AgentSchedulingGroupHost. The browser receives it, calls OnUnloadACK(), hits the is_attaching_inner_delegate() bypass, and prematurely executes RenderFrameDeleted().

Potential Exploitation Steps

(Note: These are suggested steps based on static analysis; a working Proof of Concept has not yet been developed.)

  1. Attacker Setup: A compromised renderer creates an <iframe> and installs a beforeunload event handler on it.
  2. Trigger Attachment: The renderer navigates the iframe to a PDF file, triggering MimeHandlerView creation.
  3. Browser State Change: The browser intercepts the request, the renderer loads the placeholder, and signals ReadyToCreateMimeHandlerView. The browser calls PrepareForInnerDelegateAttach, setting attach_to_inner_delegate_state_ to PREPARE_FRAME.
  4. Dispatch BeforeUnload: Because of the beforeunload handler, the browser dispatches a BeforeUnload IPC to the renderer and starts a 1-second timeout timer. The browser’s is_waiting_for_unload_ack_ state remains false.
  5. Spoof IPC: The compromised renderer intentionally ignores the BeforeUnload IPC. Instead, it sends a spoofed DidUnloadRenderFrame IPC for the iframe.
  6. Premature Deletion: The browser receives the spoofed IPC and calls RenderFrameHostImpl::OnUnloadACK. The logic flaw bypasses the is_waiting_for_unload_ack_ check, directly executing RenderFrameDeleted().
  7. Observer State Freed: RenderFrameDeleted() notifies all WebContentsObservers (e.g., Extensions, Autofill), which proceed to free memory and state mappings associated with this RenderFrameHost*.
  8. Use-After-Free: The frame remains in the FrameTree (the beforeunload timeout is still running). When a downstream browser component iterates the frame tree (e.g., WebContentsImpl::ForEachRenderFrameHost), it yields this zombie RenderFrameHost*. Observers accessing their now-freed state mappings for this pointer will trigger a Use-After-Free (Sandbox Escape).

Suggested Fix

The is_waiting_for_unload_ack_ check should be moved up in RenderFrameHostImpl::OnUnloadACK so that spurious Unload ACKs are rejected before evaluating the inner delegate attachment state.

void RenderFrameHostImpl::OnUnloadACK() {
  // ... (existing early returns)

  // Ignore spurious unload ack MUST happen first.
  if (!is_waiting_for_unload_ack_) {
    return;
  }

  RenderFrameHostOwner* owner =
      IsPendingDeletion() ? GetFrameTreeNodeForUnload() : owner_;
  if (!is_main_frame() &&
      owner->GetRenderFrameHostManager().is_attaching_inner_delegate()) {
    RenderFrameDeleted();
    return;
  }
  // ...
}

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