CVE-2026-84335
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/views/tabs/dragging/tab_drag_controller.cc |
modified | |
DetachTabWithDeferredShowDragControllerTestchrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc |
modified | |
DetachTabWithDeferredShowDragControllerTestchrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc |
modified | |
BindLambdaForTestingchrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc |
modified |
Files Changed
chrome/browser/ui/views/tabs/dragging/tab_drag_controller.ccchrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
Patch
From 6c3c52528bb2ddd5223595dafb705f7addde535f Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <elkurin@google.com>
Date: Wed, 29 Jul 2026 21:08:56 -0700
Subject: [PATCH] [Ozone] Suppress data drag during tab-detach visibility wait
When InitialWebUI defers Show() for a freshly-detached browser,
TabDragController::DetachIntoNewBrowserAndRunMoveLoop spins a
kNestableTasksAllowed RunLoop (VisibilityWaiter) before reaching
Widget::RunMoveLoop, and therefore before the
ScopedSuppressForWindowMove() scope established by
DesktopWindowTreeHostPlatform::RunMoveLoop takes effect. A data drag
started from another top-level window inside that loop is not rejected
by the process-global guard and on X11 ends up releasing the tab drag's
pointer capture and running its own XDND move loop on the user's
still-held button.
Extend the suppression scope to also cover the VisibilityWaiter loop on
Linux, mirroring the existing scope around RunMoveLoop.
Add an interactive_ui_test that enables the deferred-Show path, detaches
a tab, and from a task posted into the kWaitingForWindowToShow loop
attempts a data drag on the source window's DragDropClient and expects
it to be rejected with kNone.
Bug: 522302504
Change-Id: I5b50e43f446718f2eab18cbb3bb297442b67bbc8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8140363
Reviewed-by: Kaan Alsan <alsan@chromium.org>
Auto-Submit: Eriko Kurimoto <elkurin@chromium.org>
Commit-Queue: Eriko Kurimoto <elkurin@chromium.org>
Reviewed-by: Elly <ellyjones@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1670819}
---
diff --git a/chrome/browser/ui/views/tabs/dragging/tab_drag_controller.cc b/chrome/browser/ui/views/tabs/dragging/tab_drag_controller.cc
index 553fc6af..c4e5d46e 100644
--- a/chrome/browser/ui/views/tabs/dragging/tab_drag_controller.cc
+++ b/chrome/browser/ui/views/tabs/dragging/tab_drag_controller.cc
@@ -102,6 +102,10 @@
#include "ui/wm/core/window_modality_controller.h" // nogncheck
#endif
+#if BUILDFLAG(IS_LINUX)
+#include "ui/views/widget/desktop_aura/desktop_drag_drop_client_ozone.h"
+#endif
+
using content::OpenURLParams;
using content::WebContents;
@@ -1767,6 +1771,13 @@
current_state_ = DragState::kWaitingForWindowToShow;
VisibilityWaiter waiter(dragged_widget);
+#if BUILDFLAG(IS_LINUX)
+ // VisibilityWaiter runs a kNestableTasksAllowed loop while the user holds
+ // the pointer button, before the move loop's own suppression scope is
+ // established; suppress data drags for its duration as well.
+ auto suppress_data_drag =
+ views::DesktopDragDropClientOzone::ScopedSuppressForWindowMove();
+#endif
base::WeakPtr<TabDragController> ref(weak_factory_.GetWeakPtr());
waiter.Wait();
if (!ref) {
diff --git a/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc b/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
index 76662db..0efae4b 100644
--- a/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
+++ b/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
@@ -159,6 +159,9 @@
#if BUILDFLAG(IS_LINUX)
#include "chrome/browser/ui/views/frame/browser_native_widget_aura_linux.h"
+#include "chrome/common/chrome_features.h"
+#include "ui/base/dragdrop/drag_drop_types.h"
+#include "ui/base/dragdrop/os_exchange_data.h"
#include "ui/linux/linux_ui.h"
#define DESKTOP_BROWSER_FRAME_AURA BrowserNativeWidgetAuraLinux
#else
@@ -688,6 +691,14 @@
GetTabDragController(tab_strip)->started_drag();
}
+bool TabDragControllerTest::IsWaitingForWindowToShow(
+ TabStrip* tab_strip) const {
+ TabDragController* controller = GetTabDragController(tab_strip);
+ return controller &&
+ controller->current_state_ ==
+ TabDragController::DragState::kWaitingForWindowToShow;
+}
+
void TabDragControllerTest::SetTabDragPointResolver(
TabDragPointResolver& resolver) {
TabDragController::SetTabDragPointResolver(resolver);
@@ -3652,6 +3663,106 @@
#endif // BUILDFLAG(IS_MAC)
+#if BUILDFLAG(IS_LINUX)
+
+// Variant of DetachToBrowserTabDragControllerTest that enables InitialWebUI
+// with deferred BrowserView::Show() so that the detached browser is not
+// immediately visible after Show() and TabDragController spins its
+// kWaitingForWindowToShow nested RunLoop before entering the move loop.
+class DetachTabWithDeferredShowDragControllerTest
+ : public DetachToBrowserTabDragControllerTest {
+ public:
+ DetachTabWithDeferredShowDragControllerTest() {
+ deferred_show_feature_list_.InitWithFeaturesAndParameters(
+ {{features::kInitialWebUI, {}},
+ {features::kWebUIReloadButton, {}},
+ {features::kArtificialUIDelay,
+ {{"initial_web_ui_delay_duration", "2s"},
+ {"views_ui_delay_duration", "0ms"}}}},
+ {});
+ }
+
+ private:
+ base::test::ScopedFeatureList deferred_show_feature_list_;
+};
+
+// Detaches a tab into a new browser and, while the controller is waiting for
+// the new browser's deferred Show() to complete, attempts to start a data drag
+// from the source browser's window. The data drag must be rejected so that it
+// cannot release the tab drag's pointer capture and take over the gesture.
+IN_PROC_BROWSER_TEST_P(DetachTabWithDeferredShowDragControllerTest,
+ RejectDataDragWhileWaitingForDetachedWindow) {
+ // The kWaitingForWindowToShow state is only reached on the RunMoveLoop path.
+ if (!test::PlatformSupportsScreenCoordinates()) {
+ GTEST_SKIP() << "RunMoveLoop is not used on this platform.";
+ }
+
+ AddTabsAndResetBrowser(browser(), 1);
+ TabStrip* tab_strip = GetTabStripForBrowser(browser());
+
+ aura::Window* const source_window = browser()->GetWindow()->GetNativeWindow();
+ aura::Window* const source_root = source_window->GetRootWindow();
+
+ bool data_drag_attempted = false;
+ TabStrip* new_tab_strip = nullptr;
+ // Releases the pointer once the controller has left kWaitingForWindowToShow
+ // and entered the move loop, so the move loop sees the button-up.
+ base::RepeatingClosure release_when_in_move_loop =
+ base::BindLambdaForTesting([&]() {
+ if (new_tab_strip && IsWaitingForWindowToShow(new_tab_strip)) {
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, release_when_in_move_loop);
+ return;
+ }
+ ASSERT_TRUE(ReleaseInput(0, /*async=*/true));
+ });
+
+ Tab* const tab = tab_strip->tab_at(0);
+ ASSERT_TRUE(PressInputAtCenter(tab));
+ ASSERT_TRUE(DragInputToCenterNotifyWhenDone(
+ tab, base::BindLambdaForTesting([&]() {
+ // The mouse-move that triggered detach has entered the
+ // kWaitingForWindowToShow nested loop; post into it.
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindLambdaForTesting([&]() {
+ data_drag_attempted = true;
+ ASSERT_TRUE(TabDragController::IsActive());
+ ASSERT_EQ(2u, GlobalBrowserCollection::GetInstance()->GetSize());
+ BrowserWindowInterface* const new_browser =
+ ui_test_utils::GetBrowserNotInSet({browser()});
+ ASSERT_TRUE(new_browser);
+ new_tab_strip = GetTabStripForBrowser(new_browser);
+ ASSERT_TRUE(IsWaitingForWindowToShow(new_tab_strip));
+
+ auto data = std::make_unique<ui::OSExchangeData>();
+ data->SetString(u"test");
+ ui::mojom::DragOperation result =
+ aura::client::GetDragDropClient(source_root)
+ ->StartDragAndDrop(std::move(data), source_root,
+ source_window, gfx::Point(),
+ ui::DragDropTypes::DRAG_COPY,
+ ui::mojom::DragEventSource::kMouse);
+ EXPECT_EQ(ui::mojom::DragOperation::kNone, result);
+
+ release_when_in_move_loop.Run();
+ }));
+ }),
+ gfx::Vector2d(0, GetDetachY(tab_strip))));
+
+ ASSERT_TRUE(
+ base::test::RunUntil([]() { return !TabDragController::IsActive(); }));
+ EXPECT_TRUE(data_drag_attempted);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+ TabDragging,
+ DetachTabWithDeferredShowDragControllerTest,
+ ::testing::Combine(
+ /*kTearOffWebAppTabOpensWebAppWindow=*/::testing::Values(false),
+ /*input_source=*/::testing::Values("mouse")));
+
+#endif // BUILDFLAG(IS_LINUX)
+
namespace {
// Invoked from the nested run loop.
diff --git a/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.h b/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.h
Regression Test / PoC
diff --git a/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc b/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
index 76662db..0efae4b 100644
--- a/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
+++ b/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
@@ -159,6 +159,9 @@
#if BUILDFLAG(IS_LINUX)
#include "chrome/browser/ui/views/frame/browser_native_widget_aura_linux.h"
+#include "chrome/common/chrome_features.h"
+#include "ui/base/dragdrop/drag_drop_types.h"
+#include "ui/base/dragdrop/os_exchange_data.h"
#include "ui/linux/linux_ui.h"
#define DESKTOP_BROWSER_FRAME_AURA BrowserNativeWidgetAuraLinux
#else
@@ -688,6 +691,14 @@
GetTabDragController(tab_strip)->started_drag();
}
+bool TabDragControllerTest::IsWaitingForWindowToShow(
+ TabStrip* tab_strip) const {
+ TabDragController* controller = GetTabDragController(tab_strip);
+ return controller &&
+ controller->current_state_ ==
+ TabDragController::DragState::kWaitingForWindowToShow;
+}
+
void TabDragControllerTest::SetTabDragPointResolver(
TabDragPointResolver& resolver) {
TabDragController::SetTabDragPointResolver(resolver);
@@ -3652,6 +3663,106 @@
#endif // BUILDFLAG(IS_MAC)
+#if BUILDFLAG(IS_LINUX)
+
+// Variant of DetachToBrowserTabDragControllerTest that enables InitialWebUI
+// with deferred BrowserView::Show() so that the detached browser is not
+// immediately visible after Show() and TabDragController spins its
+// kWaitingForWindowToShow nested RunLoop before entering the move loop.
+class DetachTabWithDeferredShowDragControllerTest
+ : public DetachToBrowserTabDragControllerTest {
+ public:
+ DetachTabWithDeferredShowDragControllerTest() {
+ deferred_show_feature_list_.InitWithFeaturesAndParameters(
+ {{features::kInitialWebUI, {}},
+ {features::kWebUIReloadButton, {}},
+ {features::kArtificialUIDelay,
+ {{"initial_web_ui_delay_duration", "2s"},
+ {"views_ui_delay_duration", "0ms"}}}},
+ {});
+ }
+
+ private:
+ base::test::ScopedFeatureList deferred_show_feature_list_;
+};
+
+// Detaches a tab into a new browser and, while the controller is waiting for
+// the new browser's deferred Show() to complete, attempts to start a data drag
+// from the source browser's window. The data drag must be rejected so that it
+// cannot release the tab drag's pointer capture and take over the gesture.
+IN_PROC_BROWSER_TEST_P(DetachTabWithDeferredShowDragControllerTest,
+ RejectDataDragWhileWaitingForDetachedWindow) {
+ // The kWaitingForWindowToShow state is only reached on the RunMoveLoop path.
+ if (!test::PlatformSupportsScreenCoordinates()) {
+ GTEST_SKIP() << "RunMoveLoop is not used on this platform.";
+ }
+
+ AddTabsAndResetBrowser(browser(), 1);
+ TabStrip* tab_strip = GetTabStripForBrowser(browser());
+
+ aura::Window* const source_window = browser()->GetWindow()->GetNativeWindow();
+ aura::Window* const source_root = source_window->GetRootWindow();
+
+ bool data_drag_attempted = false;
+ TabStrip* new_tab_strip = nullptr;
+ // Releases the pointer once the controller has left kWaitingForWindowToShow
+ // and entered the move loop, so the move loop sees the button-up.
+ base::RepeatingClosure release_when_in_move_loop =
+ base::BindLambdaForTesting([&]() {
+ if (new_tab_strip && IsWaitingForWindowToShow(new_tab_strip)) {
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, release_when_in_move_loop);
+ return;
+ }
+ ASSERT_TRUE(ReleaseInput(0, /*async=*/true));
+ });
+
+ Tab* const tab = tab_strip->tab_at(0);
+ ASSERT_TRUE(PressInputAtCenter(tab));
+ ASSERT_TRUE(DragInputToCenterNotifyWhenDone(
+ tab, base::BindLambdaForTesting([&]() {
+ // The mouse-move that triggered detach has entered the
+ // kWaitingForWindowToShow nested loop; post into it.
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindLambdaForTesting([&]() {
+ data_drag_attempted = true;
+ ASSERT_TRUE(TabDragController::IsActive());
+ ASSERT_EQ(2u, GlobalBrowserCollection::GetInstance()->GetSize());
+ BrowserWindowInterface* const new_browser =
+ ui_test_utils::GetBrowserNotInSet({browser()});
+ ASSERT_TRUE(new_browser);
+ new_tab_strip = GetTabStripForBrowser(new_browser);
+ ASSERT_TRUE(IsWaitingForWindowToShow(new_tab_strip));
+
+ auto data = std::make_unique<ui::OSExchangeData>();
+ data->SetString(u"test");
+ ui::mojom::DragOperation result =
+ aura::client::GetDragDropClient(source_root)
+ ->StartDragAndDrop(std::move(data), source_root,
+ source_window, gfx::Point(),
+ ui::DragDropTypes::DRAG_COPY,
+ ui::mojom::DragEventSource::kMouse);
+ EXPECT_EQ(ui::mojom::DragOperation::kNone, result);
+
+ release_when_in_move_loop.Run();
+ }));
+ }),
+ gfx::Vector2d(0, GetDetachY(tab_strip))));
+
+ ASSERT_TRUE(
+ base::test::RunUntil([]() { return !TabDragController::IsActive(); }));
+ EXPECT_TRUE(data_drag_attempted);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+ TabDragging,
+ DetachTabWithDeferredShowDragControllerTest,
+ ::testing::Combine(
+ /*kTearOffWebAppTabOpensWebAppWindow=*/::testing::Values(false),
+ /*input_source=*/::testing::Values("mouse")));
+
+#endif // BUILDFLAG(IS_LINUX)
+
namespace {
// Invoked from the nested run loop.
diff --git a/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.h b/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.h
index 5b50d9c..afdd71b 100644
--- a/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.h
+++ b/chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.h
@@ -62,6 +62,11 @@
bool HasDragStarted(TabStrip* tab_strip) const;
+ // Returns true if `tab_strip`'s drag controller is in the state where it has
+ // detached into a new browser and is waiting for that browser's widget to
+ // become visible before entering the window-move loop.
+ bool IsWaitingForWindowToShow(TabStrip* tab_strip) const;
+
void SetTabDragPointResolver(TabDragPointResolver& resolver);
// InProcessBrowserTest:
Original Bug Report
Gesture hijack in TabDragController via nested VisibilityWaiter run loop
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 vulnerability in TabDragController allows a compromised renderer to hijack an active tab-drag gesture. When a tab is detached into a new window under the kInitialWebUI feature, a nested RunLoop spins before global drag-suppression guards are set. An attacker can potentially exploit this window of opportunity to release the user’s active mouse grab and substitute an attacker-controlled drag-and-drop payload.
Affected files:
chrome/browser/ui/views/tabs/dragging/tab_drag_controller.ccui/views/widget/desktop_aura/desktop_window_tree_host_platform.ccui/views/widget/desktop_aura/desktop_drag_drop_client_ozone.ccui/views/widget/desktop_aura/desktop_drag_drop_client_win.cc
Estimated timestamp from git blame: 2026-04-01
Summary
We have identified a potential gesture-hijack vulnerability in TabDragController where a compromised renderer can potentially intercept and hijack an active user tab-drag gesture.
When a user drags a tab to detach it into a new window, if the kInitialWebUI feature is enabled (which is currently active in the WebUIReloadButtonStudy field trial on Linux, macOS, and Windows), the browser process temporarily spins a nested base::RunLoop of type kNestableTasksAllowed inside VisibilityWaiter::Wait() while awaiting the first paint. Because this nested loop runs before the platform’s native window-movement loop begins, the global re-entrancy prevention guard g_is_dragging has not yet been set. A compromised renderer can potentially exploit this timing window to request a new drag-and-drop session, hijacking the user’s active pointer grab or triggering a browser crash.
Note: Since our security evaluation tooling does not have the ability to execute code, these are potential steps and analysis based on first-principles inspection of the source code.
Technical Analysis
When a tab is detached, TabDragController::DetachIntoNewBrowserAndRunMoveLoop is invoked:
// chrome/browser/ui/views/tabs/dragging/tab_drag_controller.cc
if (base::FeatureList::IsEnabled(features::kInitialWebUI)) {
if (!dragged_widget->IsVisible()) {
current_state_ = DragState::kWaitingForWindowToShow;
VisibilityWaiter waiter(dragged_widget);
base::WeakPtr<TabDragController> ref(weak_factory_.GetWeakPtr());
waiter.Wait(); // Pumps Mojo IPCs via nestable run loop
if (!ref) { return Liveness::kDeleted; }
current_state_ = DragState::kDraggingWindow;
}
}
Inside VisibilityWaiter, a nested run loop is started:
class VisibilityWaiter : public views::WidgetObserver {
...
void Wait() { run_loop_.Run(); }
base::RunLoop run_loop_{base::RunLoop::Type::kNestableTasksAllowed};
};
Because the nested loop permits nested tasks, the UI thread processes incoming Mojo messages.
Normally, when the browser enters a window-move loop, DesktopWindowTreeHostPlatform::RunMoveLoop instantiates ScopedSuppressForWindowMove() to set g_is_dragging = true globally, preventing renderer-initiated drag-and-drop:
// ui/views/widget/desktop_aura/desktop_window_tree_host_platform.cc
Widget::MoveLoopResult DesktopWindowTreeHostPlatform::RunMoveLoop(...) {
auto suppress_data_drag =
DesktopDragDropClientOzone::ScopedSuppressForWindowMove();
...
}
However, because VisibilityWaiter::Wait() spins before RunMoveLoop is reached, g_is_dragging remains false.
Exploit Mechanism
- A compromised renderer in another visible window (e.g., W2) issues a
LocalFrameHost::StartDraggingMojo IPC. - The browser UI thread dispatches the IPC while blocked inside
waiter.Wait(), leading toWebContentsViewAura::StartDragging(incontent/browser/web_contents/web_contents_view_aura.cc). - Since the attacker-controlled window W2 does not have an active webpage-initiated drag session,
drag_security_info_.did_initiate()returnsfalse, bypassing the WebContents re-entrancy check. - The request reaches
DesktopDragDropClientOzone::StartDragAndDrop(orDesktopDragDropClientWin::StartDragAndDrop). Sinceg_is_draggingisfalse, the re-entrancy checks pass. - On Ozone/X11 or Windows:
- The platform client releases the user’s active tab-drag pointer capture (
capture_window->ReleaseCapture()) and initiates a new system drag session, hijacking the physical pointer grab. - When the user releases the mouse button, the attacker’s custom drag-and-drop payload (such as a
javascript:URL) is dropped onto the drop target.
- The platform client releases the user’s active tab-drag pointer capture (
- On Ozone/Wayland:
- Registering a new drag delegate while a drag session is active triggers a browser crash due to
CHECK(!drag_delegate_)inui/ozone/platform/wayland/host/wayland_data_device.cc:48.
- Registering a new drag delegate while a drag session is active triggers a browser crash due to
Potential Steps to Trigger
- Ensure the
kInitialWebUIfeature is active (e.g., by launching with--enable-features=InitialWebUI). - Compromise a renderer process and open a second top-level visible window W2.
- Have the user initiate a tab-detach gesture.
- While the tab-drag widget is waiting to show itself (inside
VisibilityWaiter::Wait()), the compromised renderer sends aLocalFrameHost::StartDraggingIPC message. - The browser processes the drag request, releasing pointer capture from the tab-drag widget and binding the active pointer grab to the attacker’s new drag-and-drop session or triggering a crash.
Suggested Fix
To mitigate this potential issue, we should prevent renderer-initiated drags from being processed while TabDragController is in a waiting state, or instantiate the suppression scoper earlier.
One potential fix is to instantiate ScopedSuppressForWindowMove inside TabDragController::DetachIntoNewBrowserAndRunMoveLoop prior to executing waiter.Wait() to suppress incoming drag-and-drop requests during the paint delay window.
Evaluated with Chrome root at commit: b2fea2e31df308d0f04e4ae47def4c4f939ee141
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.