CVE-2026-113199
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/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.javachrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediator.javachrome/browser/payments/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediatorTest.javachrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarCoordinator.javachrome/browser/payments/android/BUILD.gnchrome/android/javatests/src/org/chromium/chrome/browser/payments/ExpandablePaymentHandlerTest.javachrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarProperties.java
Patch
From d6d85a80d183b4059ddedb69a39438625f883b4e Mon Sep 17 00:00:00 2001 From: Stephen McGruer <[email protected]> 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 <[email protected]> Reviewed-by: Darwin Yang <[email protected]> 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",
Original Bug Report
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:
chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarViewBinder.java:23-28— formats the URL (not origin) for the security labelchrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediator.java:127-131— feedsgetVisibleUrl()into the label on every committed navigation, including same-documentchrome/browser/ui/android/toolbar/java/res/layout/sheet_tab_toolbar.xml:116-124— origin TextView isellipsize="start"+singleLine="true"components/url_formatter/elide_url.cc:351-355— non-standard URLs bypass security-display formatting, fall back to full speccomponents/payments/core/url_util.cc:58-59,128-134— about:blank deliberately admitted into the payment-handler window
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, soFormatUrlForSecurityDisplayfalls back to the full spec (elide_url.cc:351-355) and unescapes%20to literal spaces (FormatUrlusesbase::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.comrenders as…paypal.com— indistinguishable from a genuinely elided long origin. - about:blank is allowed to live in the payment-handler window:
UrlUtil::IsValidUrlInPaymentHandlerWindowpassesAboutBlank::kIsAllowedInPaymentHandlerWindow(components/payments/core/url_util.cc:128-134, check at:58-59), andSslValidityChecker::IsValidPageInPaymentHandlerWindowaccepts 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
- Label derives from the URL, not the origin:
PaymentHandlerToolbarMediator.java:127-131— every committed primary-main-frame navigation (same-document included; contrastPaymentHandlerMediator.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()); - Security-display formatting silently degrades for non-standard schemes:
PaymentHandlerToolbarViewBinder.java:23-28callsUrlFormatter.formatUrlForSecurityDisplay, which forabout: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 -> ' ' } - Start-ellipsis shows only the attacker-chosen tail:
sheet_tab_toolbar.xml:116-124— the origin TextView isandroid:ellipsize="start"+android:singleLine="true", so theabout:blank#prefix and the space padding vanish behind…. - about:blank is admitted and keeps the sheet open:
components/payments/core/url_util.cc:58-59,128-134allows about:blank in the payment-handler window (GURL::IsAboutBlank()ignores query/ref, soabout:blank#<fragment>qualifies);ssl_validity_checker.cc:62-81accepts it as a valid page, socloseIfInsecure(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). - 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-originabout:blankiframe sets the top document’s URL toabout:blank(SetURL(new_url)+DidOpenDocumentInputStream) while the document stays alive fordocument.write(). The follow-up fragment-only navigation commits same-document and re-enters step 1.
Reproduction
- 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. - 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’spaymentrequesthandler callsopenWindow('<origin>/checkout'), opening the payment-handler bottom sheet with a truthful origin label. - Escalation variant: the checkout page embeds a same-origin
about:blankiframe whose script runstop.document.open(); top.document.write('<title>PayPal</title>' + fake PayPal login markup); top.document.close();, after which the written script runslocation.hash = ' '.repeat(250) + 'paypal.com'. - Base variant: the checkout page simply runs
location.href = 'about:blank#' + ' '.repeat(250) + 'paypal.com'. - 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:
- One tap on the attacker page (
index.html) →PaymentRequest.show()with the URL payment method<origin>/pay. - 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. - The SW’s
paymentrequesthandler callsopenWindow('/checkout.html')— the Chrome-drawn payment-handler bottom sheet opens, origin line truthfully shows the attacker origin. - Escalation (content-preserving): a same-origin
about:blankiframe incheckout.htmlrunstop.document.write(<fake PayPal page>). With no insertion point, the write runs the document open steps with the iframe’sabout:blankdocument as entry document → the top document’s URL is rewritten toabout:blankwhile the attacker markup (fake PayPal login,<title>PayPal</title>) is parsed into the still-alive top document. The written script then runslocation.hash = ' '.repeat(250) + 'paypal.com'— a fragment-only same-document commit that the toolbar mediator does not filter → origin label becomes…paypal.comover the fake PayPal content. - Base form (no document.open):
location.href = 'about:blank#' + ' '.repeat(250) + 'paypal.com'— cross-document commit; the sheet stays open (about:blank carve-out inSslValidityChecker::IsValidPageInPaymentHandlerWindow) and the origin label reads…paypal.comover a blank sheet.
Prerequisites
ChromePublic.apkbuilt fromout/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, soadb reversecannot bridge to a workstation-local server; the site is instead served on the device athttp://localhost:8000byhttpd.sh(a toyboxnc -LHTTP server). With a local adb server,python3 server.py+adb reverse tcp:8000 tcp:8000is equivalent.localhostis used purely as the serving convenience sanctioned by Chromium for payments development (components/payments/core/url_util.cctreats 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 readslocalhost: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.jsonproof-site__checkout.html→proof-site/checkout.htmlproof-site__icon.png→proof-site/icon.pngproof-site__index.html→proof-site/index.htmlproof-site__payment-manifest.json→proof-site/payment-manifest.jsonproof-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.
- http://localhost:8000
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarMediator.java;l=127
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/android/java/src/org/chromium/chrome/browser/payments/handler/toolbar/PaymentHandlerToolbarViewBinder.java;l=23
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/views/payments/payment_handler_web_flow_view_controller.cc;l=372
- https://source.chromium.org/chromium/chromium/src/+/main:components/payments/content/payment_handler_navigation_throttle.cc;l=54
- https://source.chromium.org/chromium/chromium/src/+/main:components/payments/content/ssl_validity_checker.cc;l=62
- https://source.chromium.org/chromium/chromium/src/+/main:components/payments/core/url_util.cc
- https://source.chromium.org/chromium/chromium/src/+/main:components/payments/core/url_util.cc;l=128
- https://source.chromium.org/chromium/chromium/src/+/main:components/payments/core/url_util.cc;l=58
- https://source.chromium.org/chromium/chromium/src/+/main:components/url_formatter/elide_url.cc;l=351
- https://source.chromium.org/chromium/chromium/src/+/main:components/url_formatter/url_formatter.h;l=167
- https://source.chromium.org/chromium/chromium/src/+/main:org.chromium.chrome/com.google.android.apps.c
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/document.cc;l=3856