CVE-2026-78912
Overview
Background
- Exclusive access bubble
- The browser-UI notice (e.g. “Press Esc to exit full screen”) shown when a page enters fullscreen, pointer lock, or keyboard lock. It is the user’s main anti-spoofing cue that the browser chrome is gone.
- Snooze / re-show on input
ExclusiveAccessBubble::OnUserInput()re-shows the bubble on user input only ifbase::TimeTicks::Now() > snooze_until_, so it does not keep reappearing during active use.- "Show on next interaction"
- When a tab enters fullscreen without recent user interaction (
!tab->HasRecentInteraction()), the bubble must be shown again on the user’s first input so that a user who was not looking still sees how to exit. - `hide_timeout_` / `kSnoozeTime`
- Timers in
ExclusiveAccessBubble. Re-showing or starting the hide timer pushessnooze_until_forward toNow() + kSnoozeTime.
Root Cause Analysis
Before the patch, the “must show on next interaction” requirement was not stored as its own state. The two ExclusiveAccessBubbleViews code paths in exclusive_access_bubble_views.cc encoded it by setting snooze_until_ = base::TimeTicks::Min(), so that the Now() > snooze_until_ check in OnUserInput() would pass. Any later show attempt, such as the downloads-triggered re-show the commit message mentions, went through ShowAndStartTimers()/StartHideTimer() and its snooze reset (snooze_until_ = Now() + kSnoozeTime). That overwrote the sentinel and silently cancelled the forced re-show, so the next user input would not display the exit-fullscreen instructions.
The fix adds a dedicated must_show_next_interaction_ flag, set via SetMustShowOnNextInteraction(), which timer resets do not touch. OnUserInput() now shows the bubble if the flag is set or the snooze has expired, and clears the flag afterwards.
The fix also makes snooze_until_ and hide_timeout_ private so subclasses cannot tamper with them directly.
OnUserInput() consumes makes the forced re-show survive later show or hide-timer calls.Attack Path
- Fullscreen without recent interaction
A malicious page gets a tab into fullscreen at a moment when
tab->HasRecentInteraction()is false, so the views code requests a show on next interaction (previouslysnooze_until_ = Min()). - Trigger another bubble show
The page causes another exclusive-access bubble show or hide-timer restart, such as the downloads path the commit message names. That path resets
snooze_until_toNow() + kSnoozeTimeand drops the pending forced re-show. - User returns without seeing the notice
On the user’s first input,
OnUserInput()sees an active snooze and does not show the bubble. The user never sees the “how to exit fullscreen” prompt. - Spoofed UI With the browser chrome hidden and no fullscreen notice, the page can draw fake browser UI (address bar, dialogs) to mislead the user (inferred).
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 |
Files Changed
chrome/browser/ui/exclusive_access/exclusive_access_bubble.hchrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.ccchrome/browser/ui/views/exclusive_access/exclusive_access_bubble_views.ccchrome/browser/ui/exclusive_access/exclusive_access_bubble.cc
Audit Directions
- Sentinel values in shared timing stateLook for security UI (fullscreen, pointer lock, permission prompts, download and PiP indicators) that encodes “must show” or “must block” as a special timestamp or counter value that other paths reset in the normal course of updating it.
- Concurrent triggers for the same security bubbleCheck how overlapping show requests (fullscreen plus downloads, keyboard lock, pointer lock) interact. A later, less strict request must not downgrade the visibility guarantees of an earlier one.
- Protected mutable state in UI base classesBase classes that expose timers or flags to subclasses as
protectedinvite bypasses of invariants. Audit similar bubbles and indicators for direct subclass writes that skip base-class logic.
Patch
From d124770dd6b6b289f9bb01276e024e66d4811abc Mon Sep 17 00:00:00 2001 From: Dana Fried <[email protected]> Date: Wed, 22 Jul 2026 17:05:17 -0700 Subject: [PATCH] [Exclusive Access Bubble] Fix issue with 'show on next interaction' There were certain conditions which caused the "how to exit fullscreen" bubble to show on the next user interaction without a snooze happening. However, since this state was stored as a special snooze timer value, another show attempt via e.g. downloads would put the bubble into an incorrect state. This separates the two along with encapsulating a lot of stuff in the base class that should ideally be private. It also adds some new tests to cover edge cases fixed by the change. Fixed: 514078852 Change-Id: I8429831baa69478c409f11dee1fc58377a9194b0 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8120956 Reviewed-by: David Pennington <[email protected]> Commit-Queue: Dana Fried <[email protected]> Cr-Commit-Position: refs/heads/main@{#1666719} --- diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc index 3bc3c10..a8a3c2c 100644 --- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc +++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc @@ -19,7 +19,8 @@ void ExclusiveAccessBubble::OnUserInput() { // Re-show the bubble if no user input occurred during the snooze period. - if (base::TimeTicks::Now() > snooze_until_) { + if (must_show_next_interaction_ || base::TimeTicks::Now() > snooze_until_) { + must_show_next_interaction_ = false; ShowAndStartTimers(); } @@ -40,3 +41,7 @@ // Restart the snooze period; to only re-show after a period of inactivity. snooze_until_ = base::TimeTicks::Now() + kSnoozeTime; } + +void ExclusiveAccessBubble::SetMustShowOnNextInteraction() { + must_show_next_interaction_ = true; +} diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h index 6325a826..155547f 100644 --- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h +++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h @@ -5,6 +5,7 @@ #ifndef CHROME_BROWSER_UI_EXCLUSIVE_ACCESS_EXCLUSIVE_ACCESS_BUBBLE_H_ #define CHROME_BROWSER_UI_EXCLUSIVE_ACCESS_EXCLUSIVE_ACCESS_BUBBLE_H_ +#include "base/gtest_prod_util.h" #include "base/memory/raw_ptr.h" #include "base/timer/timer.h" #include "chrome/browser/ui/exclusive_access/exclusive_access_bubble_type.h" @@ -48,18 +49,28 @@ // Reset the timeout for user input before we auto-show again. void Snooze(); + // Indicate that the bubble must show immediately on next user interaction, + // regardless of timeouts. + void SetMustShowOnNextInteraction(); + // Cached content and traits for this bubble. ExclusiveAccessBubbleParams params_; - // Hides the bubble after it has been displayed for a short time. - base::RetainingOneShotTimer hide_timeout_; - - // Bubble re-shows on user input are suppressed until this time elapses. - base::TimeTicks snooze_until_; - private: friend class ExclusiveAccessTest; friend class ExclusiveAccessBubbleViewsTest; + FRIEND_TEST_ALL_PREFIXES(ExclusiveAccessBubbleTest, ShowAndStartTimers); + FRIEND_TEST_ALL_PREFIXES(ExclusiveAccessBubbleTest, + StartHideTimerRestartsTimer); + + // Bubble re-shows on user input are suppressed until this time elapses. + base::TimeTicks snooze_until_; + + // Hides the bubble after it has been displayed for a short time. + base::RetainingOneShotTimer hide_timeout_; + + // Flag indicating that the bubble must show on the next user interaction. + bool must_show_next_interaction_ = false; }; #endif // CHROME_BROWSER_UI_EXCLUSIVE_ACCESS_EXCLUSIVE_ACCESS_BUBBLE_H_ diff --git a/chrome/browser/ui/views/exclusive_access/exclusive_access_bubble_views.cc b/chrome/browser/ui/views/exclusive_access/exclusive_access_bubble_views.cc index 17f5d05..8e8a3ec 100644 --- a/chrome/browser/ui/views/exclusive_access/exclusive_access_bubble_views.cc +++ b/chrome/browser/ui/views/exclusive_access/exclusive_access_bubble_views.cc @@ -143,7 +143,7 @@ ->fullscreen_controller() ->exclusive_access_tab(); if (entering_tab_fullscreen && tab && !tab->HasRecentInteraction()) { - snooze_until_ = base::TimeTicks::Min(); + SetMustShowOnNextInteraction(); } } @@ -214,7 +214,7 @@ ->fullscreen_controller() ->exclusive_access_tab(); if (entering_tab_fullscreen && tab && !tab->HasRecentInteraction()) { - snooze_until_ = base::TimeTicks::Min(); + SetMustShowOnNextInteraction(); } } @@ -397,7 +397,6 @@ // This function is guarded by the `ExclusiveAccessBubble::hide_timeout_` // timer, so the bubble has been displayed for at least // `ExclusiveAccessBubble::kShowTime`. - DCHECK(!hide_timeout_.IsRunning()); RunHideCallbackIfNeeded(ExclusiveAccessBubbleHideReason::kTimeout); presentation_cb_.Reset(); 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 a129897..2fa4cd5 100644 --- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc +++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc @@ -16,14 +16,16 @@ MOCK_METHOD(void, Show, (), (override)); MOCK_METHOD(void, Hide, (), (override)); - using ExclusiveAccessBubble::hide_timeout_; + using ExclusiveAccessBubble::SetMustShowOnNextInteraction; using ExclusiveAccessBubble::ShowAndStartTimers; - using ExclusiveAccessBubble::snooze_until_; using ExclusiveAccessBubble::StartHideTimer; }; class ExclusiveAccessBubbleTest : public testing::Test { public: + static constexpr base::TimeDelta kEnsureHideTime = + ExclusiveAccessBubble::kShowTime + base::Seconds(1); + ExclusiveAccessBubbleTest() : bubble_( {.type = @@ -81,3 +83,37 @@ bubble_.hide_timeout_.desired_run_time() - base::TimeTicks::Now(); EXPECT_LT(remaining, new_remaining); } + +TEST_F(ExclusiveAccessBubbleTest, ReshowOnFirstInputPreservedByStartHideTimer) { + EXPECT_CALL(bubble_, Show()).Times(1); + bubble_.ShowAndStartTimers(); + bubble_.SetMustShowOnNextInteraction(); + bubble_.StartHideTimer(); + task_environment_.FastForwardBy(kEnsureHideTime); + EXPECT_CALL(bubble_, Show()).Times(1); + bubble_.OnUserInput(); +} + +TEST_F(ExclusiveAccessBubbleTest, + ReshowOnFirstInputPreservedByShowAndStartTimers) { + EXPECT_CALL(bubble_, Show()).Times(1); + bubble_.ShowAndStartTimers(); + bubble_.SetMustShowOnNextInteraction(); + EXPECT_CALL(bubble_, Show()).Times(1); + bubble_.ShowAndStartTimers(); + task_environment_.FastForwardBy(kEnsureHideTime); + EXPECT_CALL(bubble_, Show()).Times(1); + bubble_.OnUserInput(); +} + +TEST_F(ExclusiveAccessBubbleTest, ReshowOnFirstInputOnlyOnce) { + EXPECT_CALL(bubble_, Show()).Times(1); + bubble_.ShowAndStartTimers(); + bubble_.SetMustShowOnNextInteraction(); + task_environment_.FastForwardBy(kEnsureHideTime); + EXPECT_CALL(bubble_, Show()).Times(1); + bubble_.OnUserInput(); + task_environment_.FastForwardBy(kEnsureHideTime); + EXPECT_CALL(bubble_, Show()).Times(0); + bubble_.OnUserInput(); +}
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 a129897..2fa4cd5 100644
--- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc
+++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc
@@ -16,14 +16,16 @@
MOCK_METHOD(void, Show, (), (override));
MOCK_METHOD(void, Hide, (), (override));
- using ExclusiveAccessBubble::hide_timeout_;
+ using ExclusiveAccessBubble::SetMustShowOnNextInteraction;
using ExclusiveAccessBubble::ShowAndStartTimers;
- using ExclusiveAccessBubble::snooze_until_;
using ExclusiveAccessBubble::StartHideTimer;
};
class ExclusiveAccessBubbleTest : public testing::Test {
public:
+ static constexpr base::TimeDelta kEnsureHideTime =
+ ExclusiveAccessBubble::kShowTime + base::Seconds(1);
+
ExclusiveAccessBubbleTest()
: bubble_(
{.type =
@@ -81,3 +83,37 @@
bubble_.hide_timeout_.desired_run_time() - base::TimeTicks::Now();
EXPECT_LT(remaining, new_remaining);
}
+
+TEST_F(ExclusiveAccessBubbleTest, ReshowOnFirstInputPreservedByStartHideTimer) {
+ EXPECT_CALL(bubble_, Show()).Times(1);
+ bubble_.ShowAndStartTimers();
+ bubble_.SetMustShowOnNextInteraction();
+ bubble_.StartHideTimer();
+ task_environment_.FastForwardBy(kEnsureHideTime);
+ EXPECT_CALL(bubble_, Show()).Times(1);
+ bubble_.OnUserInput();
+}
+
+TEST_F(ExclusiveAccessBubbleTest,
+ ReshowOnFirstInputPreservedByShowAndStartTimers) {
+ EXPECT_CALL(bubble_, Show()).Times(1);
+ bubble_.ShowAndStartTimers();
+ bubble_.SetMustShowOnNextInteraction();
+ EXPECT_CALL(bubble_, Show()).Times(1);
+ bubble_.ShowAndStartTimers();
+ task_environment_.FastForwardBy(kEnsureHideTime);
+ EXPECT_CALL(bubble_, Show()).Times(1);
+ bubble_.OnUserInput();
+}
+
+TEST_F(ExclusiveAccessBubbleTest, ReshowOnFirstInputOnlyOnce) {
+ EXPECT_CALL(bubble_, Show()).Times(1);
+ bubble_.ShowAndStartTimers();
+ bubble_.SetMustShowOnNextInteraction();
+ task_environment_.FastForwardBy(kEnsureHideTime);
+ EXPECT_CALL(bubble_, Show()).Times(1);
+ bubble_.OnUserInput();
+ task_environment_.FastForwardBy(kEnsureHideTime);
+ EXPECT_CALL(bubble_, Show()).Times(0);
+ bubble_.OnUserInput();
+}
Original Bug Report
Bypass of fullscreen exit instruction reminder via automatic download
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 logic error in ExclusiveAccessBubbleViews::Update() allows an automatic download notification to overwrite the ’re-show on first input’ security mitigation for gesture-less fullscreen. This allows a site to suppress the exit instruction reminder, facilitating persistent UI spoofing in fullscreen mode.
Affected files:
chrome/browser/ui/views/exclusive_access_bubble_views.ccchrome/browser/ui/exclusive_access/exclusive_access_bubble.ccchrome/browser/download/bubble/download_display_controller.cc
Estimated timestamp from git blame: Unknown (Google3 checkout)
Summary
A logic error in ExclusiveAccessBubbleViews::Update() causes the security mitigation for gesture-less fullscreen entry to be clobbered by subsequent updates, such as those triggered by automatic downloads. This mitigation is designed to ensure that if a site enters fullscreen without a user gesture (e.g., via the Automatic Fullscreen permission), an exit instruction bubble is re-shown upon the first user interaction. By triggering a download immediately after entry, a malicious site can suppress this reminder.
Root Cause Analysis
When a tab enters fullscreen without recent user interaction, ExclusiveAccessBubbleViews sets a ‘sentinel’ value to ensure the exit instruction re-appears when the user eventually interacts with the page. This is handled in the constructor:
// chrome/browser/ui/views/exclusive_access_bubble_views.cc:134
if (entering_tab_fullscreen && tab && !tab->HasRecentInteraction()) {
snooze_until_ = base::TimeTicks::Min();
}
The base::TimeTicks::Min() value tells ExclusiveAccessBubble::OnUserInput() to ignore the standard 15-minute ‘snooze’ period and show the bubble immediately.
However, if a download starts while the bubble is active or recently hidden, DownloadDisplayController::OnNewItem calls UpdateExclusiveAccessBubble with {.has_download=true, .force_update=true}. This triggers ExclusiveAccessBubbleViews::Update():
Update()callsShowAndStartTimers()(line 198), which callsSnooze().Snooze()unconditionally resets the snooze timer:snooze_until_ = base::TimeTicks::Now() + kSnoozeTime(15 minutes).Update()then evaluatesentering_tab_fullscreento determine if the sentinel should be restored (line 186):const bool entering_tab_fullscreen = !IsTabFullscreenType(params_.type) && IsTabFullscreenType(params.type);- Since the tab is already in fullscreen,
IsTabFullscreenType(params_.type)is already true, soentering_tab_fullscreenevaluates tofalse. - The code skips the block that restores the
base::TimeTicks::Min()sentinel (lines 205-207).
Consequently, the 15-minute snooze period remains in effect, and the exit instruction will not be re-shown when the user interacts with the page.
Potential Attack Scenario
An attacker could follow these suggested steps to trigger the vulnerability (note that our tooling cannot currently execute code to verify this):
- A user visits a site with
Automatic Fullscreenpermission (e.g., an Isolated Web App or an enterprise-managed site). - The site waits for several seconds of inactivity to ensure
HasRecentInteraction()is false. - The site programmatically enters fullscreen.
- The site immediately initiates an automatic download (permitted for the first download in a tab without a gesture).
- The download notification clobbers the
snooze_until_sentinel in the browser process. - The user, unaware that the site entered fullscreen automatically, interacts with the page. The security instruction that should have appeared is suppressed, allowing the site to spoof browser UI elements effectively.
Suggested Fix
Modify ExclusiveAccessBubbleViews::Update() to preserve or re-evaluate the snooze_until_ sentinel if the bubble is already in a tab-initiated fullscreen state and the tab still lacks recent interaction. Specifically, the logic should ensure that a ‘forced update’ from a download does not discard the security state intended to protect gesture-less entries.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.