CVE-2026-95337
Overview
Background
- Message banner
- Android in-product
MessagesUI shown at the top of the screen with a title, description, and actionable buttons such as the primary action button. - Tap protection
- a short interval after a banner appears or changes during which taps on its buttons are ignored, guarding against tapjacking and accidental confirmation of an action the user did not have time to read.
- `PropertyModel`
- the observable state container for a banner whose keys (
MessageBannerProperties) drive both visual rendering (viaPropertyModelChangeProcessor) and mediator behavior. - `MessageBannerMediator`
- the internal controller that arms the tap protection window and mediates position/property changes for a banner.
Root Cause Analysis
Tap protection was only armed by MessageBannerMediator when a banner moved into the Position.FRONT (foreground) slot, so the protection window covered the banner’s initial appearance but nothing afterward. The invariant that a user must be given a fresh reading interval before any actionable or user-facing state can be tapped was violated because mutating keys on an already-displayed banner’s PropertyModel (for example changing PRIMARY_BUTTON_TEXT or ON_PRIMARY_ACTION via model.set(...)) updated the rendered UI without re-arming protection. An attacker controlling banner content could therefore swap the visible action or its handler immediately before the user’s finger landed, causing a tap intended for the old content to trigger the new one.
The fix segregates MessageBannerProperties into content/action keys versus internal mechanism keys and introduces TAP_PROTECTION_RESETTING_KEYS, then has MessageBannerMediator observe property mutations (via PropertyObservable) and re-arm tap protection whenever a resetting key changes on a foreground banner. MessageBannerCoordinator now retains the PropertyModelChangeProcessor and adds a destroy() method so the added observer/processor are torn down cleanly, preventing leaks or stale callbacks.
TAP_PROTECTION_RESETTING_KEYS) instead of only on the front-position move.Attack Path
- Enqueue a banner
A caller (attacker-influenced content source) enqueues a window-scoped
Messagesbanner that becomes displayed inPosition.FRONT, arming the initial tap protection window once. - Wait out initial protection After the initial protection interval elapses, the primary button becomes tappable and the user is presented with the banner’s original, benign-looking action text.
- Mutate actionable properties just-in-time
The content source calls
model.set(...)on a resetting key such asMessageBannerProperties.PRIMARY_BUTTON_TEXTorON_PRIMARY_ACTION, changing what the button does or shows while, pre-patch, no new protection interval is armed. - Capture the tap
Because protection is not re-armed, a tap the user aims at the previously-shown content immediately activates the freshly-substituted action, confirming an action the user never actually read (a tapjacking/UI-misrepresentation outcome).
(inferred)the precise attacker-desired action depends on which handler is swapped in.
Impact Assessment
Messages UI so that a user’s tap confirms an action different from what was displayed, without any memory-safety violation; this is UI misrepresentation confined to the Android browser UI thread, requiring an on-screen banner and a user tap. The precondition is the ability to mutate a foreground banner’s actionable/visual properties after the initial tap protection interval expires. The advisory states the bug class as LogicError with impact “UI misrepresentation in Messages” and does not indicate exploitation in the wild; any escalation to a stronger primitive is not demonstrated by the patch (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerCoordinator.java |
modified | |
ifcomponents/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediator.java |
modified |
Files Changed
components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerCoordinator.javacomponents/messages/android/internal/java/src/org/chromium/components/messages/SingleActionMessageTest.javacomponents/messages/android/java/src/org/chromium/components/messages/MessageBannerProperties.javacomponents/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediator.javacomponents/messages/android/internal/java/src/org/chromium/components/messages/SingleActionMessage.javacomponents/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediatorUnitTest.javachrome/android/javatests/src/org/chromium/chrome/browser/messages/MessageTest.java
Audit Directions
- Protection armed on transitions, not stateLook for other UI guard windows (tap protection, focus/confirm delays, obscured-view checks) that arm on a lifecycle/position event but ignore subsequent in-place property mutations.
- Mutable actionable UI stateAudit any
PropertyModel-backed component where handlers or labels (ON_PRIMARY_ACTION, button/text keys) can be reset on a live view without revalidating anti-tapjacking safeguards. - Observer lifecycle gapsReview coordinators that create a
PropertyModelChangeProcessoror observer without retaining anddestroy()-ing it, which both leaks the newly added re-arming observer and can leave stale callbacks on a dismissed banner.
Patch
From c895485c725700bad5959ce6076471e0b80cae63 Mon Sep 17 00:00:00 2001
From: Aishwarya Rajesh <aishwaryarj@google.com>
Date: Thu, 27 Aug 2026 22:59:04 -0700
Subject: [PATCH] [Messages] Reset tap protection on banner property mutations
Tap protection on Android message banners was previously only armed
when moving to the front position. Mutating actionable or visual
properties on an already-displayed banner did not reset the protection
window, allowing rapid property changes right before user interaction.
This CL:
- Segregates MessageBannerProperties into content/action keys and
internal mechanism keys.
- Observes property mutations in MessageBannerMediator and re-arms
tap protection whenever user-facing or actionable properties change
on a foreground banner.
Bug: 514019137
Change-Id: I75c932a0402d6260d9ce8bc567478857e49adca1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8301994
Reviewed-by: Charles Hager <clhager@google.com>
Commit-Queue: Aishwarya Rajesh <aishwaryarj@google.com>
Cr-Commit-Position: refs/heads/main@{#1687787}
---
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/messages/MessageTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/messages/MessageTest.java
index 32377330..1d970f8 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/messages/MessageTest.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/messages/MessageTest.java
@@ -223,6 +223,63 @@
Assert.assertEquals("Not clickable with tap protection period", 0, helper.getCallCount());
mFakeTimeTestRule.advanceMillis(1500);
onView(withId(R.id.message_primary_button)).perform(click());
- helper.waitForNext("Should able to click when tap protection period ends");
+ helper.waitForNext("Should be able to click when tap protection period ends");
+ }
+
+ /** Test that mutating message properties resets the tap protection period. */
+ @Test
+ @SmallTest
+ public void testTapProtection_PropertyMutation() throws TimeoutException {
+ MessagesTestHelper.enableTapProtectionDuration(500);
+ CallbackHelper helper = new CallbackHelper();
+ PropertyModel model =
+ ThreadUtils.runOnUiThreadBlocking(
+ () ->
+ new PropertyModel.Builder(MessageBannerProperties.ALL_KEYS)
+ .with(MessageBannerProperties.TITLE, "Test title")
+ .with(MessageBannerProperties.PRIMARY_BUTTON_TEXT, "Action")
+ .with(
+ MessageBannerProperties.ON_DISMISSED,
+ CallbackUtils.emptyCallback())
+ .with(
+ MessageBannerProperties.ON_PRIMARY_ACTION,
+ () -> {
+ helper.notifyCalled();
+ return PrimaryActionClickBehavior
+ .DO_NOT_DISMISS;
+ })
+ .build());
+ ThreadUtils.runOnUiThreadBlocking(
+ () -> {
+ mMessageDispatcher.enqueueWindowScopedMessage(model, true);
+ });
+ onView(withId(R.id.message_primary_button)).check(matches(isDisplayed()));
+
+ // Wait for initial tap protection period to end.
+ mFakeTimeTestRule.advanceMillis(1500);
+ onView(withId(R.id.message_primary_button)).perform(click());
+ helper.waitForNext("Should be able to click when initial tap protection period ends");
+ Assert.assertEquals(1, helper.getCallCount());
+
+ // Mutating property should reset tap protection.
+ ThreadUtils.runOnUiThreadBlocking(
+ () -> model.set(MessageBannerProperties.PRIMARY_BUTTON_TEXT, "New Action"));
+ onView(withId(R.id.message_primary_button)).perform(click());
+ Assert.assertEquals(
+ "Not clickable immediately after property mutation", 1, helper.getCallCount());
+
+ // Still within tap protection period.
+ mFakeTimeTestRule.advanceMillis(450);
+ onView(withId(R.id.message_primary_button)).perform(click());
+ Assert.assertEquals(
+ "Not clickable within tap protection period after property mutation",
+ 1,
+ helper.getCallCount());
+
+ // Advance time past tap protection.
+ mFakeTimeTestRule.advanceMillis(1500);
+ onView(withId(R.id.message_primary_button)).perform(click());
+ helper.waitForNext("Should be able to click after post-mutation tap protection ends");
+ Assert.assertEquals(2, helper.getCallCount());
}
}
diff --git a/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerCoordinator.java b/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerCoordinator.java
index d4e1185..b93eabf 100644
--- a/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerCoordinator.java
+++ b/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerCoordinator.java
@@ -31,6 +31,7 @@
private final MessageBannerView mView;
private final View mParentView;
private final PropertyModel mModel;
+ private final PropertyModelChangeProcessor mModelChangeProcessor;
private final RunnableTimer mTimer;
private final Supplier<Long> mAutodismissDurationMs;
private final Runnable mOnTimeUp;
@@ -68,7 +69,8 @@
mView = view;
mParentView = parentView;
mModel = model;
- PropertyModelChangeProcessor.create(model, view, MessageBannerViewBinder::bind);
+ mModelChangeProcessor =
+ PropertyModelChangeProcessor.create(model, view, MessageBannerViewBinder::bind);
mMediator =
new MessageBannerMediator(
model,
@@ -215,6 +217,11 @@
mMediator.setOnTouchRunnable(runnable);
}
+ void destroy() {
+ mMediator.destroy();
+ mModelChangeProcessor.destroy();
+ }
+
private void updateAccessibilityPane(int toIndex) {
String msg = "";
if (toIndex == Position.FRONT) {
diff --git a/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediator.java b/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediator.java
index 336fd04..75f725d 100644
--- a/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediator.java
+++ b/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediator.java
@@ -10,6 +10,7 @@
import static org.chromium.components.messages.MessageBannerProperties.ENABLE_CLOSE_BUTTON;
import static org.chromium.components.messages.MessageBannerProperties.IS_WITHIN_TAP_PROTECTION_PERIOD_SUPPLIER;
import static org.chromium.components.messages.MessageBannerProperties.MARGIN_TOP;
+import static org.chromium.components.messages.MessageBannerProperties.TAP_PROTECTION_RESETTING_KEYS;
import static org.chromium.components.messages.MessageBannerProperties.TRANSLATION_X;
import static org.chromium.components.messages.MessageBannerProperties.TRANSLATION_Y;
import static org.chromium.components.messages.MessageBannerProperties.VISUAL_HEIGHT;
@@ -34,14 +35,17 @@
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.SwipeHandler;
import org.chromium.components.messages.MessageStateHandler.Position;
import org.chromium.ui.interpolators.Interpolators;
+import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModel.WritableFloatPropertyKey;
import org.chromium.ui.modelutil.PropertyModelAnimatorFactory;
+import org.chromium.ui.modelutil.PropertyObservable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
import java.util.function.Supplier;
/** Mediator responsible for the business logic in a message banner. */
@@ -61,6 +65,9 @@
int GESTURE = 3;
}
+ private static final Set<PropertyKey> sTapProtectionResettingKeys =
+ Set.of(TAP_PROTECTION_RESETTING_KEYS);
+
private static final long TAP_PROTECTION_DURATION_MS = 500;
private static final int ENTER_DURATION_MS = 550;
@@ -83,12 +90,16 @@
private final SwipeAnimationHandler mSwipeAnimationHandler;
private final int mPeekingMarginTop;
private final int mDefaultMarginTop;
+ private final PropertyObservable.PropertyObserver<PropertyKey> mPropertyObserver =
+ this::onPropertyChanged;
private @Nullable Animator mAnimation;
@State private int mCurrentState = State.HIDDEN;
@ScrollDirection private int mSwipeDirection;
private float mSwipeStartTranslation;
private boolean mDidFling;
+ private long mTapProtectionEndTimestamp;
+ private boolean mInFront;
/** Constructs the message banner mediator. */
MessageBannerMediator(
@@ -112,6 +123,7 @@
mPeekingMarginTop =
resources.getDimensionPixelSize(R.dimen.message_peeking_layer_height)
+ mDefaultMarginTop;
+ mModel.addObserver(mPropertyObserver);
}
/**
@@ -141,19 +153,19 @@
mModel.set(TRANSLATION_Y, mModel.get(MARGIN_TOP) - mDefaultMarginTop);
mModel.set(MARGIN_TOP, mDefaultMarginTop);
}
- if (toIndex == Position.FRONT
- && (!BuildConfig.IS_FOR_TEST || sTapProtectionDurationMsForTesting > 0)) {
- long startTimestamp = TimeUtils.elapsedRealtimeMillis();
- long protectionDuration =
- sTapProtectionDurationMsForTesting > 0
- ? sTapProtectionDurationMsForTesting
- : TAP_PROTECTION_DURATION_MS;
+ mInFront = toIndex == Position.FRONT;
+ long protectionDuration = getTapProtectionDuration();
+ if (mInFront && protectionDuration > 0) {
+ // The banner takes ENTER_DURATION_MS to animate in and becomes sufficiently visible
+ // around the halfway point (~275ms). Add half the entrance animation duration so that
+ // tap protection lasts the full protection duration once the banner is visible.
+ mTapProtectionEndTimestamp =
+ TimeUtils.elapsedRealtimeMillis() + ENTER_DURATION_MS / 2 + protectionDuration;
mModel.set(
IS_WITHIN_TAP_PROTECTION_PERIOD_SUPPLIER,
- () -> {
- return TimeUtils.elapsedRealtimeMillis()
- < ENTER_DURATION_MS / 2 + protectionDuration + startTimestamp;
- });
+ () -> TimeUtils.elapsedRealtimeMillis() < mTapProtectionEndTimestamp);
+ } else if (!mInFront) {
+ mTapProtectionEndTimestamp = 0;
}
mModel.set(
ENABLE_CLOSE_BUTTON,
@@ -182,6 +194,10 @@
boolean animate,
Runnable messageHidden) {
cancelAnyAnimations();
+ mInFront = toIndex == Position.FRONT;
+ if (!mInFront) {
+ mTapProtectionEndTimestamp = 0;
+ }
float translateTo = toIndex == Position.FRONT ? 0 : -mTopOffsetSupplier.get();
if (!animate) {
mModel.set(CONTENT_ALPHA, 0.f);
@@ -201,6 +217,10 @@
mModel.set(MessageBannerProperties.ON_TOUCH_RUNNABLE, runnable);
}
+ void destroy() {
+ mModel.removeObserver(mPropertyObserver);
+ }
+
// region SwipeHandler implementation
// ---------------------------------------------------------------------------------------------
@@ -422,6 +442,35 @@
return mModel.get(TRANSLATION_Y) == 0.f && mModel.get(TRANSLATION_X) == 0.f;
}
+ private void onPropertyChanged(
+ PropertyObservable<PropertyKey> source, @Nullable PropertyKey propertyKey) {
+ // A null propertyKey indicates a bulk update where multiple properties changed at once.
+ // Re-arm tap protection for safety on bulk updates as well as key-specific mutations.
+ if (!mInFront
+ || (propertyKey != null && !sTapProtectionResettingKeys.contains(propertyKey))) {
+ return;
+ }
+
+ long protectionDuration = getTapProtectionDuration();
+ if (protectionDuration <= 0) {
+ return;
+ }
+
+ mTapProtectionEndTimestamp =
+ Math.max(
+ mTapProtectionEndTimestamp,
+ TimeUtils.elapsedRealtimeMillis() + protectionDuration);
+ }
+
+ private long getTapProtectionDuration() {
+ if (BuildConfig.IS_FOR_TEST && sTapProtectionDurationMsForTesting <= 0) {
+ return 0;
+ }
+ return sTapProtectionDurationMsForTesting > 0
+ ? sTapProtectionDurationMsForTesting
+ : TAP_PROTECTION_DURATION_MS;
+ }
+
/** Set duration of tap protection period. */
static void setTapProtectionDurationMsForTesting(long duration) {
sTapProtectionDurationMsForTesting = duration;
diff --git a/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediatorUnitTest.java b/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediatorUnitTest.java
index ecaa69d6..4969719 100644
--- a/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediatorUnitTest.java
+++ b/components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediatorUnitTest.java
@@ -7,6 +7,9 @@
import static android.os.Looper.getMainLooper;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
@@ -28,14 +31,18 @@
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
+import org.robolectric.shadows.ShadowSystemClock;
import org.chromium.base.MathUtils;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.Features;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection;
import org.chromium.components.messages.MessageStateHandler.Position;
+import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
+import java.time.Duration;
+import java.util.Set;
import java.util.function.Supplier;
/** Unit tests for {@link MessageBannerMediator}. */
@@ -561,6 +568,177 @@
verify(mDismissedRunnable, times(1)).run();
}
+ @Test
+ public void testTapProtection_ShowFront() {
+ MessageBannerMediator.setTapProtectionDurationMsForTesting(500);
+ mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable);
+
+ Supplier<Boolean> supplier =
+ mModel.get(MessageBannerProperties.IS_WITHIN_TAP_PROTECTION_PERIOD_SUPPLIER);
+ assertNotNull("Tap protection supplier should be set.", supplier);
+ assertTrue("Tap protection should be active upon show.", supplier.get());
+
+ // Advance past enter animation (275ms) + tap protection (500ms) = 775ms.
+ ShadowSystemClock.advanceBy(Duration.ofMillis(800));
+ assertFalse("Tap protection should end after duration expires.", supplier.get());
+ }
+
+ @Test
+ public void testTapProtection_PropertyMutationResetsTapProtection() {
+ MessageBannerMediator.setTapProtectionDurationMsForTesting(500);
+ mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable);
+
+ Supplier<Boolean> supplier =
+ mModel.get(MessageBannerProperties.IS_WITHIN_TAP_PROTECTION_PERIOD_SUPPLIER);
+ assertNotNull("Tap protection supplier should be set.", supplier);
+
+ // Advance time so initial tap protection expires.
+ ShadowSystemClock.advanceBy(Duration.ofSeconds(1));
+ assertFalse("Tap protection should have expired.", supplier.get());
+
+ // Mutating primary button text should restart tap protection.
+ mModel.set(MessageBannerProperties.PRIMARY_BUTTON_TEXT, "New Action");
+ assertTrue("Tap protection should be reactivated on button text change.", supplier.get());
+
+ // Advance time partially (300ms < 500ms).
+ ShadowSystemClock.advanceBy(Duration.ofMillis(300));
+ assertTrue("Tap protection should still be active within 500ms window.", supplier.get());
+
+ // Advance time past 500ms.
+ ShadowSystemClock.advanceBy(Duration.ofMillis(300));
+ assertFalse("Tap protection should expire after 500ms from mutation.", supplier.get());
+ }
+
+ @Test
+ public void testTapProtection_ActionAndAppearanceMutationsResetTapProtection() {
+ MessageBannerMediator.setTapProtectionDurationMsForTesting(500);
+ mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable);
+
+ Supplier<Boolean> supplier =
+ mModel.get(MessageBannerProperties.IS_WITHIN_TAP_PROTECTION_PERIOD_SUPPLIER);
+ assertNotNull("Tap protection supplier should be set.", supplier);
+
+ ShadowSystemClock.advanceBy(Duration.ofSeconds(1));
+ assertFalse("Tap protection should have expired.", supplier.get());
+
+ // Mutating title should restart tap protection.
+ mModel.set(MessageBannerProperties.TITLE, "New Title");
+ assertTrue("Tap protection should be reactivated on title change.", supplier.get());
+
+ ShadowSystemClock.advanceBy(Duration.ofMillis(600));
+ assertFalse("Tap protection should have expired.", supplier.get());
+
+ // Mutating appearance should restart tap protection.
+ mModel.set(
+ MessageBannerProperties.PRIMARY_WIDGET_APPEARANCE,
+ PrimaryWidgetAppearance.PROGRESS_SPINNER);
+ assertTrue("Tap protection should be reactivated on appearance change.", supplier.get());
+
+ ShadowSystemClock.advanceBy(Duration.ofMillis(600));
+ assertFalse("Tap protection should have expired.", supplier.get());
+
+ // Mutating primary action callback should restart tap protection.
+ mModel.set(
+ MessageBannerProperties.ON_PRIMARY_ACTION,
+ () -> PrimaryActionClickBehavior.DISMISS_IMMEDIATELY);
+ assertTrue(
+ "Tap protection should be reactivated on primary action change.", supplier.get());
+ }
+
+ @Test
+ public void testTapProtection_NonFrontPositionDoesNotActivateTapProtection() {
+ MessageBannerMediator.setTapProtectionDurationMsForTesting(500);
+ mMediator.show(Position.FRONT, Position.BACK, 0, mShownRunnable);
+
... (file diff truncated)
Original Bug Report
Potential tap-protection bypass on Android Message banner property mutation
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: The Android Messages framework fails to re-arm tap protection when a banner’s title or description is updated in-place. This allows a malicious renderer to swap security-sensitive content and action targets in an existing banner after the initial safety window has expired, potentially leading to clickjacking.
Affected files:
components/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerMediator.javacomponents/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerCoordinator.javacomponents/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerView.javacomponents/messages/android/internal/java/src/org/chromium/components/messages/MessageBannerViewBinder.javachrome/browser/android/framebust_intervention/framebust_blocked_delegate_android.cccomponents/blocked_content/android/popup_blocked_message_delegate.cccomponents/messages/android/message_wrapper.cc
Estimated timestamp from git blame: 2025-04-03
Summary
The Android Messages framework (the UI component used for banners like ‘Popup blocked’ or ‘Redirect blocked’) implements a tap-protection mechanism to prevent clickjacking. This mechanism ignores touch events for a short period (~775ms) after a banner is displayed. However, this protection appears to be armed only once when the banner is initially shown. If the banner’s content (Title or Description) is updated while it is already visible, the tap protection is not re-armed, allowing a potential swap of the action target just as a user is about to tap.
Technical Details
In MessageBannerMediator.java, the IS_WITHIN_TAP_PROTECTION_PERIOD_SUPPLIER is set exactly once in the show() method when the banner reaches Position.FRONT. It captures a startTimestamp which is never updated during the banner’s lifetime:
// MessageBannerMediator.java
if (toIndex == Position.FRONT) {
long startTimestamp = TimeUtils.elapsedRealtimeMillis();
// ...
mModel.set(IS_WITHIN_TAP_PROTECTION_PERIOD_SUPPLIER, () -> {
return TimeUtils.elapsedRealtimeMillis() < ENTER_DURATION_MS / 2 + protectionDuration + startTimestamp;
});
}
The FramebustBlockedMessageDelegate in the browser process explicitly supports updating an existing banner’s description when a new navigation is blocked:
// framebust_blocked_delegate_android.cc
if (message_ != nullptr) {
blocked_url_ = blocked_url;
message_->SetDescription(url_formatter::FormatUrlForSecurityDisplay(blocked_url, ...));
return false;
}
When SetDescription is called, the PropertyModel updates the MessageBannerView, but the MessageBannerMediator (which manages the tap protection logic) is not notified to refresh the startTimestamp. Consequently, the safety window is not reset for the new content.
Potential Exploitation Scenario
- A malicious site triggers a framebust intervention to a benign URL (e.g.,
https://example.com) to show the banner. - The attacker waits for the initial tap protection period (> 775ms) to expire.
- The attacker triggers a second framebust intervention to a malicious URL.
- The banner’s description is updated to the malicious URL in-place. Because tap protection is not re-armed, the banner remains in a state where touch events are accepted immediately.
- If the user taps the ‘Always allow’ button to permit the original benign redirect, the action is performed against the newly swapped malicious URL without the intended safety delay.
Note: These steps are based on a code analysis; a functional proof-of-concept has not yet been verified.
Impact
This is a Time-of-Check to Time-of-Use (TOCTOU) logic flaw that enables clickjacking. A compromised or malicious renderer can programmatically swap the displayed information and the resulting action target of a banner after the user has already decided to interact with it, leading to unintended user consent for sensitive actions like navigation.
Suggested Fix
The Messages framework should re-arm the tap protection supplier whenever security-sensitive properties (Title, Description, or Primary Button Text) are mutated. This could be achieved by having the MessageBannerMediator observe property changes in the PropertyModel and refresh the startTimestamp when these specific keys are updated.
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.