Medium watch CVSS 8.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in TabStrip
DescriptionIncorrect authorization in TabStrip
ComponentTabStrip
Sub-componentOzone
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker522302504
Fix commit6c3c52528bb2 (chromium/src) +128/-1
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-10 (per vendor advisory)
Fix landed2026-07-30 (public git)
Disclosed2026-09-01
Patch gap33 days — fix public in source before the advisory

Background

Tab drag / `TabDragController`
the controller that drives dragging a tab out of a window; when a tab is torn off it detaches into a new browser and then runs a platform window-move loop so the new window follows the pointer.
`VisibilityWaiter` / `kWaitingForWindowToShow`
when InitialWebUI defers BrowserView::Show(), DetachIntoNewBrowserAndRunMoveLoop spins a nested kNestableTasksAllowed RunLoop (VisibilityWaiter) waiting for the detached widget to become visible, entered before Widget::RunMoveLoop.
`ScopedSuppressForWindowMove()`
a process-global guard installed by DesktopWindowTreeHostPlatform::RunMoveLoop (via DesktopDragDropClientOzone) that rejects data (XDND) drag-and-drop while a window move is in progress.
Pointer capture / XDND move loop
on X11/Ozone, a data drag can grab pointer capture and run its own drag loop; if it starts during a tab drag it steals the still-held button gesture from the tab drag.

Root Cause Analysis

On Linux, TabDragController::DetachIntoNewBrowserAndRunMoveLoop enters the kWaitingForWindowToShow state and spins a nested VisibilityWaiter RunLoop while the user still holds the pointer button, but this happens before DesktopWindowTreeHostPlatform::RunMoveLoop installs its ScopedSuppressForWindowMove() scope. During that window, the process-global data-drag suppression guard is not yet active, so a data drag started from another top-level window inside the nested loop is not rejected. On X11 that unsuppressed data drag releases the tab drag’s pointer capture and runs its own XDND move loop on the user’s held button, hijacking the in-progress gesture — an authorization/state-confusion gap where a data drag is permitted at a moment it should be forbidden.

The fix adds, under #if BUILDFLAG(IS_LINUX), a views::DesktopDragDropClientOzone::ScopedSuppressForWindowMove() scoped object right before waiter.Wait(), extending the suppression to cover the VisibilityWaiter loop and mirroring the existing scope around RunMoveLoop. The shipped interactive test enables the deferred-Show() path, detaches a tab, posts a task into the kWaitingForWindowToShow loop that attempts a data drag via the source window’s DragDropClient, and asserts the result is ui::mojom::DragOperation::kNone, confirming the drag is now rejected.

Key insight
The single mistake was that the data-drag suppression scope began only at RunMoveLoop, leaving the earlier VisibilityWaiter nested loop unguarded; the fix installs ScopedSuppressForWindowMove() around that loop too so data drags are rejected for the entire duration the button is held.

Attack Path

  1. Enable deferred show With InitialWebUI/deferred BrowserView::Show() in effect, the user begins dragging a tab so it detaches into a new browser and TabDragController enters kWaitingForWindowToShow.
  2. Spin the unguarded nested loop DetachIntoNewBrowserAndRunMoveLoop runs the VisibilityWaiter kNestableTasksAllowed RunLoop while the pointer button is still held, before RunMoveLoop’s suppression scope exists.
  3. Start a data drag inside the loop A task/event dispatched within that nested loop starts a data (XDND) drag from another top-level window via a DragDropClient; pre-patch the process-global guard does not reject it.
  4. Hijack the gesture On X11, the data drag releases the tab drag’s pointer capture and runs its own XDND move loop on the still-held button, taking over the in-progress tab-drag gesture. (inferred) Any further consequence beyond this drag-state confusion/gesture takeover is not demonstrated by the patch.

Impact Assessment

The flaw lets a data drag begin during a tab-detach visibility wait when it should be suppressed, allowing it to seize the tab drag’s pointer capture and hijack the user’s held-button gesture in the browser UI on Linux/X11 (Ozone). The advisory classifies this as incorrect authorization in TabStrip; it does not report exploitation in the wild, and any escalation beyond drag-state confusion/gesture takeover (e.g. tricking the user into an unintended drop) is (inferred). Preconditions include Linux/X11 with the deferred-Show() (kInitialWebUI) path active and a data drag initiated during the narrow kWaitingForWindowToShow window.

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/views/tabs/dragging/tab_drag_controller.cc
modified
DetachTabWithDeferredShowDragControllerTest
chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
modified
DetachTabWithDeferredShowDragControllerTest
chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
modified
BindLambdaForTesting
chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
modified
if
ui/views/BUILD.gn
modified

Files Changed

  • chrome/browser/ui/views/tabs/dragging/tab_drag_controller.cc
  • chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.h
  • chrome/browser/ui/views/tabs/dragging/tab_drag_controller_interactive_uitest.cc
  • ui/views/BUILD.gn

Audit Directions

  • Suppression/guard scope vs. nested run loops
    Audit places that spin nested RunLoops (especially kNestableTasksAllowed) before installing a protective scoped guard, and verify the guard covers the entire interval the sensitive gesture/state is held, not just the final phase.
  • Pointer-capture and drag interactions on Ozone/X11
    Review paths where a tab drag, window move, and data drag-and-drop can overlap on Linux, checking that DesktopDragDropClientOzone suppression is active whenever another drag could grab pointer capture.
  • Deferred-`Show()` timing paths
    Look for other flows gated on kInitialWebUI/kArtificialUIDelay deferred visibility where nested waiting introduces a window in which normally-forbidden operations become reachable.
From 6c3c52528bb2ddd5223595dafb705f7addde535f Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <[email protected]>
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 <[email protected]>
Auto-Submit: Eriko Kurimoto <[email protected]>
Commit-Queue: Eriko Kurimoto <[email protected]>
Reviewed-by: Elly <[email protected]>
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
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:
diff --git a/ui/views/BUILD.gn b/ui/views/BUILD.gn
index 84b5d8eb..1d033e9 100644
--- a/ui/views/BUILD.gn
+++ b/ui/views/BUILD.gn
@@ -981,12 +981,12 @@
       }
       if (is_linux || is_fuchsia) {
         public += [
+          "widget/desktop_aura/desktop_drag_drop_client_ozone.h",
           "widget/desktop_aura/desktop_window_tree_host_platform.h",
           "widget/desktop_aura/window_move_client_platform.h",
         ]
         sources += [
           "widget/desktop_aura/desktop_drag_drop_client_ozone.cc",
-          "widget/desktop_aura/desktop_drag_drop_client_ozone.h",
           "widget/desktop_aura/desktop_window_tree_host_platform.cc",
           "widget/desktop_aura/window_move_client_platform.cc",
         ]
Loading diff…

Regression Test / PoC

shipped with the fix
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:
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • ui/views/widget/desktop_aura/desktop_window_tree_host_platform.cc
  • ui/views/widget/desktop_aura/desktop_drag_drop_client_ozone.cc
  • ui/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

  1. A compromised renderer in another visible window (e.g., W2) issues a LocalFrameHost::StartDragging Mojo IPC.
  2. The browser UI thread dispatches the IPC while blocked inside waiter.Wait(), leading to WebContentsViewAura::StartDragging (in content/browser/web_contents/web_contents_view_aura.cc).
  3. Since the attacker-controlled window W2 does not have an active webpage-initiated drag session, drag_security_info_.did_initiate() returns false, bypassing the WebContents re-entrancy check.
  4. The request reaches DesktopDragDropClientOzone::StartDragAndDrop (or DesktopDragDropClientWin::StartDragAndDrop). Since g_is_dragging is false, the re-entrancy checks pass.
  5. 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.
  6. On Ozone/Wayland:
    • Registering a new drag delegate while a drag session is active triggers a browser crash due to CHECK(!drag_delegate_) in ui/ozone/platform/wayland/host/wayland_data_device.cc:48.

Potential Steps to Trigger

  1. Ensure the kInitialWebUI feature is active (e.g., by launching with --enable-features=InitialWebUI).
  2. Compromise a renderer process and open a second top-level visible window W2.
  3. Have the user initiate a tab-detach gesture.
  4. While the tab-drag widget is waiting to show itself (inside VisibilityWaiter::Wait()), the compromised renderer sends a LocalFrameHost::StartDragging IPC message.
  5. 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.

View on issue tracker