High watch CVSS 8.3 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Payments
DescriptionUse after free in Payments
ComponentPayments
Sub-componentPayments
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.
Tracker533053621
Fix commitae3143abb013 (chromium/src) +75/-3
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-09 (per vendor advisory)
Fix landed2026-07-28 (public git)
Disclosed2026-08-06
Patch gap9 days — fix public in source before the advisory

Background

`PaymentRequest`
the browser-process object in components/payments/content/ that implements the mojom::PaymentRequest interface for a page’s Payment Request API calls; it lives as long as its hosting WebContents and is destroyed with it.
`PaymentRequestDelegate::ShowErrorMessage()`
the delegate hook that disables the payment dialog and shows a “transaction failed” message; the patch now documents that it “may result in the synchronous teardown of the PaymentRequest object”.
`observer_for_testing_`
a base::WeakPtr<ObserverForTest> member of PaymentRequest, notified through OnErrorDisplayed() after the error UI is shown.
Synchronous teardown
per the new unit test’s comment, closing the dialog widget can shift activation to an observer that closes the payment window, destroying the WebContents and its PaymentRequest before ShowErrorMessage() returns.

Root Cause Analysis

In PaymentRequest::ShowErrorMessageAndAbortPayment() (named in the commit subject; the hunk shows only the body), when display_handle_ exists and was_shown(), the code called delegate_->ShowErrorMessage() and then read the member observer_for_testing_ to call OnErrorDisplayed(). Because ShowErrorMessage() can synchronously tear down the hosting WebContents, this may already be freed when that line runs. Reading observer_for_testing_ is then a read of freed browser-process memory, followed by a virtual call through whatever pointer it yields. The broken assumption is that this survives the delegate call.

The fix copies observer_for_testing_ into a local base::WeakPtr<ObserverForTest> observer before calling ShowErrorMessage() and uses only that local afterwards, with the comment “Do not access this below this point”. It also adds a warning to the ShowErrorMessage() declaration in payment_request_delegate.h. The hunk ends inside the else branch, so the diff does not show whether any code after the if/else in this function also touches members.

Key insight
ShowErrorMessageAndAbortPayment() read a member right after calling a delegate method that can delete the PaymentRequest. The fix copies the only post-call dependency, the observer WeakPtr, into a local before the call and stops touching this afterwards.

Attack Path

  1. Page opens payment UI A page calls the Payment Request API and show() with user activation (the test uses had_user_activation=true), so the browser shows the payment dialog (display_handle_->was_shown() is true).
  2. Page completes with failure The page reports a failed payment, which the test models as Complete(mojom::PaymentComplete::FAIL); this leads to ShowErrorMessageAndAbortPayment() calling delegate_->ShowErrorMessage().
  3. Dialog close destroys the `WebContents` Showing the error closes or changes the dialog widget, and activation moves to an observer that closes the payment window, synchronously destroying the WebContents and the PaymentRequest (from the test’s comment; which production observer does this is not shown).
  4. Freed member read Control returns into the destroyed PaymentRequest, which reads observer_for_testing_ from freed memory and may call OnErrorDisplayed() through it. If that memory were reclaimed, this could become a controlled virtual call (inferred).

Impact Assessment

The bug is a use-after-free in the browser process, which is unsandboxed, triggered from Payment Request API calls made by web content. Its preconditions are a shown payment dialog (which needs a user gesture for show()), a failed completion, and the specific window-activation sequence that tears down the WebContents synchronously, so it is a narrow, UI-state-dependent path. The stale read is of a test-only WeakPtr member that is normally null in production, so turning it into code execution would require reclaiming the freed PaymentRequest memory with controlled contents (inferred). The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.3.

Changed Functions

FunctionChangeNotes
if
components/payments/content/payment_request.cc
modified
MockObserverForTest
components/payments/content/payment_request_unittest.cc
modified

Files Changed

  • components/payments/content/payment_request.cc
  • components/payments/content/payment_request_unittest.cc
  • components/payments/core/payment_request_delegate.h

Audit Directions

  • Other `delegate_` calls that close UI
    Check every PaymentRequest call site of delegate_->CloseDialog(), ShowErrorMessage(), ShowProcessingSpinner() and similar UI-changing calls for member access afterwards, and do the same in PaymentHandlerHost, SecurePaymentConfirmation and PaymentAppService code.
  • Widget close → activation → `WebContents` destruction
    Any browser-side feature that closes a dialog widget and then keeps running (autofill popups, permission bubbles, WebAuthn and FedCM dialogs) can hit the same “activation shift closes the tab” re-entrancy. Look for missing WeakPtr checks after such calls.
  • Test-only members on production paths
    Reads of *_for_testing_ members after a re-entrant call are still real UAFs in shipping builds, so they should be audited like any other member access.
From ae3143abb013464882b25c5ad5b5a569c21e596c Mon Sep 17 00:00:00 2001
From: Luis Antunes <[email protected]>
Date: Tue, 28 Jul 2026 11:38:43 -0700
Subject: [PATCH] [Payments] Fix UAF in PaymentRequest::ShowErrorMessageAndAbortPayment

`delegate_->ShowErrorMessage()` can trigger synchronous teardown of the
hosting `WebContents`. This destroys the `PaymentRequest` object while
it is still executing `ShowErrorMessageAndAbortPayment()`, causing a UAF
when subsequently notifying observers. This CL prevents the UAF by
saving `observer_for_testing_` to a local variable before closing the
dialog so that it can be safely invoked even if `this` is destroyed.

Fixed: 533053621
Change-Id: Idecfb82f56695ea4b07cb372ea38bad3f97cc6b1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8144841
Reviewed-by: Darwin Yang <[email protected]>
Commit-Queue: Luis Antunes <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1669631}
---
diff --git a/components/payments/content/payment_request.cc b/components/payments/content/payment_request.cc
index 5b270bc..2b1b2821 100644
--- a/components/payments/content/payment_request.cc
+++ b/components/payments/content/payment_request.cc
@@ -1290,9 +1290,14 @@
   if (display_handle_ && display_handle_->was_shown()) {
     // Will invoke OnUserCancelled() asynchronously when the user closes the
     // error message UI.
+    // ShowErrorMessage() can synchronously close the dialog and destroy `this`.
+    // We save `observer_for_testing_` locally so it can be safely invoked
+    // even during teardown. Do not access `this` below this point.
+    base::WeakPtr<ObserverForTest> observer = observer_for_testing_;
     delegate_->ShowErrorMessage();
-    if (observer_for_testing_)
-      observer_for_testing_->OnErrorDisplayed();
+    if (observer) {
+      observer->OnErrorDisplayed();
+    }
   } else {
     // Only app store billing apps do not display any browser payment UI.
     DCHECK(spec_->IsAppStoreBillingAlsoRequested());
diff --git a/components/payments/core/payment_request_delegate.h b/components/payments/core/payment_request_delegate.h
index 95cfa66d..5173302 100644
--- a/components/payments/core/payment_request_delegate.h
+++ b/components/payments/core/payment_request_delegate.h
@@ -34,7 +34,9 @@
   virtual void CloseDialog() = 0;
 
   // Disables the dialog and shows an error message that the transaction has
-  // failed.
+  // failed. Warning: This method may result in the synchronous teardown of
+  // the PaymentRequest object. Callers should not access their members after
+  // calling this method.
   virtual void ShowErrorMessage() = 0;
 
   // Disables user interaction by showing a spinner.
diff --git a/components/payments/content/payment_request_unittest.cc b/components/payments/content/payment_request_unittest.cc
index 4465d72..21d8a88 100644
--- a/components/payments/content/payment_request_unittest.cc
+++ b/components/payments/content/payment_request_unittest.cc
@@ -267,6 +267,71 @@
   EXPECT_FALSE(bad_message_observer.got_bad_message());
 }
 
+class MockObserverForTest : public PaymentRequest::ObserverForTest {
+ public:
+  MOCK_METHOD(void, OnErrorDisplayed, (), (override));
+
+  void OnCanMakePaymentCalled() override {}
+  void OnCanMakePaymentReturned() override {}
+  void OnHasEnrolledInstrumentCalled() override {}
+  void OnHasEnrolledInstrumentReturned() override {}
+  void OnNotSupportedError() override {}
+  void OnConnectionTerminated() override {}
+  void OnPayCalled() override {}
+  void OnAbortCalled() override {}
+  void OnInternalError() override {}
+
+  base::WeakPtr<MockObserverForTest> GetWeakPtr() {
+    return weak_ptr_factory_.GetWeakPtr();
+  }
+
+ private:
+  base::WeakPtrFactory<MockObserverForTest> weak_ptr_factory_{this};
+};
+
+// Tests that PaymentRequest handles the case where showing the error message
+// synchronously destroys the hosting WebContents (and therefore the
+// PaymentRequest itself), which can happen when closing the dialog widget
+// shifts activation to an observer that closes the payment window.
+TEST_P(PaymentRequestValidationTest,
+       CompleteFailWithSynchronousWebContentsDestruction) {
+  auto delegate = CreateMockDelegate();
+  auto* delegate_ptr = delegate.get();
+
+  mojo::Remote<mojom::PaymentRequest> payment_request;
+  PaymentRequest* request = new PaymentRequest(
+      std::move(delegate), payment_request.BindNewPipeAndPassReceiver());
+  base::WeakPtr<PaymentRequest> weak_request = request->GetWeakPtr();
+
+  MockObserverForTest mock_observer;
+  request->set_observer_for_test(mock_observer.GetWeakPtr());
+
+  mojo::PendingRemote<mojom::PaymentRequestClient> client_remote;
+  auto dummy_receiver = client_remote.InitWithNewPipeAndPassReceiver();
+
+  std::vector<mojom::PaymentMethodDataPtr> method_data;
+  method_data.push_back(CreateSpcMethodData());
+
+  payment_request->Init(std::move(client_remote), std::move(method_data),
+                        CreateDummyDetails(), mojom::PaymentOptions::New());
+  payment_request->Show(/*wait_for_updated_details=*/false,
+                        /*had_user_activation=*/true);
+
+  // Due to the observer local copy trick, the observer still gets notified
+  // even if the PaymentRequest is synchronously destroyed.
+  EXPECT_CALL(mock_observer, OnErrorDisplayed()).Times(1);
+
+  EXPECT_CALL(*delegate_ptr, ShowErrorMessage()).WillOnce([this] {
+    DeleteContents();
+  });
+
+  payment_request->Complete(mojom::PaymentComplete::FAIL);
+  payment_request.FlushForTesting();
+
+  // The PaymentRequest was safely destroyed without triggering the UAF.
+  EXPECT_FALSE(weak_request);
+}
+
 INSTANTIATE_TEST_SUITE_P(All, PaymentRequestValidationTest, testing::Bool());
 
 }  // namespace
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/payments/content/payment_request_unittest.cc b/components/payments/content/payment_request_unittest.cc
index 4465d72..21d8a88 100644
--- a/components/payments/content/payment_request_unittest.cc
+++ b/components/payments/content/payment_request_unittest.cc
@@ -267,6 +267,71 @@
   EXPECT_FALSE(bad_message_observer.got_bad_message());
 }
 
+class MockObserverForTest : public PaymentRequest::ObserverForTest {
+ public:
+  MOCK_METHOD(void, OnErrorDisplayed, (), (override));
+
+  void OnCanMakePaymentCalled() override {}
+  void OnCanMakePaymentReturned() override {}
+  void OnHasEnrolledInstrumentCalled() override {}
+  void OnHasEnrolledInstrumentReturned() override {}
+  void OnNotSupportedError() override {}
+  void OnConnectionTerminated() override {}
+  void OnPayCalled() override {}
+  void OnAbortCalled() override {}
+  void OnInternalError() override {}
+
+  base::WeakPtr<MockObserverForTest> GetWeakPtr() {
+    return weak_ptr_factory_.GetWeakPtr();
+  }
+
+ private:
+  base::WeakPtrFactory<MockObserverForTest> weak_ptr_factory_{this};
+};
+
+// Tests that PaymentRequest handles the case where showing the error message
+// synchronously destroys the hosting WebContents (and therefore the
+// PaymentRequest itself), which can happen when closing the dialog widget
+// shifts activation to an observer that closes the payment window.
+TEST_P(PaymentRequestValidationTest,
+       CompleteFailWithSynchronousWebContentsDestruction) {
+  auto delegate = CreateMockDelegate();
+  auto* delegate_ptr = delegate.get();
+
+  mojo::Remote<mojom::PaymentRequest> payment_request;
+  PaymentRequest* request = new PaymentRequest(
+      std::move(delegate), payment_request.BindNewPipeAndPassReceiver());
+  base::WeakPtr<PaymentRequest> weak_request = request->GetWeakPtr();
+
+  MockObserverForTest mock_observer;
+  request->set_observer_for_test(mock_observer.GetWeakPtr());
+
+  mojo::PendingRemote<mojom::PaymentRequestClient> client_remote;
+  auto dummy_receiver = client_remote.InitWithNewPipeAndPassReceiver();
+
+  std::vector<mojom::PaymentMethodDataPtr> method_data;
+  method_data.push_back(CreateSpcMethodData());
+
+  payment_request->Init(std::move(client_remote), std::move(method_data),
+                        CreateDummyDetails(), mojom::PaymentOptions::New());
+  payment_request->Show(/*wait_for_updated_details=*/false,
+                        /*had_user_activation=*/true);
+
+  // Due to the observer local copy trick, the observer still gets notified
+  // even if the PaymentRequest is synchronously destroyed.
+  EXPECT_CALL(mock_observer, OnErrorDisplayed()).Times(1);
+
+  EXPECT_CALL(*delegate_ptr, ShowErrorMessage()).WillOnce([this] {
+    DeleteContents();
+  });
+
+  payment_request->Complete(mojom::PaymentComplete::FAIL);
+  payment_request.FlushForTesting();
+
+  // The PaymentRequest was safely destroyed without triggering the UAF.
+  EXPECT_FALSE(weak_request);
+}
+
 INSTANTIATE_TEST_SUITE_P(All, PaymentRequestValidationTest, testing::Bool());
 
 }  // namespace
Loading diff…

Original Bug Report

reported by [email protected]

Potential Browser Use-After-Free in PaymentRequest::ShowErrorMessageAndAbortPayment

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: A potential Use-After-Free (UAF) vulnerability exists in the browser-process PaymentRequest class. When calling delegate_->ShowErrorMessage(), the hosting WebContents (and thus the PaymentRequest DocumentService itself) can be synchronously destroyed. Subsequent execution inside the method continues to access members of the freed PaymentRequest instance, potentially leading to memory corruption.

Affected files:

  • components/payments/content/payment_request.cc

Estimated timestamp from git blame: 2021-06-16

Description

A potential Use-After-Free (UAF) vulnerability has been identified in PaymentRequest::ShowErrorMessageAndAbortPayment() located in components/payments/content/payment_request.cc.

PaymentRequest is a browser-process content::DocumentService<mojom::PaymentRequest> whose lifetime is tied to the RenderFrameHost’s document. When a Secure Payment Confirmation (SPC) dialog is shown, calling delegate_->ShowErrorMessage() can trigger synchronous dismissal of the widget dialog. Crucially, closing the widget can trigger activation/focus observers (such as ExtensionPopup::OnWidgetTreeActivated) that synchronously destroy the hosting WebContents.

When the WebContents is destroyed, ~DocumentAssociatedData synchronously deletes all registered DocumentService objects, executing ~PaymentRequest(). This frees the PaymentRequest heap allocation. However, when the execution stack unwinds and returns to the caller frame PaymentRequest::ShowErrorMessageAndAbortPayment(), the code continues to read from and execute virtual calls through member variables of the freed this pointer.

Potential Call Chain

  1. PaymentRequest::ShowErrorMessageAndAbortPayment()
  2. ChromePaymentRequestDelegate::ShowErrorMessage()
  3. SecurePaymentConfirmationController::ShowErrorMessage()
  4. SecurePaymentConfirmationController::OnCancel()
  5. SecurePaymentConfirmationController::CloseDialog()
  6. SecurePaymentConfirmationDialogView::HideDialog()
  7. views::Widget::Close()
  8. Focus/activation shifts, triggering observers like ExtensionPopup::OnWidgetTreeActivated which synchronously call WebContents::Close() / destroy the host.
  9. DocumentAssociatedData::~DocumentAssociatedData() executes and synchronously destroys the PaymentRequest instance.
  10. Execution resumes in PaymentRequest::ShowErrorMessageAndAbortPayment() at line 1294:
// components/payments/content/payment_request.cc:1287-1301
void PaymentRequest::ShowErrorMessageAndAbortPayment() {
  if (display_handle_ && display_handle_->was_shown()) {
    delegate_->ShowErrorMessage();               // <--- synchronously frees |this|
    if (observer_for_testing_)                   // <--- UAF read of freed member variable
      observer_for_testing_->OnErrorDisplayed(); // <--- Virtual method call on potentially corrupted/reclaimed heap memory
  } else {
    ...
  }
}

Why Previous Hardening is Insufficient

While the hardening fix for crbug.com/521495992 applied a weak_this guard inside SecurePaymentConfirmationController::OnCancel() to prevent the controller itself from accessing freed memory, it did not protect the caller frame PaymentRequest::ShowErrorMessageAndAbortPayment(). When the WebContents is destroyed, both the controller and the PaymentRequest parent service are freed. The execution resumes in PaymentRequest without any self-lifetime validation.

Suggested / Potential Trigger Steps

(Note: These are suggested steps based on source-code tracing; our tooling does not currently have the capability to run a live proof of concept).

  1. Embed a PaymentRequest-capable iframe within a focus-sensitive parent window (such as an extension popup dialog).
  2. Initialize a PaymentRequest specifying Secure Payment Confirmation (SPC) with valid credentials matching a registered authenticator.
  3. Call Show() to bring up the SPC dialog modal.
  4. Before any user interaction, have the compromised renderer issue a mojom::PaymentRequest::Complete(mojom::PaymentComplete::FAIL) Mojo message.
  5. PaymentRequest::Complete receives the failure command and routes execution to ShowErrorMessageAndAbortPayment(), triggering the synchronous destruction flow described above.

Suggested Fix

To remediate this issue, check if the PaymentRequest object has been destroyed during the synchronous call by capturing and checking a base::WeakPtr of this before accessing any member variables:

void PaymentRequest::ShowErrorMessageAndAbortPayment() {
  if (display_handle_ && display_handle_->was_shown()) {
    base::WeakPtr<PaymentRequest> weak_self = weak_ptr_factory_.GetWeakPtr();
    delegate_->ShowErrorMessage();
    if (!weak_self) {
      return;
    }
    if (observer_for_testing_)
      observer_for_testing_->OnErrorDisplayed();
  } else {
    ...
  }
}

Evaluated with Chrome root at commit: 84065d9121f6e48f67755f0ae963cc09617e5c85


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