High watch CVSS 6.5 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Navigation
DescriptionInappropriate implementation in Navigation
ComponentNavigation
Sub-componentUI>Browser>Navigation
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker379652406
Fix commit2a80258ed798 (chromium/src) +220/-11
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedAlesandro Ortiz
Reported2024-11-18 · bounty $7000 (per vendor advisory)
Fix landed2024-11-26 (public git)
Disclosed2025-01-14
Patch gap49 days — fix public in source before the advisory

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 or history.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.

Key insight
The core mistake was treating an unlock decision as a property of a single (latest) navigation/load event rather than of the set of all in-flight navigations, so an interleaved fast navigation prematurely unlocked controls that a slow one still required locked; the fix makes unlocking conditional on mOutstandingNavigations being empty.

Attack Path

  1. 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.
  2. 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.
  3. Premature finish event The fast navigation’s onDidFinishNavigationInPrimaryMainFrame / onPageLoadFinished fires and re-arms MSG_ID_ENABLE_FULLSCREEN_AFTER_LOAD, since the old code did not check whether other navigations were still outstanding.
  4. 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.
  5. 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

An attacker gains control over browser-controls visibility timing in the Android Chrome browser UI process, hiding trusted chrome (the URL/security indicators) at a moment when it should remain locked, which supports UI spoofing and deception. Exploitation requires the ability to drive interleaved navigations in a tab’s primary main frame — for example a malicious or cooperating web page issuing a slow load followed by a fast or same-document navigation. No memory corruption is involved; the impact is a logic/inappropriate-implementation flaw in navigation-driven UI state.

Files Changed

  • chrome/android/java/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegate.java
  • chrome/android/junit/src/org/chromium/chrome/browser/tab/TabStateBrowserControlsVisibilityDelegateTest.java

Audit Directions

  • Interleaved-event lock/unlock
    Audit 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 coupling
    Look for UI or security state driven by onPageLoadStarted/onPageLoadFinished that assumes one-load-at-a-time semantics, and check whether same-document navigations or overlapping loads can desynchronize it.
  • Kill-switch-guarded logic changes
    Review new behavior wrapped in feature flags like sControlsVisibilityFromNavigations to confirm both the enabled and disabled branches are correct and that a missed event cannot strand controls in a locked or unlocked state.
From 2a80258ed798263eeff7ca49f3c13d389eb87df8 Mon Sep 17 00:00:00 2001
From: Sky Malice <skym@chromium.org>
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 <skym@chromium.org>
Reviewed-by: Patrick Noland <pnoland@chromium.org>
Reviewed-by: Sinan Sahin <sinansahin@google.com>
Reviewed-by: Yaron Friedman <yfriedman@chromium.org>
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 al...@alesandroortiz.com

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