Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Payments
DescriptionUse after free in Payments
ComponentPayments
Bug ClassUAF
Tracker533053621
Fix commitae3143abb013 (chromium/src) +75/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-06

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
From ae3143abb013464882b25c5ad5b5a569c21e596c Mon Sep 17 00:00:00 2001
From: Luis Antunes <luisantunes@google.com>
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 <darwinyang@chromium.org>
Commit-Queue: Luis Antunes <luisantunes@google.com>
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/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
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.
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 vm...@google.com

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