CVE-2026-78912
Overview
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.ccchrome/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.cc
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/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(); +} 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();
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.