Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Payments
DescriptionOut of bounds read in Payments
ComponentPayments
Sub-componentPayments
Bug ClassOOB
Tracker562226316
Fix commitd6d85a80d183 (chromium/src) +81/-11
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarViewBinder.java
modified

Files Changed

  • chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarViewBinder.java
  • chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediator.java
  • chrome/browser/payments/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediatorTest.java
  • chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarCoordinator.java
  • chrome/browser/payments/android/BUILD.gn
  • chrome/android/javatests/src/org/chromium/chrome/browser/payments/ExpandablePaymentHandlerTest.java
  • chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarProperties.java
From d6d85a80d183b4059ddedb69a39438625f883b4e Mon Sep 17 00:00:00 2001
From: Stephen McGruer <smcgruer@chromium.org>
Date: Thu, 24 Sep 2026 12:25:24 -0700
Subject: [PATCH] [Payments] Format Origin in PaymentHandler toolbar

Previously, the Android PaymentHandler toolbar stored a GURL in
PaymentHandlerToolbarProperties, updated it from
WebContents.getVisibleUrl() on navigation completion, and formatted it
using UrlFormatter.formatUrlForSecurityDisplay(). For non-standard URLs
such as about:blank, getVisibleUrl() and formatUrlForSecurityDisplay()
include the full URL spec rather than the document's security origin.

Update the toolbar property to store an org.chromium.url.Origin
(initialized from Origin.create(url) and updated from
RenderFrameHost.getLastCommittedOrigin() on committed primary main-frame
navigations) and format it using
UrlFormatter.formatOriginForSecurityDisplay(), matching Desktop's
PaymentHandlerWebFlowViewController.

Bug: 562226316
Change-Id: I13cf11601fae53761094df8c760114b9c9c246d1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8463342
Commit-Queue: Stephen McGruer <smcgruer@chromium.org>
Reviewed-by: Darwin Yang <darwinyang@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1704510}
---
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarCoordinator.java b/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarCoordinator.java
index ff614fb3..0f4abda 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarCoordinator.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarCoordinator.java
@@ -27,6 +27,7 @@
 import org.chromium.ui.modelutil.PropertyModel;
 import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
 import org.chromium.url.GURL;
+import org.chromium.url.Origin;
 
 import java.util.function.Supplier;
 
@@ -87,7 +88,7 @@
                         .with(
                                 PaymentHandlerToolbarProperties.SECURITY_ICON_CONTENT_DESCRIPTION,
                                 getSecurityIconContentDescription(defaultSecurityLevel))
-                        .with(PaymentHandlerToolbarProperties.URL, url)
+                        .with(PaymentHandlerToolbarProperties.ORIGIN, Origin.create(url))
                         .with(
                                 PaymentHandlerToolbarProperties.SECURITY_ICON_ON_CLICK_CALLBACK,
                                 this::showPageInfoDialog)
@@ -121,12 +122,16 @@
         return mToolbarView.getToolbarHeightPx();
     }
 
-    /** @return The height of the toolbar shadow height in px. */
+    /**
+     * @return The height of the toolbar shadow height in px.
+     */
     public int getShadowHeightPx() {
         return mToolbarView.getShadowHeightPx();
     }
 
-    /** @return The toolbar of the PaymentHandler. */
+    /**
+     * @return The toolbar of the PaymentHandler.
+     */
     public View getView() {
         return mToolbarView.getView();
     }
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediator.java b/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediator.java
index 383bf04..ec11906 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediator.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediator.java
@@ -21,6 +21,7 @@
 import org.chromium.ui.modelutil.PropertyModel;
 import org.chromium.url.GURL;
 
+import java.util.Objects;
 import java.util.function.Supplier;
 
 /**
@@ -78,8 +79,9 @@
 
     /**
      * Build a new mediator that handle events from outside the payment handler toolbar component.
+     *
      * @param model The {@link PaymentHandlerToolbarProperties} that holds all the view state for
-     *         the payment handler toolbar component.
+     *     the payment handler toolbar component.
      * @param webContents The web-contents that loads the payment app.
      * @param delegate The delegate of this class.
      */
@@ -126,7 +128,9 @@
     @Override
     public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigation) {
         if (!navigation.hasCommitted()) return;
-        mModel.set(PaymentHandlerToolbarProperties.URL, mWebContentsRef.getVisibleUrl());
+        mModel.set(
+                PaymentHandlerToolbarProperties.ORIGIN,
+                Objects.requireNonNull(mWebContentsRef.getMainFrame().getLastCommittedOrigin()));
         mModel.set(PaymentHandlerToolbarProperties.PROGRESS_VISIBLE, false);
     }
 
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarProperties.java b/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarProperties.java
index 64b9a95..a3c76a12 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarProperties.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarProperties.java
@@ -10,12 +10,12 @@
 import org.chromium.ui.modelutil.PropertyModel.WritableFloatPropertyKey;
 import org.chromium.ui.modelutil.PropertyModel.WritableIntPropertyKey;
 import org.chromium.ui.modelutil.PropertyModel.WritableObjectPropertyKey;
-import org.chromium.url.GURL;
+import org.chromium.url.Origin;
 
 /** PaymentHandlerToolbar UI properties, which fully describe the state of the UI. */
 @NullMarked
 /* package */ class PaymentHandlerToolbarProperties {
-    /* package */ static final WritableObjectPropertyKey<GURL> URL =
+    /* package */ static final WritableObjectPropertyKey<Origin> ORIGIN =
             new WritableObjectPropertyKey<>();
 
     /* package */ static final WritableObjectPropertyKey<String> TITLE =
@@ -45,7 +45,7 @@
 
     /* package */ static final PropertyKey[] ALL_KEYS =
             new PropertyKey[] {
-                URL,
+                ORIGIN,
                 TITLE,
                 LOAD_PROGRESS,
                 PROGRESS_VISIBLE,
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarViewBinder.java b/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarViewBinder.java
index e61e476..7bb218a39 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarViewBinder.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarViewBinder.java
@@ -20,10 +20,10 @@
 /* package */ class PaymentHandlerToolbarViewBinder {
     /* package */ static void bind(
             PropertyModel model, PaymentHandlerToolbarView view, PropertyKey propertyKey) {
-        if (PaymentHandlerToolbarProperties.URL == propertyKey) {
+        if (PaymentHandlerToolbarProperties.ORIGIN == propertyKey) {
             String origin =
-                    UrlFormatter.formatUrlForSecurityDisplay(
-                            model.get(PaymentHandlerToolbarProperties.URL),
+                    UrlFormatter.formatOriginForSecurityDisplay(
+                            model.get(PaymentHandlerToolbarProperties.ORIGIN),
                             SchemeDisplay.OMIT_HTTP_AND_HTTPS);
             view.mOriginView.setText(origin);
         } else if (PaymentHandlerToolbarProperties.TITLE == propertyKey) {
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/payments/ExpandablePaymentHandlerTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/payments/ExpandablePaymentHandlerTest.java
index bf8e880..535d72a 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/payments/ExpandablePaymentHandlerTest.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/payments/ExpandablePaymentHandlerTest.java
@@ -64,6 +64,7 @@
 import org.chromium.content_public.browser.WebContents;
 import org.chromium.content_public.browser.WebContentsObserver;
 import org.chromium.content_public.browser.test.util.DOMUtils;
+import org.chromium.content_public.browser.test.util.JavaScriptUtils;
 import org.chromium.content_public.browser.test.util.TestTouchUtils;
 import org.chromium.net.test.EmbeddedTestServer;
 import org.chromium.net.test.ServerCertificate;
@@ -612,4 +613,26 @@
                 });
         waitForUiClosed();
     }
+
+    @Test
+    @SmallTest
+    @Feature({"Payments"})
+    public void testOriginNotSpoofedByAboutBlankNavigation() throws Throwable {
+        startDefaultServer();
+        PaymentHandlerCoordinator paymentHandler = createPaymentHandlerAndShow();
+        waitForTitleShown(paymentHandler.getWebContentsForTest(), "Max Pay");
+        onView(withId(R.id.origin)).check(matches(withText(getOrigin(mServer))));
+
+        JavaScriptUtils.executeJavaScriptAndWaitForResult(
+                paymentHandler.getWebContentsForTest(),
+                "document.open();"
+                        + "document.write('<title>Spoofed</title>');"
+                        + "document.close();"
+                        + "location.href = '#' + '%20'.repeat(200) + 'paypal.com';");
+        waitForTitleShown(paymentHandler.getWebContentsForTest(), "Spoofed");
+        onView(withId(R.id.origin)).check(matches(withText(getOrigin(mServer))));
+
+        ThreadUtils.runOnUiThreadBlocking(() -> paymentHandler.hide());
+        waitForUiClosed();
+    }
 }
diff --git a/chrome/browser/payments/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediatorTest.java b/chrome/browser/payments/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediatorTest.java
index be230e8..1598e76 100644
--- a/chrome/browser/payments/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediatorTest.java
+++ b/chrome/browser/payments/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediatorTest.java
@@ -23,8 +23,10 @@
 import org.chromium.chrome.browser.payments.handler.toolbar.PaymentHandlerToolbarMediator.PaymentHandlerToolbarMediatorDelegate;
 import org.chromium.components.security_state.ConnectionSecurityLevel;
 import org.chromium.content_public.browser.NavigationHandle;
+import org.chromium.content_public.browser.RenderFrameHost;
 import org.chromium.content_public.browser.test.mock.MockWebContents;
 import org.chromium.ui.modelutil.PropertyModel;
+import org.chromium.url.Origin;
 
 /** A test for PaymentHandlerToolbarMediator. */
 @RunWith(BaseRobolectricTestRunner.class)
@@ -82,4 +84,39 @@
                 "this is content description.",
                 mModel.get(PaymentHandlerToolbarProperties.SECURITY_ICON_CONTENT_DESCRIPTION));
     }
+
+    @Test
+    @Feature({"Payments"})
+    public void testDidFinishNavigation_Committed() {
+        mModel.set(PaymentHandlerToolbarProperties.PROGRESS_VISIBLE, true);
+        RenderFrameHost mainFrame = Mockito.mock(RenderFrameHost.class);
+        Origin committedOrigin = Mockito.mock(Origin.class);
+        Mockito.doReturn(mainFrame).when(mMockWebContents).getMainFrame();
+        Mockito.doReturn(committedOrigin).when(mainFrame).getLastCommittedOrigin();
+
+        NavigationHandle navigation = Mockito.mock(NavigationHandle.class);
+        Mockito.when(navigation.hasCommitted()).thenReturn(true);
+
+        mMediator.didFinishNavigationInPrimaryMainFrame(navigation);
+
+        Assert.assertEquals(committedOrigin, mModel.get(PaymentHandlerToolbarProperties.ORIGIN));
+        Assert.assertFalse(mModel.get(PaymentHandlerToolbarProperties.PROGRESS_VISIBLE));
+    }
+
+    @Test
+    @Feature({"Payments"})
+    public void testDidFinishNavigation_NotCommitted() {
+        Origin initialOrigin = Mockito.mock(Origin.class);
+        mModel.set(PaymentHandlerToolbarProperties.ORIGIN, initialOrigin);
+        mModel.set(PaymentHandlerToolbarProperties.PROGRESS_VISIBLE, true);
+
+        NavigationHandle navigation = Mockito.mock(NavigationHandle.class);
+        Mockito.when(navigation.hasCommitted()).thenReturn(false);
+
+        mMediator.didFinishNavigationInPrimaryMainFrame(navigation);
+
+        Mockito.verify(mMockWebContents, Mockito.never()).getMainFrame();
+        Assert.assertEquals(initialOrigin, mModel.get(PaymentHandlerToolbarProperties.ORIGIN));
+        Assert.assertTrue(mModel.get(PaymentHandlerToolbarProperties.PROGRESS_VISIBLE));
+    }
 }
diff --git a/chrome/browser/payments/android/BUILD.gn b/chrome/browser/payments/android/BUILD.gn
index 3052aa48..58d7294 100644
--- a/chrome/browser/payments/android/BUILD.gn
+++ b/chrome/browser/payments/android/BUILD.gn
@@ -83,6 +83,7 @@
     "//third_party/junit",
     "//third_party/mockito:mockito_java",
     "//ui/android:ui_no_recycler_view_java",
+    "//url:gurl_java",
   ]
   sources = [
     "java/src/org/chromium/chrome/browser/payments/AutofillContactTest.java",
Loading diff…

Original Bug Report

reported by vm...@google.com

Origin spoofing in Android PaymentHandler toolbar via about:blank URL eliding

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: The Android payment-handler bottom sheet (the Chrome-drawn window in which service-worker payment apps show checkout UI) derives its origin-attribution label from the WebContents visible URL instead of the committed origin, and renders it in a start-ellipsized single-line TextView. Because the formatter falls back to the full URL spec (with %20 unescaped to spaces) for the non-standard about: scheme, navigating the sheet to about:blank#<250 spaces>paypal.com renders the label as …paypal.com. Combined with a document.open() trick that keeps attacker-written content in the document whose URL becomes about:blank, a web attacker gets full control of both the sheet content and the origin label from one ordinary click. Demonstrated end-to-end on a stock Android release build with default flags: a fake PayPal login form shown under the Chrome-drawn label …paypal.com and title “PayPal”.

Affected files:

Estimated timestamp from git blame: 2019-11-12

Summary

The origin line in the payment-handler sheet toolbar is the sheet’s only identity indicator — it exists so users can tell whose checkout page they are typing into. On Android it is computed as formatUrlForSecurityDisplay(visibleUrl) and displayed tail-only via ellipsize="start".

Three properties combine into a deterministic spoof:

  • about: is not a standard scheme, so FormatUrlForSecurityDisplay falls back to the full spec (elide_url.cc:351-355) and unescapes %20 to literal spaces (FormatUrl uses base::UnescapeRule::SPACES, components/url_formatter/url_formatter.h:167-170).
  • The label shows only the tail of an overlong string, so about:blank# + 250 spaces + paypal.com renders as …paypal.com — indistinguishable from a genuinely elided long origin.
  • about:blank is allowed to live in the payment-handler window: UrlUtil::IsValidUrlInPaymentHandlerWindow passes AboutBlank::kIsAllowedInPaymentHandlerWindow (components/payments/core/url_util.cc:128-134, check at :58-59), and SslValidityChecker::IsValidPageInPaymentHandlerWindow accepts non-cryptographic about:blank (components/payments/content/ssl_validity_checker.cc:62-81), so the close-on-insecure logic keeps the sheet open.

An escalation preserves attacker content under the spoofed label: a same-origin about:blank iframe calls top.document.open()/write(). Per the HTML document-open steps, Blink rewrites the top document’s URL to the entry document’s URL — about:blank — while the attacker markup stays alive in the document (third_party/blink/renderer/core/dom/document.cc:3856-3861). A subsequent fragment-only location.hash = ' '.repeat(250) + 'paypal.com' is a same-document commit that PaymentHandlerMediator ignores (:236) but PaymentHandlerToolbarMediator does not (:128), so the label flips to …paypal.com over the attacker’s phishing page.

The entire chain runs on default-enabled features (kWebPayments, JIT install of service-worker payment apps, single-app skip-UI) and requires exactly one click on the attacker’s page (transient activation for PaymentRequest.show()). Desktop is not affected: the desktop sheet formats GetLastCommittedOrigin() via FormatOriginForSecurityDisplay (chrome/browser/ui/views/payments/payment_handler_web_flow_view_controller.cc:372-376,675-681), which keeps showing the attacker origin.


Root Cause Analysis

  1. Label derives from the URL, not the origin: PaymentHandlerToolbarMediator.java:127-131 — every committed primary-main-frame navigation (same-document included; contrast PaymentHandlerMediator.java:236, which filters same-document) pushes the visible URL into the model:
    // PaymentHandlerToolbarMediator.java:127-131
    public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigation) {
        if (!navigation.hasCommitted()) return;   // <-- same-document commits NOT skipped
        mModel.set(PaymentHandlerToolbarProperties.URL, mWebContentsRef.getVisibleUrl());
    
  2. Security-display formatting silently degrades for non-standard schemes: PaymentHandlerToolbarViewBinder.java:23-28 calls UrlFormatter.formatUrlForSecurityDisplay, which for about: URLs falls back to the full spec:
    // [`components/url_formatter/elide_url.cc:351-355`](https://source.chromium.org/chromium/chromium/src/+/main:components/url_formatter/elide_url.cc;l=351)
    if (!url.is_valid() || url.is_empty() || !url.IsStandard()) {
      return url_formatter::FormatUrl(url);   // <-- full spec, %20 -> ' '
    }
    
  3. Start-ellipsis shows only the attacker-chosen tail: sheet_tab_toolbar.xml:116-124 — the origin TextView is android:ellipsize="start" + android:singleLine="true", so the about:blank# prefix and the space padding vanish behind ….
  4. about:blank is admitted and keeps the sheet open: components/payments/core/url_util.cc:58-59,128-134 allows about:blank in the payment-handler window (GURL::IsAboutBlank() ignores query/ref, so about:blank#<fragment> qualifies); ssl_validity_checker.cc:62-81 accepts it as a valid page, so closeIfInsecure (PaymentHandlerMediator.java:248-252) never fires. The MIME-type navigation throttle (components/payments/content/payment_handler_navigation_throttle.cc:54-84) never sees about:blank (no response headers).
  5. document.open() keeps attacker content under the about:blank URL: third_party/blink/renderer/core/dom/document.cc:3856-3861 — per the HTML spec’s document-open steps, top.document.open() from a same-origin about:blank iframe sets the top document’s URL to about:blank (SetURL(new_url) + DidOpenDocumentInputStream) while the document stays alive for document.write(). The follow-up fragment-only navigation commits same-document and re-enters step 1.

Reproduction

  1. Host on an https origin: a URL payment method at /pay (payment-method manifest + web-app manifest + icon + service worker) and a checkout page. All of this is default-on, just-in-time-installable Web Payments machinery.
  2. Victim taps once on the attacker page, which runs new PaymentRequest([{supportedMethods: '<origin>/pay'}], details).show(). With one matching app, default skip-UI invokes it directly; the SW’s paymentrequest handler calls openWindow('<origin>/checkout'), opening the payment-handler bottom sheet with a truthful origin label.
  3. Escalation variant: the checkout page embeds a same-origin about:blank iframe whose script runs top.document.open(); top.document.write('<title>PayPal</title>' + fake PayPal login markup); top.document.close();, after which the written script runs location.hash = ' '.repeat(250) + 'paypal.com'.
  4. Base variant: the checkout page simply runs location.href = 'about:blank#' + ' '.repeat(250) + 'paypal.com'.
  5. Expected: the label always reflects the committed origin of the page in the sheet (as on desktop), or the sheet closes on navigation away from a trustworthy origin.

Observed (executed on a stock arm64 release build, default flags, Android 17; served from a trustworthy test origin, which the payments code treats identically to any https origin per url_util.cc): after one tap and the escalation step, the sheet displays the fake PayPal login form, toolbar title “PayPal”, and origin label …paypal.com — the origin TextView holds the 272-char string about:blank# + 250 literal spaces + paypal.com, start-ellipsized to its tail. In the base variant the sheet stays open across the about:blank commit and shows …paypal.com over a blank page. In the base variant the security icon downgrades to the neutral info icon; in the same-document escalation the icon reset is skipped (PaymentHandlerToolbarMediator.java:134-137), so the prior page’s icon may persist — neither outcome corrects the origin text or closes the sheet.

Severity

medium — Deterministic, single-click spoof of the sole origin-identity indicator in a Chrome-drawn payment surface, simultaneous with fully attacker-controlled sheet content and title, reachable from any website via default-enabled Web Payments features. No memory corruption, so higher tiers do not apply; no improbable preconditions, so low does not fit. Chromium has consistently rated this class Medium (CVE-2024-0814, CVE-2025-0442, CVE-2020-6494 — payments-UI spoofs; CVE-2017-5110 — payments omnibox spoof via non-standard schemes). Android-only; desktop already uses the committed origin.

Suggested Fix Direction

Mirror desktop: display FormatOriginForSecurityDisplay(GetLastCommittedOrigin()) instead of formatting the visible URL (a document.open-rewritten or about:blank page keeps the origin it inherited, so the label stays truthful — this change was verified to defeat both variants). Independently: use end/middle ellipsis for non-standard display strings, and consider disallowing about:blank in IsValidUrlInPaymentHandlerWindow for post-initial navigations.


POC: Android PaymentHandler sheet origin label spoof — …paypal.com via about:blank#<padding> with attacker content preserved through document.open()

Strategy: web-content Bug Type: origin/identity spoof in Chrome-drawn payment trust UI (CWE-451); no memory corruption

The Android payment-handler bottom sheet derives its only origin-attribution line from the WebContents URL (PaymentHandlerToolbarMediator.java:127-131), formats it with UrlFormatter.formatUrlForSecurityDisplay (PaymentHandlerToolbarViewBinder.java:23-28), and renders it in a singleLine TextView with ellipsize="start" (sheet_tab_toolbar.xml). For the non-standard about: scheme the formatter falls back to the full spec and unescapes %20 to literal spaces, so about:blank#<250 spaces>paypal.com renders as …paypal.com — indistinguishable from a genuinely elided long origin. about:blank is deliberately admitted in the payment-handler window (components/payments/core/url_util.cc), so the sheet stays open.

Demonstrated end-to-end on a stock ChromePublic.apk (arm64 release build, default feature flags) on an Android 17 arm64 device:

  1. One tap on the attacker page (index.html) → PaymentRequest.show() with the URL payment method <origin>/pay.
  2. Chrome just-in-time installs the attacker’s service-worker payment app (payment-manifest.json → app.json → sw.js, all default-enabled behavior) and, via default skip-UI, invokes it directly.
  3. The SW’s paymentrequest handler calls openWindow('/checkout.html') — the Chrome-drawn payment-handler bottom sheet opens, origin line truthfully shows the attacker origin.
  4. Escalation (content-preserving): a same-origin about:blank iframe in checkout.html runs top.document.write(<fake PayPal page>). With no insertion point, the write runs the document open steps with the iframe’s about:blank document as entry document → the top document’s URL is rewritten to about:blank while the attacker markup (fake PayPal login, <title>PayPal</title>) is parsed into the still-alive top document. The written script then runs location.hash = ' '.repeat(250) + 'paypal.com' — a fragment-only same-document commit that the toolbar mediator does not filter → origin label becomes …paypal.com over the fake PayPal content.
  5. Base form (no document.open): location.href = 'about:blank#' + ' '.repeat(250) + 'paypal.com' — cross-document commit; the sheet stays open (about:blank carve-out in SslValidityChecker::IsValidPageInPaymentHandlerWindow) and the origin label reads …paypal.com over a blank sheet.

Prerequisites

  • ChromePublic.apk built from out/android-arm64 (stock args, no source changes) — any recent Chrome for Android build behaves the same.
  • A rooted Android device/VM (root is only needed to install the APK’s command-line file and to run the on-device web server; the vulnerability itself needs nothing but a web page).
  • The attacker site (proof-site/) served to the browser. In this environment the adb server runs on a remote host, so adb reverse cannot bridge to a workstation-local server; the site is instead served on the device at http://localhost:8000 by httpd.sh (a toybox nc -L HTTP server). With a local adb server, python3 server.py + adb reverse tcp:8000 tcp:8000 is equivalent. localhost is used purely as the serving convenience sanctioned by Chromium for payments development (components/payments/core/url_util.cc treats localhost like any trustworthy https origin in all relevant checks); a real attacker would serve the identical files from any https origin.

Reproduction

SERIAL=<adb-serial> ./run-poc.sh     # full scripted flow, or step by step:

adb -s $SERIAL root
adb -s $SERIAL shell 'echo "_ --disable-fre --no-first-run --no-default-browser-check" > /data/local/tmp/chrome-command-line'
adb -s $SERIAL install -r -t out/android-arm64/apks/ChromePublic.apk
adb -s $SERIAL shell "mkdir -p /data/local/tmp/pocsite"
adb -s $SERIAL push proof-site/* httpd.sh /data/local/tmp/pocsite/
adb -s $SERIAL shell "echo 0 > /data/local/tmp/pocsite/trigger.txt"
adb -s $SERIAL shell "(nohup nc -s 127.0.0.1 -p 8000 -L sh /data/local/tmp/pocsite/httpd.sh &)"
adb -s $SERIAL shell am start -n org.chromium.chrome/com.google.android.apps.chrome.Main -a android.intent.action.VIEW -d http://localhost:8000/
# dismiss the first-launch notification dialog if shown, then ONE tap:
adb -s $SERIAL shell input tap 360 520
# sheet opens ("localhost:8000"); fire the spoof:
adb -s $SERIAL shell "echo esc > /data/local/tmp/pocsite/trigger.txt"   # or "base"
adb -s $SERIAL exec-out screencap -p > after.png

Note (test VM only): on Cuttlefish the embedded web surface sometimes lags its first frame; resuming the activity (am start -n [org.chromium.chrome/com.google.android.apps.c](https://source.chromium.org/chromium/chromium/src/+/main:org.chromium.chrome/com.google.android.apps.c)hrome.Main) kicks the compositor. Chrome UI (the spoofed toolbar) is unaffected.

Expected Result

After the esc trigger, the payment-handler sheet toolbar shows title “PayPal” and origin label "…paypal.com" above a full fake-PayPal login form. The origin TextView (org.chromium.chrome:id/origin) contains the 272-char string about:blank# + 250 spaces + paypal.com; the start-ellipsis hides everything but the tail.

Output

Recorded evidence from the run (stock build, Android 17 arm64 VM):

  • screenshot-1-landing.png — merchant page, one big button (the 1 click).
  • screenshot-2-sheet-before-spoof.png — payment-handler sheet opened by the SW payment app; origin line truthfully reads localhost:8000.
  • screenshot-3-sheet-after-spoof.png — the spoof: title “PayPal”, origin line “…paypal.com”, fake PayPal email/password form in the sheet.
  • screenshot-4-base-form-spoof.png — base variant: “…paypal.com” over a blank sheet; sheet remained open across the about:blank commit.
  • ui-dump-after-spoof.xml / ui-dump-base-form.xml — uiautomator dumps. Origin TextView text in both (272 chars):
id=org.chromium.chrome:id/title    text='PayPal'          (escalation run)
id=org.chromium.chrome:id/origin   text='about:blank#                     ...
      [250 literal spaces total, unescaped from %20 by the formatter]
                                                            ...paypal.com'
  • device-access.log — on-device server log showing the whole default-on JIT chain from the single tap at 02:10:03: HEAD /pay → GET /payment-manifest.json → GET /app.json → GET /icon.png → GET /sw.js → GET /checkout.html (payment-handler window).

Fix

bandaid-fix.patch (verified): make PaymentHandlerToolbarMediator.didFinishNavigationInPrimaryMainFrame display the last committed origin instead of the visible URL, mirroring the desktop payment-handler sheet (payment_handler_web_flow_view_controller.cc, which formats GetLastCommittedOrigin() — desktop is not affected by this bug). about:blank / document.open()-rewritten documents keep the origin they inherited, so the label stays truthful.

Verified on-device with the patched build against both variants:

  • escalation: title “PayPal” + fake PayPal content, but origin line = localhost:8000 (screenshot-5-with-fix-escalation-defeated.png);
  • base form: origin line = localhost:8000 (uiautomator-confirmed).

Files

  • proof-site/ — the complete attacker site: index.html (merchant page), checkout.html (payment-handler page with both spoof variants), sw.js (payment app), payment-manifest.json, app.json, icon.png.
  • server.py — workstation server (Link header + trigger endpoint) for local-adb-server setups; httpd.sh — equivalent on-device server used in the recorded run.
  • run-poc.sh — exact device orchestration.
  • bandaid-fix.patch — verified fix.

Attached file hierarchy:

The following files were flattened for attachment. Double-underscores (__) in filenames represent directory separators.

  • proof-site__app.json → proof-site/app.json
  • proof-site__checkout.html → proof-site/checkout.html
  • proof-site__icon.png → proof-site/icon.png
  • proof-site__index.html → proof-site/index.html
  • proof-site__payment-manifest.json → proof-site/payment-manifest.json
  • proof-site__sw.js → proof-site/sw.js

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