CVE-2026-12464
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcontent/browser/web_contents/web_contents_impl.cc |
modified | |
ifcontent/browser/web_contents/web_contents_impl.cc |
modified | |
DestroyTargetOnFullscreenExitDelegatecontent/browser/web_contents/web_contents_impl_browsertest.cc |
modified | |
target_to_destroy_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.cccontent/browser/web_contents/web_contents_impl_browsertest.cc
Patch
From 1e3bc75aca52df1d9e5e8e4ce87d6d8fd46dcc22 Mon Sep 17 00:00:00 2001 From: Jordan Bayles <[email protected]> Date: Tue, 09 Jun 2026 14:55:48 -0700 Subject: [PATCH] Fix Use-After-Free in ForSecurityDropFullscreen When WebContentsImpl::ForSecurityDropFullscreen exits fullscreen on openers, it can trigger synchronous destruction of the target WebContents (e.g. if a window.close() was pending). If the target WebContents is destroyed during the last iteration of the opener loop, the loop completes and the function would previously return a valid base::ScopedClosureRunner, leading to a Use-After-Free in the caller (like Browser::RegisterProtocolHandler) which assumes the contents is still alive. This CL adds a check for !weak_this at the end of the function to return std::nullopt if the contents was destroyed. It also ensures that any blocker counts incremented for openers are cleaned up if we abort early. Fixed: 519358344 Change-Id: I7dfcdc3328e10ac5d8c490c765a6939a6b070127 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7910908 Reviewed-by: Bo Liu <[email protected]> Commit-Queue: Jordan Bayles <[email protected]> Cr-Commit-Position: refs/heads/main@{#1644246} --- diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc index 238243c..eed3b20 100644 --- a/content/browser/web_contents/web_contents_impl.cc +++ b/content/browser/web_contents/web_contents_impl.cc @@ -7559,6 +7559,15 @@ // checked. (See CanEnterFullscreenMode().) std::vector<base::WeakPtr<WebContentsImpl>> blocked_contents_list; + auto cleanup_blockers = [](std::vector<base::WeakPtr<WebContentsImpl>> list) { + for (auto& wc : list) { + if (wc) { + DCHECK_GT(wc->fullscreen_blocker_count_, 0); + --wc->fullscreen_blocker_count_; + } + } + }; + std::vector<base::WeakPtr<WebContentsImpl>> openers; for (WebContentsImpl* opener : GetAllOpeningWebContents(this)) { openers.push_back(opener->weak_factory_.GetWeakPtr()); @@ -7566,6 +7575,7 @@ for (auto& opener : openers) { if (!weak_this) { + cleanup_blockers(std::move(blocked_contents_list)); return std::nullopt; } if (!opener) { @@ -7585,17 +7595,13 @@ blocked_contents_list.push_back(opener); } - return base::ScopedClosureRunner(base::BindOnce( - [](std::vector<base::WeakPtr<WebContentsImpl>> blocked_contents_list) { - for (base::WeakPtr<WebContentsImpl>& web_contents : - blocked_contents_list) { - if (web_contents) { - DCHECK_GT(web_contents->fullscreen_blocker_count_, 0); - --web_contents->fullscreen_blocker_count_; - } - } - }, - std::move(blocked_contents_list))); + if (!weak_this) { + cleanup_blockers(std::move(blocked_contents_list)); + return std::nullopt; + } + + return base::ScopedClosureRunner( + base::BindOnce(cleanup_blockers, std::move(blocked_contents_list))); } void WebContentsImpl::ResumeLoadingCreatedWebContents() { diff --git a/content/browser/web_contents/web_contents_impl_browsertest.cc b/content/browser/web_contents/web_contents_impl_browsertest.cc index 8353436be..f0606177 100644 --- a/content/browser/web_contents/web_contents_impl_browsertest.cc +++ b/content/browser/web_contents/web_contents_impl_browsertest.cc @@ -8786,4 +8786,83 @@ EXPECT_FALSE(popup_rwh); } +class DestroyTargetOnFullscreenExitDelegate : public WebContentsDelegate { + public: + DestroyTargetOnFullscreenExitDelegate(WebContentsDelegate* original_delegate, + Shell* target_to_destroy) + : original_delegate_(original_delegate), + target_to_destroy_(target_to_destroy) {} + + void ExitFullscreenModeForTab(WebContents* web_contents) override { + if (target_to_destroy_) { + target_to_destroy_->Close(); + target_to_destroy_ = 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<Shell, DisableDanglingPtrDetection> target_to_destroy_; +}; + +IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest, + ForSecurityDropFullscreenUAF) { + 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()); + + DestroyTargetOnFullscreenExitDelegate intercepting_delegate( + opener_contents->GetDelegate(), popup_shell); + opener_contents->SetDelegate(&intercepting_delegate); + + base::WeakPtr<WebContents> weak_popup = popup_contents->GetWeakPtr(); + + auto blocker = popup_contents->ForSecurityDropFullscreen( + /*display_id=*/display::kInvalidDisplayId); + + EXPECT_EQ(weak_popup, nullptr); + EXPECT_FALSE(blocker.has_value()); + + 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 8353436be..f0606177 100644
--- a/content/browser/web_contents/web_contents_impl_browsertest.cc
+++ b/content/browser/web_contents/web_contents_impl_browsertest.cc
@@ -8786,4 +8786,83 @@
EXPECT_FALSE(popup_rwh);
}
+class DestroyTargetOnFullscreenExitDelegate : public WebContentsDelegate {
+ public:
+ DestroyTargetOnFullscreenExitDelegate(WebContentsDelegate* original_delegate,
+ Shell* target_to_destroy)
+ : original_delegate_(original_delegate),
+ target_to_destroy_(target_to_destroy) {}
+
+ void ExitFullscreenModeForTab(WebContents* web_contents) override {
+ if (target_to_destroy_) {
+ target_to_destroy_->Close();
+ target_to_destroy_ = 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<Shell, DisableDanglingPtrDetection> target_to_destroy_;
+};
+
+IN_PROC_BROWSER_TEST_F(WebContentsImplBrowserTest,
+ ForSecurityDropFullscreenUAF) {
+ 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());
+
+ DestroyTargetOnFullscreenExitDelegate intercepting_delegate(
+ opener_contents->GetDelegate(), popup_shell);
+ opener_contents->SetDelegate(&intercepting_delegate);
+
+ base::WeakPtr<WebContents> weak_popup = popup_contents->GetWeakPtr();
+
+ auto blocker = popup_contents->ForSecurityDropFullscreen(
+ /*display_id=*/display::kInvalidDisplayId);
+
+ EXPECT_EQ(weak_popup, nullptr);
+ EXPECT_FALSE(blocker.has_value());
+
+ if (opener_contents) {
+ opener_contents->SetDelegate(shell());
+ }
+}
+
} // namespace content
Original Bug Report
Potential Browser Process UAF in Browser::RegisterProtocolHandler via ForSecurityDropFullscreen
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential Use-After-Free (UAF) vulnerability exists in the browser process during custom protocol handler registration. When Browser::RegisterProtocolHandler calls ForSecurityDropFullscreen, synchronous destruction of the target WebContents can occur. A logic gap in ForSecurityDropFullscreen allows it to return a valid blocker instead of std::nullopt after destruction, resulting in a UAF when dereferencing the freed PermissionRequestManager and RenderFrameHost pointers.
Affected files:
chrome/browser/ui/browser.cccontent/browser/web_contents/web_contents_impl.cc
Estimated timestamp from git blame: 2026-05-07
Detailed Analysis of the Potential Vulnerability
In chrome/browser/ui/browser.cc:2461-2475, Browser::RegisterProtocolHandler captures raw, bare (non-raw_ptr) pointers to permissions::PermissionRequestManager and content::RenderFrameHost on the stack and then makes a synchronous call to ForSecurityDropFullscreen:
// chrome/browser/ui/browser.cc:2461-2475
permissions::PermissionRequestManager* permission_request_manager = // bare T*, not raw_ptr<>
permissions::PermissionRequestManager::FromWebContents(web_contents);
if (permission_request_manager) {
auto blocker = web_contents->ForSecurityDropFullscreen(
/*display_id=*/display::kInvalidDisplayId);
if (!blocker) { // ← Gap: can return non-nullopt after free
return;
}
permission_request_manager->AddRequest( // ← UAF: member call on freed WebContentsUserData
requesting_frame, // ← UAF: bare RenderFrameHost* parameter
std::make_unique<
custom_handlers::RegisterProtocolHandlerPermissionRequest>(
registry, handler, url, std::move(*blocker)));
}
Root Cause in ForSecurityDropFullscreen
In WebContentsImpl::ForSecurityDropFullscreen (content/browser/web_contents/web_contents_impl.cc:7544-7576), the code iterates through all opener WebContents to exit fullscreen. Within this loop, calling opener->ExitFullscreen(true) can trigger a synchronous platform-specific window-state change (e.g., via Win32 ::SetWindowPos or macOS [NSWindow toggleFullScreen:]). These synchronous OS calls are documented to spin a nested message loop and run arbitrary pending tasks on the UI thread.
If the target WebContents (which requested the protocol registration) has queued a close operation (such as via a script-initiated window.close()), the nested loop can process this close task and synchronously destroy both the WebContents, its associated PermissionRequestManager (which is a WebContentsUserData), and the RenderFrameHostImpl.
While ForSecurityDropFullscreen has a weak_this check at the beginning of each loop iteration, it completely lacks a weak_this check at the end of the second loop. If destruction of this occurs during the last iteration of the loop, the loop completes, and the function falls through and returns a valid base::ScopedClosureRunner instead of std::nullopt:
// content/browser/web_contents/web_contents_impl.cc:7564-7576
}
// BUG: Missing check for !weak_this here
return base::ScopedClosureRunner(base::BindOnce(
[](std::vector<base::WeakPtr<WebContentsImpl>> blocked_contents_list) {
...
},
std::move(blocked_contents_list)));
}
This defeats the if (!blocker) check in Browser::RegisterProtocolHandler. The code proceeds to invoke permission_request_manager->AddRequest on the freed objects. This results in:
- A virtual function call (
source_frame->IsInactiveAndDisallowActivation()) on a freedRenderFrameHostImplobject, which is a control-flow hijack primitive. - Member writes to the freed
PermissionRequestManager’s pending request lists, which is a heap corruption primitive.
Potential Step-by-Step Scenario to Trigger the Vulnerability
Please note: These are theoretical/potential steps; our tooling does not currently have the capability to run code to confirm them.
- A user visits an attacker-controlled page (Origin A) which opens a same-origin popup (B).
- Origin A requests and enters content fullscreen via a user gesture.
- Popup B queues a window close via
window.close(). - A compromised renderer process bypasses Blink-side restrictions and directly issues the
blink::mojom::LocalFrameHost::RegisterProtocolHandlerMojo IPC from Popup B, settinguser_gesture = true(trusted by the browser with no independent verification). - In
Browser::RegisterProtocolHandler, the browser captures raw pointers to B’sPermissionRequestManagerand its main frameRenderFrameHostand then callsForSecurityDropFullscreen. - While iterating over openers in
ForSecurityDropFullscreen, B exits fullscreen on A. This synchronous call spins a nested loop during window-repositioning, executing B’s queued window close and synchronously destroying B. - Since A was the last opener in the set, the loop terminates and falls through to return a valid blocker closure.
- The
!blockerguard is bypassed, leading to a UAF call topermission_request_manager->AddRequest(requesting_frame, ...), executing a virtual call on the freedRenderFrameHostand corrupting heap memory.
Suggested Fix
Add a weak_this check at the end of WebContentsImpl::ForSecurityDropFullscreen right before returning the ScopedClosureRunner:
if (!weak_this) {
return std::nullopt;
}
return base::ScopedClosureRunner(base::BindOnce(
[](std::vector<base::WeakPtr<WebContentsImpl>> blocked_contents_list) {
...
},
std::move(blocked_contents_list)));
Evaluated with Chrome root at commit: 87214e6721f6c34afd9181b80769a24c0c601c50
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.