Medium watch CVSS 6.1 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Password Manager
DescriptionUse after free in Password Manager
ComponentPassword Manager
Sub-componentUI>Browser>Passwords
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.
Tracker460599518
Fix commit001d0da1d38d (chromium/src) +21/-4
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
CISA KEVNot listed
CreditedWeipeng Jiang (@Krace) of VRI
Reported2025-11-14 · bounty $2000 (per vendor advisory)
Fix landed2025-11-21 (public git)
Disclosed2025-12-10
Patch gap19 days — fix public in source before the advisory

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 a LoginCheckResult such as kLoggedOut.
`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::OnPageContentReceived once the earlier request finishes with a “not logged in” answer.
Optimization Guide `ExecuteModel` callback
the asynchronous OptimizationGuideModelExecutionResultCallback through 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.

Key insight
A cached-content replay was started synchronously from inside a model-result callback, so the checker could be torn down or re-entered beneath its own active stack frame. Deferring the replay to a posted task with a WeakPtr breaks the re-entrancy and makes it safe against destruction.

Attack Path

  1. Start a password-change login check the user starts Chrome’s automated password change on a site, and LoginStateChecker begins capturing page content and asking the model whether the user is logged in.
  2. 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_.
  3. Model answers "not logged in" the first request completes with is_logged_in == false, so the response handler reports kLoggedOut and, before the fix, synchronously calls OnPageContentReceived with the cached content.
  4. 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

This is a use-after-free in the browser process, within the password manager’s AI-assisted password change feature. It is reachable only when that feature is available and the user starts a password change. The trigger depends on timing between page-content captures and asynchronous model responses, which a malicious site might influence by mutating its page (inferred). The patch does not show a memory-corruption primitive, so any escalation to code execution in the browser process is (inferred). The vendor rates it medium severity, and the advisory data given does not report exploitation in the wild.

Files Changed

  • chrome/browser/password_manager/password_change/login_state_checker.cc
  • chrome/browser/password_manager/password_change/login_state_checker_unittest.cc

Audit Directions

  • Synchronous re-dispatch inside completion callbacks
    in 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 delete this while it is still on the stack.
  • Owner callbacks that can destroy the callee
    find places where a result callback such as LoginCheckResult lets the owner reset the unique_ptr holding the checker, and confirm that nothing touches members after the callback runs.
  • Missing `WeakPtr` on deferred work
    check that cached or replayed work in sibling classes (for example AnnotatedPageContentCapturer and the model quality log uploader) is bound with weak pointers rather than base::Unretained.
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