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.
Tracker517910756
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-29 (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
an Android Custom Tabs API through which RealtimeEngagementSignalObserver reports user-interaction data such as vertical scroll direction (onVerticalScrollEvent) and greatest scroll percentage (onGreatestScrollPercentageIncreased) back to the native app that launched the tab.
URL fragment / Text Fragment
the #... portion of a URL (GURL::getRef()); an element-id fragment (#target) or a Text Fragment directive (#:~:text=...) can make the browser scroll the page automatically to a matching element or piece of text.
`mSignalsPaused`
a flag in RealtimeEngagementSignalObserver that stops scroll signals from being sent while the page might have been scrolled by a fragment, so the launching app cannot learn from scroll data where the page landed.
Visible vs. last committed URL
WebContents.getVisibleUrl() can show a pending navigation’s URL, while getLastCommittedUrl() is the URL of the document actually shown; a same-document navigation (NavigationHandle.isSameDocument()) changes the URL without loading a new document.

Root Cause Analysis

RealtimeEngagementSignalObserver paused engagement signals only when LinkToTextHelper.hasTextFragment() returned true, so the pause covered Text Fragment directives and nothing else. Ordinary element-id fragments like #target also scroll the page automatically, and they left signals running. The pause logic was also unreliable in two other ways. On tab attach it read mWebContents.getVisibleUrl(), which can show a pending navigation’s URL rather than the page actually committed. And the didFinishNavigationInPrimaryMainFrame override re-checked the pause on every committed navigation, including same-document ones, so a same-document URL change to a fragment-free URL turned signals back on while the same scrolled document was still showing. In all three cases scroll position and percentage data reached the launching app for a page whose scroll offset had been set by a fragment. That data can show whether the fragment target (an element or a text string) exists in the page and where it is. That is an observable discrepancy across the app/web boundary.

The fix pauses signals whenever the URL has any non-empty ref (!getRef().isEmpty()), uses getLastCommittedUrl() on attach, and recomputes the pause only for cross-document commits (hasCommitted() && !isSameDocument()).

Key insight
Engagement signals were paused only for Text Fragment URLs, and the pause state was taken from the wrong URL (visible rather than committed) and reset by same-document navigations. The patch widens the pause to any URL fragment and ties it to the committed document’s lifetime.

Attack Path

  1. App launches a Custom Tab with a fragment URL a native Android app that uses the Custom Tabs engagement-signals API opens a third-party page with a fragment (an element-id or Text Fragment) chosen to test for content on that page.
  2. Pause is skipped or cleared before the fix, signals stayed on for a non-text-fragment ref, for a committed fragment URL while getVisibleUrl() showed a different pending URL, or after a same-document navigation reset mSignalsPaused to false.
  3. Scroll signals are reported when the page scrolls (the observer’s GestureStateListener path), onVerticalScrollEvent / onGreatestScrollPercentageIncreased send scroll data that reflects the fragment-induced starting offset.
  4. Inference about page content by comparing the scroll data received for different fragments, the app could infer whether a given element or text exists on the page, and roughly where (inferred).

Impact Assessment

The attacker is the Android app that launched the Custom Tab, receiving engagement callbacks outside the browser. The leak is limited and probabilistic: information about the existence or position of content (element ids or text) in a cross-origin page the user views in the tab, which may include logged-in content (inferred). It needs an app that has opted into engagement signals, Chrome’s privacy and feature checks for that API to pass, and actual scroll activity on the page. No memory corruption is involved, and the advisory gives no in-the-wild exploitation status.

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

  • Fragment-driven scroll leaks
    check other places where scroll offset, viewport position or “scrolled to” state is reported outside the page’s own origin (embedders, WebView callbacks, metrics sent to apps). They must account for all fragment types (element-id, Text Fragment, and others), not only :~:text=.
  • Visible vs. committed URL in security decisions
    look for privacy or security gating that uses getVisibleUrl() or a pending-navigation URL where the decision is really about the committed document (getLastCommittedUrl()).
  • State reset on same-document navigation
    check observers that recompute per-page protection state in didFinishNavigation*. Make sure same-document navigations (pushState, hash changes) cannot clear protections tied to the original document load.
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 CCT EngagementSignals privacy guard via same-document navigations

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: An asymmetry in RealtimeEngagementSignalObserver allows same-document navigations to clear the text-fragment privacy guard (mSignalsPaused) without resetting the scroll state baseline. This potentially allows a malicious Android app using the Custom Tabs Engagement Signals API to exploit a scroll-position oracle (XS-Leak) and determine whether and where an attacker-specified text fragment appears on a cross-origin page.

Affected files:

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

Estimated timestamp from git blame: 2026-04-29

Description

In Chrome for Android, the Custom Tabs Engagement Signals API provides scroll percentage updates and other engagement signals to an embedder application. To prevent a cross-origin text-fragment scroll-position leakage (an XS-Leak where an app can detect if a specific :~:text= fragment matches and causes an auto-scroll on a target site), Chrome employs a privacy guard (mSignalsPaused) that pauses signals when a text fragment is present in the URL.

However, a potential vulnerability exists in RealtimeEngagementSignalObserver.java because the didFinishNavigationInPrimaryMainFrame handler unconditionally re-evaluates and clears mSignalsPaused when any primary main frame navigation commits, without checking if the navigation is a same-document navigation:

@Override
public void didFinishNavigationInPrimaryMainFrame(
        NavigationHandle navigationHandle) {
    if (navigationHandle.hasCommitted()) {
        mSignalsPaused =
                LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
    }
}

By contrast, the sibling handler navigationEntryCommitted does check and exclude same-document navigations:

@Override
public void navigationEntryCommitted(LoadCommittedDetails details) {
    if (details.isMainFrame() && !details.isSameDocument()) {
        assumeNonNull(mScrollState).resetMaxScrollPercentage();
    }
}

This asymmetry creates a potential security bypass:

  1. Initial Load: When a page is first loaded via Custom Tabs with a :~:text= directive, the privacy guard is correctly engaged (mSignalsPaused = true) because the text fragment is present in the initial URL. The browser processes the text fragment and auto-scrolls the viewport to the target text fragment (if present).
  2. Same-Document Navigation: If the page subsequently performs a same-document navigation (such as calling history.pushState/history.replaceState or updating location.hash — which is common in Single Page Applications or can be forced by the launching application via fragment-only navigations), a primary main frame navigation commits with hasCommitted() == true and isSameDocument() == true.
  3. Guard Cleared: Because the fragment directive #:~:text= is stripped from the URL of same-document navigations, LinkToTextHelper.hasTextFragment() returns false, which sets mSignalsPaused = false and clears the privacy guard.
  4. State Preserved: Crucially, because the navigation is same-document, navigationEntryCommitted does not reset the scroll state baseline (resetMaxScrollPercentage() is not called). The previous auto-scroll position is preserved.
  5. Leaking Scroll Position: When the user performs a single scroll gesture, the observer records the absolute scroll offset (which includes the text-fragment auto-scroll offset) and transmits the scroll percentage to the third-party app, leaking whether the target text was found on the page.

Potential Steps to Reproduce

Note: These are potential steps that have not been verified via a live proof-of-concept run.

  1. Build an Android app that binds to Chrome’s CustomTabsService and registers an EngagementSignalsCallback via setEngagementSignalsCallback().
  2. Ensure the device has Chrome’s ‘Help improve Chrome’s features and performance’ (UMA) toggle enabled (the default setting).
  3. Launch a Custom Tab to a page containing a text-fragment directive (e.g., https://example.com/page#:~:text=SECRETSTRING), where the target page performs a same-document navigation (such as history.replaceState or history.pushState) after loading, or initiate a same-document navigation to a different fragment (e.g., https://example.com/page#done) from the app.
  4. Wait for the navigation to commit, which silently disables the mSignalsPaused guard.
  5. Perform a single scroll gesture on the page.
  6. Observe that the onGreatestScrollPercentageIncreased callback fires with a non-zero percentage if the text fragment matched and caused an auto-scroll, potentially leaking the presence and approximate vertical location of the secret text.

Suggested Fix

Prevent same-document navigations from clearing the mSignalsPaused guard by adding a !navigationHandle.isSameDocument() check:

@Override
public void didFinishNavigationInPrimaryMainFrame(
        NavigationHandle navigationHandle) {
    if (navigationHandle.hasCommitted() && !navigationHandle.isSameDocument()) {
        mSignalsPaused =
                LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
    }
}

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


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