CVE-2026-7956
Overview
Background
- Inner WebContents / inner delegate
- a nested
WebContents(e.g. the PDF viewerMimeHandlerView) attached into a placeholder subframe of an outer page; attachment proceeds through states tracked byAttachToInnerDelegateState. - `PrepareForInnerWebContentsAttach` phase
- an early, asynchronous “preparing to attach” step that runs
beforeunloadhandlers before the real attachment inWebContents::AttachInnerWebContents()/SwapOuterDelegateFrame(). - Unload ACK / `OnUnloadACK`
- the IPC handler invoked when a renderer reports its frame unloaded; for inner-delegate cases it can call
RenderFrameDeleted(), dissociating theRenderFrameHostImplfrom its liveRenderFrame. - `is_attaching_inner_delegate()` vs `is_inner_delegate_attached()`
- the former is true from the start of the prepare phase (state
!= NONE); the latter, added by this patch, is true only once state reachesATTACHED.
Root Cause Analysis
OnUnloadACK() (named in the commit message and regression test; the @@ -7061 header does not name the enclosing function) decided whether to treat an unload ACK as a legitimate inner-delegate unload by checking owner->GetRenderFrameHostManager().is_attaching_inner_delegate(). That predicate is already true during the early PrepareForInnerWebContentsAttach phase — before the attachment has actually begun and before any Unload IPC has been sent — so a renderer could send a spoofed unload ACK in that window and drive the browser into the RenderFrameDeleted() branch even though no unload was pending. Calling RenderFrameDeleted() prematurely dissociates/destroys the RenderFrame association while the asynchronous attachment is still in flight, leaving the in-progress attachment operating on a frame that has been torn down — a use-after-free condition in the browser process.
The fix narrows the predicate to is_inner_delegate_attached() (state == ATTACHED), so RenderFrameDeleted() only runs after the attachment has genuinely completed, which — as the commit notes — is the only point where a legitimate unload ACK can arrive (the Unload IPC is sent in SwapOuterDelegateFrame() in the same task that sets the completed state). This is a tightening of the guard.
is_attaching_inner_delegate()), letting a compromised renderer spoof it and trigger premature RenderFrameDeleted(); the fix requires the stricter is_inner_delegate_attached() (fully ATTACHED) state before that teardown runs.Attack Path
- Start inner-delegate attachment
A navigation to a PDF (MimeHandlerView) begins attaching an inner WebContents to a placeholder subframe, entering the async
PrepareForInnerWebContentsAttachphase that runsbeforeunload. - Widen the window
A compromised renderer registers a
beforeunloadhandler on the placeholder subframe so the browser lingers in the prepare phase waiting for the beforeunload ACK (as the regression test does). - Spoof the unload ACK
While in that phase, the renderer sends a
DidUnloadRenderFrame/unload ACK IPC even though the browser is not actually waiting for one. - Premature teardown
Pre-patch,
OnUnloadACK()seesis_attaching_inner_delegate()as true and callsRenderFrameDeleted(), dissociating/destroying theRenderFramemid-attachment. - Use-after-free The still-running attachment then operates on the torn-down frame, yielding a browser-process use-after-free (the specific freed-object dereference is inferred; the diff and advisory establish the UAF class and the premature deletion).
Impact Assessment
RenderFrameDeleted(), causing a use-after-free in the browser process. Preconditions are an in-progress MimeHandlerView/inner-WebContents attachment (e.g. navigating to a PDF) and a renderer able to emit the spoofed IPC. The vendor advisory rates this medium (use-after-free in Navigation) and does not report exploitation in the wild; any escalation to code execution beyond the UAF is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
EXPECT_TRUEcontent/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/renderer_host/render_frame_host_manager.hcontent/browser/security_exploit_browsertest.cc
Audit Directions
- State predicates that are too broadAudit IPC handlers that gate destructive actions (frame/object deletion) on “in progress” style predicates; prefer a terminal/
COMPLETEstate check so renderer-timed messages cannot trigger teardown early. - Unsolicited ACK/IPC acceptanceReview handlers that act on renderer ACKs without confirming the browser is actually awaiting that ACK (
is_waiting_for_*), since a compromised renderer can send them at any time. - Async attachment/detachment windowsExamine multi-phase async flows (inner WebContents, portal/fenced-frame attachment, prerender activation) for lifetime assumptions that hold at completion but not during the intermediate, renderer-observable phases.
Patch
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,
Regression Test / PoC
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,
Original Bug Report
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.cccontent/browser/renderer_host/agent_scheduling_group_host.cccontent/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.)
- Attacker Setup: A compromised renderer creates an
<iframe>and installs abeforeunloadevent handler on it. - Trigger Attachment: The renderer navigates the iframe to a PDF file, triggering
MimeHandlerViewcreation. - Browser State Change: The browser intercepts the request, the renderer loads the placeholder, and signals
ReadyToCreateMimeHandlerView. The browser callsPrepareForInnerDelegateAttach, settingattach_to_inner_delegate_state_toPREPARE_FRAME. - Dispatch BeforeUnload: Because of the
beforeunloadhandler, the browser dispatches aBeforeUnloadIPC to the renderer and starts a 1-second timeout timer. The browser’sis_waiting_for_unload_ack_state remainsfalse. - Spoof IPC: The compromised renderer intentionally ignores the
BeforeUnloadIPC. Instead, it sends a spoofedDidUnloadRenderFrameIPC for the iframe. - Premature Deletion: The browser receives the spoofed IPC and calls
RenderFrameHostImpl::OnUnloadACK. The logic flaw bypasses theis_waiting_for_unload_ack_check, directly executingRenderFrameDeleted(). - Observer State Freed:
RenderFrameDeleted()notifies allWebContentsObservers (e.g., Extensions, Autofill), which proceed to free memory and state mappings associated with thisRenderFrameHost*. - Use-After-Free: The frame remains in the
FrameTree(thebeforeunloadtimeout is still running). When a downstream browser component iterates the frame tree (e.g.,WebContentsImpl::ForEachRenderFrameHost), it yields this zombieRenderFrameHost*. 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.