Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Navigation
DescriptionInappropriate implementation in Navigation
ComponentNavigation
Bug ClassLogic Error
Tracker379652406
Fix commit2a80258ed798 (chromium/src) +220/-11
CISA KEVNot listed
CreditedAlesandro Ortiz
Disclosed2025-01-14

Files Changed

  • chrome/android/java/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegate.java
  • chrome/android/junit/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegateTest.java
From 2a80258ed798263eeff7ca49f3c13d389eb87df8 Mon Sep 17 00:00:00 2001
From: Sky Malice <[email protected]>
Date: Tue, 26 Nov 2024 22:13:00 +0000
Subject: [PATCH] Rework locking controls from navigations.

Page load and navigation events are not guaranteed to be delivered for one navigation at a time. Instead they can be interleaved. This is causing a problem when a slow navigation starts before the previous navigation is completed. After the 3 second wait, we erroneously unlocked the browser controls when they should not be.

To fix this, all page load events were switched to navigation events. And we now track navigation ids in a set, and only unlock when the set is empty. Special casing for same document navigations was added to handle the difference between navigation events and page load events. This approach has the potential danger of leaving browser controls locked forever if we miss an event.

To mitigate this risk, this CL adds a kill switch and a histogram that counts the number of outstanding navigations when a navigation completes.

Bug: 379652406
Change-Id: I6a837b7f7b2103c6f811e130bf582ac38c66c763
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6040200
Commit-Queue: Sky Malice <[email protected]>
Reviewed-by: Patrick Noland <[email protected]>
Reviewed-by: Sinan Sahin <[email protected]>
Reviewed-by: Yaron Friedman <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1388487}
---

diff --git a/chrome/android/java/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegate.java b/chrome/android/java/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegate.java
index ba6186e..68a99aa 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegate.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegate.java
@@ -10,8 +10,10 @@
 
 import androidx.annotation.Nullable;
 
+import org.chromium.base.metrics.RecordHistogram;
 import org.chromium.cc.input.BrowserControlsState;
 import org.chromium.chrome.browser.device.DeviceClassManager;
+import org.chromium.chrome.browser.flags.ChromeFeatureList;
 import org.chromium.chrome.browser.util.ChromeAccessibilityUtil;
 import org.chromium.components.browser_ui.util.BrowserControlsVisibilityDelegate;
 import org.chromium.components.dom_distiller.core.DomDistillerUrlUtils;
@@ -24,6 +26,9 @@
 import org.chromium.ui.base.WindowAndroid;
 import org.chromium.url.GURL;
 
+import java.util.HashSet;
+import java.util.Set;
+
 /**
  * Determines the desired visibility of the browser controls based on the current state of a given
  * tab.
@@ -43,8 +48,11 @@
     private boolean mIsFullscreenWaitingForLoad;
     private boolean mIsFocusedNodeEditable;
 
+    private final Set<Long> mOutstandingNavigations = new HashSet<>();
+
     /**
      * Basic constructor.
+     *
      * @param tab The associated {@link Tab}.
      */
     public TabStateBrowserControlsVisibilityDelegate(Tab tab) {
@@ -100,25 +108,65 @@
                     }
 
                     @Override
+                    public void onDidStartNavigationInPrimaryMainFrame(
+                            Tab tab, NavigationHandle navigation) {
+                        if (!ChromeFeatureList.sControlsVisibilityFromNavigations.isEnabled()) {
+                            return;
+                        }
+
+                        if (navigation.isSameDocument()) return;
+
+                        boolean changed = mOutstandingNavigations.add(navigation.getNavigationId());
+                        RecordHistogram.recordBooleanHistogram(
+                                "Android.BrowserControls.OutstandingChangedOnStart", changed);
+
+                        mHandler.removeMessages(MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD);
+                        boolean safe = DomDistillerUrlUtils.isDistilledPage(navigation.getUrl());
+                        updateWaitingForLoad(!safe);
+                    }
+
+                    @Override
                     public void onDidFinishNavigationInPrimaryMainFrame(
                             Tab tab, NavigationHandle navigation) {
-                        if (!navigation.hasCommitted()) return;
-                        mHandler.removeMessages(MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD);
-                        mHandler.sendEmptyMessageDelayed(
-                                MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD, getLoadDelayMs());
+                        if (ChromeFeatureList.sControlsVisibilityFromNavigations.isEnabled()) {
+                            if (navigation.isSameDocument()) return;
+
+                            boolean changed =
+                                    mOutstandingNavigations.remove(navigation.getNavigationId());
+                            RecordHistogram.recordBooleanHistogram(
+                                    "Android.BrowserControls.OutstandingChangedOnFinish", changed);
+
+                            if (mOutstandingNavigations.isEmpty()) {
+                                mHandler.removeMessages(MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD);
+                                mHandler.sendEmptyMessageDelayed(
+                                        MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD, getLoadDelayMs());
+                            }
+                            RecordHistogram.recordCount100Histogram(
+                                    "Android.BrowserControls.OutstandingNavigationsOnFinish",
+                                    mOutstandingNavigations.size());
+                        } else {
+                            if (!navigation.hasCommitted()) return;
+                            mHandler.removeMessages(MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD);
+                            mHandler.sendEmptyMessageDelayed(
+                                    MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD, getLoadDelayMs());
+                        }
                     }
 
                     @Override
                     public void onPageLoadStarted(Tab tab, GURL url) {
-                        mHandler.removeMessages(MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD);
-                        updateWaitingForLoad(!DomDistillerUrlUtils.isDistilledPage(url));
+                        if (!ChromeFeatureList.sControlsVisibilityFromNavigations.isEnabled()) {
+                            mHandler.removeMessages(MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD);
+                            updateWaitingForLoad(!DomDistillerUrlUtils.isDistilledPage(url));
+                        }
                     }
 
                     @Override
                     public void onPageLoadFinished(Tab tab, GURL url) {
                         // Handle the case where a commit or prerender swap notification failed to
                         // arrive and the enable fullscreen message was never enqueued.
-                        scheduleEnableFullscreenLoadDelayIfNecessary();
+                        if (!ChromeFeatureList.sControlsVisibilityFromNavigations.isEnabled()) {
+                            scheduleEnableFullscreenLoadDelayIfNecessary();
+                        }
                     }
 
                     @Override
@@ -127,7 +175,9 @@
                         // urls, so that we can fully unlock controls here possible here.
                         // May have already received the start of a different navigation. Do not
                         // cancel the outstanding delay. See https://crbug.com/1447237.
-                        scheduleEnableFullscreenLoadDelayIfNecessary();
+                        if (!ChromeFeatureList.sControlsVisibilityFromNavigations.isEnabled()) {
+                            scheduleEnableFullscreenLoadDelayIfNecessary();
+                        }
                     }
 
                     @Override
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegateTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegateTest.java
index 761cc43..83aa649e 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegateTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegateTest.java
@@ -8,8 +8,6 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
-import androidx.test.filters.SmallTest;
-
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -26,6 +24,7 @@
 import org.robolectric.shadows.ShadowSystemClock;
 
 import org.chromium.base.test.BaseRobolectricTestRunner;
+import org.chromium.base.test.util.Features.DisableFeatures;
 import org.chromium.cc.input.BrowserControlsState;
 import org.chromium.components.security_state.SecurityStateModel;
 import org.chromium.components.security_state.SecurityStateModelJni;
@@ -61,13 +60,20 @@
     }
 
     @Test
-    @SmallTest
+    @DisableFeatures("ControlsVisibilityFromNavigations")
     public void testOnPageLoadFailedDuringNavigation() {
         // Inspired by https://crbug.com/1447237.
         GURL blueGurl = JUnitTestGURLs.BLUE_1;
         GURL redGurl = JUnitTestGURLs.RED_1;
         when(mTabImpl.getUrl()).thenReturn(blueGurl);
 
+        when(mNavigationHandle1.getNavigationId()).thenReturn(1L);
+        when(mNavigationHandle1.getUrl()).thenReturn(blueGurl);
+        when(mNavigationHandle2.getNavigationId()).thenReturn(2L);
+        when(mNavigationHandle2.getUrl()).thenReturn(blueGurl);
+        when(mNavigationHandle3.getNavigationId()).thenReturn(3L);
+        when(mNavigationHandle3.getUrl()).thenReturn(redGurl);
+
         TabStateBrowserControlsVisibilityDelegate controlsVisibilityDelegate =
                 new TabStateBrowserControlsVisibilityDelegate(mTabImpl);
         verify(mTabImpl).addObserver(mTabObserverCaptor.capture());
@@ -102,4 +108,93 @@
                 BrowserControlsState.BOTH,
                 controlsVisibilityDelegate.calculateVisibilityConstraints());
     }
+
+    @Test
+    public void sameDocumentNavigationsIgnored() {
+        when(mTabImpl.getUrl()).thenReturn(JUnitTestGURLs.BLUE_1);
+        when(mNavigationHandle1.getNavigationId()).thenReturn(1L);
+        when(mNavigationHandle1.getUrl()).thenReturn(JUnitTestGURLs.BLUE_1);
+        when(mNavigationHandle1.isSameDocument()).thenReturn(true);
+
+        TabStateBrowserControlsVisibilityDelegate controlsVisibilityDelegate =
+                new TabStateBrowserControlsVisibilityDelegate(mTabImpl);
+        verify(mTabImpl).addObserver(mTabObserverCaptor.capture());
Loading diff…

Original Bug Report

reported by [email protected]

Security: Android address bar hidden after slow navigation finishes, if slow nav is initiated on page load

SUMMARY

The address bar can be hidden by a page with no user interaction after 3 seconds. The page can then spoof the address bar within the page.

VULNERABILITY DETAILS

When a page loads and immediately starts a new navigation, if the navigation takes at least 3 seconds, the browser will hide the address bar when the slow navigation is completed.

I’ve also verified this works for navigations that take over a minute.

Hiding the address bar does not require any user interaction. There are no apparent prerequisites to the navigation (can be directly from address bar, or from another page with/without user interaction).

Expected behavior is that the user needs to be on a page for 3 seconds and then intentionally scroll down to hide the address bar. The observed behavior bypasses the user interaction requirement.

For some reason, the initiating page must have a <style> element for repro (can be empty). Repro also does not work if there is any delay in initiating the navigation, e.g. with setTimeout(..., 0).

Focusing on input fields forces the address bar to show, but a user is more likely to check the address bar before interacting with the page, when the URL spoof is still displayed. (There’s a chance a compromised renderer could prevent input focus from showing the address bar, but I’m not certain.)

VERSION

Chrome Version: 133.0.6835.0 Dev, 133.0.6838.0 Canary, 133.0.6844.0 Canary

Only seems to repro on branded Chrome builds. There’s no repro on the same Chromium versions above.

Operating System: Android 12

BISECT

I’m still working on a bisect, but this is proving to be tricky since it only seems to repro on branded Chrome builds, not Chromium builds. Bisect so far:

  • Does not repro on 132.0.6834.5 Beta, 133.0.6822.0 Dev
  • Repros on 133.0.6835.0 Dev, 133.0.6838.0 Canary, 133.0.6844.0 Canary

Based on the 132.0.6834.5 Beta - 133.0.6835.0 Dev range: https://chromium.googlesource.com/chromium/src/+log/e96bb49e536d5d504aba1b56874d190923ba44a2..2014f0f91242702a3fb80c9baae252fb6bec583c/

This seems like a suspect CL, but not certain: https://chromium.googlesource.com/chromium/src/+/0351d764b858cd585f2b0dae27e08e531e091fbb

I’ll continue trying to bisect with older branded Canary versions.

I’ve verified repro and versions above on multiple devices, and AFAICT from chrome://version/?show-variations-cmd my Canary and Dev browsers don’t have field trials enabled, so it doesn’t seem due to an experiment in Canary/Dev that isn’t active in Beta/Stable.

REPRODUCTION CASE

Some aspects of the realistic PoC can be improved, such as matching browser’s light/dark mode and hiding spoof on window resize event.

The realistic PoC prevents scrolling up from re-showing the address bar using CSS. The minimal PoC does not prevent scrolling up, so the address bar is easily re-shown.

In these PoCs, the initiator is cross-site from the destination, but the PoCs also work if both the initiator and destination pages are same-origin. This doesn’t seem to affect behavior.

Setup for self-hosting (not required for hosted PoCs below):
  1. Host a slow-loading page that takes at least 3 seconds to load. See attached slow.php and slow-android-hide-address-bar.php for examples.
  2. Update the initiator pages to navigate to your hosted slow-loading page (instead of the aogarantiza.com-hosted pages)
Minimal PoC
  1. Navigate to https://alesandroortiz.com/security/chromium/android-hide-address-bar-minimal.html
  2. Wait for next page to load.
Realistic PoC
  1. Navigate to https://alesandroortiz.com/security/chromium/android-hide-address-bar.html
  2. Wait for next page to load.

For both PoCs:

Observed: When the next page is loaded, the address bar is hidden by the browser.

Expected: The address bar is always shown unless the address bar has been visible for at least 3 seconds since the last navigation and then the user scrolls down the page.

CREDIT INFORMATION

Reporter credit: Alesandro Ortiz https://AlesandroOrtiz.com

View on issue tracker