CVE-2026-15773
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/render_frame_host_impl.cc |
modified | |
ifcontent/browser/web_contents/web_contents_impl.cc |
modified | |
DestroyOpenerOnAddNewContentsDelegatecontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified | |
ifcontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified | |
BindLambdaForTestingcontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified | |
OutgoingSetRendererPrefsMojoWatchercontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/web_contents/web_contents_impl.cccontent/browser/web_contents/web_contents_impl_browsertest.cc
Patch
From c889717537a805003583d4f5d88fd45204b58c67 Mon Sep 17 00:00:00 2001 From: Xinchao Tian <[email protected]> Date: Fri, 26 Jun 2026 15:40:50 -0700 Subject: [PATCH] Guard against re-entrant destruction in CreateNewWindow CreateNewWindow() (opener-suppressed path) calls delegate_->AddNewContents() and then continues using state owned by this (opener, delegate_, primary frame tree). On Windows, AddNewContents() may enter a nested message loop while showing a new browser window. A window-close message can destroy the opener WebContents during this call, causing use-after-free. Fix by capturing a WeakPtr to this before calling AddNewContents() and aborting if it is invalidated, similar to existing weak_new_contents logic. Add a browsertest that destroys the opener inside AddNewContents() to prevent regressions. Bug: 527676561 Change-Id: If224916a349113845cce68c438b04f451126d6ee Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8003496 Commit-Queue: Xinchao Tian <[email protected]> Reviewed-by: Alex Moshchuk <[email protected]> Reviewed-by: Bo Liu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1653497} --- diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc index 48ed416a..667a3d27 100644 --- a/content/browser/renderer_host/render_frame_host_impl.cc +++ b/content/browser/renderer_host/render_frame_host_impl.cc @@ -10455,9 +10455,16 @@ // The non-owning pointer |new_frame_tree| is valid in this stack frame at // least until the call to ShowCreatedWindow() below. + base::WeakPtr<RenderFrameHostImpl> weak_self = GetWeakPtr(); FrameTree* new_frame_tree = delegate_->CreateNewWindow(this, *params, is_new_browsing_instance, was_consumed, cloned_namespace.get()); + if (!weak_self) { + // This RFH may be deleted after CreateNewWindow() due to a nested message + // loop (e.g. showing the new window closes the window hosting `this`). See + // crbug.com/527676561. + return; + } transient_allow_popup_.Deactivate(); diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc index 256b12d..66e57cf 100644 --- a/content/browser/web_contents/web_contents_impl.cc +++ b/content/browser/web_contents/web_contents_impl.cc @@ -5708,12 +5708,20 @@ bool was_blocked = false; base::WeakPtr<WebContentsImpl> weak_new_contents = new_contents_impl->weak_factory_.GetWeakPtr(); + base::WeakPtr<WebContentsImpl> weak_this = weak_factory_.GetWeakPtr(); WebContentsImpl* contents_to_load = new_contents_impl; if (delegate_) { WebContents* web_contents_navigated = delegate_->AddNewContents( this, std::move(new_contents), params.target_url, params.disposition, *params.features, has_user_gesture, &was_blocked); + if (!weak_this) { + // `this` may be deleted after AddNewContents() due to a nested message + // loop (e.g. the window hosting the opener is closed). See + // crbug.com/527676561. + return nullptr; + } + if (base::FeatureList::IsEnabled(features::kPwaNavigationCapturing)) { // The delegate may delete |new_contents_impl| during AddNewContents(). // If that occurs and there isn't a replacement contents returned, exit. diff --git a/content/browser/web_contents/web_contents_impl_browsertest.cc b/content/browser/web_contents/web_contents_impl_browsertest.cc index 392b34d..2ab335b 100644 --- a/content/browser/web_contents/web_contents_impl_browsertest.cc +++ b/content/browser/web_contents/web_contents_impl_browsertest.cc @@ -3622,6 +3622,94 @@ namespace { +// A WebContentsDelegate whose AddNewContents() runs a caller-provided closure +// before returning. The closure is used to destroy the opener WebContents +// synchronously, simulating the re-entrant destruction that can happen when +// AddNewContents() spins a nested run loop (e.g. on Windows, showing the new +// browser window dispatches native messages that can close the opener window). +class DestroyOpenerOnAddNewContentsDelegate : public WebContentsDelegate { + public: + explicit DestroyOpenerOnAddNewContentsDelegate(base::OnceClosure on_add) + : on_add_(std::move(on_add)) {} + + WebContents* AddNewContents( + WebContents* source, + std::unique_ptr<WebContents> new_contents, + const GURL& target_url, + WindowOpenDisposition disposition, + const blink::mojom::WindowFeatures& window_features, + bool user_gesture, + bool* was_blocked) override { + // Keep the new popup alive so that CreateNewWindow()'s `weak_new_contents` + // guard does NOT short-circuit. Otherwise the function would return early + // before reaching the code that dereferences the (now destroyed) opener, + // and the regression would not be exercised. + new_contents_ = std::move(new_contents); + WebContents* raw_new_contents = new_contents_.get(); + if (on_add_) { + std::move(on_add_).Run(); + } + return raw_new_contents; + } + + private: + base::OnceClosure on_add_; + std::unique_ptr<WebContents> new_contents_; +}; + +} // namespace + +// Regression test for a use-after-free where the opener WebContents is +// destroyed re-entrantly while WebContentsImpl::CreateNewWindow() is calling +// WebContentsDelegate::AddNewContents(). With an opener-suppressed +// (`noopener`) window.open(), CreateNewWindow() drives the new window through +// the delegate and then keeps using `this` (and `delegate_`/`opener`) after +// AddNewContents() returns. If the opener is torn down during that call, the +// trailing code used to run on freed memory. See crbug.com/527676561. +IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest, + CreateNewWindowOpenerDestroyedInAddNewContents) { + ASSERT_TRUE(embedded_test_server()->Start()); + + // Create an opener WebContents owned by the test, so the delegate can destroy + // it from within AddNewContents(). + WebContents::CreateParams create_params( + shell()->web_contents()->GetBrowserContext()); + create_params.desired_renderer_state = + WebContents::CreateParams::kInitializeAndWarmupRendererProcess; + std::unique_ptr<WebContents> opener(WebContents::Create(create_params)); + WebContents* opener_ptr = opener.get(); + + base::RunLoop run_loop; + DestroyOpenerOnAddNewContentsDelegate delegate( + base::BindLambdaForTesting([&]() { + // Destroy the opener (`this` inside CreateNewWindow()) synchronously. + opener.reset(); + run_loop.Quit(); + })); + opener_ptr->SetDelegate(&delegate); + + const GURL opener_url( + embedded_test_server()->GetURL("a.com", "/title1.html")); + ASSERT_TRUE(NavigateToURL(opener_ptr, opener_url)); + + // Open a new window with `noopener` so CreateNewWindow() takes the + // opener-suppressed path that shows/navigates the window via the delegate. + // The script is run fire-and-forget because the opener frame is destroyed + // while the window.open() IPC is being handled. + const GURL popup_url( + embedded_test_server()->GetURL("a.com", "/title2.html")); + ExecuteScriptAsync( + opener_ptr, + JsReplace("window.open($1, '_blank', 'noopener');", popup_url)); + + // The opener is destroyed during AddNewContents(). The test passes if this + // completes without a use-after-free (caught under ASAN). + run_loop.Run(); + EXPECT_FALSE(opener); +} + +namespace { + class OutgoingSetRendererPrefsMojoWatcher { public: explicit OutgoingSetRendererPrefsMojoWatcher(RenderViewHostImpl* rvh)
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 392b34d..2ab335b 100644
--- a/content/browser/web_contents/web_contents_impl_browsertest.cc
+++ b/content/browser/web_contents/web_contents_impl_browsertest.cc
@@ -3622,6 +3622,94 @@
namespace {
+// A WebContentsDelegate whose AddNewContents() runs a caller-provided closure
+// before returning. The closure is used to destroy the opener WebContents
+// synchronously, simulating the re-entrant destruction that can happen when
+// AddNewContents() spins a nested run loop (e.g. on Windows, showing the new
+// browser window dispatches native messages that can close the opener window).
+class DestroyOpenerOnAddNewContentsDelegate : public WebContentsDelegate {
+ public:
+ explicit DestroyOpenerOnAddNewContentsDelegate(base::OnceClosure on_add)
+ : on_add_(std::move(on_add)) {}
+
+ WebContents* AddNewContents(
+ WebContents* source,
+ std::unique_ptr<WebContents> new_contents,
+ const GURL& target_url,
+ WindowOpenDisposition disposition,
+ const blink::mojom::WindowFeatures& window_features,
+ bool user_gesture,
+ bool* was_blocked) override {
+ // Keep the new popup alive so that CreateNewWindow()'s `weak_new_contents`
+ // guard does NOT short-circuit. Otherwise the function would return early
+ // before reaching the code that dereferences the (now destroyed) opener,
+ // and the regression would not be exercised.
+ new_contents_ = std::move(new_contents);
+ WebContents* raw_new_contents = new_contents_.get();
+ if (on_add_) {
+ std::move(on_add_).Run();
+ }
+ return raw_new_contents;
+ }
+
+ private:
+ base::OnceClosure on_add_;
+ std::unique_ptr<WebContents> new_contents_;
+};
+
+} // namespace
+
+// Regression test for a use-after-free where the opener WebContents is
+// destroyed re-entrantly while WebContentsImpl::CreateNewWindow() is calling
+// WebContentsDelegate::AddNewContents(). With an opener-suppressed
+// (`noopener`) window.open(), CreateNewWindow() drives the new window through
+// the delegate and then keeps using `this` (and `delegate_`/`opener`) after
+// AddNewContents() returns. If the opener is torn down during that call, the
+// trailing code used to run on freed memory. See crbug.com/527676561.
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+ CreateNewWindowOpenerDestroyedInAddNewContents) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ // Create an opener WebContents owned by the test, so the delegate can destroy
+ // it from within AddNewContents().
+ WebContents::CreateParams create_params(
+ shell()->web_contents()->GetBrowserContext());
+ create_params.desired_renderer_state =
+ WebContents::CreateParams::kInitializeAndWarmupRendererProcess;
+ std::unique_ptr<WebContents> opener(WebContents::Create(create_params));
+ WebContents* opener_ptr = opener.get();
+
+ base::RunLoop run_loop;
+ DestroyOpenerOnAddNewContentsDelegate delegate(
+ base::BindLambdaForTesting([&]() {
+ // Destroy the opener (`this` inside CreateNewWindow()) synchronously.
+ opener.reset();
+ run_loop.Quit();
+ }));
+ opener_ptr->SetDelegate(&delegate);
+
+ const GURL opener_url(
+ embedded_test_server()->GetURL("a.com", "/title1.html"));
+ ASSERT_TRUE(NavigateToURL(opener_ptr, opener_url));
+
+ // Open a new window with `noopener` so CreateNewWindow() takes the
+ // opener-suppressed path that shows/navigates the window via the delegate.
+ // The script is run fire-and-forget because the opener frame is destroyed
+ // while the window.open() IPC is being handled.
+ const GURL popup_url(
+ embedded_test_server()->GetURL("a.com", "/title2.html"));
+ ExecuteScriptAsync(
+ opener_ptr,
+ JsReplace("window.open($1, '_blank', 'noopener');", popup_url));
+
+ // The opener is destroyed during AddNewContents(). The test passes if this
+ // completes without a use-after-free (caught under ASAN).
+ run_loop.Run();
+ EXPECT_FALSE(opener);
+}
+
+namespace {
+
class OutgoingSetRendererPrefsMojoWatcher {
public:
explicit OutgoingSetRendererPrefsMojoWatcher(RenderViewHostImpl* rvh)
Original Bug Report
UAF: WebContentsImpl::CreateNewWindow can use `this` after it is destroyed re-entrantly during `delegate_->AddNewContents()`
Summary
In WebContentsImpl::CreateNewWindow, the opener-suppressed (noopener) branch
calls delegate_->AddNewContents() and then keeps dereferencing state owned by
this (opener, delegate_, and finally returns
&new_contents_impl->GetPrimaryFrameTree()). On Windows, AddNewContents() can
synchronously spin a nested run loop while showing the new browser window
(ShowWindow dispatches native window messages). A window-close message
(WM_SYSCOMMAND / SC_CLOSE) processed in that nested loop can destroy the
opener WebContents — i.e. the this currently executing CreateNewWindow —
before AddNewContents() returns. Execution then resumes on a freed this /
opener, leading to a use-after-free.
Details
The current code only guards the newly created contents via
weak_new_contents; it does not guard this. The re-entrant destruction path:
delegate_->AddNewContents() → Browser::AddNewContents → Navigate →
BrowserView::Show → views::Widget::Show → DesktopWindowTreeHostWin::Show →
ShowWindow (synchronously pumps native window messages) → nested WndProc
dispatch receives WM_SYSCOMMAND(SC_CLOSE) for the opener’s browser window →
Browser::OnWindowClosing → TabStripModel::CloseAllTabs → destroys all tabs'
WebContents, including the opener (this).
After AddNewContents() returns, CreateNewWindow runs
load_params->initiator_origin = opener->GetLastCommittedOrigin(); on the freed
opener → UAF.
Location
content/browser/web_contents/web_contents_impl.cc,
WebContentsImpl::CreateNewWindow, the opener-suppressed tail (the
delegate_->AddNewContents(...) call and the code that follows it).
Evidence (representative stack)
Top frame is the crash point. Win32/comctl32/uxtheme message-dispatch noise is
collapsed but preserved enough to show this is a genuine nested WndProc
dispatch triggered synchronously by ShowWindow.
// ===== (4) Re-entrant destruction: opener WebContents (this) destroyed in nested loop =====
content::WebContentsImpl::~WebContentsImpl content/browser/web_contents/web_contents_impl.cc
std::unique_ptr<content::WebContents>::reset/~ (WebContents owned by the tab)
tabs::TabModel::~TabModel chrome/browser/ui/tabs/tab_model.cc
std::unique_ptr<tabs::TabModel>::reset
TabStripModel::SendDetachWebContentsNotifications chrome/browser/ui/tabs/tab_strip_model.cc
TabStripModel::InternalCloseTabsImpl chrome/browser/ui/tabs/tab_strip_model.cc
TabStripModel::CloseTabs chrome/browser/ui/tabs/tab_strip_model.cc
TabStripModel::CloseAllTabs chrome/browser/ui/tabs/tab_strip_model.cc
Browser::OnWindowClosingPostClearBrowsingData chrome/browser/ui/browser.cc
Browser::OnWindowClosing chrome/browser/ui/browser.cc
BrowserView::OnWindowCloseRequested chrome/browser/ui/views/frame/browser_view.cc
views::Widget::CloseWithReason ui/views/widget/widget.cc
views::HWNDMessageHandler::ProcessWindowMessage ui/views/win/hwnd_message_handler.h
views::HWNDMessageHandler::OnWndProc ui/views/win/hwnd_message_handler.cc
gfx::WindowImpl::WndProc ui/gfx/win/window_impl.cc
base::win::WrappedWindowProc<> base/win/wrapped_window_proc.h
... user32!CallWindowProc / comctl32 subclass procs ...
ui::WindowSubclass::StaticSubClassWndProc ui/base/win/window_subclass.cc
... user32!DispatchClientMessage / ntdll!KiUserCallbackDispatcher ...
... uxtheme!OnDwpSysCommand / user32!DefWindowProcW ... // default SC_CLOSE handling
views::HWNDMessageHandler::OnSysCommand ui/views/win/hwnd_message_handler.cc // WM_SYSCOMMAND (SC_CLOSE)
views::HWNDMessageHandler::ProcessWindowMessage ui/views/win/hwnd_message_handler.h
views::HWNDMessageHandler::OnWndProc ui/views/win/hwnd_message_handler.cc
gfx::WindowImpl::WndProc ui/gfx/win/window_impl.cc
base::win::WrappedWindowProc<> base/win/wrapped_window_proc.h
... user32!CallWindowProc / comctl32 subclass / KiUserCallbackDispatcher ...
// ===== (3) Re-entrancy boundary: ShowWindow synchronously pumps native messages =====
win32u!ZwUserShowWindow // ::ShowWindow dispatches window messages
views::HWNDMessageHandler::Show ui/views/win/hwnd_message_handler.cc
views::DesktopWindowTreeHostWin::Show ui/views/widget/desktop_aura/desktop_window_tree_host_win.cc
views::Widget::Show ui/views/widget/widget.cc
BrowserView::Show chrome/browser/ui/views/frame/browser_view.cc
(anonymous namespace)::ScopedBrowserShower::~ScopedBrowserShower chrome/browser/ui/browser_navigator.cc
Navigate chrome/browser/ui/browser_navigator.cc
chrome::AddWebContents chrome/browser/ui/browser_tabstrip.cc
// ===== (2) Delegate shows/navigates the new window =====
Browser::AddNewContents chrome/browser/ui/browser.cc
// = content::WebContentsDelegate::AddNewContents
// ===== (1) opener is still on the CreateNewWindow stack frame =====
content::WebContentsImpl::CreateNewWindow content/browser/web_contents/web_contents_impl.cc
// call site: delegate_->AddNewContents(...);
// after it returns, runs load_params->initiator_origin = opener->GetLastCommittedOrigin();
// → opener / this already destroyed by (4) → heap-use-after-free
content::RenderFrameHostImpl::CreateNewWindow content/browser/renderer_host/render_frame_host_impl.cc
// reached from renderer's window.open(url, '_blank', 'noopener') via mojo
> Note: exact frames between OnSysCommand and OnWindowClosing (the widget
> teardown callbacks) may vary slightly by milestone.
Reachability
The crash was first observed downstream where the opener WebContents is
destroyed inside AddNewContents()’s nested ShowWindow message loop via
TabStripModel::CloseAllTabs. The same nested-loop-during-Show() race is
reachable in upstream Chromium via a noopener window.open() whose synchronous
window display coincides with the opener’s browser window closing. It is
extremely timing-sensitive and rarely hit naturally, which is likely why it has
not been reported upstream.
Why the existing guard is insufficient
CreateNewWindow already captures weak_new_contents for the newly created
WebContents and even notes “The delegate may delete new_contents_impl during
AddNewContents()”. However, nothing guards this. When the opener itself is
destroyed re-entrantly, the trailing code (opener->GetLastCommittedOrigin(),
opener->GetFrameToken(), delegate_, &new_contents_impl->GetPrimaryFrameTree())
runs on freed memory.
Proposed fix
Capture base::WeakPtr<WebContentsImpl> weak_this before AddNewContents() and
bail out (return nullptr) if it has been invalidated afterward, mirroring the
existing weak_new_contents guard.