CVE-2026-95313
Overview
Background
- `ForSecurityDropFullscreen()`
- a
WebContentsImplmethod that exits HTML fullscreen across all relatedWebContentsbefore a security-sensitive UI (dialog, file chooser) is shown, returning a scopedblockerobject. - HTML fullscreen exit
- leaving fullscreen synchronously runs observers and dispatches DOM events (e.g.
fullscreenchange), which can execute page script that mutates the DOM. - `RenderFrameHostImpl`
- the browser-process object representing a renderer frame; it is destroyed when its frame is removed (e.g. an iframe detached from the DOM).
- `base::WeakPtr<RenderFrameHostImpl>`
- a weak reference that becomes null when the pointee is freed, allowing callers to detect a destroyed frame instead of dereferencing a dangling pointer.
Root Cause Analysis
beforeunload confirm, or file chooser is initiated, WebContentsImpl first calls ForSecurityDropFullscreen() to drop HTML fullscreen. Dropping fullscreen synchronously runs observers and dispatches DOM events, which can re-enter page script and remove the initiating frame from the DOM, synchronously destroying its RenderFrameHostImpl. The affected methods held the frame only as a raw RenderFrameHostImpl* across the ForSecurityDropFullscreen() call and continued to dereference it on return. Because the destruction happens during fullscreen-exit dispatch — while control is still inside these methods — the raw pointer is left dangling, producing a heap use-after-free in the browser process. The old code only checked if (!blocker) { return; }, which does not detect that the frame itself was freed. The same raw-pointer-across-fullscreen-drop pattern existed in RunJavaScriptDialog, RunBeforeUnloadConfirm, RunFileChooser, and ViewSource.RenderFrameHostImpl the caller is about to use, so the frame pointer must be revalidated (not just the blocker) after ForSecurityDropFullscreen() returns.Attack Path
- Enter fullscreen A malicious page places an iframe (or subframe) into HTML fullscreen so that a later fullscreen-exit will fire observers/events.
- Trigger the sensitive UI
Script in the frame invokes a JS dialog,
beforeunloadconfirm, or file chooser, causingWebContentsImplto callForSecurityDropFullscreen(). - Free the frame during exit
A
fullscreenchangehandler (or observer) run during fullscreen exit removes the initiating iframe from the DOM, synchronously destroying itsRenderFrameHostImpl. - Dangling dereference
Control returns into the method, which uses the now-freed raw
RenderFrameHostImpl*, triggering the use-after-free.
Impact Assessment
RenderFrameHostImpl lives in the privileged browser process, controlled reuse of the freed allocation could lead to memory corruption and potential remote code execution outside the renderer sandbox. The trigger is fully script-driven and deterministic, making exploitation practical.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/web_contents/web_contents_impl.cc |
modified | |
DetachFrameOnFullscreenExitDelegatecontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified | |
target_contents_content/browser/web_contents/web_contents_impl_browsertest.cc |
modified | |
ifcontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified |
Files Changed
content/browser/web_contents/web_contents_impl_browsertest.cccontent/browser/web_contents/web_contents_impl.cccontent/browser/web_contents/web_contents_impl.h
Audit Directions
- Raw frame pointer across a re-entrant callAudit every site that holds a
RenderFrameHostImpl*(orRenderFrameHost*) acrossForSecurityDropFullscreen(), event dispatch, or observer notifications, and require abase::WeakPtrrevalidation plus anIsActive()check afterward. - `blocker`-only null checksFlag code that checks only the returned scoped object (e.g.
if (!blocker)) after fullscreen exit while assuming the initiating frame survived; the frame liveness is a separate condition. - Fullscreen-exit side effectsReview other browser-side flows that call into fullscreen exit before showing UI (dialogs, choosers, permission prompts), since exit can synchronously destroy frames via
fullscreenchangehandlers.
Patch
From 0f8885e487bd85ac7d73ffa728986f23c0079632 Mon Sep 17 00:00:00 2001
From: Frank Liberato <liberato@chromium.org>
Date: Tue, 01 Sep 2026 16:22:34 -0700
Subject: [PATCH] Fix UAF in WebContentsImpl when dropping fullscreen for dialogs
When a JavaScript dialog, beforeunload confirm, or file chooser is
initiated, WebContentsImpl calls ForSecurityDropFullscreen() to exit
HTML fullscreen across related WebContents. Exiting fullscreen can
execute observers or dispatch events that destroy the initiating frame
(for example, if an iframe is removed from DOM).
When ForSecurityDropFullscreen() returns, WebContentsImpl accessed the
raw RenderFrameHostImpl pointer directly, resulting in a
heap-use-after-free.
This CL tracks the RenderFrameHostImpl using a base::WeakPtr across the
ForSecurityDropFullscreen() call in
WebContentsImpl::RunJavaScriptDialog,
WebContentsImpl::RunBeforeUnloadConfirm,
WebContentsImpl::RunFileChooser, and WebContentsImpl::ViewSource, and
aborts if the frame is destroyed or no longer active.
TAG=agy
CONV=39a54c06-057a-48b2-a586-305ee9fe5b8b
Bug: 552665794
Change-Id: I14d6ff6b02cf19cfb10f80b9e875f62576d74c05
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8324908
Auto-Submit: Frank Liberato <liberato@chromium.org>
Reviewed-by: Ted (Chromium) Meyer <tmathmeyer@chromium.org>
Reviewed-by: Dave Tapuska <dtapuska@chromium.org>
Commit-Queue: Dave Tapuska <dtapuska@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1690285}
---
diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc
index 0d07dd8b..2fe25b82 100644
--- a/content/browser/web_contents/web_contents_impl.cc
+++ b/content/browser/web_contents/web_contents_impl.cc
@@ -8666,9 +8666,13 @@
// Any new WebContents opened while this WebContents is in fullscreen can be
// used to confuse the user, so drop fullscreen.
+ base::WeakPtr<RenderFrameHostImpl> weak_frame = frame->GetWeakPtr();
if (!ForSecurityDropFullscreen(/*display_id=*/display::kInvalidDisplayId)) {
return;
}
+ if (!weak_frame) {
+ return;
+ }
// We intentionally don't share the SiteInstance with the original frame so
// that view source has a consistent process model and always ends up in a new
@@ -8697,7 +8701,8 @@
// iframe, so preserve the IsolationInfo from the origin frame, to use the
// same network shard and increase chances of a cache hit.
navigation_entry->set_isolation_info(
- frame->ComputeIsolationInfoForNavigation(navigation_entry->GetURL()));
+ weak_frame->ComputeIsolationInfoForNavigation(
+ navigation_entry->GetURL()));
// Do not restore scroller position.
// TODO(creis, lukasza, arthursonzogni): Do not reuse the original PageState,
@@ -9503,9 +9508,10 @@
// Running a dialog causes an exit to webpage-initiated fullscreen.
// http://crbug.com/728276
+ base::WeakPtr<RenderFrameHostImpl> weak_rfh = render_frame_host->GetWeakPtr();
auto blocker =
ForSecurityDropFullscreen(/*display_id=*/display::kInvalidDisplayId);
- if (!blocker) {
+ if (!blocker || !weak_rfh || !weak_rfh->IsActive()) {
return;
}
@@ -9639,9 +9645,10 @@
// Running a dialog causes an exit to webpage-initiated fullscreen.
// http://crbug.com/728276
+ base::WeakPtr<RenderFrameHostImpl> weak_rfh = render_frame_host->GetWeakPtr();
auto blocker =
ForSecurityDropFullscreen(/*display_id=*/display::kInvalidDisplayId);
- if (!blocker) {
+ if (!blocker || !weak_rfh || !weak_rfh->IsActive()) {
return;
}
@@ -9722,7 +9729,7 @@
void WebContentsImpl::RunFileChooser(
base::WeakPtr<FileChooserImpl> file_chooser,
- RenderFrameHost* render_frame_host,
+ RenderFrameHostImpl* render_frame_host,
scoped_refptr<FileChooserImpl::FileSelectListenerImpl> listener,
const blink::mojom::FileChooserParams& params) {
OPTIONAL_TRACE_EVENT1("content", "WebContentsImpl::RunFileChooser",
@@ -9746,16 +9753,18 @@
// Any explicit focusing of another window while this WebContents is in
// fullscreen can be used to confuse the user, so drop fullscreen.
+ base::WeakPtr<RenderFrameHostImpl> weak_rfh =
+ render_frame_host ? render_frame_host->GetWeakPtr() : nullptr;
auto blocker =
ForSecurityDropFullscreen(/*display_id=*/display::kInvalidDisplayId);
- if (!blocker) {
+ if (!blocker || (render_frame_host && (!weak_rfh || !weak_rfh->IsActive()))) {
return;
}
listener->SetFullscreenBlock(std::move(*blocker));
if (delegate_) {
active_file_chooser_ = std::move(file_chooser);
- delegate_->RunFileChooser(render_frame_host, std::move(listener), params);
+ delegate_->RunFileChooser(weak_rfh.get(), std::move(listener), params);
std::move(cancel_chooser).Cancel();
}
}
diff --git a/content/browser/web_contents/web_contents_impl.h b/content/browser/web_contents/web_contents_impl.h
index 6264cfd..3618a045 100644
--- a/content/browser/web_contents/web_contents_impl.h
+++ b/content/browser/web_contents/web_contents_impl.h
@@ -1531,7 +1531,7 @@
// Called when a file selection is to be done.
void RunFileChooser(
base::WeakPtr<FileChooserImpl> file_chooser,
- RenderFrameHost* render_frame_host,
+ RenderFrameHostImpl* render_frame_host,
scoped_refptr<FileChooserImpl::FileSelectListenerImpl> listener,
const blink::mojom::FileChooserParams& params);
diff --git a/content/browser/web_contents/web_contents_impl_browsertest.cc b/content/browser/web_contents/web_contents_impl_browsertest.cc
index 9ba98e1..3ebc97d73 100644
--- a/content/browser/web_contents/web_contents_impl_browsertest.cc
+++ b/content/browser/web_contents/web_contents_impl_browsertest.cc
@@ -9589,4 +9589,152 @@
}
}
+class DetachFrameOnFullscreenExitDelegate : public WebContentsDelegate {
+ public:
+ DetachFrameOnFullscreenExitDelegate(WebContentsDelegate* original_delegate,
+ WebContents* target_contents)
+ : original_delegate_(original_delegate),
+ target_contents_(target_contents) {}
+
+ void ExitFullscreenModeForTab(WebContents* web_contents) override {
+ if (target_contents_) {
+ EXPECT_TRUE(ExecJs(target_contents_,
+ "document.querySelector('iframe').remove();"));
+ target_contents_ = nullptr;
+ }
+ if (original_delegate_) {
+ original_delegate_->ExitFullscreenModeForTab(web_contents);
+ }
+ }
+
+ FullscreenState GetFullscreenState(
+ const WebContents* web_contents) const override {
+ if (original_delegate_) {
+ return original_delegate_->GetFullscreenState(web_contents);
+ }
+ return FullscreenState();
+ }
+
+ bool IsFullscreenForTabOrPending(const WebContents* web_contents) override {
+ if (original_delegate_) {
+ return original_delegate_->IsFullscreenForTabOrPending(web_contents);
+ }
+ return false;
+ }
+
+ private:
+ raw_ptr<WebContentsDelegate> original_delegate_;
+ raw_ptr<WebContents, DisableDanglingPtrDetection> target_contents_;
+};
+
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+ RunJavaScriptDialogFrameDetachOnFullscreenExit) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url(embedded_test_server()->GetURL("/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ WebContentsImpl* opener_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+
+ ShellAddedObserver new_shell_observer;
+ EXPECT_TRUE(ExecJs(opener_contents, "window.open('about:blank', 'popup')"));
+ Shell* popup_shell = new_shell_observer.GetShell();
+ WebContentsImpl* popup_contents =
+ static_cast<WebContentsImpl*>(popup_shell->web_contents());
+
+ EXPECT_EQ(opener_contents,
+ popup_contents->GetFirstWebContentsInLiveOriginalOpenerChain());
+
+ FullscreenWebContentsObserver observer(
+ opener_contents, opener_contents->GetPrimaryMainFrame());
+ EXPECT_TRUE(ExecJs(opener_contents->GetPrimaryMainFrame(),
+ "document.body.webkitRequestFullscreen();"));
+ observer.Wait();
+ EXPECT_TRUE(opener_contents->IsFullscreen());
+
+ EXPECT_TRUE(ExecJs(popup_contents, R"(
+ new Promise(resolve => {
+ let iframe = document.createElement('iframe');
+ iframe.src = 'about:blank';
+ iframe.onload = resolve;
+ document.body.appendChild(iframe);
+ });
+ )"));
+
+ RenderFrameHostImpl* child_rfh = static_cast<RenderFrameHostImpl*>(
+ ChildFrameAt(popup_contents->GetPrimaryMainFrame(), 0));
+ ASSERT_TRUE(child_rfh);
+
+ base::WeakPtr<RenderFrameHostImpl> weak_child_rfh = child_rfh->GetWeakPtr();
+
+ DetachFrameOnFullscreenExitDelegate intercepting_delegate(
+ opener_contents->GetDelegate(), popup_contents);
+ opener_contents->SetDelegate(&intercepting_delegate);
+
+ popup_contents->RunJavaScriptDialog(
+ child_rfh, u"test message", u"default prompt",
+ JAVASCRIPT_DIALOG_TYPE_ALERT,
+ /*disable_third_party_subframe_suppresion=*/false, base::DoNothing());
+
+ EXPECT_EQ(weak_child_rfh, nullptr);
+
+ if (opener_contents) {
+ opener_contents->SetDelegate(shell());
+ }
+}
+
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+ RunBeforeUnloadConfirmFrameDetachOnFullscreenExit) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url(embedded_test_server()->GetURL("/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ WebContentsImpl* opener_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+
+ ShellAddedObserver new_shell_observer;
+ EXPECT_TRUE(ExecJs(opener_contents, "window.open('about:blank', 'popup')"));
+ Shell* popup_shell = new_shell_observer.GetShell();
+ WebContentsImpl* popup_contents =
+ static_cast<WebContentsImpl*>(popup_shell->web_contents());
+
+ EXPECT_EQ(opener_contents,
+ popup_contents->GetFirstWebContentsInLiveOriginalOpenerChain());
+
+ FullscreenWebContentsObserver observer(
+ opener_contents, opener_contents->GetPrimaryMainFrame());
+ EXPECT_TRUE(ExecJs(opener_contents->GetPrimaryMainFrame(),
+ "document.body.webkitRequestFullscreen();"));
+ observer.Wait();
+ EXPECT_TRUE(opener_contents->IsFullscreen());
+
+ EXPECT_TRUE(ExecJs(popup_contents, R"(
+ new Promise(resolve => {
+ let iframe = document.createElement('iframe');
+ iframe.src = 'about:blank';
+ iframe.onload = resolve;
+ document.body.appendChild(iframe);
+ });
+ )"));
+
+ RenderFrameHostImpl* child_rfh = static_cast<RenderFrameHostImpl*>(
+ ChildFrameAt(popup_contents->GetPrimaryMainFrame(), 0));
+ ASSERT_TRUE(child_rfh);
+
+ base::WeakPtr<RenderFrameHostImpl> weak_child_rfh = child_rfh->GetWeakPtr();
+
+ DetachFrameOnFullscreenExitDelegate intercepting_delegate(
+ opener_contents->GetDelegate(), popup_contents);
+ opener_contents->SetDelegate(&intercepting_delegate);
+
+ popup_contents->RunBeforeUnloadConfirm(child_rfh, /*is_reload=*/false,
+ base::DoNothing());
+
+ EXPECT_EQ(weak_child_rfh, nullptr);
+
+ if (opener_contents) {
+ opener_contents->SetDelegate(shell());
+ }
+}
+
} // namespace content
Regression Test / PoC
diff --git a/content/browser/web_contents/web_contents_impl_browsertest.cc b/content/browser/web_contents/web_contents_impl_browsertest.cc
index 9ba98e1..3ebc97d73 100644
--- a/content/browser/web_contents/web_contents_impl_browsertest.cc
+++ b/content/browser/web_contents/web_contents_impl_browsertest.cc
@@ -9589,4 +9589,152 @@
}
}
+class DetachFrameOnFullscreenExitDelegate : public WebContentsDelegate {
+ public:
+ DetachFrameOnFullscreenExitDelegate(WebContentsDelegate* original_delegate,
+ WebContents* target_contents)
+ : original_delegate_(original_delegate),
+ target_contents_(target_contents) {}
+
+ void ExitFullscreenModeForTab(WebContents* web_contents) override {
+ if (target_contents_) {
+ EXPECT_TRUE(ExecJs(target_contents_,
+ "document.querySelector('iframe').remove();"));
+ target_contents_ = nullptr;
+ }
+ if (original_delegate_) {
+ original_delegate_->ExitFullscreenModeForTab(web_contents);
+ }
+ }
+
+ FullscreenState GetFullscreenState(
+ const WebContents* web_contents) const override {
+ if (original_delegate_) {
+ return original_delegate_->GetFullscreenState(web_contents);
+ }
+ return FullscreenState();
+ }
+
+ bool IsFullscreenForTabOrPending(const WebContents* web_contents) override {
+ if (original_delegate_) {
+ return original_delegate_->IsFullscreenForTabOrPending(web_contents);
+ }
+ return false;
+ }
+
+ private:
+ raw_ptr<WebContentsDelegate> original_delegate_;
+ raw_ptr<WebContents, DisableDanglingPtrDetection> target_contents_;
+};
+
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+ RunJavaScriptDialogFrameDetachOnFullscreenExit) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url(embedded_test_server()->GetURL("/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ WebContentsImpl* opener_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+
+ ShellAddedObserver new_shell_observer;
+ EXPECT_TRUE(ExecJs(opener_contents, "window.open('about:blank', 'popup')"));
+ Shell* popup_shell = new_shell_observer.GetShell();
+ WebContentsImpl* popup_contents =
+ static_cast<WebContentsImpl*>(popup_shell->web_contents());
+
+ EXPECT_EQ(opener_contents,
+ popup_contents->GetFirstWebContentsInLiveOriginalOpenerChain());
+
+ FullscreenWebContentsObserver observer(
+ opener_contents, opener_contents->GetPrimaryMainFrame());
+ EXPECT_TRUE(ExecJs(opener_contents->GetPrimaryMainFrame(),
+ "document.body.webkitRequestFullscreen();"));
+ observer.Wait();
+ EXPECT_TRUE(opener_contents->IsFullscreen());
+
+ EXPECT_TRUE(ExecJs(popup_contents, R"(
+ new Promise(resolve => {
+ let iframe = document.createElement('iframe');
+ iframe.src = 'about:blank';
+ iframe.onload = resolve;
+ document.body.appendChild(iframe);
+ });
+ )"));
+
+ RenderFrameHostImpl* child_rfh = static_cast<RenderFrameHostImpl*>(
+ ChildFrameAt(popup_contents->GetPrimaryMainFrame(), 0));
+ ASSERT_TRUE(child_rfh);
+
+ base::WeakPtr<RenderFrameHostImpl> weak_child_rfh = child_rfh->GetWeakPtr();
+
+ DetachFrameOnFullscreenExitDelegate intercepting_delegate(
+ opener_contents->GetDelegate(), popup_contents);
+ opener_contents->SetDelegate(&intercepting_delegate);
+
+ popup_contents->RunJavaScriptDialog(
+ child_rfh, u"test message", u"default prompt",
+ JAVASCRIPT_DIALOG_TYPE_ALERT,
+ /*disable_third_party_subframe_suppresion=*/false, base::DoNothing());
+
+ EXPECT_EQ(weak_child_rfh, nullptr);
+
+ if (opener_contents) {
+ opener_contents->SetDelegate(shell());
+ }
+}
+
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+ RunBeforeUnloadConfirmFrameDetachOnFullscreenExit) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url(embedded_test_server()->GetURL("/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ WebContentsImpl* opener_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+
+ ShellAddedObserver new_shell_observer;
+ EXPECT_TRUE(ExecJs(opener_contents, "window.open('about:blank', 'popup')"));
+ Shell* popup_shell = new_shell_observer.GetShell();
+ WebContentsImpl* popup_contents =
+ static_cast<WebContentsImpl*>(popup_shell->web_contents());
+
+ EXPECT_EQ(opener_contents,
+ popup_contents->GetFirstWebContentsInLiveOriginalOpenerChain());
+
+ FullscreenWebContentsObserver observer(
+ opener_contents, opener_contents->GetPrimaryMainFrame());
+ EXPECT_TRUE(ExecJs(opener_contents->GetPrimaryMainFrame(),
+ "document.body.webkitRequestFullscreen();"));
+ observer.Wait();
+ EXPECT_TRUE(opener_contents->IsFullscreen());
+
+ EXPECT_TRUE(ExecJs(popup_contents, R"(
+ new Promise(resolve => {
+ let iframe = document.createElement('iframe');
+ iframe.src = 'about:blank';
+ iframe.onload = resolve;
+ document.body.appendChild(iframe);
+ });
+ )"));
+
+ RenderFrameHostImpl* child_rfh = static_cast<RenderFrameHostImpl*>(
+ ChildFrameAt(popup_contents->GetPrimaryMainFrame(), 0));
+ ASSERT_TRUE(child_rfh);
+
+ base::WeakPtr<RenderFrameHostImpl> weak_child_rfh = child_rfh->GetWeakPtr();
+
+ DetachFrameOnFullscreenExitDelegate intercepting_delegate(
+ opener_contents->GetDelegate(), popup_contents);
+ opener_contents->SetDelegate(&intercepting_delegate);
+
+ popup_contents->RunBeforeUnloadConfirm(child_rfh, /*is_reload=*/false,
+ base::DoNothing());
+
+ EXPECT_EQ(weak_child_rfh, nullptr);
+
+ if (opener_contents) {
+ opener_contents->SetDelegate(shell());
+ }
+}
+
} // namespace content
Original Bug Report
Browser-process use-after-free in WebContentsImpl::RunJavaScriptDialog: the RenderFrameHost is used after ForSecurityDropFullscreen() can destroy it
VULNERABILITY DETAILS
WebContentsImpl::RunJavaScriptDialog takes a raw RenderFrameHostImpl* parameter. Before showing the dialog it drops fullscreen for security, and then keeps using that pointer:
auto blocker = ForSecurityDropFullscreen(/display_id=/display::kInvalidDisplayId); if (!blocker) { return; } … render_frame_host->GetProcess()->GetDeprecatedID(), render_frame_host->GetRoutingID(),
At HEAD the drop is web_contents_impl.cc:9505-9506, the check is :9507-9509, and the first dereference is :9513-9514. In the 2026-08-17 revision I built they are :9304 and :9311, which is what the ASAN log below shows.
The “if (!blocker)” check looks like it covers this, and it does not. ForSecurityDropFullscreen returns std::nullopt only from its four early returns, and every one of them sits under “if (!weak_this)” where weak_this is a WeakPtr to the WebContentsImpl captured at the top of that function. So a null blocker means the WebContents died. It says nothing about the RenderFrameHost, about a DocumentService bound to it, or about anything that frame owns.
The case that is not covered is the one where the WebContents survives and only a subframe’s RenderFrameHost is destroyed. ForSecurityDropFullscreen calls ExitFullscreen(true), and WebContentsImpl::ExitFullscreenMode already carries the comment that this “may spin the message loop and destroy this object” (crbug.com/1506535, crbug.com/498752242). When a nested loop runs there, a pending FrameDetach for a subframe is processed inside it. The tab is still alive, so blocker is valid, and execution falls straight through to the dereference.
Nothing in between revalidates the frame. The DCHECK(render_frame_host->IsActive()) earlier in the function runs before the drop, and is a DCHECK.
There is already a regression test for this primitive, WebContentsImplBrowserTest.ForSecurityDropFullscreenUAF, added together with DestroyTargetOnFullscreenExitDelegate. It destroys the WebContents during the exit and asserts that blocker comes back empty. That case is handled. The subframe case is not, and it is the same mechanism as bug 552379473 (crrev.com/c/8287909, “device-choosers: Fix reentrancy UAF during RunChooser”), where the WebContents likewise survives and only the requesting subframe goes away.
Reachability is alert() from a cross-origin iframe on a page that is in fullscreen. No flag and no special configuration.
While looking at this I found the same shape in three more functions in the same file, each using the frame after the same call with no revalidation: RunBeforeUnloadConfirm, RunFileChooser (there the dangling pointer is handed to the embedder), and ViewSource. Happy to file those separately if you would rather track them apart.
VERSION Chrome Version: trunk at 5f88f2fa37e (2026-08-17), content_browsertests built with is_asan=true is_debug=false is_component_build=true. The code is unchanged at HEAD today. Operating System: Linux (Ubuntu 22.04, x86_64)
REPRODUCTION CASE
I am attaching repro.patch, a browsertest that reproduces this deterministically under ASAN. It applies to content/browser/web_contents/web_contents_impl_browsertest.cc and includes one small addition to the existing TestWCDelegateForDialogsAndFullscreen so a test can run code inside the fullscreen exit:
void SetExitFullscreenClosure(base::OnceClosure closure) { exit_fullscreen_closure_ = std::move(closure); }
void ExitFullscreenModeForTab(WebContents*) override { if (exit_fullscreen_closure_) { std::move(exit_fullscreen_closure_).Run(); } fullscreen_mode_ = FullscreenMode::kWindowed; … }
base::OnceClosure exit_fullscreen_closure_;
What the test does:
- Load a page and put it in HTML fullscreen.
- Add a cross-origin subframe (b.com). It has to be cross-origin: alert() blocks the calling renderer, so a same-process subframe would block the main frame too and the detach could never run.
- Call alert() from the subframe.
- While the browser is inside ForSecurityDropFullscreen() -> ExitFullscreen() -> WebContentsDelegate::ExitFullscreenModeForTab(), remove the iframe from the parent document. ExecJs() spins a nested run loop, so the FrameDetach mojo IPC is processed right there.
- ForSecurityDropFullscreen() returns a valid blocker, because the WebContents is fine, and the next statement dereferences the freed RenderFrameHostImpl.
The test forces the detach from inside the synchronous call rather than waiting on a platform nested loop, which is the same approach the two regression tests in crrev.com/c/8287909 take. That is why it reproduces on Linux.
Run it with: ninja -C out/asan content_browsertests ./content_browsertests –gtest_filter=WebContentsImplBrowserTest.FrameDetachDuringRunJavaScriptDialog
FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION Type of crash: browser process. Reproduced in content_browsertests (is_asan=true), 2 out of 2 runs. Crash State:
==2080774==ERROR: AddressSanitizer: heap-use-after-free on address 0x7d6cb9155100 READ of size 8 at 0x7d6cb9155100 thread T0 (content_browser) #0 content::WebContentsImpl::RunJavaScriptDialog(content::RenderFrameHostImpl*, …) content/browser/web_contents/web_contents_impl.cc:9311:26 #1 content::RenderFrameHostImpl::RunJavaScriptDialog(…) content/browser/renderer_host/render_frame_host_impl.cc:7346:14 #2 content::RenderFrameHostImpl::RunModalAlertDialog(…) content/browser/renderer_host/render_frame_host_impl.cc:7285:3 #3 blink::mojom::LocalFrameHostStubDispatch::AcceptWithResponder(…) #4 mojo::InterfaceEndpointClient::HandleValidatedMessage(…)
0x7d6cb9155100 is located 0 bytes inside of 5712-byte region
freed by thread T0 (content_browser) here: #4 content::RenderFrameHostManager::~RenderFrameHostManager() content/browser/renderer_host/render_frame_host_manager.cc:803:3 #5 content::FrameTreeNode::~FrameTreeNode() content/browser/renderer_host/frame_tree_node.cc:316:1 #9 content::RenderFrameHostImpl::RemoveChild(content::FrameTreeNode*) content/browser/renderer_host/render_frame_host_impl.cc:5955:22 #10 content::RenderFrameHostImpl::PendingDeletionCheckCompleted() content/browser/renderer_host/render_frame_host_impl.cc:12543:16 #12 content::RenderFrameHostImpl::DetachFromProxy() content/browser/renderer_host/render_frame_host_impl.cc:6874:3 #13 blink::mojom::RemoteFrameHostStubDispatch::Accept(…) … #45 content::WebContentsImpl::RunJavaScriptDialog(content::RenderFrameHostImpl*, …) content/browser/web_contents/web_contents_impl.cc:9304:7
The two ends of that log are worth reading together. The same function appears at the top of the use stack (:9311) and at the bottom of the free stack (:9304). The free happens inside the ForSecurityDropFullscreen() call made on :9304, and the read on :9311 is after it returns, in the same stack frame, seven lines later. The full log is attached as asan_report.txt.
In the wild the loop that processes the detach is AppKit’s, inside -[NSWindow toggleFullScreen:]; components/remote_cocoa/app_shim/native_widget_ns_window_bridge.mm takes a WeakPtr around that call for exactly this reason. That is the same platform property the device-chooser fix had, and its regression tests force the detach the same way rather than waiting on the transition.
CREDIT INFORMATION Reporter credit: WinD39 - Huynh Dinh Vu