CVE-2026-87526
Overview
Background
- `SuccessfulPasswordChangeView`
- a
views::Viewbubble shown after a successful password change that displays the new credential and an eye icon to reveal the obscured password. - `base::RepeatingCallback` / raw pointer binding
- a bound function object that, in the pre-patch code, captured plain
views::Label*andviews::ToggleImageButton*pointers that remain valid only while the owning view lives. - `base::WeakPtr` / `WeakPtrFactory`
- a smart pointer that automatically becomes null when its referent is destroyed, so a callback bound to it is silently dropped rather than dereferencing freed memory.
- `AuthenticateUser` (async re-auth)
- a controller method that triggers an OS/biometric authentication dialog and later invokes a supplied callback with the
bool auth_result, potentially after the bubble has been dismissed.
Root Cause Analysis
In the pre-patch code, the free function CreateUsernamePasswordWithEyeIcon built an auth_result_callback via base::BindRepeating that captured the raw pointers eye_icon and password_label, and this callback was handed to controller->AuthenticateUser(auth_callback) to run asynchronously after OS re-authentication. Because the callback held raw (non-owning) pointers into view objects owned by the bubble, dismissing the bubble or tab while the async auth dialog was still open destroyed the views::View and its labels, freeing that memory. When the OS then completed authentication and invoked the callback, it executed password_label->SetObscured(...) and toggle_button->SetToggled(...) on freed objects — a use-after-free.
The fix moves the result handling into the member method OnAuthenticationResult(bool auth_result) and passes it to AuthenticateUser bound through weak_ptr_factory_.GetWeakPtr() with base::BindOnce; the pointers are now stored as raw_ptr members password_label_ and eye_icon_. Because the callback is bound to a WeakPtr<SuccessfulPasswordChangeView>, if the view is destroyed before auth completes the callback is dropped instead of dereferencing freed memory, which is exactly what the added regression test AuthCallbackAfterViewDestruction verifies.
base::WeakPtr of the view so it self-cancels when the view is destroyed.Attack Path
- Trigger the reveal flow
With the
SuccessfulPasswordChangeViewbubble open, the user clicks the eye icon while the password is obscured, which in the old code reachescontroller->AuthenticateUser(auth_callback)and starts an asynchronous OS re-authentication. - Free the view mid-auth
While the auth dialog is still pending, the bubble or its tab is closed, destroying the
views::Viewand itspassword_labelso their heap memory is freed (the diff’s removed callback held raw pointers to exactly these objects). - Fire the stale callback
The OS auth completes and invokes the still-registered
auth_result_callback, which dereferences the freedpassword_labelviapassword_label->SetObscured(...)and the freedtoggle_buttonviaSetToggled(...), producing the use-after-free (reproduced by theAuthCallbackAfterViewDestructiontest). - Downstream escalation
Any control over the contents of the freed-and-reallocated view memory to convert this into a controlled read/write or code execution is
(inferred); the diff demonstrates only the dangling-pointer dereference, not a concrete exploitation primitive.
Impact Assessment
chrome/browser/ui UI code, not a sandboxed renderer), with the precondition that an asynchronous authentication is in flight when the bubble/tab is closed. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 9.6. Escalation of this UAF to information disclosure, memory corruption, or code execution is (inferred) and not demonstrated by the patch, which shows only a dangling-pointer dereference.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc |
modified | |
CreateUsernamePasswordWithEyeIconchrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc |
modified | |
Labelchrome/browser/ui/views/passwords/password_change/successful_password_change_view.h |
modified | |
ToggleImageButtonchrome/browser/ui/views/passwords/password_change/successful_password_change_view.h |
modified | |
SuccessfulPasswordChangeBubbleControllerchrome/browser/ui/views/passwords/password_change/successful_password_change_view.h |
modified | |
ifchrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc |
modified | |
TEST_Fchrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc |
modified |
Files Changed
chrome/browser/ui/views/passwords/password_change/successful_password_change_view.hchrome/browser/ui/views/passwords/password_change/successful_password_change_view.ccchrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc
Audit Directions
- Async callbacks capturing raw view pointersSearch UI code for
base::BindRepeating/base::BindOncelambdas that capture bareviews::View*,views::Label*, or similar pointers and are then handed to asynchronous APIs (auth, network, file dialogs); these must useWeakPtrinstead. - OS/biometric re-auth result handlersAudit every caller of
AuthenticateUser-style methods to confirm the result callback is bound to aWeakPtr(or otherwise lifetime-checked) so it self-cancels if the initiating view or bubble is dismissed while the dialog is open. - Bubble/dialog dismissal racesReview views that can be closed by the user (tab/bubble close) while an operation completes later, ensuring no pending callback dereferences members of the destroyed view; the
AuthCallbackAfterViewDestructionpattern is a good template for regression coverage.
Patch
From 6192ad9e4b4f24b9979c164e5aca27832ffa5273 Mon Sep 17 00:00:00 2001
From: Anna Tsvirchkova <atsvirchkova@google.com>
Date: Mon, 17 Aug 2026 03:16:50 -0700
Subject: [PATCH] Fix Use-after-free in SuccessfulPasswordChangeBubbleController
The use-after-free would be the following:
User->>View: 1. Clicks Eye Icon to reveal password
View->>OS: 2. Starts Async Re-Auth (passes callback with raw pointers)
User->>View: 3. Closes Bubble / Tab while Auth dialog is open
Note over View: 4. View & Labels destroyed (Heap memory FREED)
OS->>View: 5. Auth finishes & executes callback
Note over View: 6. Callback calls password_label->SetObscured()
The CL wraps handling the authentication result into a method, which is
passed to biometric auth via a WeakPtr. It also moves eye button click
handler into a method for conveniency.
Bug: 544484669
Change-Id: Ib6bb0c7e6ddda5b7ed07e0c381d799dc9460c130
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8261134
Commit-Queue: Anna Tsvirchkova <atsvirchkova@google.com>
Reviewed-by: Mohamed Amir Yosef <mamir@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1680431}
---
diff --git a/chrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc b/chrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc
index a90fbbb..d0a36bd 100644
--- a/chrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc
+++ b/chrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc
@@ -67,102 +67,6 @@
return view;
}
-// Row containing favicon, username, password and eye icon:
-// *--------------------------------------------------------*
-// | | Username | |
-// | Favicon |-----------------------------------| Eye icon |
-// | | Password | |
-// *--------------------------------------------------------*
-std::unique_ptr<views::View> CreateUsernamePasswordWithEyeIcon(
- SuccessfulPasswordChangeBubbleController* controller) {
- CHECK(controller);
-
- auto parent_view = std::make_unique<views::BoxLayoutView>();
- parent_view->SetInsideBorderInsets(ComputeRowMargins());
-
- // Set the same spacing as for RichHoverButton which is displayed bellow.
- const int icon_label_spacing = ChromeLayoutProvider::Get()->GetDistanceMetric(
- DISTANCE_RICH_HOVER_BUTTON_ICON_HORIZONTAL);
- parent_view->SetBetweenChildSpacing(icon_label_spacing);
-
- // Add favicon.
- views::ImageView* favicon_view =
- parent_view->AddChildView(std::make_unique<views::ImageView>());
- const int icon_size = GetLayoutConstant(LayoutConstant::kPageInfoIconSize);
- favicon_view->SetImageSize({icon_size, icon_size});
- favicon_view->SetImage(ui::ImageModel::FromVectorIcon(
- features::IsRoundedIconsEnabled() ? vector_icons::kGlobeIcon
- : vector_icons::kGlobeOldIcon,
- ui::kColorIcon, gfx::kFaviconSize));
- controller->RequestFavicon(base::BindOnce(
- [](views::ImageView* favicon_view, const gfx::Image& favicon) {
- if (!favicon.IsEmpty()) {
- favicon_view->SetImage(ui::ImageModel::FromImage(favicon));
- }
- },
- favicon_view));
-
- // Add username/password labels.
- auto* username_password_view =
- parent_view->AddChildView(CreateVerticalStackView());
- username_password_view->SetProperty(views::kBoxLayoutFlexKey,
- views::BoxLayoutFlexSpecification());
- username_password_view->AddChildView(
- CreateUsernameLabel(controller->GetUsername()));
- views::Label* password_label = username_password_view->AddChildView(
- CreatePasswordLabel(controller->GetNewPassword()));
-
- // Add eye icon which allows to reveal a password.
- auto* eye_icon = parent_view->AddChildView(
- CreateVectorToggleImageButton(views::Button::PressedCallback()));
- eye_icon->SetTooltipText(
- l10n_util::GetStringUTF16(IDS_MANAGE_PASSWORDS_SHOW_PASSWORD));
- eye_icon->SetToggledTooltipText(
- l10n_util::GetStringUTF16(IDS_MANAGE_PASSWORDS_HIDE_PASSWORD));
- eye_icon->SetImageVerticalAlignment(views::ImageButton::ALIGN_MIDDLE);
- views::SetImageFromVectorIconWithColor(eye_icon,
- features::IsRoundedIconsEnabled()
- ? views::kVisibilityFilledIcon
- : views::kEyeOldIcon,
- {ui::kColorIcon, ui::kColorIcon});
- views::SetToggledImageFromVectorIconWithColor(
- eye_icon,
- features::IsRoundedIconsEnabled() ? views::kVisibilityOffFilledIcon
- : views::kEyeCrossedOldIcon,
- {ui::kColorIcon, ui::kColorIcon});
-
- base::RepeatingCallback<void(bool)> auth_result_callback =
- base::BindRepeating(
- [](views::ToggleImageButton* toggle_button,
- views::Label* password_label, bool auth_result) {
- if (!auth_result) {
- return;
- }
- password_label->SetObscured(!password_label->GetObscured());
- toggle_button->SetToggled(!toggle_button->GetToggled());
- },
- eye_icon, password_label);
-
- eye_icon->SetCallback(base::BindRepeating(
- [](base::WeakPtr<SuccessfulPasswordChangeBubbleController> controller,
- views::Label* password_label,
- base::RepeatingCallback<void(bool)> auth_callback) {
- if (!password_label->GetObscured()) {
- // Run callback to hide the password. No auth needed to do it.
- auth_callback.Run(true);
- return;
- }
- if (controller) {
- controller->AuthenticateUser(auth_callback);
- }
- },
- controller->GetWeakPtr(), password_label,
- std::move(auth_result_callback)));
- eye_icon->SetID(SuccessfulPasswordChangeView::kEyeIconButtonId);
-
- return parent_view;
-}
-
std::unique_ptr<views::View> CreateManagePasswordsView(
base::RepeatingClosure open_password_manager_closure) {
auto manage_passwords_button = std::make_unique<RichHoverButton>(
@@ -217,15 +121,14 @@
// width.
set_margins(gfx::Insets());
- views::View* username_password_row = root_view->AddChildView(
- CreateUsernamePasswordWithEyeIcon(controller_.get()));
+ root_view->AddChildView(CreateUsernamePasswordWithEyeIcon());
root_view->AddChildView(std::make_unique<views::Separator>());
root_view->AddChildView(CreateManagePasswordsView(base::BindRepeating(
&SuccessfulPasswordChangeBubbleController::OpenPasswordManager,
controller_->GetWeakPtr())));
SetShowIcon(true);
- SetInitiallyFocusedView(username_password_row->GetViewByID(kEyeIconButtonId));
+ SetInitiallyFocusedView(eye_icon_);
SetButtons(static_cast<int>(ui::mojom::DialogButton::kNone));
SetCloseCallback(base::BindRepeating(
[](SuccessfulPasswordChangeView* view) {
@@ -259,5 +162,85 @@
SetBubbleHeaderLottie(IDR_PASSWORD_CHANGE_SUCCESS_LOTTIE);
}
+std::unique_ptr<views::View>
+SuccessfulPasswordChangeView::CreateUsernamePasswordWithEyeIcon() {
+ auto parent_view = std::make_unique<views::BoxLayoutView>();
+ parent_view->SetInsideBorderInsets(ComputeRowMargins());
+
+ // Set the same spacing as for RichHoverButton which is displayed below.
+ const int icon_label_spacing = ChromeLayoutProvider::Get()->GetDistanceMetric(
+ DISTANCE_RICH_HOVER_BUTTON_ICON_HORIZONTAL);
+ parent_view->SetBetweenChildSpacing(icon_label_spacing);
+
+ // Add favicon.
+ views::ImageView* favicon_view =
+ parent_view->AddChildView(std::make_unique<views::ImageView>());
+ const int icon_size = GetLayoutConstant(LayoutConstant::kPageInfoIconSize);
+ favicon_view->SetImageSize({icon_size, icon_size});
+ favicon_view->SetImage(ui::ImageModel::FromVectorIcon(
+ features::IsRoundedIconsEnabled() ? vector_icons::kGlobeIcon
+ : vector_icons::kGlobeOldIcon,
+ ui::kColorIcon, gfx::kFaviconSize));
+ controller_->RequestFavicon(base::BindOnce(
+ [](views::ImageView* favicon_view, const gfx::Image& favicon) {
+ if (!favicon.IsEmpty()) {
+ favicon_view->SetImage(ui::ImageModel::FromImage(favicon));
+ }
+ },
+ favicon_view));
+
+ // Add username/password labels.
+ auto* username_password_view =
+ parent_view->AddChildView(CreateVerticalStackView());
+ username_password_view->SetProperty(views::kBoxLayoutFlexKey,
+ views::BoxLayoutFlexSpecification());
+ username_password_view->AddChildView(
+ CreateUsernameLabel(controller_->GetUsername()));
+ password_label_ = username_password_view->AddChildView(
+ CreatePasswordLabel(controller_->GetNewPassword()));
+
+ // Add eye icon which allows to reveal a password.
+ eye_icon_ = parent_view->AddChildView(views::CreateVectorToggleImageButton(
+ base::BindRepeating(&SuccessfulPasswordChangeView::OnEyeIconClicked,
+ base::Unretained(this))));
+ eye_icon_->SetTooltipText(
+ l10n_util::GetStringUTF16(IDS_MANAGE_PASSWORDS_SHOW_PASSWORD));
+ eye_icon_->SetToggledTooltipText(
+ l10n_util::GetStringUTF16(IDS_MANAGE_PASSWORDS_HIDE_PASSWORD));
+ eye_icon_->SetImageVerticalAlignment(views::ImageButton::ALIGN_MIDDLE);
+ views::SetImageFromVectorIconWithColor(eye_icon_,
+ features::IsRoundedIconsEnabled()
+ ? views::kVisibilityFilledIcon
+ : views::kEyeOldIcon,
+ {ui::kColorIcon, ui::kColorIcon});
+ views::SetToggledImageFromVectorIconWithColor(
+ eye_icon_,
+ features::IsRoundedIconsEnabled() ? views::kVisibilityOffFilledIcon
+ : views::kEyeCrossedOldIcon,
+ {ui::kColorIcon, ui::kColorIcon});
+ eye_icon_->SetID(SuccessfulPasswordChangeView::kEyeIconButtonId);
+
+ return parent_view;
+}
+
+void SuccessfulPasswordChangeView::OnEyeIconClicked() {
+ if (!password_label_->GetObscured()) {
+ password_label_->SetObscured(true);
+ eye_icon_->SetToggled(false);
+ return;
+ }
+ controller_->AuthenticateUser(
+ base::BindOnce(&SuccessfulPasswordChangeView::OnAuthenticationResult,
+ weak_ptr_factory_.GetWeakPtr()));
+}
+
+void SuccessfulPasswordChangeView::OnAuthenticationResult(bool auth_result) {
+ if (!auth_result) {
+ return;
+ }
+ password_label_->SetObscured(false);
+ eye_icon_->SetToggled(true);
+}
+
BEGIN_METADATA(SuccessfulPasswordChangeView)
END_METADATA
diff --git a/chrome/browser/ui/views/passwords/password_change/successful_password_change_view.h b/chrome/browser/ui/views/passwords/password_change/successful_password_change_view.h
index 89ed90b..14b9824 100644
--- a/chrome/browser/ui/views/passwords/password_change/successful_password_change_view.h
+++ b/chrome/browser/ui/views/passwords/password_change/successful_password_change_view.h
@@ -5,9 +5,16 @@
#ifndef CHROME_BROWSER_UI_VIEWS_PASSWORDS_PASSWORD_CHANGE_SUCCESSFUL_PASSWORD_CHANGE_VIEW_H_
#define CHROME_BROWSER_UI_VIEWS_PASSWORDS_PASSWORD_CHANGE_SUCCESSFUL_PASSWORD_CHANGE_VIEW_H_
+#include "base/memory/raw_ptr.h"
+#include "base/memory/weak_ptr.h"
#include "chrome/browser/ui/views/passwords/password_bubble_view_base.h"
#include "ui/base/metadata/metadata_header_macros.h"
+namespace views {
+class Label;
+class ToggleImageButton;
+} // namespace views
+
class SuccessfulPasswordChangeBubbleController;
// Bubble view, which is displayed when password change flow is successfully
@@ -36,7 +43,26 @@
// views::View:
void AddedToWidget() override;
+ // Creates row containing favicon, username, password and eye icon:
+ // *--------------------------------------------------------*
+ // | | Username | |
+ // | Favicon |-----------------------------------| Eye icon |
+ // | | Password | |
+ // *--------------------------------------------------------*
+ std::unique_ptr<views::View> CreateUsernamePasswordWithEyeIcon();
+
+ // Callback for handling the eye icon (password reveal button) click.
+ void OnEyeIconClicked();
+ // Callback for handling the result of the authentication (revealing password
+ // may require authentication).
+ void OnAuthenticationResult(bool auth_result);
+
std::unique_ptr<SuccessfulPasswordChangeBubbleController> controller_;
+
+ raw_ptr<views::Label> password_label_ = nullptr;
+ raw_ptr<views::ToggleImageButton> eye_icon_ = nullptr;
+
+ base::WeakPtrFactory<SuccessfulPasswordChangeView> weak_ptr_factory_{this};
};
#endif // CHROME_BROWSER_UI_VIEWS_PASSWORDS_PASSWORD_CHANGE_SUCCESSFUL_PASSWORD_CHANGE_VIEW_H_
diff --git a/chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc b/chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc
index 7e29468..d6d78ae 100644
--- a/chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc
+++ b/chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc
@@ -40,9 +40,11 @@
}
void TearDown() override {
- view_->GetWidget()->CloseWithReason(
- views::Widget::ClosedReason::kUnspecified);
- view_ = nullptr;
+ if (view_) {
+ view_->GetWidget()->CloseWithReason(
+ views::Widget::ClosedReason::kUnspecified);
+ view_ = nullptr;
+ }
PasswordBubbleViewTestBase::TearDown();
}
@@ -55,6 +57,7 @@
}
SuccessfulPasswordChangeView* view() { return view_; }
+ void reset_view() { view_ = nullptr; }
views::Label* GetLabelById(int id) {
return static_cast<views::Label*>(view()->GetViewByID(id));
@@ -128,3 +131,30 @@
EXPECT_TRUE(GetLabelById(SuccessfulPasswordChangeView::kPasswordLabelId)
->GetObscured());
}
+
+TEST_F(SuccessfulPasswordChangeViewTest, AuthCallbackAfterViewDestruction) {
+ CreateAndShowView();
+
+ base::OnceCallback<void(bool)> captured_auth_callback;
+ EXPECT_CALL(*model_delegate_mock(), AuthenticateUserWithMessage)
+ .WillOnce(
+ testing::WithArg<1>([&](base::OnceCallback<void(bool)> callback) {
+ captured_auth_callback = std::move(callback);
+ }));
+
+ views::Button* eye_icon = static_cast<views::Button*>(
+ view()->GetViewByID(SuccessfulPasswordChangeView::kEyeIconButtonId));
+ EXPECT_TRUE(eye_icon);
+
+ views::test::ButtonTestApi(eye_icon).NotifyClick(ui::test::TestEvent());
+ EXPECT_TRUE(captured_auth_callback);
+
+ // Destroy the widget and view before the async auth callback runs.
+ views::Widget* widget = view()->GetWidget();
+ reset_view();
+ widget->CloseNow();
+
+ // Executing the callback when the view is destroyed must not crash or trigger
+ // UAF.
+ std::move(captured_auth_callback).Run(true);
+}
Regression Test / PoC
diff --git a/chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc b/chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc
index 7e29468..d6d78ae 100644
--- a/chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc
+++ b/chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc
@@ -40,9 +40,11 @@
}
void TearDown() override {
- view_->GetWidget()->CloseWithReason(
- views::Widget::ClosedReason::kUnspecified);
- view_ = nullptr;
+ if (view_) {
+ view_->GetWidget()->CloseWithReason(
+ views::Widget::ClosedReason::kUnspecified);
+ view_ = nullptr;
+ }
PasswordBubbleViewTestBase::TearDown();
}
@@ -55,6 +57,7 @@
}
SuccessfulPasswordChangeView* view() { return view_; }
+ void reset_view() { view_ = nullptr; }
views::Label* GetLabelById(int id) {
return static_cast<views::Label*>(view()->GetViewByID(id));
@@ -128,3 +131,30 @@
EXPECT_TRUE(GetLabelById(SuccessfulPasswordChangeView::kPasswordLabelId)
->GetObscured());
}
+
+TEST_F(SuccessfulPasswordChangeViewTest, AuthCallbackAfterViewDestruction) {
+ CreateAndShowView();
+
+ base::OnceCallback<void(bool)> captured_auth_callback;
+ EXPECT_CALL(*model_delegate_mock(), AuthenticateUserWithMessage)
+ .WillOnce(
+ testing::WithArg<1>([&](base::OnceCallback<void(bool)> callback) {
+ captured_auth_callback = std::move(callback);
+ }));
+
+ views::Button* eye_icon = static_cast<views::Button*>(
+ view()->GetViewByID(SuccessfulPasswordChangeView::kEyeIconButtonId));
+ EXPECT_TRUE(eye_icon);
+
+ views::test::ButtonTestApi(eye_icon).NotifyClick(ui::test::TestEvent());
+ EXPECT_TRUE(captured_auth_callback);
+
+ // Destroy the widget and view before the async auth callback runs.
+ views::Widget* widget = view()->GetWidget();
+ reset_view();
+ widget->CloseNow();
+
+ // Executing the callback when the view is destroyed must not crash or trigger
+ // UAF.
+ std::move(captured_auth_callback).Run(true);
+}
Original Bug Report
Use-after-free in SuccessfulPasswordChangeBubbleController: password-reveal callback holds raw View pointers across async OS reauth
Summary
The “Password changed successfully” bubble’s eye icon binds raw views::Label* and views::ToggleImageButton* into a callback that is forwarded, without a WeakPtr wrap, to ManagePasswordsUIController and stored inside a DeviceAuthenticator while the OS reauth prompt (Windows Hello / Touch ID) runs asynchronously.
If the bubble closes while the prompt is pending — via a path that does not navigate — the view subtree is freed but the authenticator is not reset. When the user then dismisses the prompt, the stored callback runs and dereferences freed heap in the browser process.
SuccessfulPasswordChangeBubbleController::AuthenticateUser is the only password bubble controller that does not wrap this callback through its own WeakPtrFactory.
Affected code
chrome/browser/ui/passwords/bubble_controllers/password_change/successful_password_change_bubble_controller.cc — AuthenticateUser:
void SuccessfulPasswordChangeBubbleController::AuthenticateUser(
base::OnceCallback<void(bool)> auth_callback) {
...
if (delegate_) {
delegate_->AuthenticateUserWithMessage(message, std::move(auth_callback));
// ^^^^^^^^^^^^^^^^^^^^^^^^
// forwarded verbatim — no weak_ptr_factory_ wrap
}
}
chrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc — the callback being forwarded (sink):
base::RepeatingCallback<void(bool)> auth_result_callback =
base::BindRepeating(
[](views::ToggleImageButton* toggle_button,
views::Label* password_label, bool auth_result) {
if (!auth_result) {
return;
}
password_label->SetObscured(!password_label->GetObscured()); // UAF
toggle_button->SetToggled(!toggle_button->GetToggled()); // UAF
},
eye_icon, password_label);
Root cause
ManagePasswordsUIController is a WebContentsUserData and outlives the bubble. AuthenticateUserWithMessage parks the callback inside biometric_authenticator_:
// chrome/browser/ui/passwords/manage_passwords_ui_controller.cc
biometric_authenticator_ = passwords_data_.client()->GetDeviceAuthenticator();
biometric_authenticator_->AuthenticateWithMessage(
message, std::move(callback).Then(std::move(on_reauth_completed)));
The .Then() WeakPtr guards only the trailing OnReauthCompleted closure, not the caller’s callback.
CancelAnyOngoingBiometricAuth() — which would drop the callback by resetting the authenticator — is called from PrimaryPageChanged() but not from OnBubbleHidden(). So bubble-close paths that don’t navigate leave the dangling callback live:
LocationBarBubbleDelegateView::OnVisibilityChanged(HIDDEN)(tab switch, or web content opening a foreground tab)LocationBarBubbleDelegateView::OnFullscreenStateChanged()(F11)
On Windows, DeviceAuthenticatorWin::Cancel() is NOTIMPLEMENTED(), so the callback is only dropped when the authenticator object itself is destroyed.
Every sibling controller wraps the callback first:
| Controller | Wrap site |
|---|---|
ManagePasswordsBubbleController |
manage_passwords_bubble_controller.cc — BindOnce(&Self::OnUserAuthenticationCompleted, weak_ptr_factory_.GetWeakPtr(), ...) |
SaveUpdateBubbleController |
save_update_bubble_controller.cc — same pattern |
ItemsBubbleController |
items_bubble_controller.cc — same pattern |
CredentialManagerDialogControllerImpl |
credential_manager_dialog_controller_impl.cc — same pattern |
SuccessfulPasswordChangeBubbleController |
none |
Data flow
click eye icon
-> auth_result_callback bound with raw views::Label* / views::ToggleImageButton*
-> SuccessfulPasswordChangeBubbleController::AuthenticateUser(cb) [NO WeakPtr]
-> ManagePasswordsUIController::AuthenticateUserWithMessage(msg, cb.Then(weak))
-> DeviceAuthenticator::AuthenticateWithMessage -- async OS prompt --
-> [tab switch / F11] LocationBarBubbleDelegateView::OnVisibilityChanged(HIDDEN)
-> CloseBubble() -> ~SuccessfulPasswordChangeView -> label + button FREED
-> ManagePasswordsUIController::OnBubbleHidden() [does NOT reset auth]
-> user dismisses prompt -> std::move(callback).Run(success)
-> SINK: password_label->SetObscured(...) on freed memory
Repro
ASan build, PASSWORD_CHANGE_STATE reachable (or instantiate SuccessfulPasswordChangeView directly in a browser test via ManagePasswordsUIController::ShowChangePasswordBubble):
- Complete an automated password change so the “Password changed successfully” bubble appears.
- Click the eye icon. The OS reauth prompt appears.
- Without dismissing the prompt, press Ctrl+Tab to switch tabs. The bubble disappears.
- Return to the prompt and click Cancel (or authenticate).
Expected ASan output:
==ERROR: AddressSanitizer: heap-use-after-free on address 0x... READ of size 8
at ... thread T0 (CrBrowserMain)
#0 views::Label::GetObscured()
#1 SuccessfulPasswordChangeView::...::<lambda>::operator()
#2 DeviceAuthenticatorWin::OnAuthenticationCompleted
freed by thread T0 here:
#0 views::View::~View()
#1 views::Widget::CloseNow()
#2 LocationBarBubbleDelegateView::OnVisibilityChanged
Non-triggers (already mitigated): window deactivation alone (set_close_on_deactivate(false)); same-tab navigation (PrimaryPageChanged -> CancelAnyOngoingBiometricAuth); tab close (destroys the WebContents and therefore the authenticator).
Impact
Heap UAF in the unsandboxed browser process. The first dereference is a virtual call on a freed views::Label, followed by member writes — with heap grooming, a control-flow/write primitive.
Bounded by trust principal: the initiating eye-icon click requires local user interaction, and the destruction step requires a second user action (Ctrl+Tab / F11) or web content opening a foreground tab while the prompt is up. There is no purely remote driver.
Note: bound T* args are stored as UnretainedWrapper<T, MayNotDangle> (i.e. raw_ptr internally), so MiraclePtr does apply and dangling-detector builds should CHECK here — but that is a mitigation, not a fix.
Suggested fix
Wrap the callback through the controller’s WeakPtrFactory, matching every sibling. The controller is a unique_ptr member of SuccessfulPasswordChangeView, so it is destroyed before the base views::View destructor frees the children — the weak pointers are invalidated before the label and button go away.
// successful_password_change_bubble_controller.cc
void SuccessfulPasswordChangeBubbleController::AuthenticateUser(
base::OnceCallback<void(bool)> auth_callback) {
...
if (delegate_) {
// Bind OnUserAuthenticationCompleted() using a weak_ptr such that if the
// bubble is closed (and this controller destroyed) while the reauth flow is
// running, `auth_callback` is never invoked. It holds pointers into the
// bubble's view hierarchy, which is destroyed together with the bubble.
delegate_->AuthenticateUserWithMessage(
message, base::BindOnce(&SuccessfulPasswordChangeBubbleController::
OnUserAuthenticationCompleted,
weak_ptr_factory_.GetWeakPtr(),
std::move(auth_callback)));
}
}
void SuccessfulPasswordChangeBubbleController::OnUserAuthenticationCompleted(
base::OnceCallback<void(bool)> auth_callback,
bool authentication_result) {
std::move(auth_callback).Run(authentication_result);
}
// successful_password_change_bubble_controller.h -- private section
void OnUserAuthenticationCompleted(
base::OnceCallback<void(bool)> auth_callback,
bool authentication_result);
Considered but not proposed: calling CancelAnyOngoingBiometricAuth() from ManagePasswordsUIController::OnBubbleHidden() as defense-in-depth. AuthenticateUserWithMessage is also driven by PasswordCredentialUIController (WebAuthn) and CredentialManagerDialogControllerImpl, so cancelling on any bubble hide would abort unrelated in-flight auth.
Suggested regression tests
SuccessfulPasswordChangeBubbleControllerTest— capture the delegate’sAvailabilityCallback, destroy the controller, then run the captured callback; assert the user callback did not run.SuccessfulPasswordChangeViewTest— click the eye icon, capture the callback,GetWidget()->CloseNow(), then run the callback; passes clean under ASan.