CVE-2026-110145
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/passwords/credential_manager_dialog_controller_impl.cc |
modified | |
ifchrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.cc |
modified |
Files Changed
chrome/browser/ui/passwords/credential_manager_dialog_controller_impl_unittest.ccchrome/browser/ui/passwords/credential_manager_dialog_controller_impl.cc
Patch
From 692ebf6c7f7e823892ded7e0e597ef5951dcf1c5 Mon Sep 17 00:00:00 2001 From: Adem Derinel <[email protected]> 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 <[email protected]> Commit-Queue: Adem Derinel <[email protected]> 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
Regression Test / PoC
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
Original Bug Report
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.
- 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 itsCredentialManagerpipe (clearingpending_request_, the only guard) and re-issueGet(REQUIRED). - One saved password for the attacker’s origin.
How it happens.
OnChooseCredentials()(chrome/browser/ui/passwords/manage_passwords_ui_controller.cc) checksHasBrowserWindow()once, then runsdialog_controller_.reset().- An account chooser is already showing, so the reset closes it:
~CredentialManagerDialogControllerImpl→Widget::Close()→WillClose()→BlockWebContentsInteraction(false)→web_contents->Focus(). - In a PiP window that focus deactivates a WebUSB chooser bubble.
PictureInPictureOcclusionTrackerthen finds the PiP over the page’s PWA install dialog and callsExitPictureInPicture(). - That runs a synchronous
ClosePage();~WebContentsImpl→RemoveUserData()freesthis. OnChooseCredentials()then runsCopyFormVector()andpasswords_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 (DevToolsBrowser.setWindowBounds, standing in for the page’s ownmoveTo()/resizeTo()): it works headless and on X11; on native Wayland, which ignores programmatic placement, use--ozone-platform=x11or 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); releaseis_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=0a dcheck build instead stops atdesktop_native_widget_aura.cc:1576after 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 ona/b/c.localhostand127.0.0.1. - DevTools (
--remote-debugging-port) is used only foruserGestureevaluations andBrowser.setWindowBounds(they stand in for that renderer’s own activations andSetWindowRect()) and stays attached to the PiP target (widens the timing window). NoInput.*, no dialog handling. - Run time: 15–40 s on ASan, about 10 s on release.
- If it does not reproduce, please send
chrome://versionand the script’sRESULT:andpage …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