CVE-2026-8561
Overview
Background
- Exclusive access bubble
- the transient fullscreen/pointer-lock notification (“Press Esc to exit full screen”) that must stay visible long enough for the user to read it before auto-hiding.
- `hide_timeout_`
- the timer that auto-hides the bubble after
kShowTime; it is started when the bubble is shown. - `snooze_until_`
- the deadline controlling when the bubble may re-show after user input, set in the
Snooze()logic. - Presentation commit
- the point at which a frame is actually displayed on screen (tracked via
vizFrameTimingDetails), which can lag behind the code that requests the bubble be shown.
Root Cause Analysis
The auto-hide timer was started at the moment the code asked to show the bubble rather than when the bubble was actually presented, so on a slow present the hide_timeout_ could count down (or expire) while the notification was not yet visible, shortening or eliminating the window in which the user sees the fullscreen security UI.
The patch refactors exclusive_access_bubble.cc so ShowAndStartTimers() calls Show() then the new StartHideTimer() (which does hide_timeout_.Reset() then Snooze()), and factors the snooze-deadline update into a dedicated Snooze(); OnUserInput() now re-shows via ShowAndStartTimers() without the old early return and always calls Snooze(). Crucially, ShowAndStartTimers() is made virtual (header change), allowing the Views implementation in exclusive_access_bubble_views.cc — which now includes components/viz/common/frame_timing_details.h — to override it and defer StartHideTimer() until the frame is committed/presented (the override body itself is not shown in this diff, so the commit-time wiring is inferred).
The result is that the hide timer only begins once the bubble is genuinely on screen, so the notification is displayed for its full intended duration; the browsertest change Wait(ExclusiveAccessBubble::kShowTime * 2) reflects the added presentation delay.
StartHideTimer()/Snooze() and makes ShowAndStartTimers() virtual so the Views bubble can defer the hide timer until the frame commits.Attack Path
- Enter fullscreen An attacker page requests fullscreen (or pointer lock), triggering the exclusive-access bubble that warns the user how to exit.
- Race the presentation
Because the
hide_timeout_started before the bubble was actually presented, under presentation delay the timer elapses while the bubble is not yet (or only briefly) visible. - Notification missed The security UI auto-hides before the user can perceive it, so the user is unaware they are in fullscreen or how to leave.
- UI spoofing With the warning effectively suppressed, the fullscreen page can draw fake browser chrome or security indicators and mislead the user (incorrect security UI / spoofing, as the advisory describes).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ExclusiveAccessBubbleTestchrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc |
modified | |
TEST_Fchrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc |
modified | |
FullscreenControllerInteractiveTestchrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc |
modified |
Files Changed
chrome/browser/ui/exclusive_access/exclusive_access_bubble.ccchrome/browser/ui/exclusive_access/exclusive_access_bubble.hchrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.ccchrome/browser/ui/exclusive_access/exclusive_access_test.ccchrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.ccchrome/browser/ui/views/exclusive_access_bubble_views.cc
Audit Directions
- Timers started before displayAudit any security/notification UI whose auto-hide or validity timer starts at request time rather than at actual presentation/commit, especially where present can be delayed.
- Fullscreen/exclusive-access notificationsVerify every exclusive-access or permission bubble guarantees a minimum on-screen visibility independent of rendering latency.
- Early-return timer bugsReview input/snooze handlers where an early
returnskips refreshing a deadline, which can leave the UI in an inconsistent show/hide state.
Patch
From fdac21de070f3523b7315a268092d528a542a6d6 Mon Sep 17 00:00:00 2001 From: Frank Liberato <[email protected]> Date: Tue, 31 Mar 2026 16:30:44 -0700 Subject: [PATCH] Don't start the exclusive access bubble timer until commit Bug: 343352552 Change-Id: If0f8704a23c51e1431f9919ea88b989f74de4a4c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7615417 Reviewed-by: Mike Wasserman <[email protected]> Commit-Queue: Frank Liberato <[email protected]> Reviewed-by: Muyao Xu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1608174} --- diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc index 3df48a00..3bc3c10 100644 --- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc +++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc @@ -21,19 +21,22 @@ // Re-show the bubble if no user input occurred during the snooze period. if (base::TimeTicks::Now() > snooze_until_) { ShowAndStartTimers(); - return; } - // Restart the snooze period; to only re-show after a period of inactivity. - snooze_until_ = base::TimeTicks::Now() + kSnoozeTime; + Snooze(); } void ExclusiveAccessBubble::ShowAndStartTimers() { Show(); + StartHideTimer(); +} - // Restart the timer to hide the bubble after a few seconds. +void ExclusiveAccessBubble::StartHideTimer() { hide_timeout_.Reset(); + Snooze(); +} +void ExclusiveAccessBubble::Snooze() { // Restart the snooze period; to only re-show after a period of inactivity. snooze_until_ = base::TimeTicks::Now() + kSnoozeTime; } diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h index 0051d623..376bae87 100644 --- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h +++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h @@ -37,7 +37,13 @@ virtual void Show() = 0; // Shows the bubble and sets up timers to auto-hide and snooze. - void ShowAndStartTimers(); + virtual void ShowAndStartTimers(); + + // Starts the timer to hide the bubble after a short time. + void StartHideTimer(); + + // Reset the timeout for user input before we auto-show again. + void Snooze(); // Cached content and traits for this bubble. ExclusiveAccessBubbleParams params_; diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc b/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc index 5b87012b..a129897 100644 --- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc +++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc @@ -13,12 +13,13 @@ explicit MockExclusiveAccessBubble(ExclusiveAccessBubbleParams params) : ExclusiveAccessBubble(params) {} ~MockExclusiveAccessBubble() override = default; - MOCK_METHOD(void, Hide, (), (override)); MOCK_METHOD(void, Show, (), (override)); + MOCK_METHOD(void, Hide, (), (override)); using ExclusiveAccessBubble::hide_timeout_; using ExclusiveAccessBubble::ShowAndStartTimers; using ExclusiveAccessBubble::snooze_until_; + using ExclusiveAccessBubble::StartHideTimer; }; class ExclusiveAccessBubbleTest : public testing::Test { @@ -63,3 +64,20 @@ task_environment_.FastForwardBy(base::Minutes(16)); bubble_.OnUserInput(); } + +TEST_F(ExclusiveAccessBubbleTest, StartHideTimerRestartsTimer) { + EXPECT_CALL(bubble_, Show()).Times(1); + bubble_.ShowAndStartTimers(); + EXPECT_TRUE(bubble_.hide_timeout_.IsRunning()); + + task_environment_.FastForwardBy(base::Seconds(1)); + auto remaining = + bubble_.hide_timeout_.desired_run_time() - base::TimeTicks::Now(); + EXPECT_LT(remaining, ExclusiveAccessBubble::kShowTime); + + // Verify that the time increases. + bubble_.StartHideTimer(); + auto new_remaining = + bubble_.hide_timeout_.desired_run_time() - base::TimeTicks::Now(); + EXPECT_LT(remaining, new_remaining); +} diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_test.cc b/chrome/browser/ui/exclusive_access/exclusive_access_test.cc index 2756a83..91fd829 100644 --- a/chrome/browser/ui/exclusive_access/exclusive_access_test.cc +++ b/chrome/browser/ui/exclusive_access/exclusive_access_test.cc @@ -74,6 +74,8 @@ ExclusiveAccessTest::~ExclusiveAccessTest() = default; void ExclusiveAccessTest::SetUpOnMainThread() { + ExclusiveAccessBubbleViews::set_skip_presentation_delay_for_testing(true); + permission_controller_ = std::make_unique<content::MockPermissionController>(); ON_CALL(*permission_controller_, RequestPermissionsFromCurrentDocument) @@ -110,6 +112,8 @@ } void ExclusiveAccessTest::TearDownOnMainThread() { + ExclusiveAccessBubbleViews::set_skip_presentation_delay_for_testing(false); + GetExclusiveAccessManager() ->pointer_lock_controller() ->bubble_hide_callback_for_test_ = diff --git a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc index e34c562..0245179 100644 --- a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc +++ b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc @@ -80,13 +80,13 @@ } // namespace class FullscreenControllerInteractiveTest : public ExclusiveAccessTest { + protected: void SetUpOnMainThread() override { ExclusiveAccessTest::SetUpOnMainThread(); SetDisableFullscreenWithinTab(true); } - protected: void SetUpCommandLine(base::CommandLine* command_line) override { ExclusiveAccessTest::SetUpCommandLine(command_line); // Slow bots are flaky due to slower loading interacting with @@ -981,6 +981,7 @@ } void SetUpOnMainThread() override { + FullscreenControllerInteractiveTest::SetUpOnMainThread(); auto allow_automatic_fullscreen = [&](const GURL& url) { HostContentSettingsMapFactory::GetForProfile(browser()->profile()) ->SetContentSettingDefaultScope( @@ -1014,7 +1015,10 @@ ASSERT_TRUE(WaitForRenderFrameReady(web_contents_->GetPrimaryMainFrame())); } - void TearDownOnMainThread() override { web_contents_ = nullptr; } + void TearDownOnMainThread() override { + web_contents_ = nullptr; + FullscreenControllerInteractiveTest::TearDownOnMainThread(); + } bool RequestFullscreen(bool gesture = false, content::RenderFrameHost* rfh = nullptr) { @@ -1327,6 +1331,7 @@ : public FullscreenControllerInteractiveTest { public: void SetUpOnMainThread() override { + FullscreenControllerInteractiveTest::SetUpOnMainThread(); if (!SetUpVirtualDisplays()) { GTEST_SKIP() << "Skipping test; unavailable multi-screen support."; } @@ -1350,6 +1355,7 @@ #if BUILDFLAG(IS_MAC) ui::NSWindowFakedForTesting::SetEnabled(ns_window_faked_for_testing_); #endif + FullscreenControllerInteractiveTest::TearDownOnMainThread(); } // Create virtual displays as needed, ensuring 2 displays are available for @@ -1775,7 +1781,7 @@ // Explicitly check for, and destroy, the exclusive access bubble. EXPECT_TRUE(IsExclusiveAccessBubbleDisplayed()); - Wait(ExclusiveAccessBubble::kShowTime); + Wait(ExclusiveAccessBubble::kShowTime * 2); FinishExclusiveAccessBubbleAnimation(); EXPECT_FALSE(IsExclusiveAccessBubbleDisplayed()); diff --git a/chrome/browser/ui/views/exclusive_access_bubble_views.cc b/chrome/browser/ui/views/exclusive_access_bubble_views.cc index 5c0e580..db807b2e 100644 --- a/chrome/browser/ui/views/exclusive_access_bubble_views.cc +++ b/chrome/browser/ui/views/exclusive_access_bubble_views.cc @@ -24,10 +24,12 @@ #include "chrome/grit/generated_resources.h" #include "components/fullscreen_control/fullscreen_features.h" #include "components/fullscreen_control/subtle_notification_view.h" +#include "components/viz/common/frame_timing_details.h" #include "content/public/browser/web_contents.h"
Regression Test / PoC
diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc b/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc
index 5b87012b..a129897 100644
--- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc
+++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc
@@ -13,12 +13,13 @@
explicit MockExclusiveAccessBubble(ExclusiveAccessBubbleParams params)
: ExclusiveAccessBubble(params) {}
~MockExclusiveAccessBubble() override = default;
- MOCK_METHOD(void, Hide, (), (override));
MOCK_METHOD(void, Show, (), (override));
+ MOCK_METHOD(void, Hide, (), (override));
using ExclusiveAccessBubble::hide_timeout_;
using ExclusiveAccessBubble::ShowAndStartTimers;
using ExclusiveAccessBubble::snooze_until_;
+ using ExclusiveAccessBubble::StartHideTimer;
};
class ExclusiveAccessBubbleTest : public testing::Test {
@@ -63,3 +64,20 @@
task_environment_.FastForwardBy(base::Minutes(16));
bubble_.OnUserInput();
}
+
+TEST_F(ExclusiveAccessBubbleTest, StartHideTimerRestartsTimer) {
+ EXPECT_CALL(bubble_, Show()).Times(1);
+ bubble_.ShowAndStartTimers();
+ EXPECT_TRUE(bubble_.hide_timeout_.IsRunning());
+
+ task_environment_.FastForwardBy(base::Seconds(1));
+ auto remaining =
+ bubble_.hide_timeout_.desired_run_time() - base::TimeTicks::Now();
+ EXPECT_LT(remaining, ExclusiveAccessBubble::kShowTime);
+
+ // Verify that the time increases.
+ bubble_.StartHideTimer();
+ auto new_remaining =
+ bubble_.hide_timeout_.desired_run_time() - base::TimeTicks::Now();
+ EXPECT_LT(remaining, new_remaining);
+}
diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_test.cc b/chrome/browser/ui/exclusive_access/exclusive_access_test.cc
index 2756a83..91fd829 100644
--- a/chrome/browser/ui/exclusive_access/exclusive_access_test.cc
+++ b/chrome/browser/ui/exclusive_access/exclusive_access_test.cc
@@ -74,6 +74,8 @@
ExclusiveAccessTest::~ExclusiveAccessTest() = default;
void ExclusiveAccessTest::SetUpOnMainThread() {
+ ExclusiveAccessBubbleViews::set_skip_presentation_delay_for_testing(true);
+
permission_controller_ =
std::make_unique<content::MockPermissionController>();
ON_CALL(*permission_controller_, RequestPermissionsFromCurrentDocument)
@@ -110,6 +112,8 @@
}
void ExclusiveAccessTest::TearDownOnMainThread() {
+ ExclusiveAccessBubbleViews::set_skip_presentation_delay_for_testing(false);
+
GetExclusiveAccessManager()
->pointer_lock_controller()
->bubble_hide_callback_for_test_ =
diff --git a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc
index e34c562..0245179 100644
--- a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc
+++ b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc
@@ -80,13 +80,13 @@
} // namespace
class FullscreenControllerInteractiveTest : public ExclusiveAccessTest {
+ protected:
void SetUpOnMainThread() override {
ExclusiveAccessTest::SetUpOnMainThread();
SetDisableFullscreenWithinTab(true);
}
- protected:
void SetUpCommandLine(base::CommandLine* command_line) override {
ExclusiveAccessTest::SetUpCommandLine(command_line);
// Slow bots are flaky due to slower loading interacting with
@@ -981,6 +981,7 @@
}
void SetUpOnMainThread() override {
+ FullscreenControllerInteractiveTest::SetUpOnMainThread();
auto allow_automatic_fullscreen = [&](const GURL& url) {
HostContentSettingsMapFactory::GetForProfile(browser()->profile())
->SetContentSettingDefaultScope(
@@ -1014,7 +1015,10 @@
ASSERT_TRUE(WaitForRenderFrameReady(web_contents_->GetPrimaryMainFrame()));
}
- void TearDownOnMainThread() override { web_contents_ = nullptr; }
+ void TearDownOnMainThread() override {
+ web_contents_ = nullptr;
+ FullscreenControllerInteractiveTest::TearDownOnMainThread();
+ }
bool RequestFullscreen(bool gesture = false,
content::RenderFrameHost* rfh = nullptr) {
@@ -1327,6 +1331,7 @@
: public FullscreenControllerInteractiveTest {
public:
void SetUpOnMainThread() override {
+ FullscreenControllerInteractiveTest::SetUpOnMainThread();
if (!SetUpVirtualDisplays()) {
GTEST_SKIP() << "Skipping test; unavailable multi-screen support.";
}
@@ -1350,6 +1355,7 @@
#if BUILDFLAG(IS_MAC)
ui::NSWindowFakedForTesting::SetEnabled(ns_window_faked_for_testing_);
#endif
+ FullscreenControllerInteractiveTest::TearDownOnMainThread();
}
// Create virtual displays as needed, ensuring 2 displays are available for
@@ -1775,7 +1781,7 @@
// Explicitly check for, and destroy, the exclusive access bubble.
EXPECT_TRUE(IsExclusiveAccessBubbleDisplayed());
- Wait(ExclusiveAccessBubble::kShowTime);
+ Wait(ExclusiveAccessBubble::kShowTime * 2);
FinishExclusiveAccessBubbleAnimation();
EXPECT_FALSE(IsExclusiveAccessBubbleDisplayed());
Original Bug Report
WebGL Fullscreen Security UI Bypass
VULNERABILITY DETAILS WebGL allows malicious actors to overload GPUs and stall rendering of all Chromium/OS UI elements. Utilizing this, it is possible to obscure the dialog making users aware the browser is entering fullscreen mode (see WHATWG Fullscreen API Living Standard: users should always be informed when something is displayed in fullscreen [1]).
After a user interaction (required to use the fullscreen API), the GPU rendering can be blocked. Meanwhile the browser enters fullscreen. This is not visible to users since any rendering is stalled by resource exhaustion. After rendering has recovered, the browser should be in fullscreen, with the fullscreen notification completely skipped.
Our testing shows that the exploit only works in Windows-based systems, Linux systems are unaffected, as we were not able to reproduce the described behavior in those. This indicates that some Windows-specific implementation may be the reason for this behavior.
The exploit can be utilized for example to create convincing Windows lock-screen look-alike phishing site (if you want a more fleshed-out PoC we can provide a video and/or source code for a full demonstration).
[1] https://fullscreen.spec.whatwg.org/#security-and-privacy-considerations
VERSION Chrome Version: 124.0.6367.208 stable Operating System: Windows 10 Pro, 22H2
REPRODUCTION CASE The attached file ‘minimal_chrome.html’ demonstrates the exploit. It may be required to adjust the STRESS, FULLSCREEN_WAIT and RENDERTIME values, depending on the device. The given values give consistent results when testing on a Lenovo Thinkpad P14s Gen 3 using the integrated Intel Iris Xe Graphics for rendering.
CREDIT INFORMATION Reporter credit: Wolfgang Ettlinger (aff. Certitude Consulting GmbH) Alexander Hurbean (aff. Certitude Consulting GmbH)