Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Password Manager
DescriptionUse after free in Password Manager
ComponentPassword Manager
Bug ClassUAF
Tracker460599518
Fix commit001d0da1d38d (chromium/src) +21/-4
CISA KEVNot listed
CreditedWeipeng Jiang (@Krace) of VRI
Disclosed2025-12-10

Files Changed

  • chrome/browser/password_manager/password_change/login_state_checker.cc
  • chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
From 001d0da1d38d7da4c2f55fcd57785ef73e67bb37 Mon Sep 17 00:00:00 2001
From: Viktor Semeniuk <[email protected]>
Date: Fri, 21 Nov 2025 07:01:14 -0800
Subject: [PATCH] Post a task to check cached page content to avoid use-after-free

Bug: 460599518
Change-Id: I8213db7daa5418b42d4c77cefc6a51e907e835db
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7185380
Commit-Queue: Viktor Semeniuk <[email protected]>
Reviewed-by: Vasilii Sukhanov <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1548452}
---

diff --git a/chrome/browser/password_manager/password_change/login_state_checker.cc b/chrome/browser/password_manager/password_change/login_state_checker.cc
index 19fb3e6..325fc7fd 100644
--- a/chrome/browser/password_manager/password_change/login_state_checker.cc
+++ b/chrome/browser/password_manager/password_change/login_state_checker.cc
@@ -7,6 +7,7 @@
 #include "base/check_deref.h"
 #include "base/feature_list.h"
 #include "base/functional/bind.h"
+#include "base/task/single_thread_task_runner.h"
 #include "chrome/browser/optimization_guide/optimization_guide_keyed_service_factory.h"
 #include "chrome/browser/password_manager/password_change/annotated_page_content_capturer.h"
 #include "chrome/browser/password_manager/password_change/model_quality_logs_uploader.h"
@@ -217,7 +218,10 @@
 
   if (cached_page_content_.has_value() && !is_logged_in &&
       !ReachedAttemptsLimit()) {
-    OnPageContentReceived(std::move(cached_page_content_));
+    base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+        FROM_HERE, base::BindOnce(&LoginStateChecker::OnPageContentReceived,
+                                  weak_ptr_factory_.GetWeakPtr(),
+                                  std::move(cached_page_content_)));
     // Clear the page content to ensure that this check doesn't pass next time,
     // which would lead to a request with empty page content.
     cached_page_content_ = std::nullopt;
diff --git a/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc b/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
index e3186dd..d6e3ebb8 100644
--- a/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
+++ b/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
@@ -293,11 +293,16 @@
   // First request finishes with a failure.
   optimization_guide::OptimizationGuideModelExecutionResultCallback
       second_optimization_guide_callback;
+  base::RunLoop run_loop;
   EXPECT_CALL(*optimization_service(), ExecuteModel)
-      .WillOnce(MoveArg<3>(&second_optimization_guide_callback));
+      .WillOnce(
+          testing::DoAll(testing::Invoke(&run_loop, &base::RunLoop::Quit),
+                         MoveArg<3>(&second_optimization_guide_callback)));
   PostResponse<ResponseType::kFailure>(
       std::move(first_optimization_guide_callback));
   EXPECT_EQ(future.Take(), LoginCheckResult::kLoggedOut);
+  run_loop.Run();
+
   ASSERT_TRUE(second_optimization_guide_callback);
 
   // Second request should be processed now and succeed.
@@ -334,12 +339,17 @@
   // `ExecuteModel`.
   optimization_guide::OptimizationGuideModelExecutionResultCallback
       cached_optimization_guide_callback;
+  base::RunLoop run_loop;
   EXPECT_CALL(*optimization_service(), ExecuteModel)
       .Times(1)
-      .WillOnce(MoveArg<3>(&cached_optimization_guide_callback));
+      .WillOnce(
+          testing::DoAll(testing::Invoke(&run_loop, &base::RunLoop::Quit),
+                         MoveArg<3>(&cached_optimization_guide_callback)));
   PostResponse<ResponseType::kFailure>(
       std::move(initial_optimization_guide_callback));
   EXPECT_EQ(future.Take(), LoginCheckResult::kLoggedOut);
+  run_loop.Run();
+
   ASSERT_TRUE(cached_optimization_guide_callback);
 
   // The cached request is processed and succeeds.
@@ -372,11 +382,14 @@
 
   // Model replies that the user is not logged in.
   // This triggers the cached request.
+  base::RunLoop run_loop;
   EXPECT_CALL(*optimization_service(), ExecuteModel)
-      .WillOnce(MoveArg<3>(&optimization_guide_callback_2));
+      .WillOnce(testing::DoAll(testing::Invoke(&run_loop, &base::RunLoop::Quit),
+                               MoveArg<3>(&optimization_guide_callback_2)));
   PostResponse<ResponseType::kFailure>(
       std::move(optimization_guide_callback_1));
   EXPECT_EQ(future.Take(), LoginCheckResult::kLoggedOut);
+  run_loop.Run();
   ASSERT_TRUE(optimization_guide_callback_2);
 
   // The cached request also fails with user not being logged in.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc b/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
index e3186dd..d6e3ebb8 100644
--- a/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
+++ b/chrome/browser/password_manager/password_change/login_state_checker_unittest.cc
@@ -293,11 +293,16 @@
   // First request finishes with a failure.
   optimization_guide::OptimizationGuideModelExecutionResultCallback
       second_optimization_guide_callback;
+  base::RunLoop run_loop;
   EXPECT_CALL(*optimization_service(), ExecuteModel)
-      .WillOnce(MoveArg<3>(&second_optimization_guide_callback));
+      .WillOnce(
+          testing::DoAll(testing::Invoke(&run_loop, &base::RunLoop::Quit),
+                         MoveArg<3>(&second_optimization_guide_callback)));
   PostResponse<ResponseType::kFailure>(
       std::move(first_optimization_guide_callback));
   EXPECT_EQ(future.Take(), LoginCheckResult::kLoggedOut);
+  run_loop.Run();
+
   ASSERT_TRUE(second_optimization_guide_callback);
 
   // Second request should be processed now and succeed.
@@ -334,12 +339,17 @@
   // `ExecuteModel`.
   optimization_guide::OptimizationGuideModelExecutionResultCallback
       cached_optimization_guide_callback;
+  base::RunLoop run_loop;
   EXPECT_CALL(*optimization_service(), ExecuteModel)
       .Times(1)
-      .WillOnce(MoveArg<3>(&cached_optimization_guide_callback));
+      .WillOnce(
+          testing::DoAll(testing::Invoke(&run_loop, &base::RunLoop::Quit),
+                         MoveArg<3>(&cached_optimization_guide_callback)));
   PostResponse<ResponseType::kFailure>(
       std::move(initial_optimization_guide_callback));
   EXPECT_EQ(future.Take(), LoginCheckResult::kLoggedOut);
+  run_loop.Run();
+
   ASSERT_TRUE(cached_optimization_guide_callback);
 
   // The cached request is processed and succeeds.
@@ -372,11 +382,14 @@
 
   // Model replies that the user is not logged in.
   // This triggers the cached request.
+  base::RunLoop run_loop;
   EXPECT_CALL(*optimization_service(), ExecuteModel)
-      .WillOnce(MoveArg<3>(&optimization_guide_callback_2));
+      .WillOnce(testing::DoAll(testing::Invoke(&run_loop, &base::RunLoop::Quit),
+                               MoveArg<3>(&optimization_guide_callback_2)));
   PostResponse<ResponseType::kFailure>(
       std::move(optimization_guide_callback_1));
   EXPECT_EQ(future.Take(), LoginCheckResult::kLoggedOut);
+  run_loop.Run();
   ASSERT_TRUE(optimization_guide_callback_2);
 
   // The cached request also fails with user not being logged in.
Loading diff…

Original Bug Report

reported by [email protected]

Security: Heap-use-after-free in LoginStateChecker::OnExecutionResponseCallback

Steps to reproduce the problem

  1. apply the change.txt to the newest Chromium and compile chrome with ASAN
  2. start a server at poc.html’s folder : python -m SimpleHTTPServer 8605
  3. ./chrome –user-data-dir=/tmp/noexist –password-change-url=“http://127.0.0.1:8605/” http://127.0.0.1:8605/poc.html
  4. After the popup shown, click the “Change it for me”, and UAF occurs

Note that this UAF could be used to escape sandbox WITHOUT a compromised render. All the patch I provided is to simulate an easier way to trigger this UAF in Chromium

Problem Description

Vulnerability Analysis

login_state_checker_[1] is an unique_ptr with a callback OnLoginStateCheckResult[2]. This callback will reset the login_state_checker_, which means the login_state_checker_ will be DELETED after the callback is invoked.

[1]

  if (base::FeatureList::IsEnabled(
          password_manager::features::kCheckLoginStateBeforePasswordChange)) {
    login_state_checker_ = std::make_unique<LoginStateChecker>(
        originator_.get(), logs_uploader_.get(),
        ChromePasswordManagerClient::FromWebContents(originator_),
        base::BindRepeating(
            &PasswordChangeDelegateImpl::OnLoginStateCheckResult,
            weak_ptr_factory_.GetWeakPtr()));
  }

[2]

void PasswordChangeDelegateImpl::OnLoginStateCheckResult(bool is_logged_in) {
  if (is_logged_in) {
    // User is logged in, start password change process.
    ProceedToChangePassword();
    return;
  }

  blocking_challenge_detected_ = true;
  if (!login_state_checker_->ReachedAttemptsLimit()) { //@audit: Only when ReachedAttemptsLimit is true, then `login_state_checker_` could be reset
    // Update the UI to encourage user to complete sign in.
    UpdateState(State::kLoginFormDetected);
    return;
  }

  // Maximum number of retries reached, convert to terminal state.
  UpdateState(State::kChangePasswordFormNotFound);
  login_state_checker_.reset();
}

However, in the LoginStateChecker Class, there is a LoginStateChecker::OnExecutionResponseCallback[3] function which will access the class member after the callback is invoked in the function LoginStateChecker::OnPageContentReceived[4]. This will lead to UAF.

[3]

void LoginStateChecker::OnExecutionResponseCallback(
    optimization_guide::OptimizationGuideModelExecutionResult execution_result,
    std::unique_ptr<
        optimization_guide::proto::PasswordChangeSubmissionLoggingData>
        logging_data) {
[...]
  if (cached_page_content_.has_value() && !is_logged_in &&
      !ReachedAttemptsLimit()) {
    OnPageContentReceived(std::move(cached_page_content_)); //@audit: OnPageContentReceived will invoke the callback and delete |this|
    // Clear the page content to ensure that this check doesn't pass next time,
    // which would lead to a request with empty page content.
    cached_page_content_ = std::nullopt;  //@audit: use after free
  }

  result_check_callback_.Run(is_logged_in); //@audit: user after free
}

[4]

void LoginStateChecker::OnPageContentReceived(
    std::optional<optimization_guide::AIPageContentResult> content) {
  CHECK(content);
  if (is_request_in_flight_) {
    cached_page_content_ = std::move(content);
    return;
  }

  is_request_in_flight_ = true;
  optimization_guide::proto::PasswordChangeRequest request;
  request.set_step(kLoginCheckStep);
  *request.mutable_page_context()->mutable_annotated_page_content() =
      std::move(content->proto);

  LogMessage(client_,
             SavePasswordProgressLogger::STRING_LOGIN_STATE_CHECK_REQUEST_SENT);
  optimization_guide::ExecuteModelWithLogging(  //@audit: this function will directly call the OnExecutionResponseCallback, whihc will call the `callback`
      GetOptimizationService(),
      optimization_guide::ModelBasedCapabilityKey::kPasswordChangeSubmission,
      request, /*execution_timeout=*/std::nullopt,
      base::BindOnce(&LoginStateChecker::OnExecutionResponseCallback,
                     weak_ptr_factory_.GetWeakPtr()));
}

[1] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/password_change_delegate_impl.cc;l=386 [2] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/password_change_delegate_impl.cc;l=407 [3] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/password_change/login_state_checker.cc;l=217-223 [4] https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/password_change/login_state_checker.cc;l=142

Bisect This UAF is introduced in this commit: https://source.chromium.org/chromium/chromium/src/+/b5d8d41f2047b2afd062112ea8b39958792a655a According to the commit, this UAF affects Chrome Stable 142.0.7444.59.

Additional Comments

Info about change.txt All the patch in change.txt is only used to simulate a more easier environment to trigger the UAF.

  • Since the vulnerable function is invoked when the password is detected as “LEAK”, so I patch some code to simulate this situation in components/password_manager/core/browser/leak_detection/leak_detection_request_utils.cc

  • chrome/browser/password_manager/password_change/login_state_checker.h change the kMaxLoginChecks to 2 to trigger this issue more quickly.

  • components/optimization_guide/core/model_execution/model_execution_features_controller.cc is patched to enable the optimization_guide::UserVisibleFeatureKey::kPasswordChangeSubmission feature.

  • chrome/browser/password_manager/chrome_password_change_service.cc is patched to support IP format website(Otherwise you need a doamin name).

  • chrome/browser/password_manager/password_change/login_state_checker.cc patch the checks for response to trigger the call to OnPageContentReceived function. It also simulate the situation that the first Attempt is not logged in(which will trigger the call to OnPageContentReceived), and second Attempt will reach the ReachedAttemptsLimit and run the callback to delete this.

Summary

Security: Heap-use-after-free in LoginStateChecker::OnExecutionResponseCallback

Custom Questions

Type of crash:

browser

Crash state:

Please see the attached asan.txt for ASAN logs.

Reporter credit:

Krace

Additional Data

Category: Security
Chrome Channel: Stable
Regression: N/A \

View on issue tracker