CVE-2026-95364
Overview
Background
- `LoginStateChecker`
- A password-change component that captures page content and runs an optimization-guide model to decide whether the user is logged in or out on the current page.
- `DidFinishNavigation`
- A
content::WebContentsObserveroverride invoked by the browser for every navigation that finishes in the observedWebContents, including subframe and uncommitted (failed/aborted) navigations. - `content::NavigationHandle`
- The object describing an in-flight navigation, exposing
IsInPrimaryMainFrame()andHasCommitted()to distinguish a real top-level page load from subframe or non-committing events. - `capturer_`
- The
AnnotatedPageContentCapturermember whose reset-and-recheck cycle drives a fresh login-state evaluation after each navigation.
Root Cause Analysis
LoginStateChecker::DidFinishNavigation reset capturer_ and called CheckLoginState() on every DidFinishNavigation callback, without checking which frame the navigation targeted or whether it actually committed. Because WebContentsObserver::DidFinishNavigation fires for subframe navigations and for uncommitted navigations (e.g. an aborted or failed load), an attacker-controlled iframe or a spurious navigation event could repeatedly cancel the in-progress page-content capture and trigger a re-check against a page state that does not reflect the real committed main-frame document. The invariant that the login-state check should reason only about the primary main frame’s committed content was violated, letting non-authoritative navigation events drive password-change login detection.
The fix adds a guard that returns early unless navigation_handle->IsInPrimaryMainFrame() and navigation_handle->HasCommitted() are both true, so only genuine top-level, committed navigations reset the capturer and re-run the check. This restores the assumption that the checked content corresponds to the actual page the user is on.
DidFinishNavigation events as equivalent to a real main-frame page load; the fix filters out subframe and uncommitted navigations so the login-state re-check runs only for authoritative primary-main-frame commits.Attack Path
- Initiate a password change
The user or automated password-change flow starts on a target site, creating a
LoginStateCheckerthat begins capturing page content to determine login state. - Inject a subframe navigation
A page with an attacker-influenced iframe (or one that issues aborted navigations) fires
DidFinishNavigationfor a non-main-frame or non-committed navigation. - Cancel and re-trigger the check
The unguarded handler resets
capturer_and callsCheckLoginState(), evaluating login state against content that does not correspond to the committed primary main frame.(inferred) - Cause a misclassified login result
The mismatched evaluation can yield an incorrect logged-in/logged-out determination, steering the password-change flow based on non-authoritative input.
(inferred)
Impact Assessment
LoginStateChecker to re-evaluate login state against non-authoritative content, causing the browser-process password-change logic to misjudge whether the user is logged in. This is a logic/improper-input-validation issue in the Passwords component running in the browser process, with the precondition that a password-change login check is active on an attacker-influenced page. The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.4. Any escalation beyond incorrect login-state classification is not demonstrated by the patch. (inferred)Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fchrome/browser/password_manager/password_change/login_state_checker_unittest.cc |
modified |
Files Changed
chrome/browser/password_manager/password_change/login_state_checker_unittest.ccchrome/browser/password_manager/password_change/login_state_checker.cc
Audit Directions
- Unfiltered navigation observersReview
WebContentsObserver::DidFinishNavigationoverrides that act on state without checkingIsInPrimaryMainFrame()andHasCommitted(), since these fire for subframe and uncommitted navigations. - Security decisions on non-committed stateLook for logic that captures page content or makes trust/login decisions on navigation callbacks before the target document has actually committed in the top-level frame.
- Password and autofill navigation hooksAudit other Passwords/autofill components that reset capturers or re-run checks per navigation event to ensure they scope work to authoritative main-frame commits.
Patch
From 9ef7ab580cf24523c4995b2c1a66d1fa58811e2a Mon Sep 17 00:00:00 2001
From: Viktor Semeniuk <vsemeniuk@google.com>
Date: Fri, 28 Aug 2026 06:13:07 -0700
Subject: [PATCH] Add main-frame and committed-navigation checks to LoginStateChecker
Fix: b/553271219
Change-Id: Ic8aa835fb2d7a077aa66438d323a16e58f02fdda
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8306073
Reviewed-by: Anna Tsvirchkova <atsvirchkova@google.com>
Commit-Queue: Viktor Semeniuk <vsemeniuk@google.com>
Cr-Commit-Position: refs/heads/main@{#1687939}
---
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 322dc506..0a021302 100644
--- a/chrome/browser/password_manager/password_change/login_state_checker.cc
+++ b/chrome/browser/password_manager/password_change/login_state_checker.cc
@@ -159,6 +159,10 @@
void LoginStateChecker::DidFinishNavigation(
content::NavigationHandle* navigation_handle) {
+ if (navigation_handle && (!navigation_handle->IsInPrimaryMainFrame() ||
+ !navigation_handle->HasCommitted())) {
+ return;
+ }
capturer_.reset();
CheckLoginState(/*ignore_attempts_limit=*/false);
}
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 ec93f15..9de090f 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
@@ -22,7 +22,10 @@
#include "components/password_manager/core/browser/features/password_features.h"
#include "components/password_manager/core/browser/password_manager_metrics_util.h"
#include "components/password_manager/core/browser/stub_password_manager_client.h"
+#include "content/public/test/navigation_simulator.h"
+#include "content/public/test/test_renderer_host.h"
#include "content/public/test/web_contents_tester.h"
+#include "net/base/net_errors.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -637,3 +640,74 @@
"PasswordManager.PasswordChange.LoginCheckDuration",
kExpectedDurationTime, 1);
}
+
+TEST_F(LoginStateCheckerTest, SubframeNavigationIgnored) {
+ base::test::TestFuture<LoginCheckResult> future;
+ EXPECT_CALL(*optimization_service(), ExecuteModel).Times(0);
+
+ NavigateAndCommit(GURL("https://example.com"));
+
+ std::unique_ptr<LoginStateChecker> checker =
+ CreateChecker(future.GetRepeatingCallback());
+ ASSERT_TRUE(checker->capturer());
+
+ content::RenderFrameHost* child_rfh =
+ content::RenderFrameHostTester::For(main_rfh())
+ ->AppendChild("child_frame");
+ content::NavigationSimulator::NavigateAndCommitFromDocument(
+ GURL("https://example.com/subframe"), child_rfh);
+
+ // Capturer is not reset because subframe navigation is ignored.
+ EXPECT_TRUE(checker->capturer());
+}
+
+TEST_F(LoginStateCheckerTest, UncommittedNavigationIgnored) {
+ base::test::TestFuture<LoginCheckResult> future;
+ EXPECT_CALL(*optimization_service(), ExecuteModel).Times(0);
+
+ NavigateAndCommit(GURL("https://example.com"));
+
+ std::unique_ptr<LoginStateChecker> checker =
+ CreateChecker(future.GetRepeatingCallback());
+ ASSERT_TRUE(checker->capturer());
+
+ auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+ GURL("https://example.com/failed"), web_contents());
+ simulator->Fail(net::ERR_ABORTED);
+
+ // Capturer is not reset because uncommitted navigation is ignored.
+ EXPECT_TRUE(checker->capturer());
+}
+
+TEST_F(LoginStateCheckerTest,
+ PrimaryMainFrameCommittedNavigationTriggersRecheck) {
+ base::test::TestFuture<LoginCheckResult> future;
+ {
+ InSequence s;
+ EXPECT_CALL(*optimization_service(), ExecuteModel)
+ .WillOnce(WithArg<3>(&PostResponse<ResponseType::kFailure>));
+ EXPECT_CALL(*optimization_service(), ExecuteModel)
+ .WillOnce(WithArg<3>(&PostResponse<ResponseType::kSuccess>));
+ }
+
+ NavigateAndCommit(GURL("https://example.com"));
+
+ std::unique_ptr<LoginStateChecker> checker =
+ CreateChecker(future.GetRepeatingCallback());
+ ASSERT_TRUE(checker->capturer());
+
+ static_cast<FakeAnnotatedPageContentCapturer*>(checker->capturer())
+ ->SimulateResponse(optimization_guide::AIPageContentResult());
+ EXPECT_THAT(future.Take(),
+ HasLoginCheckStatus(LoginCheckResult::Status::kLoggedOut));
+
+ // Primary main frame committed navigation.
+ content::NavigationSimulator::NavigateAndCommitFromBrowser(
+ web_contents(), GURL("https://example.com/next"));
+
+ ASSERT_TRUE(checker->capturer());
+ static_cast<FakeAnnotatedPageContentCapturer*>(checker->capturer())
+ ->SimulateResponse(optimization_guide::AIPageContentResult());
+ EXPECT_THAT(future.Take(),
+ HasLoginCheckStatus(LoginCheckResult::Status::kLoggedIn));
+}
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 ec93f15..9de090f 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
@@ -22,7 +22,10 @@
#include "components/password_manager/core/browser/features/password_features.h"
#include "components/password_manager/core/browser/password_manager_metrics_util.h"
#include "components/password_manager/core/browser/stub_password_manager_client.h"
+#include "content/public/test/navigation_simulator.h"
+#include "content/public/test/test_renderer_host.h"
#include "content/public/test/web_contents_tester.h"
+#include "net/base/net_errors.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -637,3 +640,74 @@
"PasswordManager.PasswordChange.LoginCheckDuration",
kExpectedDurationTime, 1);
}
+
+TEST_F(LoginStateCheckerTest, SubframeNavigationIgnored) {
+ base::test::TestFuture<LoginCheckResult> future;
+ EXPECT_CALL(*optimization_service(), ExecuteModel).Times(0);
+
+ NavigateAndCommit(GURL("https://example.com"));
+
+ std::unique_ptr<LoginStateChecker> checker =
+ CreateChecker(future.GetRepeatingCallback());
+ ASSERT_TRUE(checker->capturer());
+
+ content::RenderFrameHost* child_rfh =
+ content::RenderFrameHostTester::For(main_rfh())
+ ->AppendChild("child_frame");
+ content::NavigationSimulator::NavigateAndCommitFromDocument(
+ GURL("https://example.com/subframe"), child_rfh);
+
+ // Capturer is not reset because subframe navigation is ignored.
+ EXPECT_TRUE(checker->capturer());
+}
+
+TEST_F(LoginStateCheckerTest, UncommittedNavigationIgnored) {
+ base::test::TestFuture<LoginCheckResult> future;
+ EXPECT_CALL(*optimization_service(), ExecuteModel).Times(0);
+
+ NavigateAndCommit(GURL("https://example.com"));
+
+ std::unique_ptr<LoginStateChecker> checker =
+ CreateChecker(future.GetRepeatingCallback());
+ ASSERT_TRUE(checker->capturer());
+
+ auto simulator = content::NavigationSimulator::CreateBrowserInitiated(
+ GURL("https://example.com/failed"), web_contents());
+ simulator->Fail(net::ERR_ABORTED);
+
+ // Capturer is not reset because uncommitted navigation is ignored.
+ EXPECT_TRUE(checker->capturer());
+}
+
+TEST_F(LoginStateCheckerTest,
+ PrimaryMainFrameCommittedNavigationTriggersRecheck) {
+ base::test::TestFuture<LoginCheckResult> future;
+ {
+ InSequence s;
+ EXPECT_CALL(*optimization_service(), ExecuteModel)
+ .WillOnce(WithArg<3>(&PostResponse<ResponseType::kFailure>));
+ EXPECT_CALL(*optimization_service(), ExecuteModel)
+ .WillOnce(WithArg<3>(&PostResponse<ResponseType::kSuccess>));
+ }
+
+ NavigateAndCommit(GURL("https://example.com"));
+
+ std::unique_ptr<LoginStateChecker> checker =
+ CreateChecker(future.GetRepeatingCallback());
+ ASSERT_TRUE(checker->capturer());
+
+ static_cast<FakeAnnotatedPageContentCapturer*>(checker->capturer())
+ ->SimulateResponse(optimization_guide::AIPageContentResult());
+ EXPECT_THAT(future.Take(),
+ HasLoginCheckStatus(LoginCheckResult::Status::kLoggedOut));
+
+ // Primary main frame committed navigation.
+ content::NavigationSimulator::NavigateAndCommitFromBrowser(
+ web_contents(), GURL("https://example.com/next"));
+
+ ASSERT_TRUE(checker->capturer());
+ static_cast<FakeAnnotatedPageContentCapturer*>(checker->capturer())
+ ->SimulateResponse(optimization_guide::AIPageContentResult());
+ EXPECT_THAT(future.Take(),
+ HasLoginCheckStatus(LoginCheckResult::Status::kLoggedIn));
+}
Original Bug Report
Subframe navigations and loads can stall LoginStateChecker in Automated Password Change
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: Missing frame filters in LoginStateChecker::DidFinishNavigation and an un-timeouted tab-wide loading gate in AnnotatedPageContentCapturerImpl allow embedded third-party iframes to starve page content capture. Under the default configuration, an uncompromised cross-origin iframe can permanently stall consented Automated Password Change while displaying an active progress toast. Under the private inference configuration, it can cause the check to time out and silently terminate without displaying any compromised-credential warning.
Affected files:
chrome/browser/password_manager/password_change/login_state_checker.ccchrome/browser/password_manager/password_change/annotated_page_content_capturer_impl.cc
Estimated timestamp from git blame: 2025-08-12
Potential Issue: Third-Party Iframes Can Starve LoginStateChecker and Stall Automated Password Change
Summary
In the Automated Password Change (APC) login-state verification machinery, two mechanisms permit any subframe (such as a third-party ad or widget iframe) in the tab to indefinitely prevent LoginStateChecker from completing:
-
Unfiltered
LoginStateChecker::DidFinishNavigation(chrome/browser/password_manager/password_change/login_state_checker.cc:160-164):void LoginStateChecker::DidFinishNavigation( content::NavigationHandle* navigation_handle) { capturer_.reset(); CheckLoginState(/*ignore_attempts_limit=*/false); }WebContentsObserver::DidFinishNavigationfires for all navigations across all frames in theWebContents, including same-document navigations and aborted navigations. Becausenavigation_handle->IsInPrimaryMainFrame()andnavigation_handle->HasCommitted()are not checked, any subframe navigation (e.g. hash-change or URL update) destroys the currentAnnotatedPageContentCapturerImpland restarts the check. The attempts counter (state_checks_count_) only increments upon receiving a model response (line 256), so repeated subframe navigations never exhaustkMaxLoginChecks = 5. -
Capture Start Gated on Tab-Wide
IsLoading()Without Timeout (chrome/browser/password_manager/password_change/annotated_page_content_capturer_impl.cc:46-71): WhenkAwaitPageStabilityForPasswordChangeis disabled (default),AnnotatedPageContentCapturerImplwaits forweb_contents()->IsLoading()to become false before starting content capture (OnPageStable()).WebContentsImpl::IsLoading()checks the primary frame tree including ad subframes (content/browser/web_contents/web_contents_impl.cc:2649-2651). A subframe with an ongoing or hanging subresource load keeps tab loading inLoadingState::LOADING_WITHOUT_UI(content/browser/renderer_host/frame_tree.cc:868-890), preventingDidStopLoading()from ever firing. Furthermore,LoginStateChecker::StartTimeoutTimer()only arms a timeout timer whenservice_type_ == ModelExecutionServiceType::kPrivateAi(login_state_checker.cc:166-175), leaving the default flow (kDefault) with no timeout.
Potential Impact
- Default Configuration: When the user consents to automated password rotation for a compromised credential,
PasswordChangeDelegateImplentersState::kWaitingForChangePasswordForm(displaying the “Checking sign-in…” toast). Starvation causes the flow to stall permanently in this state without ever executingactuator_->Start(). The compromised password remains in place while the user is falsely reassured by the progress indicator. kPasswordChangeWithPrivateInferenceLoginCheckEnabled Configuration: The delegate initializesLoginStateCheckerwithkPrivateAibefore displaying any UI. Starvation causes the 5-minutekLoginCheckTimeouttimer to expire, returningLoginCheckResult::Status::kError, which causesPasswordChangeDelegateImpl::OnLoginStateCheckedWithPIResult()to callStop(); ResetInternalState();, deleting the delegate. BecauseManagePasswordsUIController::OnCredentialLeak()already routed to APC without creating the fallbackCredentialLeakDialogControllerImpl, the user is never shown any alert about their leaked credential.
(Note: These are potential steps and impacts identified through static code auditing; automated runtime confirmation was not performed.)
Potential Trigger Steps
- A victim user with an APC-eligible profile logs into a site with an affiliated password-change URL using a credential that triggers leak detection.
- The site embeds a cross-origin iframe (e.g. an ad slot).
- The attacker-controlled iframe either:
- Keeps a hanging subresource request open (preventing
WebContents::IsLoading()from returningfalse), and/or - Periodically triggers same-document or subframe navigations (e.g., updating
location.hashorhistory.pushState()).
- Keeps a hanging subresource request open (preventing
- Chrome triggers the APC flow on credential leak.
- Observe that
LoginStateCheckerfails to deliver a page capture verdict, resulting in either a permanent stall of the APC flow (default config) or silent delegate termination suppressing the leak warning (flag-enabled config).
Suggested Fix
- Add main-frame and committed-navigation checks to
LoginStateChecker::DidFinishNavigationmatchingPasswordChangePageStabilityWaiter::DidFinishNavigation(chrome/browser/password_manager/password_change/password_change_page_stability_waiter.cc:116-121):void LoginStateChecker::DidFinishNavigation( content::NavigationHandle* navigation_handle) { if (navigation_handle && (!navigation_handle->IsInPrimaryMainFrame() || !navigation_handle->HasCommitted())) { return; } capturer_.reset(); CheckLoginState(/*ignore_attempts_limit=*/false); } - Add a fallback timeout or utilize
PasswordChangePageStabilityWaiteracross all capture paths rather than relying on un-timeouted tab-wideIsLoading().
Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.