CVE-2025-14372
Overview
Files Changed
chrome/browser/password_manager/password_change/login_state_checker.ccchrome/browser/password_manager/password_change/login_state_checker_unittest.cc
Patch
From 001d0da1d38d7da4c2f55fcd57785ef73e67bb37 Mon Sep 17 00:00:00 2001
From: Viktor Semeniuk <vsemeniuk@google.com>
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 <vsemeniuk@google.com>
Reviewed-by: Vasilii Sukhanov <vasilii@chromium.org>
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.
Regression Test / PoC
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.
Original Bug Report
Security: Heap-use-after-free in LoginStateChecker::OnExecutionResponseCallback
Steps to reproduce the problem
- apply the change.txt to the newest Chromium and compile chrome with ASAN
- start a server at poc.html’s folder : python -m SimpleHTTPServer 8605
- ./chrome –user-data-dir=/tmp/noexist –password-change-url=“http://127.0.0.1:8605/” http://127.0.0.1:8605/poc.html
- 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.hchange thekMaxLoginChecksto2to trigger this issue more quickly. -
components/optimization_guide/core/model_execution/model_execution_features_controller.ccis patched to enable theoptimization_guide::UserVisibleFeatureKey::kPasswordChangeSubmissionfeature. -
chrome/browser/password_manager/chrome_password_change_service.ccis patched to support IP format website(Otherwise you need a doamin name). -
chrome/browser/password_manager/password_change/login_state_checker.ccpatch the checks forresponseto trigger the call toOnPageContentReceivedfunction. It also simulate the situation that the first Attempt is not logged in(which will trigger the call toOnPageContentReceived), and second Attempt will reach theReachedAttemptsLimitand 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 \
- http://127.0.0.1:8605/
- http://127.0.0.1:8605/poc.html
- https://source.chromium.org/chromium/chromium/src/+/b5d8d41f2047b2afd062112ea8b39958792a655a
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/password_change/login_state_checker.cc;l=142
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/password_change/login_state_checker.cc;l=217-223
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/password_change_delegate_impl.cc;l=386
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/password_change_delegate_impl.cc;l=407