CVE-2026-84330
Overview
Background
- `Snackbar`
- Android’s transient bottom-of-screen notification widget that Chrome reuses to render security-critical notices such as the fullscreen exit instruction.
- `SnackbarManager`
- the shared, single-queue controller that schedules, times out, and dismisses every
Snackbar, whether it is a low-priority action snackbar or a security notice. - Exclusive Access Bubble
- the “you are in fullscreen” / exclusive-access disclosure that must remain visible so the user knows the page has taken over the screen or pointer.
- `setHighPriority`
- a
Snackbarbuilder flag that keeps a notice from being displaced or discarded by the timeouts of other snackbars sharing the queue.
Root Cause Analysis
On Android the fullscreen/exclusive-access disclosure (and the Trusted Web Activity and Privacy Sandbox notices) was enqueued as an ordinary-priority Snackbar in the shared SnackbarManager queue, so a page could inject or trigger other action snackbars that displaced or suppressed the security notice before the user ever saw it. Compounding this, the dismissal timer ran on the native side: ExclusiveAccessBubbleAndroid::Hide() (driven by the native timeout) would tear down the notice even while it was still queued behind or covered by other UI, so the countdown elapsed against a notice that was never actually visible — the invariant that a security disclosure must be shown for its full duration while visible was violated.
The fix marks every such notice setHighPriority(true) so it cannot be discarded by other snackbars, and moves the timeout to a Java-side setDuration(EXCLUSIVE_ACCESS_SNACKBAR_DURATION_MS) that only starts counting once the snackbar is genuinely displayed. It also neutralizes the native timer path by making Hide() a no-op and routing real teardown only through HideImmediately() (invoked from the destructor), so the notice is no longer dismissed while obscured.
Attack Path
- Enter exclusive access
A malicious or misleading page requests fullscreen (or pointer lock), causing Chrome to enqueue the exclusive-access disclosure
Snackbar. - Flood the queue
The page triggers or coincides with other, ordinary-priority snackbars that share the single
SnackbarManagerqueue and displace the security notice. - Race the native timer
Because the dismissal countdown runs natively via
Hide()regardless of visibility, the disclosure’s duration elapses while it is still covered or queued. - Silent suppression The user never sees a legible “you are in fullscreen” notice, leaving them unaware the page controls the whole screen.
- Spoof the UI With no disclosure shown, the page can imitate browser or system chrome and mislead the user about their context.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java |
modified | |
ifchrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc |
modified | |
SnackbarCollectionchrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java |
modified | |
ifchrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java |
modified |
Files Changed
components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/text/TemplatePreservingTextView.javachrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.javachrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarManager.javachrome/browser/ui/android/exclusive_access/exclusive_access_context_android.hchrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.ccchrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.javatools/metrics/histograms/metadata/ui/enums.xmltools/metrics/histograms/metadata/ui/histograms.xmlchrome/browser/ui/android/exclusive_access/exclusive_access_context_android.ccchrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollectionUnitTest.javachrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.javachrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.ccchrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.javachrome/browser/ui/exclusive_access/exclusive_access_bubble.hchrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.javachrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarView.javachrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java
Audit Directions
- Security notices sharing a general UI queueAudit every security-critical disclosure rendered via
SnackbarManager(or similar shared toast/queue managers) to confirm it setssetHighPriority(true)and cannot be displaced by attacker-triggerable low-priority items. - Visibility-blind timeoutsFlag any disclosure whose dismissal timer runs before or independently of actual on-screen visibility (e.g. native-side
Hide()timers), since the mandated display duration can elapse while the notice is covered or queued. - Latched one-shot noticesReview
was_shown_-style latches and re-show paths (as exercised bySnoozeResetForciblyReshowsNotice) to ensure notices are forcibly re-displayed after enough user interaction rather than permanently suppressed.
Patch
From 3e4d359b7a4de442ecd9a5ecb2e8af37639d7568 Mon Sep 17 00:00:00 2001 From: Vikram Pasupathy <[email protected]> Date: Thu, 11 Jun 2026 14:22:00 -0700 Subject: [PATCH] Android: Harden Snackbar system against security notice suppression Implement structural improvements to Android's Snackbar and Exclusive Access systems to prevent security notice bypasses and UI spoofing. Design: http://shortn/_FiA9a66mBh Bug: 517091927, 503787232, 514072194 Change-Id: Ie73529d96023e60fcaa9fb9ea28fafd73a8adae1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7896882 Reviewed-by: Jinsuk Kim <[email protected]> Reviewed-by: Foromo Daniel Soromou <[email protected]> Commit-Queue: Vikram Pasupathy <[email protected]> Reviewed-by: Avi Drissman <[email protected]> Reviewed-by: Muyao Xu <[email protected]> Reviewed-by: Sirisha Kavuluru <[email protected]> Cr-Commit-Position: refs/heads/main@{#1645607} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.java b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.java index d16bae42..d308fbca 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.java @@ -102,6 +102,7 @@ String action = mResources.getString(R.string.ok); return Snackbar.make(title, mSnackbarController, type, code) .setAction(action, null) + .setHighPriority(true) .setDefaultLines(false); } diff --git a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.java b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.java index 4e7f159..37cf26c 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.java @@ -61,6 +61,7 @@ return Snackbar.make(title, controller, type, code) .setAction(action, null) + .setHighPriority(true) .setDuration(DURATION_MS) .setDefaultLines(false); } diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java b/chrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java index bc2646bf..5509167 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java @@ -18,6 +18,8 @@ */ @NullMarked public class ExclusiveAccessBubble { + private static final int EXCLUSIVE_ACCESS_SNACKBAR_DURATION_MS = 3800; + private final ExclusiveAccessContext mParentContext; private @Nullable Snackbar mSnackbar; private final SnackbarManager.SnackbarController mSnackbarController = @@ -52,7 +54,10 @@ Snackbar.UMA_EXCLUSIVE_ACCESS_BUBBLE) // The exclusive access notice is security-critical and should not // be discarded by the timeout of other action snackbars in the queue. - .setHighPriority(true); + .setHighPriority(true) + // Use a Java-side timeout so that the timer only starts when + // the notice is actually visible to the user. + .setDuration(EXCLUSIVE_ACCESS_SNACKBAR_DURATION_MS); snackbarManager.showSnackbar(mSnackbar); } } @@ -60,7 +65,23 @@ @CalledByNative public void update(String text) { if (mText != null && mText.equals(text) && mSnackbar != null) return; + SnackbarManager snackbarManager = mParentContext.getSnackbarManager(); + if (snackbarManager == null) return; + mText = text; + if (mSnackbar != null) { + mSnackbar = + Snackbar.make( + text, + mSnackbarController, + Snackbar.TYPE_ACTION, + Snackbar.UMA_EXCLUSIVE_ACCESS_BUBBLE) + .setHighPriority(true) + .setDuration(EXCLUSIVE_ACCESS_SNACKBAR_DURATION_MS); + // This will trigger SnackbarManager.updateView() and update the existing view. + snackbarManager.showSnackbar(mSnackbar); + return; + } hide(); show(); } diff --git a/chrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.java b/chrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.java index 05d29df..ca887f1 100644 --- a/chrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.java +++ b/chrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.java @@ -41,6 +41,7 @@ Snackbar.TYPE_PERSISTENT, Snackbar.UMA_PRIVACY_SANDBOX_PAGE_OPEN) .setAction(mContext.getString(R.string.more), null) + .setHighPriority(true) .setDefaultLines(false)); } diff --git a/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.cc b/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.cc index 3ffe446..dccce5e6 100644 --- a/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.cc +++ b/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.cc @@ -96,12 +96,14 @@ } ExclusiveAccessBubbleAndroid::~ExclusiveAccessBubbleAndroid() { - Hide(); + HideImmediately(); } void ExclusiveAccessBubbleAndroid::Hide() { - was_shown_ = false; - bridge_->Hide(); + // On Android, the exclusive access notice is managed by a Java-side timer + // that only starts once the snackbar is visible. We ignore the native + // timer dismissal here to prevent the notice from being hidden while + // it is covered by other UI elements. } void ExclusiveAccessBubbleAndroid::Show() { @@ -110,7 +112,8 @@ } void ExclusiveAccessBubbleAndroid::HideImmediately() { - Hide(); + was_shown_ = false; + bridge_->Hide(); } void ExclusiveAccessBubbleAndroid::Update( diff --git a/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc b/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc index 61b736fa..312462a 100644 --- a/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc +++ b/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc @@ -22,8 +22,15 @@ j_activity_tab_provider)); } +ExclusiveAccessContextAndroid::ExclusiveAccessContextAndroid() = default; + ExclusiveAccessContextAndroid::~ExclusiveAccessContextAndroid() = default; +void ExclusiveAccessContextAndroid::SetBubbleForTesting( // IN-TEST + std::unique_ptr<ExclusiveAccessBubbleAndroid> bubble) { + exclusive_access_bubble_ = std::move(bubble); +} + void ExclusiveAccessContextAndroid::Destroy(JNIEnv* env) { Java_ExclusiveAccessContext_destroy(env, java_context_); } @@ -119,6 +126,7 @@ void ExclusiveAccessContextAndroid::DestroyAnyExclusiveAccessBubble() { exclusive_access_bubble_.reset(); exclusive_access_bubble_destruction_task_id_.reset(); + snooze_reset_count_ = 0; } bool ExclusiveAccessContextAndroid::IsExclusiveAccessBubbleDisplayed() const { @@ -130,7 +138,20 @@ // re-snooze the bubble's hide timer or re-show it if the snooze period has // elapsed. if (exclusive_access_bubble_) { - exclusive_access_bubble_->OnUserInput(); + // Prevent a malicious site from indefinitely suppressing the security + // notice by repeatedly resetting the snooze timer via interaction. + // If the user has interacted many times, we force a re-show regardless. + if (++snooze_reset_count_ >= kMaxSnoozeResets) { + snooze_reset_count_ = 0; + ExclusiveAccessBubbleParams force_params = + exclusive_access_bubble_->params(); + // Set force_update to true to bypass the was_shown_ check and force + // the notice to re-show. + force_params.force_update = true; + exclusive_access_bubble_->Update(force_params, base::NullCallback()); + } else { + exclusive_access_bubble_->OnUserInput(); + } } } diff --git a/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h b/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h index 52e5dc1..c9dc89d 100644 --- a/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h +++ b/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h @@ -22,8 +22,14 @@ const jni_zero::JavaRef<jobject>& j_context, const jni_zero::JavaRef<jobject>& j_fullscreen_manager, const jni_zero::JavaRef<jobject>& j_activity_tab_provider); + // Constructor for testing, bypasses JNI creation. + ExclusiveAccessContextAndroid(); ~ExclusiveAccessContextAndroid() override; + // Injects an exclusive access bubble instance for testing. + void SetBubbleForTesting( + std::unique_ptr<ExclusiveAccessBubbleAndroid> bubble); + void Destroy(JNIEnv* env); bool IsFullscreen() const override; @@ -70,11 +76,14 @@ void ForceActiveTab(JNIEnv* env, const jni_zero::JavaRef<jobject>& j_tab); + private: + int snooze_reset_count_ = 0; + static constexpr int kMaxSnoozeResets = 10; + base::WeakPtr<ExclusiveAccessContextAndroid> GetAsWeakPtr() { return weak_ptr_factory_.GetWeakPtr(); } - private: void DestroyAnyExclusiveAccessBubble(); base::android::ScopedJavaGlobalRef<jobject> java_context_; diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h index 376bae87..6325a826 100644 --- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h +++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h @@ -24,6 +24,9 @@ // Called on user input to update timers and/or re-show the bubble. void OnUserInput(); + // Returns the parameters used to configure the bubble. + const ExclusiveAccessBubbleParams& params() const { return params_; } + // Time the bubble is shown before hiding automatically. static constexpr base::TimeDelta kShowTime = base::Milliseconds(3800); // Time without user input that must elapse before the bubble is re-shown. diff --git a/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.java b/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.java index 9099770..72c7d65 100644 --- a/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.java +++ b/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.java @@ -31,7 +31,7 @@ public class Snackbar { /** * Snackbars that are created as an immediate response to user's action. These snackbars are - * managed in a stack and will be swiped away altogether after timeout. + * managed in a stack and will be dismissed one by one. */ public static final int TYPE_ACTION = 0; @@ -205,7 +205,7 @@ /** * Sets whether the snackbar is high priority. High priority snackbars are shielded from being * discarded by the timeout of other action-type snackbars in the queue (e.g. for security- - * critical notices). + * critical notices), and they stay at the front of the queue. */ public Snackbar setHighPriority(boolean highPriority) { mIsHighPriority = highPriority; diff --git a/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java b/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java index bac0e5bb..bb05f57e 100644 --- a/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java +++ b/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java @@ -15,87 +15,148 @@ import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager.SnackbarController; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; import java.util.Iterator; +import java.util.List; /** A data structure that holds all the {@link Snackbar}s managed by {@link SnackbarManager}. */ @NullMarked class SnackbarCollection { + // Standard FIFO queue for normal notifications (e.g., "Copied to clipboard"). private final Deque<Snackbar> mSnackbars = new ArrayDeque<>(); + // Queue for persistent notifications (e.g., "Offline mode"). private final Deque<Snackbar> mPersistentSnackbars = new ArrayDeque<>(); + // Holds a single High Priority security warning (e.g., Fullscreen). + // This isolates HP snackbars from the standard queues, allowing O(1) interruption. + private @Nullable Snackbar mActiveHighPriority; - /** - * Adds a new snackbar to the collection. If the new snackbar is of - * {@link Snackbar#TYPE_ACTION} and current snackbar is of - * {@link Snackbar#TYPE_NOTIFICATION}, the current snackbar will be removed from the - * collection immediately. Snackbars of {@link Snackbar#TYPE_PERSISTENT} will appear after all - * action and notification type snackbars are dismissed and will be hidden if an action or - * notifications are added after it. - */ + private static final int MAX_SNACKBARS = 10; + void add(Snackbar snackbar) { - if (snackbar.isTypeAction()) { + RecordHistogram.recordExactLinearHistogram( + "Snackbar.QueueDepthAtInsertion", + mSnackbars.size() + + mPersistentSnackbars.size() + + (mActiveHighPriority != null ? 1 : 0), + MAX_SNACKBARS + 1); + + if (snackbar.isHighPriority()) { + // High Priority (HP) Security Warnings bypass the regular queues entirely. + if (mActiveHighPriority != null) { + // State Deduplication: Rapid oscillation attacks (e.g., spamming requestFullscreen) + // are collapsed here. If it's the exact same warning, we ignore the duplicate. + if (mActiveHighPriority.getController() == snackbar.getController() + && objectsAreEqual( + mActiveHighPriority.getActionData(), snackbar.getActionData())) { + return; // Deduplication: already have this HP snackbar. + } + // LIFO Immediate Interruption: The new HP warning instantly overwrites the old one. + RecordHistogram.recordEnumeratedHistogram( + "Snackbar.DismissalReason", + DismissalReason.REPLACED_BY_HIGH_PRIORITY, + DismissalReason.NUM_ENTRIES); + assert mActiveHighPriority.getController() != null; + if (mActiveHighPriority.getController() != null) { + mActiveHighPriority + .getController() + .onDismissNoAction(mActiveHighPriority.getActionData()); + } + } + // Assign to the VIP slot. getCurrent() will now prioritize this above all else. + mActiveHighPriority = snackbar; + return; + } + + if (snackbar.isTypePersistent()) { + assert !TextUtils.isEmpty(snackbar.getActionText()) + : "Persistent snackbars require action text."; + mPersistentSnackbars.addFirst(snackbar); + } else if (snackbar.isTypeAction()) { if (getCurrent() != null && !getCurrent().isTypeAction()) { removeCurrent(DismissalReason.REPLACED_BY_ACTION_SNACKBAR); } mSnackbars.addFirst(snackbar); - } else if (snackbar.isTypePersistent()) { - // Although persistent snackbars set their action text by default, it is possible that - // the developer overrides it. This is a safeguard to ensure all persistent snackbars - // have a method of dismissal. - assert !TextUtils.isEmpty(snackbar.getActionText()) - : "Persistent snackbars require action text."; - mPersistentSnackbars.addFirst(snackbar); } else { mSnackbars.addLast(snackbar); } + + // Enforce a hard capacity limit to prevent queue exhaustion. + // Drop the oldest unseen standard notifications from the front of the queue. + while (mSnackbars.size() > MAX_SNACKBARS) { + removeOldestUnseenSnackbar(mSnackbars); + } + while (mPersistentSnackbars.size() > MAX_SNACKBARS) { + removeOldestUnseenSnackbar(mPersistentSnackbars); + } } - /** - * Removes the current snackbar from the collection after the user has clicked on the action - * button. - */ - Snackbar removeCurrentDueToAction() { + private void removeOldestUnseenSnackbar(Deque<Snackbar> list) { + Iterator<Snackbar> iter = list.iterator(); + if (iter.hasNext() && list.peekFirst() == getCurrent()) { + iter.next(); // Skip the currently showing one + } + if (!iter.hasNext()) return; + Snackbar toRemove = iter.next(); + iter.remove(); + RecordHistogram.recordEnumeratedHistogram( + "Snackbar.DismissalReason", DismissalReason.OTHERS, DismissalReason.NUM_ENTRIES); + SnackbarController controller = toRemove.getController(); + if (controller != null) { + controller.onDismissNoAction(toRemove.getActionData()); + } + } + + boolean contains(Snackbar snackbar) { + return snackbar == mActiveHighPriority + || mSnackbars.contains(snackbar) + || mPersistentSnackbars.contains(snackbar); + } + + @Nullable Snackbar getCurrent() { + if (mActiveHighPriority != null) return mActiveHighPriority; + Snackbar actionCurrent = mSnackbars.peekFirst(); + Snackbar persistentCurrent = mPersistentSnackbars.peekFirst(); + return actionCurrent != null ? actionCurrent : persistentCurrent; + } + + @Nullable Snackbar removeCurrentDueToAction() { return removeCurrent(DismissalReason.ACTION_BUTTON); } - /** Removes the current snackbar from the collection after the user has swiped it away. */ @Nullable Snackbar removeCurrentDueToSwipe() { return removeCurrent(DismissalReason.SWIPE); } ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc b/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc
index c29f21816c..976f848 100644
--- a/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc
+++ b/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc
@@ -4,8 +4,13 @@
#include "chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.h"
+#include <memory>
+#include <string>
+#include <utility>
+
#include "base/android/jni_string.h"
#include "base/functional/callback_helpers.h"
+#include "chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h"
#include "chrome/test/base/chrome_render_view_host_test_harness.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -106,4 +111,45 @@
EXPECT_CALL(*mock_bridge_ptr, Hide()).Times(1);
}
+TEST_F(ExclusiveAccessBubbleAndroidTest, SnoozeResetForciblyReshowsNotice) {
+ ExclusiveAccessBubbleParams params;
+ params.type = EXCLUSIVE_ACCESS_BUBBLE_TYPE_FULLSCREEN_EXIT_INSTRUCTION;
+
+ auto mock_bridge = std::make_unique<MockBridge>();
+ auto* mock_bridge_ptr = mock_bridge.get();
+
+ // Initial show on creation.
+ EXPECT_CALL(*mock_bridge_ptr, IsVisible()).WillOnce(Return(false));
+ EXPECT_CALL(*mock_bridge_ptr, IsKeyboardConnected()).WillOnce(Return(false));
+ EXPECT_CALL(*mock_bridge_ptr, Update(_)).Times(1);
+ EXPECT_CALL(*mock_bridge_ptr, Show()).Times(1);
+
+ auto bubble = std::make_unique<ExclusiveAccessBubbleAndroid>(
+ params, base::DoNothing(), std::move(mock_bridge));
+
+ testing::Mock::VerifyAndClearExpectations(mock_bridge_ptr);
+
+ ExclusiveAccessContextAndroid context;
+ context.SetBubbleForTesting(std::move(bubble));
+
+ // Verify that the first 9 user inputs don't trigger any show or update on the
+ // bridge.
+ for (int i = 1; i <= 9; ++i) {
+ context.OnExclusiveAccessUserInput();
+ }
+
+ // The 10th user input exceeds the snooze interaction threshold and must
+ // forcibly re-show the security notice regardless of whether it was
+ // previously shown in this session (i.e. force_update is set to true to
+ // override the was_shown_ latch).
+ EXPECT_CALL(*mock_bridge_ptr, IsVisible()).WillOnce(Return(false));
+ EXPECT_CALL(*mock_bridge_ptr, IsKeyboardConnected()).WillOnce(Return(false));
+ EXPECT_CALL(*mock_bridge_ptr, Update(_)).Times(1);
+ EXPECT_CALL(*mock_bridge_ptr, Show()).Times(1);
+
+ context.OnExclusiveAccessUserInput();
+
+ testing::Mock::VerifyAndClearExpectations(mock_bridge_ptr);
+}
+
} // namespace
Original Bug Report
Potential bypass of Android fullscreen security notice via action snackbar queuing
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 Chrome for Android allows the security-critical fullscreen exit notice to be suppressed. If a standard action snackbar is queued within 3.8 seconds of entering fullscreen, it displaces the exclusive access notice in the UI queue. When the native 3.8-second timer expires, the covered security notice is silently deleted from the queue without ever being shown.
Affected files:
chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.javachrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.javachrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.cc
Estimated timestamp from git blame: 2026-05-19
Description and Root Cause
When Android Chrome enters fullscreen mode, it displays a security-critical exclusive access notice implemented as a high-priority snackbar (.setHighPriority(true)) to inform the user how to exit fullscreen.
There is a safety mechanism implemented in the Java-side SnackbarCollection.removeCurrentDueToTimeout() that is designed to shield high-priority snackbars from being discarded by a timeout if other standard action snackbars are active in the queue:
void removeCurrentDueToTimeout() {
...
removeCurrent(DismissalReason.TIMEOUT);
while ((current = getCurrent()) != null
&& current.isTypeAction()
&& !current.isHighPriority()) {
removeCurrent(DismissalReason.TIMEOUT);
}
}
However, this mechanism fails to account for the independent, native timer that also governs the lifetime of the exclusive access notice. In the C++ layer (chrome/browser/ui/exclusive_access/exclusive_access_bubble.cc), a 3.8-second timer controls the notification lifecycle:
static constexpr base::TimeDelta kShowTime = base::Milliseconds(3800);
base::RetainingOneShotTimer hide_timeout_;
When hide_timeout_ fires at 3.8 seconds, it calls the virtual native Hide() method, which calls Java’s ExclusiveAccessBubble.hide() over JNI:
public void hide() {
SnackbarManager snackbarManager = mParentContext.getSnackbarManager();
if (snackbarManager != null && mSnackbar != null) {
snackbarManager.dismissSnackbars(mSnackbarController);
}
mSnackbar = null;
}
This call routes through SnackbarCollection.removeMatchingSnackbars to removeSnackbarFromList():
private static boolean removeSnackbarFromList(
Deque<Snackbar> list, SnackbarController controller, ...)
Because removeSnackbarFromList() completely ignores isHighPriority(), it silently removes the high-priority entry regardless of its position in the deque and whether it is currently visible or covered.
Meanwhile, standard TYPE_ACTION snackbars (such as those from tab closures, autofill, or dynamic feature module installation failures) are added directly to the front of the queue using mSnackbars.addFirst(snackbar) without any priority checks. Since a typical TYPE_ACTION snackbar has a timeout duration of 10 seconds, the 3.8-second native timer will always fire first and prematurely delete the covered exclusive access snackbar before it ever has a chance to be displayed to the user.
Potential Trigger Scenario
(Please note: our tooling agent does not have the capability to execute code, so these are potential steps derived from static analysis of the source code.)
- Enter Fullscreen: The user interacts with an attacker-controlled page, triggering
requestFullscreen(). The high-priority exit notice is enqueued, and the nativehide_timeout_is armed for 3.8 seconds. - Enqueue Displacing Snackbar: Within 3.8 seconds, a separate
TYPE_ACTIONsnackbar is queued (e.g., a tab close undo, an autofill notification, etc.).SnackbarCollection.add()prepends this new snackbar ahead of the exclusive access notice. The UI now displays the new snackbar instead of the security notice. - Native Timer Expiration: At 3.8 seconds, the native
hide_timeout_fires, executingExclusiveAccessBubbleAndroid::Hide()which triggers Java’shide()and invokesremoveSnackbarFromList(). The hidden/covered high-priority notice is silently removed from the deque without ever being shown to the user. - Queue Clear: At the end of the second snackbar’s timeout,
removeCurrentDueToTimeout()is executed. It removes the second snackbar, but because the high-priority notice was already removed, the queue is left empty. - Persistence: The user is left in fullscreen mode with no exit prompt. Any subsequent touch events from the user will throttle or snooze re-showing the notice for 15 minutes (
snooze_until_), allowing the attacker to maintain a spoofed fullscreen environment.
Suggested Fix
Modify removeSnackbarFromList or ExclusiveAccessBubble.java to ensure that a high-priority exclusive access notice is not dismissed/deleted if it has never been displayed to the user. Alternatively, ensure that setHighPriority(true) snackbars cannot be prepended and covered by regular priority TYPE_ACTION snackbars in SnackbarCollection.add().
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.