CVE-2026-12020
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.cc |
modified | |
BindLambdaForTestingcomponents/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc |
modified |
Files Changed
components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.cccomponents/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc
Patch
From 21a69e5e61150e8c3ce84263083d4b7b330f3dc0 Mon Sep 17 00:00:00 2001 From: Luis Antunes <[email protected]> Date: Mon, 08 Jun 2026 07:27:30 -0700 Subject: [PATCH] [Autofill] Use-After-Free in AutofillProgressDialogControllerImpl Fix a UAF on macOS when mandatory reauth falls back to the passcode prompt. The prompt spins a nested loop, during which the tab can be closed, destroying the controller. When the prompt is dismissed, execution resumes in OnDismissed, causing a UAF on deallocated members. This CL adds a base::WeakPtr self-guard to exit OnDismissed early if the controller is destroyed during callback execution. Covers both the reauth and cancel callback paths. Fixed: 516907083 Change-Id: I86d2ae77c03d1e81990ac14b51979e13498f1b59 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7896024 Commit-Queue: Luis Antunes <[email protected]> Reviewed-by: Vinny Persky <[email protected]> Reviewed-by: Stephen McGruer <[email protected]> Cr-Commit-Position: refs/heads/main@{#1643184} --- diff --git a/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.cc b/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.cc index 5f1aed3..d1407da0 100644 --- a/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.cc +++ b/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.cc @@ -67,6 +67,11 @@ void AutofillProgressDialogControllerImpl::OnDismissed( bool is_canceled_by_user) { + // On macOS without biometrics, the accept/cancel callbacks have the potential + // to destroy this controller (e.g., if the tab is closed during the nested + // run loop of the passcode prompt). We check a weak pointer to avoid a UAF. + auto weak_self = weak_ptr_factory_.GetWeakPtr(); + // Dialog is being dismissed so set the pointer to nullptr. autofill_progress_dialog_view_.reset(); if (is_canceled_by_user) { @@ -77,6 +82,10 @@ } } + if (!weak_self) { + return; + } + AutofillMetrics::LogProgressDialogResultMetric( is_canceled_by_user, autofill_progress_dialog_type_); cancel_callback_.Reset(); diff --git a/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc b/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc index 82d408a4..71a1f412 100644 --- a/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc +++ b/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc @@ -5,9 +5,16 @@ #include "components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.h" #include <memory> +#include <utility> +#include "base/functional/bind.h" +#include "base/functional/callback_helpers.h" +#include "base/test/bind.h" #include "base/test/metrics/histogram_tester.h" +#include "base/test/mock_callback.h" +#include "build/buildflag.h" #include "components/autofill/core/browser/ui/payments/autofill_progress_dialog_view.h" +#include "components/autofill/core/browser/ui/payments/autofill_progress_ui_type.h" #include "testing/gmock/include/gmock/gmock.h" #include "testing/gtest/include/gtest/gtest.h" @@ -46,9 +53,59 @@ return controller_.get(); } + void DeleteController() { controller_.reset(); } + + void InitializeController(base::OnceClosure cancel_callback) { + controller_ = std::make_unique<AutofillProgressDialogControllerImpl>( + AutofillProgressUiType::kVirtualCardUnmaskProgressUi, + std::move(cancel_callback)); +#if BUILDFLAG(IS_IOS) + controller_->ShowDialog(base::BindOnce( + &AutofillProgressDialogControllerImplTest::CreateDialogView, + base::Unretained(this))); +#else + controller_->ShowDialog( + base::BindOnce([]() -> std::unique_ptr<AutofillProgressDialogView> { + return std::make_unique<TestAutofillProgressDialogView>(); + })); +#endif + } + private: std::unique_ptr<AutofillProgressDialogView> view_; std::unique_ptr<AutofillProgressDialogControllerImpl> controller_; }; +// Tests that a Use-After-Free (UAF) is prevented when the controller is +// destroyed synchronously during the success callback. A UAF can occur if +// `OnDismissed()` accesses members after the callback deletes the controller. +TEST_F(AutofillProgressDialogControllerImplTest, + OnDismissed_Success_SafeSelfDestruction) { + base::MockCallback<base::OnceClosure> cancel_callback; + InitializeController(cancel_callback.Get()); + + base::OnceClosure no_interactive_auth_callback = + base::BindLambdaForTesting([&]() { DeleteController(); }); + + controller()->DismissDialog(/*show_confirmation_before_closing=*/false, + std::move(no_interactive_auth_callback)); + + controller()->OnDismissed(/*is_canceled_by_user=*/false); + + EXPECT_EQ(controller(), nullptr); +} + +// Tests the cancellation path of the UAF fix. Ensures that if the controller +// is destroyed synchronously during the cancel callback, the `WeakPtr` safely +// prevents `OnDismissed()` from accessing freed memory. +TEST_F(AutofillProgressDialogControllerImplTest, + OnDismissed_Canceled_SafeSelfDestruction) { + InitializeController( + base::BindLambdaForTesting([&]() { DeleteController(); })); + + controller()->OnDismissed(/*is_canceled_by_user=*/true); + + EXPECT_EQ(controller(), nullptr); +} + } // namespace autofill
Regression Test / PoC
diff --git a/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc b/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc
index 82d408a4..71a1f412 100644
--- a/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc
+++ b/components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl_unittest.cc
@@ -5,9 +5,16 @@
#include "components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.h"
#include <memory>
+#include <utility>
+#include "base/functional/bind.h"
+#include "base/functional/callback_helpers.h"
+#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
+#include "base/test/mock_callback.h"
+#include "build/buildflag.h"
#include "components/autofill/core/browser/ui/payments/autofill_progress_dialog_view.h"
+#include "components/autofill/core/browser/ui/payments/autofill_progress_ui_type.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -46,9 +53,59 @@
return controller_.get();
}
+ void DeleteController() { controller_.reset(); }
+
+ void InitializeController(base::OnceClosure cancel_callback) {
+ controller_ = std::make_unique<AutofillProgressDialogControllerImpl>(
+ AutofillProgressUiType::kVirtualCardUnmaskProgressUi,
+ std::move(cancel_callback));
+#if BUILDFLAG(IS_IOS)
+ controller_->ShowDialog(base::BindOnce(
+ &AutofillProgressDialogControllerImplTest::CreateDialogView,
+ base::Unretained(this)));
+#else
+ controller_->ShowDialog(
+ base::BindOnce([]() -> std::unique_ptr<AutofillProgressDialogView> {
+ return std::make_unique<TestAutofillProgressDialogView>();
+ }));
+#endif
+ }
+
private:
std::unique_ptr<AutofillProgressDialogView> view_;
std::unique_ptr<AutofillProgressDialogControllerImpl> controller_;
};
+// Tests that a Use-After-Free (UAF) is prevented when the controller is
+// destroyed synchronously during the success callback. A UAF can occur if
+// `OnDismissed()` accesses members after the callback deletes the controller.
+TEST_F(AutofillProgressDialogControllerImplTest,
+ OnDismissed_Success_SafeSelfDestruction) {
+ base::MockCallback<base::OnceClosure> cancel_callback;
+ InitializeController(cancel_callback.Get());
+
+ base::OnceClosure no_interactive_auth_callback =
+ base::BindLambdaForTesting([&]() { DeleteController(); });
+
+ controller()->DismissDialog(/*show_confirmation_before_closing=*/false,
+ std::move(no_interactive_auth_callback));
+
+ controller()->OnDismissed(/*is_canceled_by_user=*/false);
+
+ EXPECT_EQ(controller(), nullptr);
+}
+
+// Tests the cancellation path of the UAF fix. Ensures that if the controller
+// is destroyed synchronously during the cancel callback, the `WeakPtr` safely
+// prevents `OnDismissed()` from accessing freed memory.
+TEST_F(AutofillProgressDialogControllerImplTest,
+ OnDismissed_Canceled_SafeSelfDestruction) {
+ InitializeController(
+ base::BindLambdaForTesting([&]() { DeleteController(); }));
+
+ controller()->OnDismissed(/*is_canceled_by_user=*/true);
+
+ EXPECT_EQ(controller(), nullptr);
+}
+
} // namespace autofill
Original Bug Report
Potential Browser Process UAF in AutofillProgressDialogControllerImpl::OnDismissed on macOS
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 on macOS within AutofillProgressDialogControllerImpl::OnDismissed. During non-interactive autofill unmasking on devices without biometrics, executing a device authentication callback spins a nested CFRunLoop to display the system password prompt. If the tab is closed during this nested loop, the controller is destroyed, causing a UAF when the prompt is closed and execution resumes.
Affected files:
components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.cc
Estimated timestamp from git blame: 2023-05-10
Root Cause Analysis
A potential Use-After-Free (UAF) vulnerability exists in the browser process on macOS due to unsafe member access after a nested event loop executes in AutofillProgressDialogControllerImpl::OnDismissed located in components/autofill/core/browser/ui/payments/autofill_progress_dialog_controller_impl.cc.
void AutofillProgressDialogControllerImpl::OnDismissed(
bool is_canceled_by_user) {
// 1. View is reset here
autofill_progress_dialog_view_.reset();
if (is_canceled_by_user) {
std::move(cancel_callback_).Run();
} else {
if (no_interactive_authentication_callback_) {
// 2. Callback runs and blocks synchronously inside nested loop
std::move(no_interactive_authentication_callback_).Run();
}
}
// 4. Execution resumes here after destruction, leading to UAF
AutofillMetrics::LogProgressDialogResultMetric(
is_canceled_by_user, autofill_progress_dialog_type_);
cancel_callback_.Reset();
}
When a user authenticates on a macOS device without biometrics configured (e.g., Touch ID disabled), the device authenticator falls back to screen-lock passcode verification. This triggers AuthenticatorMac::AuthenticateUserWithNonBiometrics which calls password_manager_util_mac::AuthenticateUser. This function calls the blocking macOS API base::mac::GetAuthorizationRightsWithPrompt to prompt the user for their system login credentials.
While this system-modal dialog is active, a nested CFRunLoop is spun on the UI thread to keep the browser UI responsive. If the web page initiates a tab or popup closure (e.g., via window.close()) during this nested event loop, the corresponding WebContents is destroyed. This triggers the destruction of ChromeAutofillClient, its member ChromePaymentsAutofillClient, and finally the AutofillProgressDialogControllerImpl instance itself.
Bypassing the Safety Guard
Normally, the controller’s destructor has a safety mechanism to prevent UAF if the tab is closed:
AutofillProgressDialogControllerImpl::~AutofillProgressDialogControllerImpl() {
if (autofill_progress_dialog_view_) {
autofill_progress_dialog_view_->InvalidateControllerForCallbacks();
OnDismissed(/*is_canceled_by_user=*/true);
autofill_progress_dialog_view_ = nullptr;
}
}
However, in the described scenario, OnDismissed was already invoked by the view manager. Crucially, the very first statement of OnDismissed is autofill_progress_dialog_view_.reset(). Since autofill_progress_dialog_view_ is already nullptr when the destructor executes during the nested run loop, this safety conditional block is entirely bypassed, and this is safely deallocated while the execution frame remains on the stack.
Once the user cancels or completes the macOS prompt, the nested run loop exits and execution resumes in OnDismissed directly after the callback. Accessing autofill_progress_dialog_type_ and resetting cancel_callback_ (which is a base::OnceClosure holding a reference-counted BindStateBase) on a deallocated this pointer results in a Use-After-Free. This allows an attacker to control execution flow if they can successfully groom the heap during the nested event loop.
Potential Trigger Path
Note: These are potential steps reconstructed through static analysis. Our tooling does not currently have the capability to execute live code or verify this dynamically with a proof-of-concept.
- A user visits a page with a credit card form on a macOS device without biometrics configured (mandatory reauth enabled).
- The user selects a card from Autofill, triggering a progress dialog.
- The Payments server returns a successful response without requiring interactive challenges (
kNoAuthenticationRequired). CreditCardAccessManagertriggersCloseAutofillProgressDialog, bindingStartDeviceAuthenticationForFillingtono_interactive_authentication_callback_.- The progress dialog is closed synchronously, and
OnDismissed(false)is invoked. no_interactive_authentication_callback_runs, invoking the system-modal passcode prompt which spins a nestedCFRunLoop.- While the OS dialog is active, the opener page closes the popup. This destroys the
WebContentsand frees theAutofillProgressDialogControllerImpl. - Once the user closes the OS dialog, the nested loop exits, and
OnDismissedresumes and accesses member fields on the freedthisobject.
Suggested Remediation
To remediate this issue, use a base::WeakPtr of this to check if the controller is still alive before executing any further member access or callback resets after the synchronous callback is run:
void AutofillProgressDialogControllerImpl::OnDismissed(
bool is_canceled_by_user) {
autofill_progress_dialog_view_.reset();
if (is_canceled_by_user) {
std::move(cancel_callback_).Run();
} else {
if (no_interactive_authentication_callback_) {
base::WeakPtr<AutofillProgressDialogControllerImpl> weak_this =
weak_ptr_factory_.GetWeakPtr();
std::move(no_interactive_authentication_callback_).Run();
if (!weak_this) {
return;
}
}
}
AutofillMetrics::LogProgressDialogResultMetric(
is_canceled_by_user, autofill_progress_dialog_type_);
cancel_callback_.Reset();
}
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.