CVE-2026-19155
Overview
Background
- `PaymentRequest`
- the browser-process object in
components/payments/content/that implements themojom::PaymentRequestinterface for a page’s Payment Request API calls; it lives as long as its hostingWebContentsand 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
PaymentRequestobject”. - `observer_for_testing_`
- a
base::WeakPtr<ObserverForTest>member ofPaymentRequest, notified throughOnErrorDisplayed()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
WebContentsand itsPaymentRequestbeforeShowErrorMessage()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.
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
- Page opens payment UI
A page calls the Payment Request API and
show()with user activation (the test useshad_user_activation=true), so the browser shows the payment dialog (display_handle_->was_shown()is true). - Page completes with failure
The page reports a failed payment, which the test models as
Complete(mojom::PaymentComplete::FAIL); this leads toShowErrorMessageAndAbortPayment()callingdelegate_->ShowErrorMessage(). - 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
WebContentsand thePaymentRequest(from the test’s comment; which production observer does this is not shown). - Freed member read
Control returns into the destroyed
PaymentRequest, which readsobserver_for_testing_from freed memory and may callOnErrorDisplayed()through it. If that memory were reclaimed, this could become a controlled virtual call (inferred).
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifcomponents/payments/content/payment_request.cc |
modified | |
MockObserverForTestcomponents/payments/content/payment_request_unittest.cc |
modified |
Files Changed
components/payments/content/payment_request.cccomponents/payments/content/payment_request_unittest.cccomponents/payments/core/payment_request_delegate.h
Audit Directions
- Other `delegate_` calls that close UICheck every
PaymentRequestcall site ofdelegate_->CloseDialog(),ShowErrorMessage(),ShowProcessingSpinner()and similar UI-changing calls for member access afterwards, and do the same inPaymentHandlerHost,SecurePaymentConfirmationandPaymentAppServicecode. - Widget close → activation → `WebContents` destructionAny 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
WeakPtrchecks after such calls. - Test-only members on production pathsReads 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.
Patch
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/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
Regression Test / PoC
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
Original Bug Report
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
PaymentRequest::ShowErrorMessageAndAbortPayment()ChromePaymentRequestDelegate::ShowErrorMessage()SecurePaymentConfirmationController::ShowErrorMessage()SecurePaymentConfirmationController::OnCancel()SecurePaymentConfirmationController::CloseDialog()SecurePaymentConfirmationDialogView::HideDialog()views::Widget::Close()- Focus/activation shifts, triggering observers like
ExtensionPopup::OnWidgetTreeActivatedwhich synchronously callWebContents::Close()/ destroy the host. DocumentAssociatedData::~DocumentAssociatedData()executes and synchronously destroys thePaymentRequestinstance.- 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).
- Embed a PaymentRequest-capable iframe within a focus-sensitive parent window (such as an extension popup dialog).
- Initialize a
PaymentRequestspecifying Secure Payment Confirmation (SPC) with valid credentials matching a registered authenticator. - Call
Show()to bring up the SPC dialog modal. - Before any user interaction, have the compromised renderer issue a
mojom::PaymentRequest::Complete(mojom::PaymentComplete::FAIL)Mojo message. PaymentRequest::Completereceives the failure command and routes execution toShowErrorMessageAndAbortPayment(), 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.