CVE-2025-14372
Overview
Background
- `LoginStateChecker`
- a browser-process helper in Chrome’s automated password change flow (
chrome/browser/password_manager/password_change/). It captures annotated page content and asks an Optimization Guide model whether the user is logged in, reporting aLoginCheckResultsuch askLoggedOut. - `cached_page_content_`
- an optional snapshot of page content that is captured while a model request is still in flight. It is replayed through
LoginStateChecker::OnPageContentReceivedonce the earlier request finishes with a “not logged in” answer. - Optimization Guide `ExecuteModel` callback
- the asynchronous
OptimizationGuideModelExecutionResultCallbackthrough which the model’s answer comes back to the checker. - Re-entrancy via synchronous call
- calling a method that starts new work, and may run callbacks that change or destroy the caller, while the caller’s own frame (and
this) is still active.
Root Cause Analysis
In login_state_checker.cc, the code that handles a model response (the enclosing function is not named in the hunk) checks cached_page_content_.has_value() && !is_logged_in && !ReachedAttemptsLimit(). Before the patch it then called OnPageContentReceived(std::move(cached_page_content_)) synchronously, still inside the handling of the earlier result. That re-entered the checker’s request pipeline (a new ExecuteModel call) while the earlier response was still being processed. The same flow also reports LoginCheckResult::kLoggedOut to the owner, as the test’s future.Take() shows. If the re-entrant work, or the owner reacting to the result, destroyed or changed the checker or state it depends on, execution would continue on freed memory when control returned to the outer frame. The advisory classifies it as a use-after-free, but the diff does not name the exact object that is freed (inferred).
The fix posts OnPageContentReceived as a separate task bound to weak_ptr_factory_.GetWeakPtr(). The current call stack therefore unwinds first, and the replay is silently dropped if the LoginStateChecker has been destroyed in the meantime.
WeakPtr breaks the re-entrancy and makes it safe against destruction.Attack Path
- Start a password-change login check
the user starts Chrome’s automated password change on a site, and
LoginStateCheckerbegins capturing page content and asking the model whether the user is logged in. - Cache content during an in-flight request
page content changes while a model request is pending, so the newer snapshot is saved in
cached_page_content_. - Model answers "not logged in"
the first request completes with
is_logged_in == false, so the response handler reportskLoggedOutand, before the fix, synchronously callsOnPageContentReceivedwith the cached content. - Use after free if that nested processing or the result notification destroyed the checker or related state, the outer response handler went on to touch freed memory (inferred), as the advisory’s “Use after free” classification indicates.
Impact Assessment
Files Changed
chrome/browser/password_manager/password_change/login_state_checker.ccchrome/browser/password_manager/password_change/login_state_checker_unittest.cc
Audit Directions
- Synchronous re-dispatch inside completion callbacksin
password_change/and other Optimization Guide clients, look for result handlers that synchronously start the next request, run owner callbacks, or replay cached work, any of which could deletethiswhile it is still on the stack. - Owner callbacks that can destroy the calleefind places where a result callback such as
LoginCheckResultlets the owner reset theunique_ptrholding the checker, and confirm that nothing touches members after the callback runs. - Missing `WeakPtr` on deferred workcheck that cached or replayed work in sibling classes (for example
AnnotatedPageContentCapturerand the model quality log uploader) is bound with weak pointers rather thanbase::Unretained.
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