Medium watch CVSS 5.4 chrome Spoofing 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in Browser
DescriptionUI misrepresentation in Browser
ComponentBrowser
Sub-componentExclusive Access Bubble
Bug ClassSpoofing
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.
Tracker514078852
Fix commitd124770dd6b6 (chromium/src) +63/-12
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-05-17 (per vendor advisory)
Fix landed2026-07-23 (public git)
Disclosed2026-08-25
Patch gap33 days — fix public in source before the advisory

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 if base::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 pushes snooze_until_ forward to Now() + 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.

Key insight
A security-relevant one-shot requirement was stored as a magic value in a timer field that other code paths legitimately overwrite. Moving it into a separate flag that only OnUserInput() consumes makes the forced re-show survive later show or hide-timer calls.

Attack Path

  1. 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 (previously snooze_until_ = Min()).
  2. 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_ to Now() + kSnoozeTime and drops the pending forced re-show.
  3. 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.
  4. 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

A web page can suppress the fullscreen exit notice in the browser UI, which enables UI spoofing, matching the advisory’s “UI misrepresentation in Browser” classification. Preconditions include entering fullscreen (which normally needs a user gesture), the no-recent-interaction state, and a second bubble-show trigger such as a download. A successful attack still relies on social engineering (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.4.

Changed Functions

FunctionChangeNotes
ExclusiveAccessBubbleTest
chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc
modified
TEST_F
chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc
modified

Files Changed

  • chrome/browser/ui/exclusive_access/exclusive_access_bubble.h
  • chrome/browser/ui/exclusive_access/exclusive_access_bubble_unittest.cc
  • chrome/browser/ui/views/exclusive_access/exclusive_access_bubble_views.cc
  • chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc

Audit Directions

  • Sentinel values in shared timing state
    Look 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 bubble
    Check 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 classes
    Base classes that expose timers or flags to subclasses as protected invite bypasses of invariants. Audit similar bubbles and indicators for direct subclass writes that skip base-class logic.
From d124770dd6b6b289f9bb01276e024e66d4811abc Mon Sep 17 00:00:00 2001
From: Dana Fried <dfried@chromium.org>
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 <dpenning@chromium.org>
Commit-Queue: Dana Fried <dfried@chromium.org>
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();
+}
Loading diff…

Regression Test / PoC

shipped with the fix
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();
+}
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc
  • chrome/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():

  1. Update() calls ShowAndStartTimers() (line 198), which calls Snooze().
  2. Snooze() unconditionally resets the snooze timer: snooze_until_ = base::TimeTicks::Now() + kSnoozeTime (15 minutes).
  3. Update() then evaluates entering_tab_fullscreen to determine if the sentinel should be restored (line 186): const bool entering_tab_fullscreen = !IsTabFullscreenType(params_.type) && IsTabFullscreenType(params.type);
  4. Since the tab is already in fullscreen, IsTabFullscreenType(params_.type) is already true, so entering_tab_fullscreen evaluates to false.
  5. 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):

  1. A user visits a site with Automatic Fullscreen permission (e.g., an Isolated Web App or an enterprise-managed site).
  2. The site waits for several seconds of inactivity to ensure HasRecentInteraction() is false.
  3. The site programmatically enters fullscreen.
  4. The site immediately initiates an automatic download (permitted for the first download in a tab without a gesture).
  5. The download notification clobbers the snooze_until_ sentinel in the browser process.
  6. 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.

View on issue tracker