Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Passwords
DescriptionUse after free in Passwords
ComponentPasswords
Sub-componentPasswords
Bug ClassUAF
Tracker562242421
Fix commite4afb48bc920 (chromium/src) +167/-13
CISA KEVNot listed
CreditedXinyang Ge
Disclosed2026-10-13

Changed Functions

FunctionChangeNotes
if
components/password_manager/core/browser/credential_manager_impl_unittest.cc
modified

Files Changed

  • components/password_manager/core/browser/credential_manager_pending_request_task.cc
  • chrome/browser/password_manager/credential_manager_browsertest.cc
  • components/password_manager/core/browser/credential_manager_impl_unittest.cc
  • components/password_manager/core/browser/credential_manager_pending_request_task.h
From e4afb48bc920df1b3291804ba90fcd2e29532688 Mon Sep 17 00:00:00 2001
From: Adem Derinel <derinel@google.com>
Date: Fri, 18 Sep 2026 05:57:17 -0700
Subject: [PATCH] [Passwords] Fix UAF in CredentialManager task

When CredentialManagerPendingRequestTask prompts the user to choose
credentials, it passes a callback bound to
Unretained(CredentialManagerImpl). If the Mojo pipe disconnects while
the chooser dialog is open, CredentialManagerImpl drops the task, but
the UI controller retains the stale callback.

A subsequent Get() request with kOptional mediation triggers auto
sign-in, which synchronously closes the old chooser dialog and runs its
callback. That callback deletes the active pending_request_ while its
ProcessForms() method is still executing on the stack, causing a
use-after-free.

Fix this by binding the chooser callback to a WeakPtr on the task, and
guarding against reentrancy when callbacks are cancelled.

TAG=agy
CONV=50e40463-fcb0-472e-9e9a-873e75681685

Fixed: 562242421
Change-Id: Id4f903a9b678da301f1d76984d23cc5afb838abe
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8424659
Reviewed-by: Friedrich Hauser <friedrichh@chromium.org>
Commit-Queue: Adem Derinel <derinel@google.com>
Cr-Commit-Position: refs/heads/main@{#1700684}
---
diff --git a/components/password_manager/core/browser/credential_manager_pending_request_task.cc b/components/password_manager/core/browser/credential_manager_pending_request_task.cc
index 1ae9e355..989106b8 100644
--- a/components/password_manager/core/browser/credential_manager_pending_request_task.cc
+++ b/components/password_manager/core/browser/credential_manager_pending_request_task.cc
@@ -273,21 +273,24 @@
     return;
   }
 
-  auto split_send_callback = base::SplitOnceCallback(std::move(send_callback_));
   if (!delegate_->client()->PromptUserToChooseCredentials(
           std::move(results), origin_,
-          base::BindOnce(
-              &CredentialManagerPendingRequestTaskDelegate::SendPasswordForm,
-              base::Unretained(delegate_), std::move(split_send_callback.first),
-              mediation_))) {
+          base::BindOnce(&CredentialManagerPendingRequestTask::SendPasswordForm,
+                         weak_ptr_factory_.GetWeakPtr()))) {
     // Since PromptUserToChooseCredentials() does not invoke the callback when
-    // returning false, `repeating_send_callback` has not been run in this
-    // branch yet.
+    // returning false, `send_callback_` has not been run in this branch yet.
     LogCredentialManagerGetResult(
         metrics_util::CredentialManagerGetResult::kNone, mediation_);
-    delegate_->SendCredential(std::move(split_send_callback.second),
-                              CredentialInfo());
+    delegate_->SendCredential(std::move(send_callback_), CredentialInfo());
   }
 }
 
+void CredentialManagerPendingRequestTask::SendPasswordForm(
+    const PasswordForm* form) {
+  // `delegate_->SendPasswordForm()` invokes CredentialManagerImpl::SendCredential(),
+  // which resets `pending_request_` and deletes `this`. Do not access any member
+  // after this call.
+  delegate_->SendPasswordForm(std::move(send_callback_), mediation_, form);
+}
+
 }  // namespace password_manager
diff --git a/components/password_manager/core/browser/credential_manager_pending_request_task.h b/components/password_manager/core/browser/credential_manager_pending_request_task.h
index 1c0aa6c..64cad8cf 100644
--- a/components/password_manager/core/browser/credential_manager_pending_request_task.h
+++ b/components/password_manager/core/browser/credential_manager_pending_request_task.h
@@ -12,6 +12,7 @@
 
 #include "base/functional/callback_forward.h"
 #include "base/memory/raw_ptr.h"
+#include "base/memory/weak_ptr.h"
 #include "components/password_manager/core/browser/form_fetcher.h"
 #include "components/password_manager/core/browser/password_form_digest.h"
 #include "components/password_manager/core/common/credential_manager_types.h"
@@ -74,6 +75,7 @@
   void OnFetchCompleted() override;
 
   void ProcessForms(std::vector<std::unique_ptr<PasswordForm>> results);
+  void SendPasswordForm(const PasswordForm* form);
 
   raw_ptr<CredentialManagerPendingRequestTaskDelegate> delegate_;  // Weak;
   SendCredentialCallback send_callback_;
@@ -82,6 +84,9 @@
   const bool include_passwords_;
   std::set<std::string> federations_;
   std::unique_ptr<FormFetcher> form_fetcher_;
+
+  base::WeakPtrFactory<CredentialManagerPendingRequestTask> weak_ptr_factory_{
+      this};
 };
 
 }  // namespace password_manager
diff --git a/chrome/browser/password_manager/credential_manager_browsertest.cc b/chrome/browser/password_manager/credential_manager_browsertest.cc
index 461ca1b..532f5dc 100644
--- a/chrome/browser/password_manager/credential_manager_browsertest.cc
+++ b/chrome/browser/password_manager/credential_manager_browsertest.cc
@@ -1,12 +1,15 @@
 // Copyright 2016 The Chromium Authors
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
+#include <optional>
+
 #include "base/command_line.h"
 #include "base/memory/raw_ptr.h"
 #include "base/strings/strcat.h"
 #include "base/strings/stringprintf.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
 #include "chrome/browser/password_manager/chrome_password_manager_client.h"
 #include "chrome/browser/password_manager/password_manager_test_base.h"
 #include "chrome/browser/password_manager/password_manager_test_util.h"
@@ -23,6 +26,8 @@
 #include "components/password_manager/core/browser/password_store/test_password_store.h"
 #include "components/password_manager/core/browser/password_string.h"
 #include "components/password_manager/core/common/password_manager_features.h"
+#include "components/password_manager/core/common/password_manager_pref_names.h"
+#include "components/prefs/pref_service.h"
 #include "content/public/browser/back_forward_cache.h"
 #include "content/public/browser/browser_task_traits.h"
 #include "content/public/browser/browser_thread.h"
@@ -34,6 +39,7 @@
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/prerender_test_util.h"
+#include "mojo/public/cpp/bindings/remote.h"
 #include "net/dns/mock_host_resolver.h"
 
 namespace {
@@ -1259,4 +1265,72 @@
   EXPECT_EQ(avatar_request_counter(), 2u);
 }
 
+// Regression test for crbug.com/562242421: A disconnected CredentialManager
+// pipe with an active account chooser must not leave a stale callback in
+// ManagePasswordsUIController that deletes a subsequent auto-signin request
+// during ProcessForms().
+IN_PROC_BROWSER_TEST_F(CredentialManagerBrowserTest,
+                       AutoSigninAfterDisconnectWithPendingChooserDoesNotUAF) {
+  scoped_refptr<password_manager::TestPasswordStore> password_store =
+      GetDefaultPasswordStore(browser()->GetProfile());
+
+  NavigateToFile("/password/simple_password.html");
+  GURL url = WebContents()->GetLastCommittedURL();
+
+  password_manager::PasswordForm form;
+  form.signon_realm = url::Origin::Create(url).GetURL().spec();
+  form.url = url::Origin::Create(url).GetURL();
+  form.username_value = u"user1";
+  form.password_value = PasswordString(u"abcdef");
+  form.skip_zero_click = false;
+  password_store->AddLogin(password_manager::FromPasswordForm(form));
+  WaitForPasswordStore();
+
+  browser()->GetProfile()->GetPrefs()->SetBoolean(
+      password_manager::prefs::kWasAutoSignInFirstRunExperienceShown, true);
+
+  ChromePasswordManagerClient* client =
+      ChromePasswordManagerClient::FromWebContents(WebContents());
+  ASSERT_TRUE(client);
+
+  // 1. Bind first CredentialManager pipe and call Get(kRequired) to open the
+  // account chooser.
+  mojo::Remote<blink::mojom::CredentialManager> remote1;
+  client->GetContentCredentialManager()->BindRequest(
+      WebContents()->GetPrimaryMainFrame(),
+      remote1.BindNewPipeAndPassReceiver());
+  remote1->Get(password_manager::CredentialMediationRequirement::kRequired,
+               /*include_passwords=*/true,
+               /*federations=*/{}, base::DoNothing());
+
+  BubbleObserver(WebContents()).WaitForAccountChooser();
+  EXPECT_TRUE(IsShowingAccountChooser());
+
+  // 2. Disconnect the Mojo pipe while the account chooser is showing.
+  remote1.reset();
+  base::RunLoop().RunUntilIdle();
+
+  // 3. Bind a new CredentialManager pipe and call Get(kOptional), triggering
+  // auto sign-in and NotifyUserAutoSignin(), which destroys the old chooser.
+  mojo::Remote<blink::mojom::CredentialManager> remote2;
+  client->GetContentCredentialManager()->BindRequest(
+      WebContents()->GetPrimaryMainFrame(),
+      remote2.BindNewPipeAndPassReceiver());
+
+  base::test::TestFuture<password_manager::CredentialManagerError,
+                         std::optional<password_manager::CredentialInfo>>
+      future;
+  remote2->Get(password_manager::CredentialMediationRequirement::kOptional,
+               /*include_passwords=*/true,
+               /*federations=*/{},
+               future.GetCallback<
+                   password_manager::CredentialManagerError,
+                   const std::optional<password_manager::CredentialInfo>&>());
+
+  ASSERT_TRUE(future.Wait());
+  EXPECT_EQ(password_manager::CredentialManagerError::SUCCESS, future.Get<0>());
+  ASSERT_TRUE(future.Get<1>().has_value());
+  EXPECT_EQ(u"user1", future.Get<1>()->id);
+}
+
 }  // namespace
diff --git a/components/password_manager/core/browser/credential_manager_impl_unittest.cc b/components/password_manager/core/browser/credential_manager_impl_unittest.cc
index c7627444..aa12007 100644
--- a/components/password_manager/core/browser/credential_manager_impl_unittest.cc
+++ b/components/password_manager/core/browser/credential_manager_impl_unittest.cc
@@ -184,10 +184,14 @@
       const url::Origin& origin,
       CredentialsCallback callback) override {
     EXPECT_FALSE(local_forms.empty());
-    const PasswordForm* form = local_forms[0].get();
-    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
-        FROM_HERE, base::BindOnce(std::move(callback),
-                                  base::Owned(new PasswordForm(*form))));
+    if (should_store_callback_) {
+      stored_choose_callback_ = std::move(callback);
+    } else {
+      const PasswordForm* form = local_forms[0].get();
+      base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+          FROM_HERE, base::BindOnce(std::move(callback),
+                                    base::Owned(new PasswordForm(*form))));
+    }
     PromptUserToChooseCredentialsPtr(
         base::ToVector(local_forms, &std::unique_ptr<PasswordForm>::get),
         origin, base::DoNothing());
@@ -198,9 +202,21 @@
       std::vector<std::unique_ptr<PasswordForm>> local_forms,
       const url::Origin& origin) override {
     EXPECT_FALSE(local_forms.empty());
+    if (stored_choose_callback_) {
+      // Simulate ManagePasswordsUIController::OnAutoSignin calling
+      // DestroyPopups() -> TransitionToState(), which synchronously runs the
+      // stale chooser callback before reading `origin`.
+      std::move(stored_choose_callback_).Run(nullptr);
+      url::Origin origin_copy = origin;
+      EXPECT_FALSE(origin_copy.opaque());
+    }
     NotifyUserAutoSigninPtr();
   }
 
+  // Captures the CredentialsCallback instead of posting it asynchronously to
+  // simulate an open chooser dialog.
+  void capture_callback() { should_store_callback_ = true; }
+
   void set_zero_click_enabled(bool zero_click_enabled) {
     auto_sign_in_enabled_ = zero_click_enabled;
   }
@@ -221,6 +237,8 @@
   NiceMock<MockPasswordManager> password_manager_;
   GURL last_committed_url_{kTestWebOrigin};
   bool auto_sign_in_enabled_ = true;
+  bool should_store_callback_ = false;
+  CredentialsCallback stored_choose_callback_;
 };
 
 // Callbacks from CredentialManagerImpl methods
@@ -2138,4 +2156,58 @@
   EXPECT_FALSE(called);
 }
 
+// Regression test for crbug.com/562242421: When a Get() request with kRequired
+// opens the account chooser, and the Mojo pipe disconnects before the chooser
+// is answered, ResetAfterDisconnecting() drops the pending request while the
+// chooser callback survives in the UI controller. If a subsequent Get() request
+// with kOptional triggers auto sign-in, NotifyUserAutoSignin() closes the old
+// chooser and runs its stale callback. That stale callback must not delete the
+// active `pending_request_` while ProcessForms() is still executing on the
+// stack.
+TEST_P(CredentialManagerImplTest,
+       AutoSigninAfterDisconnectWithPendingChooserDoesNotUAF) {
+  client_->set_zero_click_enabled(true);
+  client_->set_first_run_seen(true);
+  client_->capture_callback();
+  form_.skip_zero_click = false;
+  store_->AddLogin(password_manager::FromPasswordForm(form_));
+
+  EXPECT_CALL(*client_, PromptUserToChooseCredentialsPtr);
+  EXPECT_CALL(*client_, NotifyUserAutoSigninPtr);
+
+  // 1. First Get() request with kRequired opens the account chooser and stores
+  // the callback in the UI controller.
+  bool called1 = false;
+  CredentialManagerError error1 = CredentialManagerError::UNKNOWN;
+  std::optional<CredentialInfo> credential1;
+  CallGet(
+      CredentialMediationRequirement::kRequired, /*include_passwords=*/true,
+      /*federations=*/{},
+      base::BindOnce(&GetCredentialCallback, &called1, &error1, &credential1));
+  RunAllPendingTasks();
+  EXPECT_FALSE(called1);
+
+  // 2. The renderer disconnects the Mojo pipe.
+  cm_service_impl_->ResetAfterDisconnecting();
+
+  // 3. A new pipe calls Get() with kOptional, which chooses auto sign-in and
+  // calls NotifyUserAutoSignin(), synchronously closing the old chooser and
+  // running its stale callback.
+  bool called2 = false;
+  CredentialManagerError error2 = CredentialManagerError::UNKNOWN;
+  std::optional<CredentialInfo> credential2;
+  CallGet(
+      CredentialMediationRequirement::kOptional, /*include_passwords=*/true,
+      /*federations=*/{},
+      base::BindOnce(&GetCredentialCallback, &called2, &error2, &credential2));
+  RunAllPendingTasks();
+
+  EXPECT_FALSE(called1);
+  EXPECT_TRUE(called2);
+  EXPECT_EQ(CredentialManagerError::SUCCESS, error2);
+  ASSERT_TRUE(credential2.has_value());
+  EXPECT_EQ(CredentialType::CREDENTIAL_TYPE_PASSWORD, credential2->type);
+  EXPECT_EQ(form_.username_value, credential2->id);
+}
+
 }  // namespace password_manager
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/password_manager/credential_manager_browsertest.cc b/chrome/browser/password_manager/credential_manager_browsertest.cc
index 461ca1b..532f5dc 100644
--- a/chrome/browser/password_manager/credential_manager_browsertest.cc
+++ b/chrome/browser/password_manager/credential_manager_browsertest.cc
@@ -1,12 +1,15 @@
 // Copyright 2016 The Chromium Authors
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
+#include <optional>
+
 #include "base/command_line.h"
 #include "base/memory/raw_ptr.h"
 #include "base/strings/strcat.h"
 #include "base/strings/stringprintf.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
 #include "chrome/browser/password_manager/chrome_password_manager_client.h"
 #include "chrome/browser/password_manager/password_manager_test_base.h"
 #include "chrome/browser/password_manager/password_manager_test_util.h"
@@ -23,6 +26,8 @@
 #include "components/password_manager/core/browser/password_store/test_password_store.h"
 #include "components/password_manager/core/browser/password_string.h"
 #include "components/password_manager/core/common/password_manager_features.h"
+#include "components/password_manager/core/common/password_manager_pref_names.h"
+#include "components/prefs/pref_service.h"
 #include "content/public/browser/back_forward_cache.h"
 #include "content/public/browser/browser_task_traits.h"
 #include "content/public/browser/browser_thread.h"
@@ -34,6 +39,7 @@
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/prerender_test_util.h"
+#include "mojo/public/cpp/bindings/remote.h"
 #include "net/dns/mock_host_resolver.h"
 
 namespace {
@@ -1259,4 +1265,72 @@
   EXPECT_EQ(avatar_request_counter(), 2u);
 }
 
+// Regression test for crbug.com/562242421: A disconnected CredentialManager
+// pipe with an active account chooser must not leave a stale callback in
+// ManagePasswordsUIController that deletes a subsequent auto-signin request
+// during ProcessForms().
+IN_PROC_BROWSER_TEST_F(CredentialManagerBrowserTest,
+                       AutoSigninAfterDisconnectWithPendingChooserDoesNotUAF) {
+  scoped_refptr<password_manager::TestPasswordStore> password_store =
+      GetDefaultPasswordStore(browser()->GetProfile());
+
+  NavigateToFile("/password/simple_password.html");
+  GURL url = WebContents()->GetLastCommittedURL();
+
+  password_manager::PasswordForm form;
+  form.signon_realm = url::Origin::Create(url).GetURL().spec();
+  form.url = url::Origin::Create(url).GetURL();
+  form.username_value = u"user1";
+  form.password_value = PasswordString(u"abcdef");
+  form.skip_zero_click = false;
+  password_store->AddLogin(password_manager::FromPasswordForm(form));
+  WaitForPasswordStore();
+
+  browser()->GetProfile()->GetPrefs()->SetBoolean(
+      password_manager::prefs::kWasAutoSignInFirstRunExperienceShown, true);
+
+  ChromePasswordManagerClient* client =
+      ChromePasswordManagerClient::FromWebContents(WebContents());
+  ASSERT_TRUE(client);
+
+  // 1. Bind first CredentialManager pipe and call Get(kRequired) to open the
+  // account chooser.
+  mojo::Remote<blink::mojom::CredentialManager> remote1;
+  client->GetContentCredentialManager()->BindRequest(
+      WebContents()->GetPrimaryMainFrame(),
+      remote1.BindNewPipeAndPassReceiver());
+  remote1->Get(password_manager::CredentialMediationRequirement::kRequired,
+               /*include_passwords=*/true,
+               /*federations=*/{}, base::DoNothing());
+
+  BubbleObserver(WebContents()).WaitForAccountChooser();
+  EXPECT_TRUE(IsShowingAccountChooser());
+
+  // 2. Disconnect the Mojo pipe while the account chooser is showing.
+  remote1.reset();
+  base::RunLoop().RunUntilIdle();
+
+  // 3. Bind a new CredentialManager pipe and call Get(kOptional), triggering
+  // auto sign-in and NotifyUserAutoSignin(), which destroys the old chooser.
+  mojo::Remote<blink::mojom::CredentialManager> remote2;
+  client->GetContentCredentialManager()->BindRequest(
+      WebContents()->GetPrimaryMainFrame(),
+      remote2.BindNewPipeAndPassReceiver());
+
+  base::test::TestFuture<password_manager::CredentialManagerError,
+                         std::optional<password_manager::CredentialInfo>>
+      future;
+  remote2->Get(password_manager::CredentialMediationRequirement::kOptional,
+               /*include_passwords=*/true,
+               /*federations=*/{},
+               future.GetCallback<
+                   password_manager::CredentialManagerError,
+                   const std::optional<password_manager::CredentialInfo>&>());
+
+  ASSERT_TRUE(future.Wait());
+  EXPECT_EQ(password_manager::CredentialManagerError::SUCCESS, future.Get<0>());
+  ASSERT_TRUE(future.Get<1>().has_value());
+  EXPECT_EQ(u"user1", future.Get<1>()->id);
+}
+
 }  // namespace
diff --git a/components/password_manager/core/browser/credential_manager_impl_unittest.cc b/components/password_manager/core/browser/credential_manager_impl_unittest.cc
index c7627444..aa12007 100644
--- a/components/password_manager/core/browser/credential_manager_impl_unittest.cc
+++ b/components/password_manager/core/browser/credential_manager_impl_unittest.cc
@@ -184,10 +184,14 @@
       const url::Origin& origin,
       CredentialsCallback callback) override {
     EXPECT_FALSE(local_forms.empty());
-    const PasswordForm* form = local_forms[0].get();
-    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
-        FROM_HERE, base::BindOnce(std::move(callback),
-                                  base::Owned(new PasswordForm(*form))));
+    if (should_store_callback_) {
+      stored_choose_callback_ = std::move(callback);
+    } else {
+      const PasswordForm* form = local_forms[0].get();
+      base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+          FROM_HERE, base::BindOnce(std::move(callback),
+                                    base::Owned(new PasswordForm(*form))));
+    }
     PromptUserToChooseCredentialsPtr(
         base::ToVector(local_forms, &std::unique_ptr<PasswordForm>::get),
         origin, base::DoNothing());
@@ -198,9 +202,21 @@
       std::vector<std::unique_ptr<PasswordForm>> local_forms,
       const url::Origin& origin) override {
     EXPECT_FALSE(local_forms.empty());
+    if (stored_choose_callback_) {
+      // Simulate ManagePasswordsUIController::OnAutoSignin calling
+      // DestroyPopups() -> TransitionToState(), which synchronously runs the
+      // stale chooser callback before reading `origin`.
+      std::move(stored_choose_callback_).Run(nullptr);
+      url::Origin origin_copy = origin;
+      EXPECT_FALSE(origin_copy.opaque());
+    }
     NotifyUserAutoSigninPtr();
   }
 
+  // Captures the CredentialsCallback instead of posting it asynchronously to
+  // simulate an open chooser dialog.
+  void capture_callback() { should_store_callback_ = true; }
+
   void set_zero_click_enabled(bool zero_click_enabled) {
     auto_sign_in_enabled_ = zero_click_enabled;
   }
@@ -221,6 +237,8 @@
   NiceMock<MockPasswordManager> password_manager_;
   GURL last_committed_url_{kTestWebOrigin};
   bool auto_sign_in_enabled_ = true;
+  bool should_store_callback_ = false;
+  CredentialsCallback stored_choose_callback_;
 };
 
 // Callbacks from CredentialManagerImpl methods
@@ -2138,4 +2156,58 @@
   EXPECT_FALSE(called);
 }
 
+// Regression test for crbug.com/562242421: When a Get() request with kRequired
+// opens the account chooser, and the Mojo pipe disconnects before the chooser
+// is answered, ResetAfterDisconnecting() drops the pending request while the
+// chooser callback survives in the UI controller. If a subsequent Get() request
+// with kOptional triggers auto sign-in, NotifyUserAutoSignin() closes the old
+// chooser and runs its stale callback. That stale callback must not delete the
+// active `pending_request_` while ProcessForms() is still executing on the
+// stack.
+TEST_P(CredentialManagerImplTest,
+       AutoSigninAfterDisconnectWithPendingChooserDoesNotUAF) {
+  client_->set_zero_click_enabled(true);
+  client_->set_first_run_seen(true);
+  client_->capture_callback();
+  form_.skip_zero_click = false;
+  store_->AddLogin(password_manager::FromPasswordForm(form_));
+
+  EXPECT_CALL(*client_, PromptUserToChooseCredentialsPtr);
+  EXPECT_CALL(*client_, NotifyUserAutoSigninPtr);
+
+  // 1. First Get() request with kRequired opens the account chooser and stores
+  // the callback in the UI controller.
+  bool called1 = false;
+  CredentialManagerError error1 = CredentialManagerError::UNKNOWN;
+  std::optional<CredentialInfo> credential1;
+  CallGet(
+      CredentialMediationRequirement::kRequired, /*include_passwords=*/true,
+      /*federations=*/{},
+      base::BindOnce(&GetCredentialCallback, &called1, &error1, &credential1));
+  RunAllPendingTasks();
+  EXPECT_FALSE(called1);
+
+  // 2. The renderer disconnects the Mojo pipe.
+  cm_service_impl_->ResetAfterDisconnecting();
+
+  // 3. A new pipe calls Get() with kOptional, which chooses auto sign-in and
+  // calls NotifyUserAutoSignin(), synchronously closing the old chooser and
+  // running its stale callback.
+  bool called2 = false;
+  CredentialManagerError error2 = CredentialManagerError::UNKNOWN;
+  std::optional<CredentialInfo> credential2;
+  CallGet(
+      CredentialMediationRequirement::kOptional, /*include_passwords=*/true,
+      /*federations=*/{},
+      base::BindOnce(&GetCredentialCallback, &called2, &error2, &credential2));
+  RunAllPendingTasks();
+
+  EXPECT_FALSE(called1);
+  EXPECT_TRUE(called2);
+  EXPECT_EQ(CredentialManagerError::SUCCESS, error2);
+  ASSERT_TRUE(credential2.has_value());
+  EXPECT_EQ(CredentialType::CREDENTIAL_TYPE_PASSWORD, credential2->type);
+  EXPECT_EQ(form_.username_value, credential2->id);
+}
+
 }  // namespace password_manager
Loading diff…

Original Bug Report

reported by xi...@anthropic.com

Browser-process use-after-free in CredentialManagerPendingRequestTask::ProcessForms()

Security Bug

VULNERABILITY DETAILS

Summary. A compromised renderer can make the browser process delete a password_manager::CredentialManagerPendingRequestTask (168 bytes) while that object’s own ProcessForms() is still running on the UI thread. ProcessForms() then reads four members from the freed memory and makes two calls through them. No user interaction at attack time.

Preconditions.

  1. A compromised renderer (the PoC uses MojoJS as the stand-in).
  2. One saved password for the attacker’s origin.
  3. The user once dismissed Chrome’s “Sign in automatically to sites?” prompt with “OK, got it” (synced pref profile.was_auto_sign_in_first_run_experience_shown). Without it the same steps only show a second chooser. Incognito is not affected.

How it happens.

  1. CredentialManager.Get() with mediation required shows the account chooser and stores a callback bound to Unretained(CredentialManagerImpl).
  2. The renderer closes its CredentialManager pipe. ResetAfterDisconnecting() drops the pending request; the chooser and its callback survive.
  3. On a new pipe the renderer calls Get() with mediation optional. In the password-store reply, ProcessForms() (credential_manager_pending_request_task.cc:213-227) chooses auto sign-in and calls NotifyUserAutoSignin().
  4. That closes the old chooser (ManagePasswordsUIController::OnAutoSignin() → DestroyPopups() → TransitionToState()), which runs the stale callback. It calls CredentialManagerImpl::SendCredential(), whose pending_request_.reset() deletes the task whose ProcessForms() is still on the stack.
  5. ProcessForms() lines 224 and 226 then read origin_, mediation_, delegate_ and send_callback_ from the freed slot and call SendCredential() through them. The PoC refills the slot with its own 168-byte string, so release builds fault on page-chosen values.

Not MiraclePtr-protected. The five files involved are byte-identical in 152.0.7977.64, 153.0.8010.25 and 155.0.8038.0. Line-by-line detail: attached DETAILS.md.

VERSION

Chrome Version: tested on Chromium 155.0.8038.0, Linux x86-64: ASan dcheck_always_on=false 7/7; ASan dcheck_always_on=true 7/7 stop at a DCHECK first (see gn args below); is_official_build=true 4/5 SIGSEGV on page-chosen values + 1/5 SIGABRT from the allocator quarantine (same bug, see README); UUI=on 2/2. Google Chrome for Testing 152.0.7977.83 linux64: 2/2. 153: by source only, not executed.
Operating System: Linux (tested, headless). Windows / ChromeOS / macOS: by source only, not executed.

REPRODUCTION CASE

Attachments (save into one directory): repro.py, page.html, README.md, DETAILS.md. Python 3 standard library only; no clicks.

python3 repro.py /path/to/chrome         # expected: RESULT: BROWSER CRASHED (ASan heap-use-after-free READ 2 in ManagePasswordsState::OnAutoSignin <- … <- ProcessForms); report in ./asan.txt
FRE=0 python3 repro.py /path/to/chrome   # control, first-run pref absent: RESULT: NO CRASH (exit 2)

ENVIRONMENT USED

  • Linux x86-64, no desktop. The script runs Chrome headless (--headless=new --ozone-platform=headless). No window placement is involved, so X11 vs Wayland does not matter.
  • Chromium 155.0.8038.0 (d81900a3939b) built from source; Google Chrome for Testing 152.0.7977.83.
  • gn args: ASan is_asan=true is_debug=false dcheck_always_on=false is_component_build=false symbol_level=1; release is_official_build=true is_chrome_branded=false use_thin_lto=true is_cfi=false dcheck_always_on=false symbol_level=1. Please keep dcheck_always_on=false: with DCHECKs on, the run stops inside the buggy delete itself, where generated Mojo code asserts “CredentialManager::GetCallback was destroyed without first either being run…” (credential_manager.mojom.cc) because the task’s reply callback is destroyed unanswered. Release builds have no such check.
  • Non-default switches: --enable-blink-features=MojoJS,MojoJSTest (stands in for a compromised renderer) and --disable-features=CredentialManagementUnifiedUi (branded Chrome’s default auto sign-in bubble; UUI=on keeps Chromium’s toast, same bug). Otherwise a fresh --user-data-dir, --no-first-run, --no-default-browser-check, --password-store=basic, offline --host-resolver-rules.
  • Profile: the script writes the first-run pref into Default/Preferences (stands in for the past “OK, got it” click) and saves one password via chrome.passwordsPrivate.addPassword over DevTools, then detaches (stands in for a saved password). Page served by the script on 127.0.0.1 / localhost.
  • Run time: 10–30 s on ASan, about 7 s on release.
  • If it does not reproduce, please send chrome://version and the script’s RESULT: and page … lines.

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION

Type of crash: browser process, main (UI) thread.

Chromium 155.0.8038.0 ASan (dcheck_always_on=false)
heap-use-after-free READ of size 2, thread T0 (chrome)
  #0 url::SchemeHostPort::operator=
  #1 url::Origin::operator=
  #2 ManagePasswordsState::OnAutoSignin
  #3 ManagePasswordsUIController::OnAutoSignin
  #4 ChromePasswordManagerClient::NotifyUserAutoSignin
  #5 password_manager::CredentialManagerPendingRequestTask::ProcessForms
48 bytes inside of 168-byte region, freed by thread T0:
  operator delete <- CredentialManagerImpl::SendCredential <- SendPasswordForm <- …
  <- ManagePasswordsState::TransitionToState <- ManagePasswordsUIController::OnAutoSignin
  <- NotifyUserAutoSignin <- ProcessForms   (same call as the use above)
MiraclePtr Status: NOT PROTECTED

Official 155 (4/5): SEGV_MAPERR 0x414141414158 at CredentialManagerImpl::SendCredential()+0x1a (mov 0x20(%rdi),%rdi), rdi = 0x414141414138, rsi = 0x434343434340, both page-chosen. 1/5: SIGABRT from the allocator quarantine.
Chrome for Testing 152.0.7977.83 (2/2): SEGV_MAPERR 414141414158, di: 0000414141414138 si: 0000434343434340, pc chrome+0xd885e7e.

RELATED REPORT

None of our earlier reports is involved. The silent-save precondition is adjacent to crbug 498301853.

FULL EXPLOIT CHAIN

In our Linux lab this bug also served as the final, browser-process stage of a one-URL chain behind a compromised renderer (front as in b/559726956 → b/559726782, with b/559793873 supplying browser-heap addresses), reaching command execution in the browser process with the browser surviving. The attached PoC needs no other bug.

CREDIT INFORMATION

Reporter credit: Xinyang Ge
Assisted-by: Claude

View on issue tracker