Medium watch CVSS 2.9 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
2.9
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in CustomTabs
DescriptionObservable discrepancy in CustomTabs
ComponentCustomTabs
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker517095594
Fix commitb22685a26a92 (chromium/src) +126/-9
CWECWE-203
CVSS vectorCVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-27 (per vendor advisory)
Fix landed2026-07-13 (public git)
Disclosed2026-08-25
Patch gap43 days — fix public in source before the advisory

Background

Custom Tabs Engagement Signals
On Android, RealtimeEngagementSignalObserver forwards user-engagement events from a Custom Tab, such as onVerticalScrollEvent and onGreatestScrollPercentageIncreased, to the third-party app that launched the tab through mEngagementSignalsCallback.
`mSignalsPaused`
a flag in RealtimeEngagementSignalObserver that stops scroll signals from being sent while the current page was opened at a URL fragment. The browser may auto-scroll such a page, so the scroll position can reveal something about the page’s content.
Text fragments and element fragments
A URL fragment like #:~:text=... (a text fragment) or #target (an element id) makes the browser scroll to matching content. How far it scrolls therefore depends on whether, and where, that text or element exists on a page the launching app cannot otherwise read.
Visible vs. last committed URL; same-document navigation
getVisibleUrl() can show a pending navigation’s URL, while getLastCommittedUrl() is the page actually displayed. A same-document navigation, such as a fragment change or history.pushState, changes the URL without loading a new document or resetting its scroll state.

Root Cause Analysis

RealtimeEngagementSignalObserver is meant to stop scroll engagement signals while the displayed page was opened at a fragment, so the launching app cannot tell from scroll data whether the fragment matched. The pre-patch logic had three overlapping gaps, matching the three bug IDs the commit fixes. First, the initial pause decision in the tab-attach code (the enclosing method is not named in the hunk) used LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl()). If a pending navigation without a fragment was visible, the signals stayed unpaused even though the committed page had one. Second, didFinishNavigationInPrimaryMainFrame recomputed mSignalsPaused on every committed navigation, including same-document ones. A same-document navigation to a URL without a text fragment therefore un-paused signals on a page that had already scrolled to a fragment. Third, only text fragments counted, so element fragments like #target, which also auto-scroll, were never paused.

The fix makes the check stricter in all three ways. It reads getLastCommittedUrl() at attach time, ignores same-document navigations (!navigationHandle.isSameDocument()), and pauses whenever the URL has any ref (!getRef().isEmpty()), not only a :~:text= directive.

Key insight
The pause that prevents scroll-based leaks was keyed to the wrong URL (the visible one rather than the committed one), was cleared by same-document navigations, and covered only text fragments. The patch keys it to the committed document’s URL, keeps it in place across same-document navigations, and pauses for any fragment.

Attack Path

  1. App launches a Custom Tab with engagement signals A third-party Android app that receives Custom Tabs engagement signals opens a cross-site URL with a fragment, such as #:~:text=... or #someid, that probes for content on the target page (inferred attacker model).
  2. Browser auto-scrolls on match If the text or element exists, Chrome scrolls the page to it. The scroll position now depends on content the app cannot read directly.
  3. Pause bypassed Before the fix, signals stayed active in three cases: the fragment was an element id rather than a text fragment, the visible URL at attach time differed from the committed URL, or a later same-document navigation (for example, one triggered by the page) cleared mSignalsPaused.
  4. Observe scroll signals When the user scrolls, the app gets onVerticalScrollEvent and onGreatestScrollPercentageIncreased. From their values it may infer whether the fragment matched, and so learn something about cross-site page content (inferred).

Impact Assessment

The bug is an observable-discrepancy information leak, which the advisory calls “Observable discrepancy in CustomTabs”, from the Chrome browser process on Android to the third-party app that launched the Custom Tab. That app may learn whether particular text or element ids exist on a page the user can see, such as an authenticated page. It requires Android Chrome, a malicious or curious app using Custom Tabs with engagement signals enabled for its session, and user scrolling for signals to be produced. Web content alone cannot exploit it. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 2.9. The exact amount of content that leaks is (inferred).

Changed Functions

FunctionChangeNotes
if
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
modified

Files Changed

  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
  • chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java

Audit Directions

  • Visible vs. committed URL in security decisions
    Search Android customtabs/ and other embedder-facing observers for getVisibleUrl() used to make privacy or security decisions. A pending navigation’s URL can differ from the document whose data is being exposed.
  • State reset on same-document navigation
    Review didFinishNavigation* handlers that reset protective flags without checking isSameDocument(). Pages control same-document navigations through fragments and history.pushState, which can wipe the protective state.
  • Scroll/layout side channels to embedders
    Check other APIs that expose scroll depth, viewport or timing to a cross-origin embedder or launching app, such as Custom Tabs callbacks, PostMessage channels and partial custom tab resize events. Confirm they are suppressed for every scroll-to-fragment mechanism (:~:text=, element ids, Scroll To Text) and not just one.
From b22685a26a92e6c90d171ef30cf5d04fd206b192 Mon Sep 17 00:00:00 2001
From: Mohamed Adel <[email protected]>
Date: Mon, 13 Jul 2026 15:31:06 -0700
Subject: [PATCH] Fix overlapping Text Fragment engagement signal bugs

TAG=agy

Bug: 517095594, 517910756, 517772510
Fixed: 517095594, 517910756, 517772510
Change-Id: Id81737d68897243b5a0772fad250a916a6ad5650
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8087069
Reviewed-by: Sinan Sahin <[email protected]>
Commit-Queue: Moe Adel <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1661423}
---
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
index 547ec81..6deb245 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
@@ -29,7 +29,6 @@
 import org.chromium.chrome.browser.customtabs.features.TabInteractionRecorder;
 import org.chromium.chrome.browser.flags.ChromeFeatureList;
 import org.chromium.chrome.browser.privacy.settings.PrivacyPreferencesManagerImpl;
-import org.chromium.chrome.browser.share.link_to_text.LinkToTextHelper;
 import org.chromium.chrome.browser.tab.Tab;
 import org.chromium.chrome.browser.tab.TabHidingType;
 import org.chromium.content_public.browser.GestureListenerManager;
@@ -218,7 +217,8 @@
         mScrollState = ScrollState.from(tab);
 
         if (mWebContents != null) {
-            mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl());
+            // Pause if the URL has a fragment, i.e. portion that starts with #.
+            mSignalsPaused = !mWebContents.getLastCommittedUrl().getRef().isEmpty();
         }
 
         mGestureStateListener =
@@ -294,9 +294,8 @@
                     @Override
                     public void didFinishNavigationInPrimaryMainFrame(
                             NavigationHandle navigationHandle) {
-                        if (navigationHandle.hasCommitted()) {
-                            mSignalsPaused =
-                                    LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
+                        if (navigationHandle.hasCommitted() && !navigationHandle.isSameDocument()) {
+                            mSignalsPaused = !navigationHandle.getUrl().getRef().isEmpty();
                         }
                     }
                 };
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java
index 83cf2741..f065b91 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java
@@ -822,6 +822,112 @@
     }
 
     @Test
+    @SuppressWarnings("DirectInvocationOnMock")
+    public void pauseSignalsWhenLastCommittedUrlHasTextFragment() {
+        Tab initialTab = env.prepareTab();
+        // The visible URL reflects a pending navigation without a text fragment, but the last
+        // committed page still has one.
+        when(env.webContents.getVisibleUrl()).thenReturn(JUnitTestGURLs.HTTP_URL);
+        when(env.webContents.getLastCommittedUrl()).thenReturn(JUnitTestGURLs.TEXT_FRAGMENT_URL);
+        doAnswer(
+                        invocation -> {
+                            CustomTabTabObserver observer = invocation.getArgument(0);
+                            initialTab.addObserver(observer);
+                            observer.onAttachedToInitialTab(initialTab);
+                            return null;
+                        })
+                .when(env.tabObserverRegistrar)
+                .registerActivityTabObserver(any());
+        mEngagementSignalObserver =
+                new RealtimeEngagementSignalObserver(
+                        env.tabObserverRegistrar,
+                        env.session.getSessionAsCustomTab(),
+                        mEngagementSignalsCallback,
+                        /* hadScrollDown= */ false);
+        env.tabProvider.setInitialTab(initialTab, TabCreationMode.DEFAULT);
+
+        GestureStateListener listener = captureGestureStateListener();
+
+        // Do a scroll.
+        listener.onScrollStarted(0, SCROLL_EXTENT, false);
+        when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(50);
+        listener.onScrollOffsetOrExtentChanged(50, SCROLL_EXTENT);
+        listener.onScrollEnded(50, SCROLL_EXTENT);
+        RobolectricUtil.runAllBackgroundAndUi();
+        // We shouldn't get scroll signals because the committed page has a text fragment.
+        verify(mEngagementSignalsCallback, never())
+                .onVerticalScrollEvent(anyBoolean(), any(Bundle.class));
+        verify(mEngagementSignalsCallback, never())
+                .onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class));
+    }
+
+    @Test
+    public void keepSignalsPausedAfterSameDocumentNavigation() {
+        initializeTabForTest();
+        GestureStateListener listener = captureGestureStateListener(ON_SCROLL_END);
+        WebContentsObserver webContentsObserver = captureWebContentsObserver();
+
+        // Navigate to a URL with text fragment.
+        var navigationHandle = createNavigationHandle(JUnitTestGURLs.TEXT_FRAGMENT_URL);
+        webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle);
+
+        // Same-document navigation to a URL with no text fragment.
+        var navigationHandle2 =
+                createNavigationHandle(JUnitTestGURLs.HTTP_URL, /* isSameDocument= */ true);
+        webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle2);
+
+        // Do a scroll.
+        listener.onScrollStarted(0, SCROLL_EXTENT, false);
+        when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(50);
+        listener.onScrollOffsetOrExtentChanged(50, SCROLL_EXTENT);
+        listener.onScrollEnded(50, SCROLL_EXTENT);
+        RobolectricUtil.runAllBackgroundAndUi();
+        // We shouldn't get scroll signals.
+        verify(mEngagementSignalsCallback, never())
+                .onVerticalScrollEvent(anyBoolean(), any(Bundle.class));
+        verify(mEngagementSignalsCallback, never())
+                .onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class));
+    }
+
+    @Test
+    public void pauseAndUnpauseSignalsOnPageWithFragment() {
+        initializeTabForTest();
+        GestureStateListener listener = captureGestureStateListener(ON_SCROLL_END);
+        WebContentsObserver webContentsObserver = captureWebContentsObserver();
+
+        // Navigate to a URL with an element fragment.
+        var navigationHandle = createNavigationHandle(new GURL("https://www.example.com/#target"));
+        webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle);
+
+        // Do a scroll.
+        listener.onScrollStarted(0, SCROLL_EXTENT, false);
+        when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(24);
+        listener.onScrollOffsetOrExtentChanged(24, SCROLL_EXTENT);
+        listener.onScrollEnded(24, SCROLL_EXTENT);
+        RobolectricUtil.runAllBackgroundAndUi();
+        // We shouldn't get scroll signals.
+        verify(mEngagementSignalsCallback, never())
+                .onVerticalScrollEvent(anyBoolean(), any(Bundle.class));
+        verify(mEngagementSignalsCallback, never())
+                .onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class));
+
+        // Navigate back to a URL with no fragment.
+        var navigationHandle2 = createNavigationHandle(JUnitTestGURLs.HTTP_URL);
+        webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle2);
+
+        // Do a scroll.
+        listener.onScrollStarted(24, SCROLL_EXTENT, false);
+        when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(50);
+        listener.onScrollOffsetOrExtentChanged(50, SCROLL_EXTENT);
+        listener.onScrollEnded(50, SCROLL_EXTENT);
+        RobolectricUtil.runAllBackgroundAndUi();
+        // We should normally get signals.
+        verify(mEngagementSignalsCallback).onVerticalScrollEvent(eq(false), any(Bundle.class));
+        verify(mEngagementSignalsCallback)
+                .onGreatestScrollPercentageIncreased(eq(50), any(Bundle.class));
+    }
+
+    @Test
     public void doesNotSendSignalsBeforeDownScroll() {
         initializeTabForTest();
         GestureStateListener listener = captureGestureStateListener(ON_SCROLL_END);
@@ -932,8 +1038,8 @@
     @SuppressWarnings("DirectInvocationOnMock")
     private void initializeTabForTest(boolean hadScrollDown) {
         Tab initialTab = env.prepareTab();
-        when(initialTab.getWebContents().getVisibleUrl()).thenReturn(GURL.emptyGURL());
-        when(env.webContents.getVisibleUrl()).thenReturn(GURL.emptyGURL());
+        when(initialTab.getWebContents().getLastCommittedUrl()).thenReturn(GURL.emptyGURL());
+        when(env.webContents.getLastCommittedUrl()).thenReturn(GURL.emptyGURL());
         doAnswer(
                         invocation -> {
                             CustomTabTabObserver observer = invocation.getArgument(0);
@@ -995,7 +1101,19 @@
     }
 
     private NavigationHandle createNavigationHandle(GURL url) {
-        var navigationHandle = NavigationHandle.createForTesting(url, false, 0, false);
+        return createNavigationHandle(url, /* isSameDocument= */ false);
+    }
+
+    private NavigationHandle createNavigationHandle(GURL url, boolean isSameDocument) {
+        var navigationHandle =
+                NavigationHandle.createForTesting(
+                        url,
+                        /* isInPrimaryMainFrame= */ true,
+                        isSameDocument,
+                        /* isRendererInitiated= */ false,
+                        /* transition= */ 0,
+                        /* hasUserGesture= */ false,
+                        /* isReload= */ false);
         navigationHandle.callDidFinishForTesting(url);
         return navigationHandle;
     }
@@ -1003,7 +1121,7 @@
     private Tab createNewTab() {
         Tab tab = mock(Tab.class);
         var webContents = mock(MockWebContents.class);
-        when(webContents.getVisibleUrl()).thenReturn(GURL.emptyGURL());
+        when(webContents.getLastCommittedUrl()).thenReturn(GURL.emptyGURL());
         when(tab.getWebContents()).thenReturn(webContents);
         return tab;
     }
Loading diff…

Original Bug Report

reported by [email protected]

Potential bypass of text-fragment guard in Chrome Custom Tabs via pending URL seed

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 logic bypass in Chrome Custom Tabs allows a malicious embedder app to bypass the text-fragment guard meant to prevent scroll-based XS-Leaks. By triggering a pending browser-initiated navigation and re-registering the EngagementSignalsCallback, the new observer can be tricked into setting mSignalsPaused to false. This can allow the embedder to receive scroll notifications on a cross-origin text-fragment-targeted page.

Affected files:

  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/EngagementSignalsHandler.java

Estimated timestamp from git blame: 2026-04-29

Description

A potential security bypass exists in the text-fragment guard of Chrome Custom Tabs’ (CCT) engagement signals mechanism. The text-fragment guard was introduced to set mSignalsPaused = true when a page is loaded with a text fragment (e.g., #:~:text=), preventing malicious embedders from measuring scroll percentages to execute cross-origin XS-Leaks. However, this guard can potentially be bypassed by re-registering the EngagementSignalsCallback during a pending, non-committing, browser-initiated navigation.

Root Cause Analysis

In RealtimeEngagementSignalObserver.java, mSignalsPaused is initialized at observer-creation time by checking the visible URL of the WebContents:

// RealtimeEngagementSignalObserver.java
if (mWebContents != null) {
    mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl());
}

WebContents.getVisibleUrl() retrieves the visible URL, which corresponds to the pending entry for any new, browser-initiated navigation. If a second CustomTabsIntent is sent in the same session, it creates a browser-initiated pending entry. While that navigation is pending, getVisibleUrl() returns the pending URL instead of the currently active, committed URL.

If the embedder re-registers the EngagementSignalsCallback via setEngagementSignalsCallback over Binder while this navigation is pending, EngagementSignalsHandler destroys the old observer and instantiates a new RealtimeEngagementSignalObserver (via createEngagementSignalsObserver() in EngagementSignalsHandler.java).

During its synchronous initialization, the new observer checks getVisibleUrl(), which returns the pending URL (which does not contain a text fragment), and incorrectly sets mSignalsPaused = false.

If the pending navigation is subsequently cancelled or completed with a non-committing status (such as an HTTP 204 No Content response, a network error, or a Content-Disposition: attachment response), the navigation does not commit. The observer’s didFinishNavigationInPrimaryMainFrame method skips updating mSignalsPaused because navigationHandle.hasCommitted() is false. As a result, the incorrect mSignalsPaused = false state persists on the active page, which remains scrolled to the matched text fragment.

Potential Attack Steps

Since our tooling does not yet have the ability to run code, these are potential/suggested steps to reproduce the vulnerability:

  1. A malicious Android app acting as a Custom Tabs embedder opens a Custom Tab to a target victim URL with a text-fragment directive (e.g., https://victim.example/page#:~:text=SECRET). The page commits, auto-scrolls to the match, and mSignalsPaused is set to true.
  2. The embedder dispatches a second CustomTabsIntent (reusing the same session token) targeting an attacker-controlled slow URL (e.g., https://attacker.example/slow without a fragment). This creates a browser-initiated pending entry, causing getVisibleUrl() to return the attacker-controlled URL.
  3. While the navigation is pending (e.g., held up by the attacker’s server), the embedder calls CustomTabsSession.setEngagementSignalsCallback() again.
  4. On the UI thread, EngagementSignalsHandler destroys the existing observer and creates a new RealtimeEngagementSignalObserver. The new observer initializes and reads the pending attacker-controlled URL, incorrectly setting mSignalsPaused = false.
  5. The attacker’s server responds with an HTTP 204 (No Content). The pending navigation completes without committing. The observer retains mSignalsPaused = false, and the scroll state is not reset.
  6. When the user performs a scroll gesture on the active victim page, notifyGreatestScrollPercentageIncreased delivers the rounded scroll percentage of the text-fragment match to the embedder, leaking the position/presence of the matched text.

Suggested Fix

To resolve this issue, RealtimeEngagementSignalObserver should initialize mSignalsPaused using the last committed URL instead of the visible URL. This ensures that any pending browser-initiated navigations do not affect the text-fragment guard state of the currently active document.

In RealtimeEngagementSignalObserver.java (line 221), replace:

mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl());

with:

mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getLastCommittedUrl());

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.

View on issue tracker