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 | |
TEST_Fchrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc |
modified | |
forchrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc |
modified |
Files Changed
chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.javachrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.javachrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.javachrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.javachrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.ccchrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc
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 <vpasupathy@chromium.org>
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 <jinsukkim@chromium.org>
Reviewed-by: Foromo Daniel Soromou <koretadaniel@chromium.org>
Commit-Queue: Vikram Pasupathy <vpasupathy@chromium.org>
Reviewed-by: Avi Drissman <avi@chromium.org>
Reviewed-by: Muyao Xu <muyaoxu@google.com>
Reviewed-by: Sirisha Kavuluru <skavuluru@google.com>
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_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);
+}
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