Overview

Medium
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Passwords
DescriptionUse after free in Passwords
ComponentPasswords
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker544484669
Fix commit6192ad9e4b4f (chromium/src) +141/-102
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
Creditedshab
Reported2026-08-10 (per vendor advisory)
Fix landed2026-08-17 (public git)
Disclosed2026-09-08
Patch gap22 days — fix public in source before the advisory

Background

`SuccessfulPasswordChangeView`
a views::View bubble 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* and views::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.

Key insight
The core mistake was binding an asynchronous authentication-result callback to raw, non-owning pointers of view objects whose lifetime is shorter than the pending OS auth dialog; the fix rebinds that callback to a base::WeakPtr of the view so it self-cancels when the view is destroyed.

Attack Path

  1. Trigger the reveal flow With the SuccessfulPasswordChangeView bubble open, the user clicks the eye icon while the password is obscured, which in the old code reaches controller->AuthenticateUser(auth_callback) and starts an asynchronous OS re-authentication.
  2. Free the view mid-auth While the auth dialog is still pending, the bubble or its tab is closed, destroying the views::View and its password_label so their heap memory is freed (the diff’s removed callback held raw pointers to exactly these objects).
  3. Fire the stale callback The OS auth completes and invokes the still-registered auth_result_callback, which dereferences the freed password_label via password_label->SetObscured(...) and the freed toggle_button via SetToggled(...), producing the use-after-free (reproduced by the AuthCallbackAfterViewDestruction test).
  4. 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

An attacker who can drive the password-change success bubble and its dismissal timing can cause a use-after-free in the Chrome browser process (this is 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

FunctionChangeNotes
if
chrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc
modified
CreateUsernamePasswordWithEyeIcon
chrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc
modified
Label
chrome/browser/ui/views/passwords/password_change/successful_password_change_view.h
modified
ToggleImageButton
chrome/browser/ui/views/passwords/password_change/successful_password_change_view.h
modified
SuccessfulPasswordChangeBubbleController
chrome/browser/ui/views/passwords/password_change/successful_password_change_view.h
modified
if
chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc
modified
TEST_F
chrome/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.h
  • chrome/browser/ui/views/passwords/password_change/successful_password_change_view.cc
  • chrome/browser/ui/views/passwords/password_change/successful_password_change_view_unittest.cc

Audit Directions

  • Async callbacks capturing raw view pointers
    Search UI code for base::BindRepeating/base::BindOnce lambdas that capture bare views::View*, views::Label*, or similar pointers and are then handed to asynchronous APIs (auth, network, file dialogs); these must use WeakPtr instead.
  • OS/biometric re-auth result handlers
    Audit every caller of AuthenticateUser-style methods to confirm the result callback is bound to a WeakPtr (or otherwise lifetime-checked) so it self-cancels if the initiating view or bubble is dismissed while the dialog is open.
  • Bubble/dialog dismissal races
    Review 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 AuthCallbackAfterViewDestruction pattern is a good template for regression coverage.
From 6192ad9e4b4f24b9979c164e5aca27832ffa5273 Mon Sep 17 00:00:00 2001
From: Anna Tsvirchkova <[email protected]>
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 <[email protected]>
Reviewed-by: Mohamed Amir Yosef <[email protected]>
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);
+}
Loading diff…

Regression Test / PoC

shipped with the fix
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);
+}
Loading diff…

Original Bug Report

reported by [email protected]

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):

  1. Complete an automated password change so the “Password changed successfully” bubble appears.
  2. Click the eye icon. The OS reauth prompt appears.
  3. Without dismissing the prompt, press Ctrl+Tab to switch tabs. The bubble disappears.
  4. 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

  1. SuccessfulPasswordChangeBubbleControllerTest — capture the delegate’s AvailabilityCallback, destroy the controller, then run the captured callback; assert the user callback did not run.
  2. SuccessfulPasswordChangeViewTest — click the eye icon, capture the callback, GetWidget()->CloseNow(), then run the callback; passes clean under ASan.
View on issue tracker