CVE-2026-102309
Overview
Background
- Device chooser dialog
- the permission bubble Chrome shows for WebUSB/WebBluetooth/WebHID-style device pickers, which must first exit fullscreen so a page cannot spoof browser UI while owning the screen.
- `ForSecurityDropFullscreen`
- a
content::WebContentsmethod that exits fullscreen for security and returns abase::ScopedClosureRunnerfullscreen blocker, but may synchronously spin a nested message loop or run event handlers. - `content::WeakDocumentPtr`
- a weak handle to a
RenderFrameHostthat can be revalidated (AsRenderFrameHostIfValid) after a potentially destructive operation. - `GlobalBrowserCollection::FindBrowserWithTab`
- a lookup that maps a
WebContentsto theBrowserWindowInterfacecurrently hosting it as a tab.
Root Cause Analysis
In ShowDeviceChooserDialogForExtension and ShowDeviceChooserDialog, the code captured a raw contents pointer (and a browser) from the requesting RenderFrameHost, then called contents->ForSecurityDropFullscreen(...). That drop can spin a nested message loop or run event handlers synchronously, which may close the browser window, switch the active tab, or detach the frame — and in particular can free the very WebContents whose pointer was already captured. The pre-patch code only checked that the returned fullscreen_blocker had a value (i.e., that the drop did not immediately report the WebContents gone), then reused the stale contents/browser pointers to construct the ChooserBubbleUiViewDelegate, so a WebContents destroyed during the drop could still be handed to the dialog.
The fix hoists the lookups into DropFullscreenForChooserDialog, which validates the browser/active-tab state, performs the drop, then revalidates the frame through a WeakDocumentPtr and re-derives a known-live WebContents via WebContents::FromRenderFrameHost before re-confirming the browser — returning std::nullopt if any check fails. Because the pointer used afterward is re-derived from a frame that is confirmed active after the drop, a WebContents that the drop destroyed can never reach the dialog.
WebContents (and browser) pointer captured before ForSecurityDropFullscreen, whose reentrancy can free that object; the fix revalidates a WeakDocumentPtr after the drop and re-derives the WebContents from the still-live frame instead of trusting the stale pointer.Attack Path
- Trigger a chooser from a controllable frame
A page (or extension surface) in a fullscreen tab invokes a device chooser, reaching
ShowDeviceChooserDialog/ShowDeviceChooserDialogForExtension, which capturescontentsand callsForSecurityDropFullscreen. - Exploit reentrancy during the fullscreen drop
The synchronous fullscreen exit spins a nested loop or runs handlers that close the window, switch tabs, or detach the frame, destroying the captured
WebContents(inferred as the concrete trigger, since the patch describes but does not show the destroying handler). - Use-after-free on the stale pointer
The pre-patch code proceeds with the freed
contents/browserto build and anchor theChooserBubbleUiViewDelegate, dereferencing freed memory. - Outcome
The dialog is constructed against a destroyed
WebContents, a use-after-free in the browser process (further escalation not shown by the patch).
Impact Assessment
WebContents in the privileged browser process, exploitable when a fullscreen-drop-triggered event handler destroys the tab/window mid-call. The advisory classifies this as a use-after-free in FullScreen and reports no exploited-in-the-wild status; any escalation to code execution beyond the freed-object access is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/views/permissions/chooser_bubble_ui.cc |
modified |
Files Changed
chrome/browser/ui/views/permissions/chooser_bubble_ui.cc
Audit Directions
- Pointers captured before a fullscreen drop or other reentrant callAudit every
ForSecurityDropFullscreencaller (the CL is part of a 4-part audit://content,//chromemechanical callers, this device chooser, and the cast dialog) for reuse ofWebContents/browser pointers captured before the drop. - Nested message loops and synchronous event dispatchFlag any browser-process code that spins a nested loop or runs page/event handlers and then continues to use objects looked up beforehand without revalidation.
- Weak-handle revalidation patternConfirm that code following a potentially destructive UI operation re-derives
WebContentsfrom a revalidatedWeakDocumentPtr/RenderFrameHost(asShareServiceImpl::RunShareOperationdoes) rather than trusting a “blocker has value” check as proof of liveness.
Patch
From bc670d093a9bebef6871ac71880087ccee619859 Mon Sep 17 00:00:00 2001
From: Jordan Bayles <jophba@chromium.org>
Date: Fri, 18 Sep 2026 14:56:27 -0700
Subject: [PATCH] permissions: Harden the device chooser dialog against fullscreen drops (3/4)
Showing a device chooser dialog drops fullscreen first, so that the
dialog cannot be used to spoof UI while the page owns the screen. That
drop can spin a nested message loop or run event handlers synchronously,
which may close the browser window, switch the active tab, or detach the
frame that asked for the chooser.
The browser and tab lookups are hoisted into a
DropFullscreenForChooserDialog() helper that validates the state, drops
fullscreen, and then validates it again, returning the results in a
ChooserDialogHost. The WebContents is re-derived from the revalidated
frame rather than reusing the pointer captured before the drop, so the
dialog can never be handed a WebContents that the drop destroyed. This
mirrors what ShareServiceImpl::RunShareOperation() already does.
Part 3 of a four-part audit of ForSecurityDropFullscreen callers:
1/4 //content
2/4 //chrome mechanical caller hardening
3/4 device chooser bubble (this CL)
4/4 cast dialog
TAG=agy
CONV=295b160f-d3d8-4d61-8821-75fd7227c588
Bug: 560867085
Change-Id: I7b687bd96832d17223561e72e7f5b9b588c24859
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8426065
Reviewed-by: Elias Klim <elklm@chromium.org>
Commit-Queue: Jordan Bayles <jophba@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1701110}
---
diff --git a/chrome/browser/ui/views/permissions/chooser_bubble_ui.cc b/chrome/browser/ui/views/permissions/chooser_bubble_ui.cc
index bbfbf513..31ff36d2 100644
--- a/chrome/browser/ui/views/permissions/chooser_bubble_ui.cc
+++ b/chrome/browser/ui/views/permissions/chooser_bubble_ui.cc
@@ -22,6 +22,7 @@
#include "chrome/browser/ui/views/title_origin_label.h"
#include "components/permissions/chooser_controller.h"
#include "components/tabs/public/tab_interface.h"
+#include "content/public/browser/weak_document_ptr.h"
#include "extensions/buildflags/buildflags.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/mojom/dialog_button.mojom.h"
@@ -226,42 +227,88 @@
namespace {
+// The browser hosting a chooser dialog and the tab contents the dialog
+// belongs to, along with the blocker that keeps fullscreen from being
+// re-entered while the dialog is up. All three are known to be live at the
+// point the struct is returned.
+struct ChooserDialogHost {
+ raw_ptr<BrowserWindowInterface> browser;
+ raw_ptr<content::WebContents> contents;
+ base::ScopedClosureRunner fullscreen_blocker;
+};
+
+// Verifies that `contents` is the active tab of a live browser, drops
+// fullscreen for security, and then verifies it again.
+//
+// Dropping fullscreen can spin a nested message loop or run event handlers
+// synchronously, which may close the browser window, switch the active tab, or
+// destroy the frame in `weak_document`. Everything looked up beforehand is
+// therefore re-queried afterwards. Returns `std::nullopt` if the dialog should
+// no longer be shown.
+std::optional<ChooserDialogHost> DropFullscreenForChooserDialog(
+ content::WebContents* contents,
+ const content::WeakDocumentPtr& weak_document) {
+ auto* browser =
+ GlobalBrowserCollection::GetInstance()->FindBrowserWithTab(contents);
+ if (!browser || !browser->GetActiveTabInterface() ||
+ browser->GetActiveTabInterface()->GetContents() != contents) {
+ return std::nullopt;
+ }
+
+ std::optional<base::ScopedClosureRunner> fullscreen_blocker =
+ contents->ForSecurityDropFullscreen(display::kInvalidDisplayId);
+ content::RenderFrameHost* rfh = weak_document.AsRenderFrameHostIfValid();
+ if (!fullscreen_blocker.has_value() || !rfh || !rfh->IsActive()) {
+ return std::nullopt;
+ }
+
+ // `contents` was captured before the drop and may have been freed by it, so
+ // stop using it and re-derive a known-live WebContents from the frame that
+ // was just revalidated.
+ contents = content::WebContents::FromRenderFrameHost(rfh);
+ if (!contents) {
+ return std::nullopt;
+ }
+
+ browser =
+ GlobalBrowserCollection::GetInstance()->FindBrowserWithTab(contents);
+ if (!browser || !browser->GetActiveTabInterface() ||
+ browser->GetActiveTabInterface()->GetContents() != contents) {
+ return std::nullopt;
+ }
+
+ return ChooserDialogHost{browser, contents, std::move(*fullscreen_blocker)};
+}
+
#if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
base::OnceClosure ShowDeviceChooserDialogForExtension(
content::RenderFrameHost* owner,
const extensions::Extension* extension,
std::unique_ptr<permissions::ChooserController> controller) {
+ content::WeakDocumentPtr weak_document = owner->GetWeakDocumentPtr();
auto* contents = content::WebContents::FromRenderFrameHost(owner);
- auto* browser =
- GlobalBrowserCollection::GetInstance()->FindBrowserWithTab(contents);
- if (!browser) {
+ std::optional<ChooserDialogHost> host =
+ DropFullscreenForChooserDialog(contents, weak_document);
+ if (!host) {
return base::DoNothing();
}
- if (!browser->GetActiveTabInterface() ||
- browser->GetActiveTabInterface()->GetContents() != contents) {
+ auto* browser_view = BrowserView::GetBrowserViewForBrowser(host->browser);
+ if (!browser_view) {
return base::DoNothing();
}
// `GetExtensionsContainerViews` may return `nullptr`, for instance in
// extension popup windows.
- auto* extensions_toolbar = BrowserView::GetBrowserViewForBrowser(browser)
- ->toolbar_button_provider()
- ->GetExtensionsContainerViews();
+ auto* extensions_toolbar =
+ browser_view->toolbar_button_provider()->GetExtensionsContainerViews();
if (!extensions_toolbar) {
return base::DoNothing();
}
- std::optional<base::ScopedClosureRunner> fullscreen_blocker =
- contents->ForSecurityDropFullscreen(display::kInvalidDisplayId);
- if (!fullscreen_blocker.has_value()) {
- // The WebContents ha been destroyed, bail out.
- return base::DoNothing();
- }
-
auto bubble = std::make_unique<ChooserBubbleUiViewDelegate>(
- browser, contents, std::move(controller),
- std::move(fullscreen_blocker).value());
+ host->browser, host->contents, std::move(controller),
+ std::move(host->fullscreen_blocker));
base::OnceClosure close_closure = bubble->MakeCloseClosure();
extensions_toolbar->ShowWidgetForExtension(
views::BubbleDialogDelegateView::CreateBubble(std::move(bubble)),
@@ -275,6 +322,7 @@
base::OnceClosure ShowDeviceChooserDialog(
content::RenderFrameHost* owner,
std::unique_ptr<permissions::ChooserController> controller) {
+ content::WeakDocumentPtr weak_document = owner->GetWeakDocumentPtr();
auto* contents = content::WebContents::FromRenderFrameHost(owner);
#if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
@@ -300,27 +348,16 @@
}
#endif // BUILDFLAG(ENABLE_EXTENSIONS_CORE)
- auto* browser =
- GlobalBrowserCollection::GetInstance()->FindBrowserWithTab(contents);
- if (!browser) {
+ std::optional<ChooserDialogHost> host =
+ DropFullscreenForChooserDialog(contents, weak_document);
+ if (!host) {
return base::DoNothing();
}
-
- if (!browser->GetActiveTabInterface() ||
- browser->GetActiveTabInterface()->GetContents() != contents) {
- return base::DoNothing();
- }
-
- std::optional<base::ScopedClosureRunner> fullscreen_blocker =
- contents->ForSecurityDropFullscreen(display::kInvalidDisplayId);
- if (!fullscreen_blocker.has_value()) {
- // The WebContents ha been destroyed, bail out.
- return base::DoNothing();
- }
+ BrowserWindowInterface* browser = host->browser;
auto bubble = std::make_unique<ChooserBubbleUiViewDelegate>(
- browser, contents, std::move(controller),
- std::move(fullscreen_blocker).value());
+ browser, host->contents, std::move(controller),
+ std::move(host->fullscreen_blocker));
bubble->UpdateAnchor(browser);