Medium watch CVSS 7.5 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Payments
DescriptionUse after free in Payments
ComponentPayments
Sub-componentBNPL
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker499018889
Fix commitdd2818b02583 (chromium/src) +31/-2
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedpwn2addr
Reported2026-04-02 (per vendor advisory)
Fix landed2026-04-07 (public git)
Disclosed2026-04-15
Patch gap8 days — fix public in source before the advisory

Background

`PaymentsWindowBridge` (Java)
the Android/Clank Java object that drives the BNPL (Buy Now Pay Later) ephemeral payment tab and holds a long mNativePaymentsWindowBridge pointing at its C++ counterpart.
`PaymentsWindowBridge` (C++)
the native object whose address is stored in the Java field; it owns a reference to the Java object via java_payments_window_bridge_.
JNI native-pointer pattern
Chromium passes a raw C++ this pointer into Java as an opaque long, and Java passes it back on each @CalledByNative/...Jni.get() call so the generated JNI glue can reinterpret_cast it back to the C++ object.
`@CalledByNative` / `onNativeDestroyed`
the newly added Java method the C++ destructor invokes to notify Java that the native object is gone.

Root Cause Analysis

The Java PaymentsWindowBridge stored its native counterpart in a final long mNativePaymentsWindowBridge, so once set it could never be invalidated. The C++ PaymentsWindowBridge::~PaymentsWindowBridge() was defaulted and did not tell Java it had been destroyed, so the Java object retained a stale native pointer after the C++ object was freed. Java methods such as onNavigationFinished, onWebContentsObservationStarted, and onWebContentsDestroyed forwarded that pointer straight into JNI (PaymentsWindowBridgeJni.get().…(mNativePaymentsWindowBridge, …)), which reinterpret_casts and dereferences it — a use-after-free if the native object was already gone.

The fix makes the field non-final, adds @CalledByNative onNativeDestroyed() that zeroes it, has the C++ destructor call Java_PaymentsWindowBridge_onNativeDestroyed (guarded by if (java_payments_window_bridge_)), and adds if (mNativePaymentsWindowBridge == 0) return; to each JNI-forwarding method.

The restored invariant: no JNI call is made on a native pointer that no longer references a live object.

Key insight
The core mistake was treating the Java-held native pointer as permanently valid (final, never nulled on native destruction), so Java could call into a freed C++ object; the fix adds a destruction notification that zeroes the pointer and a zero-check gate before every JNI call.

Attack Path

  1. Open a BNPL ephemeral payment tab Reach the flow that constructs both the Java PaymentsWindowBridge and its C++ peer, establishing the stored mNativePaymentsWindowBridge.
  2. Destroy the native object while Java survives Cause the C++ PaymentsWindowBridge to be destroyed (e.g. the payment window/native lifecycle ends) while the Java object is still referenced and able to receive callbacks.
  3. Trigger a Java->native callback Drive an event that invokes onNavigationFinished, onWebContentsObservationStarted, or onWebContentsDestroyed, which (pre-patch) forward the stale pointer through JNI.
  4. Use-after-free in native code The JNI glue reinterpret_casts and dereferences the freed mNativePaymentsWindowBridge, operating on released memory; the diff shows the dangling-pointer call but no further primitive, so any escalation is (inferred).

Impact Assessment

An attacker able to time native-object destruction against a pending Java callback in the BNPL payment-window flow gains a use-after-free in the browser process on Android (Clank). Preconditions are reaching the autofill/payments ephemeral-tab path and inducing the lifetime mismatch between the Java and C++ bridge objects. The advisory rates this medium severity and reports no in-the-wild exploitation; memory-corruption escalation beyond the UAF is (inferred).

Changed Functions

FunctionChangeNotes
PaymentsWindowBridge
chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridge.java
modified
if
chrome/browser/ui/android/autofill/payments/payments_window_bridge.cc
modified

Files Changed

  • chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridge.java
  • chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridgeTest.java
  • chrome/browser/ui/android/autofill/payments/payments_window_bridge.cc

Audit Directions

  • Java-held native pointers
    Audit Android bridge classes storing a native long; any final native-pointer field with no onNativeDestroyed-style invalidation is a UAF candidate.
  • Native destructors that skip Java notification
    Review C++ ~Destructors of objects referenced by Java to ensure they signal the Java peer before freeing, especially defaulted (= default) destructors.
  • Unguarded JNI forwarding methods
    Check that every Java method forwarding a native pointer into ...Jni.get() first verifies the pointer is non-zero.
From dd2818b02583be0a2569e2a53997f08a7b48dc87 Mon Sep 17 00:00:00 2001
From: viplavkadam <[email protected]>
Date: Tue, 07 Apr 2026 09:51:43 -0700
Subject: [PATCH] [BNPL][Clank] PaymentWindowBridge native pointer fix for Use-After-free

Making the native pointer in Java non-final and setting it zero when
the C++ object is destroyed. Guarding all JNI calls from Java to C++
with a zero-pointer check

Bug: 499018889
Change-Id: I5a2956160d15236dc96598ae132339a1eeb2d3ba
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7731228
Reviewed-by: Timofey Chudakov <[email protected]>
Reviewed-by: Vinny Persky <[email protected]>
Commit-Queue: Viplav Kadam <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1610834}
---

diff --git a/chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridge.java b/chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridge.java
index 818265c..2db1435 100644
--- a/chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridge.java
+++ b/chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridge.java
@@ -16,7 +16,7 @@
 @JNINamespace("autofill::payments")
 @NullMarked
 class PaymentsWindowBridge {
-    private final long mNativePaymentsWindowBridge;
+    private long mNativePaymentsWindowBridge;
     private PaymentsWindowCoordinator mPaymentsWindowCoordinator;
 
     @CalledByNative
@@ -35,6 +35,11 @@
         mPaymentsWindowCoordinator.closeEphemeralTab();
     }
 
+    @CalledByNative
+    public void onNativeDestroyed() {
+        mNativePaymentsWindowBridge = 0;
+    }
+
     PaymentsWindowCoordinator getPaymentsWindowCoordinatorForTesting() {
         return mPaymentsWindowCoordinator;
     }
@@ -50,6 +55,7 @@
      * @param clickedUrl The URL that the user initiated the navigation to.
      */
     void onNavigationFinished(GURL clickedUrl) {
+        if (mNativePaymentsWindowBridge == 0) return;
         PaymentsWindowBridgeJni.get().onNavigationFinished(mNativePaymentsWindowBridge, clickedUrl);
     }
 
@@ -59,6 +65,7 @@
      * @param webContents The WebContents that is being observed.
      */
     public void onWebContentsObservationStarted(WebContents webContents) {
+        if (mNativePaymentsWindowBridge == 0) return;
         PaymentsWindowBridgeJni.get()
                 .onWebContentsObservationStarted(mNativePaymentsWindowBridge, webContents);
     }
@@ -70,6 +77,7 @@
      * imminently destroyed.
      */
     void onWebContentsDestroyed() {
+        if (mNativePaymentsWindowBridge == 0) return;
         PaymentsWindowBridgeJni.get().onWebContentsDestroyed(mNativePaymentsWindowBridge);
     }
 
diff --git a/chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridgeTest.java b/chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridgeTest.java
index a58457d..2355101 100644
--- a/chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridgeTest.java
+++ b/chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridgeTest.java
@@ -6,6 +6,7 @@
 
 import static org.junit.Assert.assertNotNull;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 import org.junit.Before;
 import org.junit.Rule;
@@ -81,4 +82,19 @@
 
         verify(mNativeMock).onWebContentsDestroyed(AUTOFILL_PAYMENTS_WINDOW_BRIDGE_NATIVE_POINTER);
     }
+
+    @Test
+    public void testNativeMethodsNotCalledAfterNativeDestroyed() {
+        PaymentsWindowBridgeJni.setInstanceForTesting(mNativeMock);
+
+        // Simulate native object destruction.
+        mPaymentsWindowBridge.onNativeDestroyed();
+
+        // Native method calls.
+        mPaymentsWindowBridge.onNavigationFinished(ISSUER_URL);
+        mPaymentsWindowBridge.onWebContentsObservationStarted(mMerchantWebContents);
+        mPaymentsWindowBridge.onWebContentsDestroyed();
+
+        verifyNoInteractions(mNativeMock);
+    }
 }
diff --git a/chrome/browser/ui/android/autofill/payments/payments_window_bridge.cc b/chrome/browser/ui/android/autofill/payments/payments_window_bridge.cc
index f10e3000..d18a6bf 100644
--- a/chrome/browser/ui/android/autofill/payments/payments_window_bridge.cc
+++ b/chrome/browser/ui/android/autofill/payments/payments_window_bridge.cc
@@ -27,7 +27,12 @@
       base::android::AttachCurrentThread(), reinterpret_cast<int64_t>(this));
 }
 
-PaymentsWindowBridge::~PaymentsWindowBridge() = default;
+PaymentsWindowBridge::~PaymentsWindowBridge() {
+  if (java_payments_window_bridge_) {
+    Java_PaymentsWindowBridge_onNativeDestroyed(
+        base::android::AttachCurrentThread(), java_payments_window_bridge_);
+  }
+}
 
 void PaymentsWindowBridge::OpenEphemeralTab(
     const GURL& url,
Loading diff…

Original Bug Report

reported by [email protected]

Use-After-Free via dangling JNI native pointer in PaymentsWindowBridge::OnWebContentsDestroyed

Steps to reproduce the problem

Continued from https://issues.chromium.org/issues/498289905.

  1. Checkout and apply the patch:
git checkout 14e903b7ea5de658e62e89cd56ca758a5a0c2428
git apply patch3.diff
  1. Build Chromium for Android:
gn gen out/Default --args='target_os="android" target_cpu="arm64" is_debug=false use_siso=false android_static_analysis="off"'
autoninja -C out/Default chrome_public_apk
  1. Install on a device (connect an ARM64 Android device via adb):
out/Default/bin/chrome_public_apk install
  1. Start the MITM proxy on the host machine:
pip install mitmproxy
mitmproxy --mode regular --listen-port 8080 -s mitm_bnpl.py
  1. Host the PoC files (poc.html, poc_checkout.html, bnpl_page.html):
python3 -m http.server 8888
  1. Launch Chrome:
out/Default/bin/chrome_public_apk run \
  --args='--enable-features=AutofillEnableBuyNowPayLater,AutofillEnableAmountExtraction,AutofillEnableAmountExtractionTesting,AutofillDisableBnplCountryCheckForTesting,AutofillEnableBuyNowPayLaterSyncing --proxy-server=http://<host>:8080 --ignore-certificate-errors'
  1. Add a test credit card in Chrome Settings > Payment methods (Name: Test User, Number: 4111 1111 1111 1111, Expiry: 12/30).

  2. Navigate to http://<host>:8888/poc.html and click “Start PoC”.

  3. In the new tab, tap the Card Number field, select the saved card from the autofill bar, and then select “Affirm” from the Pay Later options.

  4. After the ephemeral tab (bottom sheet) appears, the parent page closes the merchant tab in 10 seconds. The browser process crashes with SIGSEGV at 0xcdcdcdcdcdcdcdcd in PaymentsWindowBridge::OnWebContentsDestroyed.

Problem Description

PaymentsWindowBridge.java stores the C++ PaymentsWindowBridge pointer as a final long field:

// PaymentsWindowBridge  PaymentsWindowBridge.java:19
class PaymentsWindowBridge {
    private final long mNativePaymentsWindowBridge;    // [1]
    ...
    void onWebContentsDestroyed() {
        PaymentsWindowBridgeJni.get().onWebContentsDestroyed(mNativePaymentsWindowBridge);  // [5]
    }
}

The pointer is set once in the constructor [1] and never cleared. The final keyword makes it impossible to set it to zero.

The PaymentsWindowCoordinator registers itself as an EphemeralTabObserver on the Activity-scoped EphemeralTabCoordinator:

// PaymentsWindowCoordinator.openEphemeralTab  PaymentsWindowCoordinator.java:47
void openEphemeralTab(GURL url, String title, WebContents merchantWebContents) {
    ...
    WindowAndroid windowAndroid = merchantWebContents.getTopLevelNativeWindow();
    ...
    mEphemeralTabCoordinator = supplier.get();
    mEphemeralTabCoordinator.addObserver(this);   // [2]
    ...
}

The EphemeralTabCoordinator is retrieved from WindowAndroid’s unowned user data [2], making it Activity-scoped – it survives individual tab closures.

The C++ ownership chain is:

WebContents (merchant tab)
  -> ContentAutofillClient (WebContentsUserData)
    -> ChromeAutofillClient (extends ContentAutofillClient)
      -> ChromePaymentsAutofillClient (member)
        -> AndroidPaymentsWindowManager (unique_ptr)
          -> PaymentsWindowBridge (unique_ptr)

AndroidPaymentsWindowManager owns PaymentsWindowBridge as a unique_ptr:

// AndroidPaymentsWindowManager  android_payments_window_manager.h:65
class AndroidPaymentsWindowManager : public PaymentsWindowManager,
                                     public PaymentsWindowDelegate {
  ...
  std::unique_ptr<PaymentsWindowBridge> payments_window_bridge_;   // [3]
};

The C++ destructor is defaulted and does not notify the Java side:

// PaymentsWindowBridge::~PaymentsWindowBridge  payments_window_bridge.cc:30
PaymentsWindowBridge::~PaymentsWindowBridge() = default;   // [4]

When the merchant tab is closed, ~WebContentsImpl destroys WebContentsUserData entries, which chains through ~ChromeAutofillClient -> ~ChromePaymentsAutofillClient -> ~AndroidPaymentsWindowManager -> ~PaymentsWindowBridge [3][4]. The defaulted destructor [4] does not call into Java to null out mNativePaymentsWindowBridge, nor does it remove the PaymentsWindowCoordinator from the EphemeralTabCoordinator’s observer list.

Subsequently, when the ephemeral tab’s WebContents is cleaned up, PaymentsWindowCoordinator.onWebContentsDestroyed() fires through the observer registered at [2], which calls PaymentsWindowBridge.onWebContentsDestroyed() [5], passing the dangling mNativePaymentsWindowBridge to JNI. The C++ side then dereferences payments_window_delegate_ on the freed object:

// PaymentsWindowBridge::OnWebContentsDestroyed  payments_window_bridge.cc:66-68
void PaymentsWindowBridge::OnWebContentsDestroyed(JNIEnv* env) {
  payments_window_delegate_->WebContentsDestroyed();   // [6] UAF: `this` is freed
}

The crash occurs at [6] because this was freed at [4], and payments_window_delegate_ (a raw_ref) now contains 0xcdcdcdcdcdcdcdcd.

The same pattern was previously found and fixed in FacilitatedPaymentsPaymentMethodsControllerBridge (crbug.com/355139788). That fix made the native pointer non-final, added an onNativeDestroyed() callback to zero it out, and guarded every JNI callback with a null check. None of these protections were applied to PaymentsWindowBridge.

[1] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridge.java;l=19;drc=14e903b7ea5de658e62e89cd56ca758a5a0c2428

[2] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowCoordinator.java;l=56;drc=14e903b7ea5de658e62e89cd56ca758a5a0c2428

[3] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/autofill/payments/android_payments_window_manager.h;l=65;drc=14e903b7ea5de658e62e89cd56ca758a5a0c2428

[4] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/android/autofill/payments/payments_window_bridge.cc;l=30;drc=14e903b7ea5de658e62e89cd56ca758a5a0c2428

[5] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/android/autofill/internal/java/src/org/chromium/chrome/browser/ui/autofill/ephemeraltab/PaymentsWindowBridge.java;l=73;drc=14e903b7ea5de658e62e89cd56ca758a5a0c2428

[6] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/android/autofill/payments/payments_window_bridge.cc;l=66;drc=14e903b7ea5de658e62e89cd56ca758a5a0c2428

Additional Comments

If a signed-in Chrome instance with a US-based Google account, a linked BNPL issuer (e.g., Affirm), and a BNPL-eligible merchant page is available, this bug can be triggered from poc.html with no patches. I don’t have such account, so I used patch to reproduce. If you still think this patch inappropriate, please let me know in the comments.

Summary

Use-After-Free via dangling JNI native pointer in PaymentsWindowBridge::OnWebContentsDestroyed

Custom Questions

Type of crash:

browser

Crash state:

Please see the attached tombstone.txt

Reporter credit:

pwn2addr

Additional Data

Category: Security
Chrome Channel: Not sure
Regression: N/A \

View on issue tracker