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
Tracker562243925
Fix commit692ebf6c7f7e (chromium/src) +95/-18
CISA KEVNot listed
CreditedXinyang Ge
Disclosed2026-10-13

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/passwords/credential_manager_dialog_controller_impl.cc
modified
if
chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc
modified

Files Changed

  • chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc
  • chrome/browser/ui/passwords/credential_manager_dialog_controller_impl.cc
From 692ebf6c7f7e823892ded7e0e597ef5951dcf1c5 Mon Sep 17 00:00:00 2001
From: Adem Derinel <derinel@google.com>
Date: Fri, 18 Sep 2026 05:17:39 -0700
Subject: [PATCH] [Passwords] Delete Credential Manager dialogs async in ResetDialog

Previously, CredentialManagerDialogControllerImpl::ResetDialog()
synchronously destroyed account_chooser_dialog_ and autosignin_dialog_.
Because AccountChooserDialogView owns its views::Widget, synchronous
destruction invokes ~Widget() -> WillClose() ->
BlockWebContentsInteraction(false) -> web_contents->Focus().

In a Document Picture-in-Picture window, focusing the WebContents can
deactivate chooser bubbles, triggering PiP exit and synchronous
WebContents destruction (and therefore ManagePasswordsUIController
destruction) while ManagePasswordsUIController or
CredentialManagerDialogControllerImpl is still executing on the stack,
leading to a heap use-after-free (UAF).

This CL updates ResetDialog() to delete the dialogs asynchronously via
DeleteSoon(), matching the existing behavior in OnCloseDialog(). This
prevents synchronous widget closing and recursive WebContents
destruction during dialog resetting.

TAG=agy
CONV=51cb8a63-900d-47ea-8b42-5208ffb2fe9c

Fixed: 562243925
Change-Id: I70f3b6a66c500a61ade7660832a828166598e39b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8419710
Reviewed-by: Rafał Godlewski <rgod@google.com>
Commit-Queue: Adem Derinel <derinel@google.com>
Cr-Commit-Position: refs/heads/main@{#1700661}
---
diff --git a/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl.cc b/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl.cc
index 449ae99c..d0761ab 100644
--- a/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl.cc
+++ b/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl.cc
@@ -191,14 +191,7 @@
       profile_->GetPrefs());
   password_manager::metrics_util::LogAutoSigninPromoUserAction(
       password_manager::metrics_util::AUTO_SIGNIN_OK_GOT_IT);
-  if (autosignin_dialog_) {
-    autosignin_dialog_->ControllerGone();
-    // Delete the dialog asynchronously because we are currently in a views
-    // callback. Synchronous destruction would cause a use-after-free crash
-    // when the call stack unwinds back to the views framework.
-    base::SingleThreadTaskRunner::GetCurrentDefault()->DeleteSoon(
-        FROM_HERE, std::move(autosignin_dialog_));
-  }
+  ResetDialog();
   delegate_->OnDialogHidden();
 }
 
@@ -209,14 +202,7 @@
       profile_->GetPrefs());
   password_manager::metrics_util::LogAutoSigninPromoUserAction(
       password_manager::metrics_util::AUTO_SIGNIN_TURN_OFF);
-  if (autosignin_dialog_) {
-    autosignin_dialog_->ControllerGone();
-    // Delete the dialog asynchronously because we are currently in a views
-    // callback. Synchronous destruction would cause a use-after-free crash
-    // when the call stack unwinds back to the views framework.
-    base::SingleThreadTaskRunner::GetCurrentDefault()->DeleteSoon(
-        FROM_HERE, std::move(autosignin_dialog_));
-  }
+  ResetDialog();
   delegate_->OnDialogHidden();
 }
 
@@ -239,13 +225,18 @@
 }
 
 void CredentialManagerDialogControllerImpl::ResetDialog() {
+  // Delete the dialogs asynchronously to prevent synchronous destruction of the
+  // underlying views::Widget, which would unblock and focus the WebContents,
+  // potentially causing re-entrancy and synchronous WebContents destruction.
   if (account_chooser_dialog_) {
     account_chooser_dialog_->ControllerGone();
-    account_chooser_dialog_.reset();
+    base::SingleThreadTaskRunner::GetCurrentDefault()->DeleteSoon(
+        FROM_HERE, std::move(account_chooser_dialog_));
   }
   if (autosignin_dialog_) {
     autosignin_dialog_->ControllerGone();
-    autosignin_dialog_.reset();
+    base::SingleThreadTaskRunner::GetCurrentDefault()->DeleteSoon(
+        FROM_HERE, std::move(autosignin_dialog_));
   }
 }
 
diff --git a/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc b/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc
index fe8ee78b..197fa66 100644
--- a/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc
+++ b/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc
@@ -39,13 +39,25 @@
                            public AutoSigninFirstRunPrompt {
  public:
   MockPasswordPrompt() = default;
+  ~MockPasswordPrompt() override {
+    if (on_destroy_) {
+      std::move(on_destroy_).Run();
+    }
+  }
 
   MockPasswordPrompt(const MockPasswordPrompt&) = delete;
   MockPasswordPrompt& operator=(const MockPasswordPrompt&) = delete;
 
+  void set_on_destroy(base::OnceClosure on_destroy) {
+    on_destroy_ = std::move(on_destroy);
+  }
+
   MOCK_METHOD(void, ShowAccountChooser, (), (override));
   MOCK_METHOD(void, ShowAutoSigninPrompt, (), (override));
   MOCK_METHOD(void, ControllerGone, (), (override));
+
+ private:
+  base::OnceClosure on_destroy_;
 };
 
 password_manager::PasswordForm GetLocalForm() {
@@ -398,4 +410,78 @@
 }
 #endif
 
+TEST_F(CredentialManagerDialogControllerTest,
+       ResetDialogDeletesDialogsAsynchronously) {
+  auto prompt = std::make_unique<StrictMock<MockPasswordPrompt>>();
+  auto* prompt_ptr = prompt.get();
+  password_manager::PasswordForm local_form = GetLocalForm();
+  std::vector<std::unique_ptr<password_manager::PasswordForm>> locals;
+  locals.push_back(
+      std::make_unique<password_manager::PasswordForm>(local_form));
+
+  base::RunLoop run_loop;
+  bool prompt_destroyed = false;
+  prompt_ptr->set_on_destroy(base::BindOnce(
+      [](bool* destroyed, base::OnceClosure quit_closure) {
+        *destroyed = true;
+        std::move(quit_closure).Run();
+      },
+      &prompt_destroyed, run_loop.QuitClosure()));
+
+  EXPECT_CALL(*prompt_ptr, ShowAccountChooser());
+  controller().ShowAccountChooser(std::move(prompt), std::move(locals));
+
+  // Close the dialog via choosing credential.
+  EXPECT_CALL(*prompt_ptr, ControllerGone());
+#if BUILDFLAG(IS_MAC) || BUILDFLAG(IS_WIN) || BUILDFLAG(IS_CHROMEOS)
+  EXPECT_CALL(feature_manager(), IsBiometricAuthenticationBeforeFillingEnabled)
+      .WillOnce(testing::Return(false));
+#endif
+  EXPECT_CALL(ui_controller_mock(),
+              ChooseCredential(
+                  local_form,
+                  password_manager::CredentialType::CREDENTIAL_TYPE_PASSWORD));
+  controller().OnChooseCredentials(
+      local_form, password_manager::CredentialType::CREDENTIAL_TYPE_PASSWORD);
+
+  // The prompt must NOT be destroyed synchronously inside OnChooseCredentials /
+  // ResetDialog.
+  EXPECT_FALSE(prompt_destroyed);
+
+  // The prompt is destroyed asynchronously once pending tasks run.
+  run_loop.Run();
+  EXPECT_TRUE(prompt_destroyed);
+}
+
+TEST_F(CredentialManagerDialogControllerTest,
+       ResetDialogDeletesAutosigninPromptAsynchronously) {
+  auto prompt = std::make_unique<StrictMock<MockPasswordPrompt>>();
+  auto* prompt_ptr = prompt.get();
+
+  base::RunLoop run_loop;
+  bool prompt_destroyed = false;
+  prompt_ptr->set_on_destroy(base::BindOnce(
+      [](bool* destroyed, base::OnceClosure quit_closure) {
+        *destroyed = true;
+        std::move(quit_closure).Run();
+      },
+      &prompt_destroyed, run_loop.QuitClosure()));
+
+  EXPECT_CALL(*prompt_ptr, ShowAutoSigninPrompt());
+  controller().ShowAutosigninPrompt(std::move(prompt));
+
+  // Close the dialog via OnAutoSigninOK (which invokes ResetDialog).
+  EXPECT_CALL(*prompt_ptr, ControllerGone());
+  EXPECT_CALL(ui_controller_mock(), OnDialogHidden());
+  controller().OnAutoSigninOK();
+
+  // The prompt must NOT be destroyed synchronously inside OnAutoSigninOK /
+  // ResetDialog.
+  EXPECT_FALSE(prompt_destroyed);
+
+  // The prompt is destroyed asynchronously once pending tasks run.
+  run_loop.Run();
+  EXPECT_TRUE(prompt_destroyed);
+}
+
 }  // namespace
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc b/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc
index fe8ee78b..197fa66 100644
--- a/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc
+++ b/chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc
@@ -39,13 +39,25 @@
                            public AutoSigninFirstRunPrompt {
  public:
   MockPasswordPrompt() = default;
+  ~MockPasswordPrompt() override {
+    if (on_destroy_) {
+      std::move(on_destroy_).Run();
+    }
+  }
 
   MockPasswordPrompt(const MockPasswordPrompt&) = delete;
   MockPasswordPrompt& operator=(const MockPasswordPrompt&) = delete;
 
+  void set_on_destroy(base::OnceClosure on_destroy) {
+    on_destroy_ = std::move(on_destroy);
+  }
+
   MOCK_METHOD(void, ShowAccountChooser, (), (override));
   MOCK_METHOD(void, ShowAutoSigninPrompt, (), (override));
   MOCK_METHOD(void, ControllerGone, (), (override));
+
+ private:
+  base::OnceClosure on_destroy_;
 };
 
 password_manager::PasswordForm GetLocalForm() {
@@ -398,4 +410,78 @@
 }
 #endif
 
+TEST_F(CredentialManagerDialogControllerTest,
+       ResetDialogDeletesDialogsAsynchronously) {
+  auto prompt = std::make_unique<StrictMock<MockPasswordPrompt>>();
+  auto* prompt_ptr = prompt.get();
+  password_manager::PasswordForm local_form = GetLocalForm();
+  std::vector<std::unique_ptr<password_manager::PasswordForm>> locals;
+  locals.push_back(
+      std::make_unique<password_manager::PasswordForm>(local_form));
+
+  base::RunLoop run_loop;
+  bool prompt_destroyed = false;
+  prompt_ptr->set_on_destroy(base::BindOnce(
+      [](bool* destroyed, base::OnceClosure quit_closure) {
+        *destroyed = true;
+        std::move(quit_closure).Run();
+      },
+      &prompt_destroyed, run_loop.QuitClosure()));
+
+  EXPECT_CALL(*prompt_ptr, ShowAccountChooser());
+  controller().ShowAccountChooser(std::move(prompt), std::move(locals));
+
+  // Close the dialog via choosing credential.
+  EXPECT_CALL(*prompt_ptr, ControllerGone());
+#if BUILDFLAG(IS_MAC) || BUILDFLAG(IS_WIN) || BUILDFLAG(IS_CHROMEOS)
+  EXPECT_CALL(feature_manager(), IsBiometricAuthenticationBeforeFillingEnabled)
+      .WillOnce(testing::Return(false));
+#endif
+  EXPECT_CALL(ui_controller_mock(),
+              ChooseCredential(
+                  local_form,
+                  password_manager::CredentialType::CREDENTIAL_TYPE_PASSWORD));
+  controller().OnChooseCredentials(
+      local_form, password_manager::CredentialType::CREDENTIAL_TYPE_PASSWORD);
+
+  // The prompt must NOT be destroyed synchronously inside OnChooseCredentials /
+  // ResetDialog.
+  EXPECT_FALSE(prompt_destroyed);
+
+  // The prompt is destroyed asynchronously once pending tasks run.
+  run_loop.Run();
+  EXPECT_TRUE(prompt_destroyed);
+}
+
+TEST_F(CredentialManagerDialogControllerTest,
+       ResetDialogDeletesAutosigninPromptAsynchronously) {
+  auto prompt = std::make_unique<StrictMock<MockPasswordPrompt>>();
+  auto* prompt_ptr = prompt.get();
+
+  base::RunLoop run_loop;
+  bool prompt_destroyed = false;
+  prompt_ptr->set_on_destroy(base::BindOnce(
+      [](bool* destroyed, base::OnceClosure quit_closure) {
+        *destroyed = true;
+        std::move(quit_closure).Run();
+      },
+      &prompt_destroyed, run_loop.QuitClosure()));
+
+  EXPECT_CALL(*prompt_ptr, ShowAutoSigninPrompt());
+  controller().ShowAutosigninPrompt(std::move(prompt));
+
+  // Close the dialog via OnAutoSigninOK (which invokes ResetDialog).
+  EXPECT_CALL(*prompt_ptr, ControllerGone());
+  EXPECT_CALL(ui_controller_mock(), OnDialogHidden());
+  controller().OnAutoSigninOK();
+
+  // The prompt must NOT be destroyed synchronously inside OnAutoSigninOK /
+  // ResetDialog.
+  EXPECT_FALSE(prompt_destroyed);
+
+  // The prompt is destroyed asynchronously once pending tasks run.
+  run_loop.Run();
+  EXPECT_TRUE(prompt_destroyed);
+}
+
 }  // namespace
Loading diff…

Original Bug Report

reported by xi...@anthropic.com

Browser-process use-after-free in ManagePasswordsUIController::OnChooseCredentials()

Security Bug

VULNERABILITY DETAILS

Summary. A compromised renderer can make the browser process delete the ManagePasswordsUIController (2672 bytes, a WebContentsUserData) of a Document Picture-in-Picture tab while that object’s own OnChooseCredentials() is still running on the UI thread. The following statements copy data into the freed object and make a virtual call through it. No user gesture.

Preconditions.

  1. A compromised renderer (the PoC uses MojoJS as the stand-in). Re-entering OnChooseCredentials() while the first chooser is up needs the renderer to close its CredentialManager pipe (clearing pending_request_, the only guard) and re-issue Get(REQUIRED).
  2. One saved password for the attacker’s origin.

How it happens.

  1. OnChooseCredentials() (chrome/browser/ui/passwords/manage_passwords_ui_controller.cc) checks HasBrowserWindow() once, then runs dialog_controller_.reset().
  2. An account chooser is already showing, so the reset closes it: ~CredentialManagerDialogControllerImpl → Widget::Close() → WillClose() → BlockWebContentsInteraction(false) → web_contents->Focus().
  3. In a PiP window that focus deactivates a WebUSB chooser bubble. PictureInPictureOcclusionTracker then finds the PiP over the page’s PWA install dialog and calls ExitPictureInPicture().
  4. That runs a synchronous ClosePage(); ~WebContentsImpl → RemoveUserData() frees this.
  5. OnChooseCredentials() then runs CopyFormVector() and passwords_data_.OnRequestCredentials() → ManagePasswordsState::ClearData() on the freed controller.

Not MiraclePtr-protected. The function body is 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 — Chromium 155.0.8038.0 Linux: ASan dcheck_always_on=true 7/7 (incl. 1 --disable-field-trial-config; zero DCHECK lines), ASan dcheck_always_on=false 2/2, is_official_build=true 2/2 (+REFILL=1 SHIELD=0 1/1 page-chosen 0x414141414140); Chrome for Testing 152.0.7977.83 linux64 1/1 with this kit (earlier choreography: CfT 4/4, ASan 15/15, official 14/14); control K=0 no crash. 153.0.8010.25: by source only, not executed.
Operating System: Linux (tested). Windows/ChromeOS: by source only, not executed. macOS: not reachable by source.

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       # 15-40 s, expected: RESULT: BROWSER CRASHED (ASan heap-use-after-free READ 8 in ManagePasswordsState::ClearData <- …::OnChooseCredentials), exit 0; report in ./asan.txt
K=0 python3 repro.py /path/to/chrome   # control, no chooser bubble so the close is asynchronous: RESULT: NO CRASH (exit 2)

ENVIRONMENT USED

  • Linux x86-64, no desktop. The script runs Chrome headless (--headless=new --ozone-platform=headless). The trigger depends on window placement (DevTools Browser.setWindowBounds, standing in for the page’s own moveTo()/resizeTo()): it works headless and on X11; on native Wayland, which ignores programmatic placement, use --ozone-platform=x11 or the headless default.
  • 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=true|false is_component_build=false symbol_level=1 (use-after-free on both, no DCHECK fires); release is_official_build=true is_chrome_branded=false is_cfi=false use_cfi_icall=false use_thin_lto=true chrome_pgo_phase=0 dcheck_always_on=false is_component_build=false symbol_level=1.
  • One extra throw-away popup: its “Leave site?” prompt is up while the PiP opens, so the PiP’s content window is never activated and DCHECK builds reach the use-after-free. Free, object and use are identical without it; with SHIELD=0 a dcheck build instead stops at desktop_native_widget_aura.cc:1576 after the free.
  • Non-default switches: --enable-blink-features=MojoJS,MojoJSTest (stands in for a compromised renderer) and --window-position/--window-size. Otherwise a fresh --user-data-dir, --no-first-run, --password-store=basic, offline --host-resolver-rules.
  • Profile and page: the script saves one password via chrome.passwordsPrivate.addPassword (stands in for a saved password). Page served by the script on a/b/c.localhost and 127.0.0.1.
  • DevTools (--remote-debugging-port) is used only for userGesture evaluations and Browser.setWindowBounds (they stand in for that renderer’s own activations and SetWindowRect()) and stays attached to the PiP target (widens the timing window). No Input.*, no dialog handling.
  • Run time: 15–40 s on ASan, about 10 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=true
heap-use-after-free READ of size 8, thread T0 (chrome)
  #0 ManagePasswordsState::ClearData
  #1 ManagePasswordsState::OnRequestCredentials
  #2 ManagePasswordsUIController::OnChooseCredentials
192 bytes inside of 2776-byte region (2672 with dcheck off), freed by thread T0:
  RemoveUserData <- WebContentsDestroyed <- ~WebContentsImpl <- … <- ExitPictureInPicture
  <- … <- WebContentsImpl::Focus <- … <- ~CredentialManagerDialogControllerImpl
  <- OnChooseCredentials   (same call as the use above)
MiraclePtr: NOT PROTECTED; no "Check failed" line

Official 155 (2/2): SIGSEGV at ManagePasswordsState::ClearData()+0x22 (mov (%rdi),%rax; call *0x8(%rax)), rdi = 0xcdcd… from the freed controller. With REFILL=1 SHIELD=0 (1/1): rdi = 0x414141414140, page-chosen.
Chrome for Testing 152.0.7977.83 (CFI on): SIGSEGV at the same load, chrome+0x69bf2f1, rdi = 0xcdcd…, 1/1 (4/4 earlier choreography).

RELATED REPORT

Same free-route family as b/560251736 (dialog close → un-block → Focus() → chooser-bubble deactivation → PiP exit; no resize; live on 152). Different object (ManagePasswordsUIController), holder and files.

FULL EXPLOIT CHAIN

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

CREDIT INFORMATION

Reporter credit: Xinyang Ge
Assisted-by: Claude

View on issue tracker