CVE-2025-0435
Overview
Background
- Browser controls
- The Android Chrome toolbar (URL bar, tab strip) whose visibility can be locked while a page loads and unlocked once loading settles.
- `TabStateBrowserControlsVisibilityDelegate`
- The class that decides whether browser controls should be shown or hidden based on a tab’s navigation and load state.
- `MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD`
- A delayed handler message (posted with
getLoadDelayMs(), roughly 3 seconds) that re-enables fullscreen and unlocks the controls after a load is presumed finished. - Same-document navigation
- A navigation reported by
NavigationHandle.isSameDocument()that changes URL state (e.g. fragment orhistory.pushState) without a full document load, and so must not be counted as an outstanding page load.
Root Cause Analysis
The pre-fix delegate keyed its lock/unlock decisions on page-load events (onPageLoadStarted, onPageLoadFinished) and a single onDidFinishNavigationInPrimaryMainFrame callback, each of which independently removed and re-posted the MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD timer. Because load and navigation events for concurrent navigations are not guaranteed to be delivered one-at-a-time and may interleave, a completing navigation could fire the 3-second unlock timer while an earlier, slower navigation was still in flight. The violated invariant is that browser controls must remain locked until every outstanding main-frame navigation has finished, not merely the most recent one.
The fix replaces the page-load bookkeeping with navigation-id tracking: onDidStartNavigationInPrimaryMainFrame inserts each non-same-document navigation.getNavigationId() into the mOutstandingNavigations set, and onDidFinishNavigationInPrimaryMainFrame removes it and only schedules the unlock timer once the set isEmpty(). Same-document navigations are explicitly skipped so they neither add nor remove ids, and a sControlsVisibilityFromNavigations kill switch plus histograms guard against the residual risk of controls staying locked forever if an event is missed.
mOutstandingNavigations being empty.Attack Path
- Start a slow navigation The user or attacker page initiates a navigation to a slow-loading resource, which locks the browser controls visible while it loads.
- Interleave a fast navigation Before the slow load completes, script triggers a second, quickly-resolving navigation (or a same-document change) in the same primary main frame.
- Premature finish event
The fast navigation’s
onDidFinishNavigationInPrimaryMainFrame/onPageLoadFinishedfires and re-armsMSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD, since the old code did not check whether other navigations were still outstanding. - Controls unlock early After the 3-second delay elapses, the controls are unlocked and fullscreen re-enabled even though the slow navigation is still pending, leaving the UI in an inconsistent, unexpected state.
- Spoofing window With the toolbar hidden while a navigation is still resolving, page content can occupy the region normally reserved for trusted browser UI, enabling deceptive presentation to the user.
Impact Assessment
Files Changed
chrome/android/java/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegate.javachrome/android/junit/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegateTest.java
Audit Directions
- Interleaved-event lock/unlockAudit any state machine that locks a resource on a “start” event and unlocks on a “finish” event using a single boolean or latest-only tracking, since interleaved concurrent operations can trigger an early unlock; require set/refcount tracking instead.
- Page-load vs navigation event couplingLook for UI or security state driven by
onPageLoadStarted/onPageLoadFinishedthat assumes one-load-at-a-time semantics, and check whether same-document navigations or overlapping loads can desynchronize it. - Kill-switch-guarded logic changesReview new behavior wrapped in feature flags like
sControlsVisibilityFromNavigationsto confirm both the enabled and disabled branches are correct and that a missed event cannot strand controls in a locked or unlocked state.
Patch
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());
Original Bug Report
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):
- 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.
- Update the initiator pages to navigate to your hosted slow-loading page (instead of the
aogarantiza.com-hosted pages)
Minimal PoC
- Navigate to https://alesandroortiz.com/security/chromium/android-hide-address-bar-minimal.html
- Wait for next page to load.
Realistic PoC
- Navigate to https://alesandroortiz.com/security/chromium/android-hide-address-bar.html
- 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
android-hide-address-bar-minimal.htmlandroid-hide-address-bar-minimal.mp4android-hide-address-bar-realistic.mp4android-hide-address-bar.htmlno-repro-logs.txtrepro-flags.txtrepro-logs.txtslow-android-hide-address-bar.phpslow.phpurlbar-google.jpg
- https://AlesandroOrtiz.com
- https://alesandroortiz.com/security/chromium/android-hide-address-bar-minimal.html
- https://alesandroortiz.com/security/chromium/android-hide-address-bar.html
- https://chromium.googlesource.com/chromium/src/+/0351d764b858cd585f2b0dae27e08e531e091fbb
- https://chromium.googlesource.com/chromium/src/+log/e96bb49e536d5d504aba1b56874d190923ba44a2..2014f0f91242702a3fb80c9baae252fb6bec583c/