CVE-2026-10900
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/device_reauth/mac/device_authenticator_mac.mm |
modified | |
TEST_Pchrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm |
modified |
Files Changed
chrome/browser/device_reauth/mac/device_authenticator_mac.hchrome/browser/device_reauth/mac/device_authenticator_mac.mmchrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm
Patch
From 2abda6c797ed977b2247d63484168e1d8ea5108f Mon Sep 17 00:00:00 2001
From: Viktor Semeniuk <vsemeniuk@google.com>
Date: Wed, 27 May 2026 04:41:46 -0700
Subject: [PATCH] Post AuthenticateUserWithNonBiometrics call
Fixed: 516957738, 516878683
Change-Id: I19a660927c166c8d0eee855ad5a82efad866e692
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7876622
Commit-Queue: Viktor Semeniuk <vsemeniuk@google.com>
Reviewed-by: Ioana Treib <ioanap@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1636852}
---
diff --git a/chrome/browser/device_reauth/mac/device_authenticator_mac.h b/chrome/browser/device_reauth/mac/device_authenticator_mac.h
index 24db5a70..265939d 100644
--- a/chrome/browser/device_reauth/mac/device_authenticator_mac.h
+++ b/chrome/browser/device_reauth/mac/device_authenticator_mac.h
@@ -48,6 +48,9 @@
// Called when the authentication completes with the result |success|.
void OnAuthenticationCompleted(bool success);
+ // Triggers non-biometric authentication asynchronously.
+ void AuthenticateWithNonBiometricsAsync(const std::u16string& message);
+
// Callback to be executed after the authentication completes.
AuthenticateCallback callback_;
diff --git a/chrome/browser/device_reauth/mac/device_authenticator_mac.mm b/chrome/browser/device_reauth/mac/device_authenticator_mac.mm
index c9cb06d..c3da5198 100644
--- a/chrome/browser/device_reauth/mac/device_authenticator_mac.mm
+++ b/chrome/browser/device_reauth/mac/device_authenticator_mac.mm
@@ -8,6 +8,7 @@
#include "base/memory/ptr_util.h"
#include "base/metrics/histogram_functions.h"
#include "base/notreached.h"
+#include "base/task/sequenced_task_runner.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/device_reauth/mac/authenticator_mac.h"
#include "chrome/browser/password_manager/password_manager_util_mac.h"
@@ -81,20 +82,16 @@
return;
}
callback_ = std::move(callback);
- // Always use CanAuthenticateWithBiometrics() before invoking the biometrics
- // API, and if it fails use password_manager_util_mac::AuthenticateUser()
- // instead, until crbug.com/40236979 is fixed.
if (!CanAuthenticateWithBiometrics()) {
- // AuthenticateUserWithNonBiometrics runs a dialog with a nested run loop,
- // so protect against this page disappearing within that nested run loop.
- // https://crbug.com/508289938
- auto weak_this = weak_ptr_factory_.GetWeakPtr();
- bool success = authenticator_->AuthenticateUserWithNonBiometrics(
- l10n_util::GetStringFUTF16(IDS_PASSWORDS_AUTHENTICATION_PROMPT_PREFIX,
- message));
- if (weak_this) {
- weak_this->OnAuthenticationCompleted(success);
- }
+ // AuthenticateUserWithNonBiometrics runs a dialog with a nested run loop.
+ // Post a task to run it asynchronously, so that it doesn't block the
+ // current call stack. This prevents UaF in the callers if the page is
+ // closed during the nested run loop. https://crbug.com/508289938
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE,
+ base::BindOnce(
+ &DeviceAuthenticatorMac::AuthenticateWithNonBiometricsAsync,
+ weak_ptr_factory_.GetWeakPtr(), message));
return;
}
@@ -105,6 +102,17 @@
weak_ptr_factory_.GetWeakPtr()));
}
+void DeviceAuthenticatorMac::AuthenticateWithNonBiometricsAsync(
+ const std::u16string& message) {
+ auto weak_this = weak_ptr_factory_.GetWeakPtr();
+ bool success = authenticator_->AuthenticateUserWithNonBiometrics(
+ l10n_util::GetStringFUTF16(IDS_PASSWORDS_AUTHENTICATION_PROMPT_PREFIX,
+ message));
+ if (weak_this) {
+ weak_this->OnAuthenticationCompleted(success);
+ }
+}
+
void DeviceAuthenticatorMac::OnAuthenticationCompleted(bool success) {
touch_id_auth_context_ = nullptr;
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
diff --git a/chrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm b/chrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm
index bd67286..36112da7 100644
--- a/chrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm
+++ b/chrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm
@@ -8,8 +8,8 @@
#include "base/functional/callback_helpers.h"
#include "base/memory/raw_ptr.h"
#include "base/test/metrics/histogram_tester.h"
-#include "base/test/mock_callback.h"
#include "base/test/task_environment.h"
+#include "base/test/test_future.h"
#include "base/time/time.h"
#include "chrome/browser/device_reauth/chrome_device_authenticator_factory.h"
#include "chrome/browser/device_reauth/mac/authenticator_mac.h"
@@ -23,8 +23,6 @@
namespace {
-using MockAuthResultCallback =
- base::MockCallback<DeviceAuthenticatorMac::AuthenticateCallback>;
using device_reauth::ReauthResult;
constexpr base::TimeDelta kAuthValidityPeriod = base::Seconds(60);
@@ -104,8 +102,6 @@
return &touch_id_test_environment_;
}
- MockAuthResultCallback& result_callback() { return result_callback_; }
-
base::HistogramTester& histogram_tester() { return histogram_tester_; }
private:
@@ -119,22 +115,20 @@
.metadata_secret = "TestMetadataSecret"};
device::fido::mac::ScopedTouchIdTestEnvironment touch_id_test_environment_{
config_};
- MockAuthResultCallback result_callback_;
base::HistogramTester histogram_tester_;
// This is owned by the authenticator.
raw_ptr<MockSystemAuthenticator> system_authenticator_ = nullptr;
};
-// If time that passed since the last successful authentication is smaller than
-// kAuthValidityPeriod, no reauthentication is needed.
TEST_P(DeviceAuthenticatorMacTest, NoReauthenticationIfLessThan60Seconds) {
SimulateReauthSuccess();
- EXPECT_CALL(result_callback(), Run(/*success=*/true));
+ base::test::TestFuture<bool> future_1;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_1.GetCallback());
+ EXPECT_TRUE(future_1.Get());
// Since the delay is smaller than kAuthValidityPeriod there shouldn't be
// another prompt, so the auth should be reported as successful. If there is a
@@ -142,21 +136,21 @@
// since there is no prompt expected.
task_environment().FastForwardBy(kAuthValidityPeriod / 2);
- EXPECT_CALL(result_callback(), Run(/*success=*/true));
+ base::test::TestFuture<bool> future_2;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_2.GetCallback());
+ EXPECT_TRUE(future_2.Get());
}
-// If the time since the last reauthentication is greater than
-// kAuthValidityPeriod or the authentication failed, reauthentication is needed.
TEST_P(DeviceAuthenticatorMacTest, ReauthenticationIfMoreThan60Seconds) {
SimulateReauthSuccess();
- EXPECT_CALL(result_callback(), Run(/*success=*/true));
+ base::test::TestFuture<bool> future_1;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_1.GetCallback());
+ EXPECT_TRUE(future_1.Get());
// Make the reauth prompt auth fail.
SimulateReauthFailure();
@@ -165,36 +159,36 @@
// authentication.
task_environment().FastForwardBy(kAuthValidityPeriod * 2);
- EXPECT_CALL(result_callback(), Run(/*success=*/false));
+ base::test::TestFuture<bool> future_2;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_2.GetCallback());
+ EXPECT_FALSE(future_2.Get());
}
-// If previous authentication failed kAuthValidityPeriod isn't started and
-// reauthentication will be needed.
TEST_P(DeviceAuthenticatorMacTest, ReauthenticationIfPreviousFailed) {
SimulateReauthFailure();
// First authentication fails, no last_good_auth_timestamp_ should be
// recorded, which fill force reauthentication.
- EXPECT_CALL(result_callback(), Run(/*success=*/false));
+ base::test::TestFuture<bool> future_1;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_1.GetCallback());
+ EXPECT_FALSE(future_1.Get());
Regression Test / PoC
diff --git a/chrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm b/chrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm
index bd67286..36112da7 100644
--- a/chrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm
+++ b/chrome/browser/device_reauth/mac/device_authenticator_mac_unittest.mm
@@ -8,8 +8,8 @@
#include "base/functional/callback_helpers.h"
#include "base/memory/raw_ptr.h"
#include "base/test/metrics/histogram_tester.h"
-#include "base/test/mock_callback.h"
#include "base/test/task_environment.h"
+#include "base/test/test_future.h"
#include "base/time/time.h"
#include "chrome/browser/device_reauth/chrome_device_authenticator_factory.h"
#include "chrome/browser/device_reauth/mac/authenticator_mac.h"
@@ -23,8 +23,6 @@
namespace {
-using MockAuthResultCallback =
- base::MockCallback<DeviceAuthenticatorMac::AuthenticateCallback>;
using device_reauth::ReauthResult;
constexpr base::TimeDelta kAuthValidityPeriod = base::Seconds(60);
@@ -104,8 +102,6 @@
return &touch_id_test_environment_;
}
- MockAuthResultCallback& result_callback() { return result_callback_; }
-
base::HistogramTester& histogram_tester() { return histogram_tester_; }
private:
@@ -119,22 +115,20 @@
.metadata_secret = "TestMetadataSecret"};
device::fido::mac::ScopedTouchIdTestEnvironment touch_id_test_environment_{
config_};
- MockAuthResultCallback result_callback_;
base::HistogramTester histogram_tester_;
// This is owned by the authenticator.
raw_ptr<MockSystemAuthenticator> system_authenticator_ = nullptr;
};
-// If time that passed since the last successful authentication is smaller than
-// kAuthValidityPeriod, no reauthentication is needed.
TEST_P(DeviceAuthenticatorMacTest, NoReauthenticationIfLessThan60Seconds) {
SimulateReauthSuccess();
- EXPECT_CALL(result_callback(), Run(/*success=*/true));
+ base::test::TestFuture<bool> future_1;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_1.GetCallback());
+ EXPECT_TRUE(future_1.Get());
// Since the delay is smaller than kAuthValidityPeriod there shouldn't be
// another prompt, so the auth should be reported as successful. If there is a
@@ -142,21 +136,21 @@
// since there is no prompt expected.
task_environment().FastForwardBy(kAuthValidityPeriod / 2);
- EXPECT_CALL(result_callback(), Run(/*success=*/true));
+ base::test::TestFuture<bool> future_2;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_2.GetCallback());
+ EXPECT_TRUE(future_2.Get());
}
-// If the time since the last reauthentication is greater than
-// kAuthValidityPeriod or the authentication failed, reauthentication is needed.
TEST_P(DeviceAuthenticatorMacTest, ReauthenticationIfMoreThan60Seconds) {
SimulateReauthSuccess();
- EXPECT_CALL(result_callback(), Run(/*success=*/true));
+ base::test::TestFuture<bool> future_1;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_1.GetCallback());
+ EXPECT_TRUE(future_1.Get());
// Make the reauth prompt auth fail.
SimulateReauthFailure();
@@ -165,36 +159,36 @@
// authentication.
task_environment().FastForwardBy(kAuthValidityPeriod * 2);
- EXPECT_CALL(result_callback(), Run(/*success=*/false));
+ base::test::TestFuture<bool> future_2;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_2.GetCallback());
+ EXPECT_FALSE(future_2.Get());
}
-// If previous authentication failed kAuthValidityPeriod isn't started and
-// reauthentication will be needed.
TEST_P(DeviceAuthenticatorMacTest, ReauthenticationIfPreviousFailed) {
SimulateReauthFailure();
// First authentication fails, no last_good_auth_timestamp_ should be
// recorded, which fill force reauthentication.
- EXPECT_CALL(result_callback(), Run(/*success=*/false));
+ base::test::TestFuture<bool> future_1;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_1.GetCallback());
+ EXPECT_FALSE(future_1.Get());
// Although it passed less than kAuthValidityPeriod no valid authentication
// should be recorded as reauth will fail.
SimulateReauthFailure();
task_environment().FastForwardBy(kAuthValidityPeriod / 2);
- EXPECT_CALL(result_callback(), Run(/*success=*/false));
+ base::test::TestFuture<bool> future_2;
authenticator()->AuthenticateWithMessage(
/*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ future_2.GetCallback());
+ EXPECT_FALSE(future_2.Get());
}
-// If pending authentication can be canceled.
TEST_P(DeviceAuthenticatorMacTest, CancelPendingAuthentication) {
// Non-biometric reauth is modal, and hence cannot be requested twice.
if (!is_biometric_available()) {
@@ -203,14 +197,14 @@
touch_id_environment()->SimulateTouchIdPromptSuccess();
touch_id_environment()->DoNotResolveNextPrompt();
+ base::test::TestFuture<bool> future;
authenticator()->AuthenticateWithMessage(
- /*message=*/u"Chrome is trying to show passwords.",
- result_callback().Get());
+ /*message=*/u"Chrome is trying to show passwords.", future.GetCallback());
// Authentication should fail as it will take 10 seconds to authenticate, and
// there will be a cancellation in the meantime.
- EXPECT_CALL(result_callback(), Run(/*success=*/false));
authenticator()->Cancel();
+ EXPECT_FALSE(future.Get());
}
TEST_P(DeviceAuthenticatorMacTest, BiometricAuthenticationAvailability) {
@@ -240,8 +234,10 @@
TEST_P(DeviceAuthenticatorMacTest, RecordSuccessAuthHistogram) {
SimulateReauthSuccess();
+ base::test::TestFuture<bool> future;
authenticator()->AuthenticateWithMessage(
- /*message=*/u"Chrome is trying to show passwords.", base::DoNothing());
+ /*message=*/u"Chrome is trying to show passwords.", future.GetCallback());
+ EXPECT_TRUE(future.Get());
histogram_tester().ExpectUniqueSample(kHistogramName, ReauthResult::kSuccess,
1);
@@ -250,10 +246,17 @@
TEST_P(DeviceAuthenticatorMacTest, RecordSkippedAuthHistogram) {
SimulateReauthSuccess();
+ base::test::TestFuture<bool> future_1;
authenticator()->AuthenticateWithMessage(
- /*message=*/u"Chrome is trying to show passwords.", base::DoNothing());
+ /*message=*/u"Chrome is trying to show passwords.",
+ future_1.GetCallback());
+ EXPECT_TRUE(future_1.Get());
+
+ base::test::TestFuture<bool> future_2;
authenticator()->AuthenticateWithMessage(
- /*message=*/u"Chrome is trying to show passwords.", base::DoNothing());
+ /*message=*/u"Chrome is trying to show passwords.",
+ future_2.GetCallback());
+ EXPECT_TRUE(future_2.Get());
histogram_tester().ExpectBucketCount(kHistogramName, ReauthResult::kSuccess,
1);
@@ -264,8 +267,10 @@
TEST_P(DeviceAuthenticatorMacTest, RecordFailAuthHistogram) {
SimulateReauthFailure();
+ base::test::TestFuture<bool> future;
authenticator()->AuthenticateWithMessage(
- /*message=*/u"Chrome is trying to show passwords.", base::DoNothing());
+ /*message=*/u"Chrome is trying to show passwords.", future.GetCallback());
+ EXPECT_FALSE(future.Get());
histogram_tester().ExpectUniqueSample(kHistogramName, ReauthResult::kFailure,
1);
Original Bug Report
Potential UAF in PasswordManualFallbackFlow::DidAcceptSuggestion via macOS nested CFRunLoop
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 in PasswordManualFallbackFlow::DidAcceptSuggestion. When performing manual fallback password authentication on macOS, a synchronous system dialog spins a nested CFRunLoop, during which the tab or page can be destroyed. When the dialog is dismissed, the stack unwinds back to the deleted flow object, leading to a virtual function dispatch on a freed this pointer.
Affected files:
components/password_manager/core/browser/password_manual_fallback_flow.cc
Estimated timestamp from git blame: 2024-02-29
Root Cause
In PasswordManualFallbackFlow::DidAcceptSuggestion (defined in components/password_manager/core/browser/password_manual_fallback_flow.cc), certain suggestion types (such as SuggestionType::kFillPassword or SuggestionType::kPasswordEntry) trigger a device re-authentication request via the device authenticator.
On macOS, if biometric authentication is not available, the code falls back to non-biometric authentication via DeviceAuthenticatorMac::AuthenticateWithMessage -> AuthenticatorMac::AuthenticateUserWithNonBiometrics -> password_manager_util_mac::AuthenticateUser -> base::mac::GetAuthorizationRightsWithPrompt -> AuthorizationCopyRights.
AuthorizationCopyRights displays a native macOS modal system password dialog and pumps a nested CFRunLoop on the UI thread to keep the application responsive. Because a nested run loop is running, tasks sent to the UI thread are executed. If the page or the popup is closed or navigated during this time (for example, via window.close() or frame navigation), the ContentPasswordManagerDriver is destroyed, which in turn destroys PasswordAutofillManager and resets its owned PasswordManualFallbackFlow instance.
When the modal dialog is eventually dismissed, the stack unwinds back to PasswordManualFallbackFlow::DidAcceptSuggestion. At the end of this function, it attempts to execute:
autofill_client_->HideSuggestions(
autofill::SuggestionHidingReason::kAcceptSuggestion,
GetMainFillingProduct());
Because the PasswordManualFallbackFlow instance has been destroyed, this is a dangling pointer. Since the stack frame holds a bare this pointer (which is not protected by raw_ptr or MiraclePtr), and no active raw_ptr keeps the allocation in the BackupRefPtr quarantine, the memory can be reclaimed. An attacker can reclaim this heap slot and control the value of the autofill_client_ pointer, turning the subsequent virtual method call HideSuggestions into an attacker-controlled virtual method dispatch (vptr hijack) in the unsandboxed browser process.
Potential Trigger Path
Note: These are potential/suggested steps. Our analysis is based on static code review, and our tooling does not currently have the ability to run code or execute a live proof of concept.
- A victim interacts with a password manual fallback dropdown (e.g., right-clicking a text field on an attacker-controlled page, selecting Autofill -> Select password, and clicking Fill password).
DidAcceptSuggestioninvokesEnsureCrossDomainPasswordUsageGetsConsent, which runs synchronously when the suggestion is same-domain.- The flow proceeds to
MaybeAuthenticateBeforeFilling, which callsauthenticator_->AuthenticateWithMessage. - If biometrics are unavailable or disabled,
DeviceAuthenticatorMaccallsAuthenticatorMac::AuthenticateUserWithNonBiometrics->password_manager_util_mac::AuthenticateUser->base::mac::GetAuthorizationRightsWithPrompt->AuthorizationCopyRightsto display the macOS authorization dialog, spinning a nestedCFRunLoop. - While the dialog is showing, the attacker page runs script to close the popup (
popup.close()) or initiates a navigation. - The nested run loop dispatches the tab/WebContents destruction task. This destroys the
ContentPasswordManagerDriver, leading to the destruction of thePasswordManualFallbackFlowobject. - The victim dismisses the OS prompt. The stack unwinds back to the end of
DidAcceptSuggestion, whereautofill_client_->HideSuggestionsis called, invoking a virtual method call on a freed object’s member.
Suggested Fix
To safely prevent this potential issue, we should use a base::WeakPtr to guard the execution of the remainder of DidAcceptSuggestion if a nested run loop was spawned. Since weak_ptr_factory_ is invalidated when the flow is destroyed, checking weak_this at the end of the function will safely prevent the UAF:
void PasswordManualFallbackFlow::DidAcceptSuggestion(
const Suggestion& suggestion,
const SuggestionMetadata& metadata) {
CHECK(SupportsSuggestionType(suggestion.type));
if (!suggestion.IsAcceptable()) {
return;
}
base::WeakPtr<PasswordManualFallbackFlow> weak_this = weak_ptr_factory_.GetWeakPtr();
// ... existing switch logic ...
if (!weak_this) {
return;
}
autofill_client_->HideSuggestions(
autofill::SuggestionHidingReason::kAcceptSuggestion,
GetMainFillingProduct());
}
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.