CVE-2026-106205
Overview
Files Changed
chrome/browser/password_manager/chrome_password_manager_client.hchrome/browser/password_manager/chrome_password_manager_client_unittest.ccchrome/browser/password_manager/chrome_password_manager_client.cc
Patch
From 04be4bcb7d7d6aa003e282c3892cda742690555b Mon Sep 17 00:00:00 2001
From: Adem Derinel <derinel@google.com>
Date: Tue, 08 Sep 2026 03:33:15 -0700
Subject: [PATCH] [Android][Passwords] Validate surface visibility before deferred show
ContinueShowKeyboardReplacingSurface() can execute asynchronously once
passkey enumeration completes (via the delay timer or the
WebAuthnCredManDelegate ready notification). By that time, another frame
in the same WebContents may already have shown a keyboard replacing
surface. Unlike the synchronous ShowKeyboardReplacingSurface() entry
point, the deferred callback did not consult the visibility controller,
allowing a late invocation to proceed while a sheet for a different
frame was already displayed.
Re-check CanBeShown() at the start of the deferred entry point and drop
the attempt if a surface is already visible or has already been shown.
CONV=d1a275a8-ff6c-452b-ab92-7df946d71822
Fixed: b:519499907
Change-Id: I3c71f7143f340a102631f9b55b48207e088e37ac
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8364247
Reviewed-by: Friedrich Hauser <friedrichh@chromium.org>
Commit-Queue: Adem Derinel <derinel@google.com>
Cr-Commit-Position: refs/heads/main@{#1693682}
---
diff --git a/chrome/browser/password_manager/chrome_password_manager_client.cc b/chrome/browser/password_manager/chrome_password_manager_client.cc
index fb2d05a..b2f3760 100644
--- a/chrome/browser/password_manager/chrome_password_manager_client.cc
+++ b/chrome/browser/password_manager/chrome_password_manager_client.cc
@@ -651,14 +651,23 @@
base::WeakPtr<password_manager::PasswordManagerDriver> weak_driver,
const autofill::PasswordSuggestionRequest& request,
password_manager::CredManController::PasskeyDelayCallback delay_callback) {
+ if (!weak_driver || !weak_driver->CanShowAutofillUi()) {
+ return;
+ }
+
+ // The visibility controller is lazily created when showing a surface. If
+ // already created, intentionally re-check whether the surface can still be
+ // shown to prevent outdated sheets or overwriting an active surface.
+ if (keyboard_replacing_surface_visibility_controller_ &&
+ !keyboard_replacing_surface_visibility_controller_->CanBeShown()) {
+ return;
+ }
+
// The delay callback gets split because one instance has to be passed to the
// CredMan controller. If CredMan will not be used, that instance is destroyed
// without being called.
auto split_delay_callback =
base::SplitOnceCallback(std::move(delay_callback));
- if (!weak_driver || !weak_driver->CanShowAutofillUi()) {
- return;
- }
password_manager::ContentPasswordManagerDriver* driver =
static_cast<password_manager::ContentPasswordManagerDriver*>(
weak_driver.get());
diff --git a/chrome/browser/password_manager/chrome_password_manager_client.h b/chrome/browser/password_manager/chrome_password_manager_client.h
index 90baf469..e700db4d 100644
--- a/chrome/browser/password_manager/chrome_password_manager_client.h
+++ b/chrome/browser/password_manager/chrome_password_manager_client.h
@@ -58,6 +58,7 @@
#include "chrome/browser/password_manager/android/save_update_password_message_delegate.h"
#include "chrome/browser/touch_to_fill/password_manager/touch_to_fill_password_manager_controller.h"
#include "components/enterprise/connectors/core/features.h"
+#include "components/password_manager/content/browser/keyboard_replacing_surface_visibility_controller.h"
#include "components/password_manager/core/browser/credential_cache.h"
#include "components/password_manager/core/browser/first_cct_page_load_passwords_ukm_recorder.h"
#endif
@@ -401,6 +402,12 @@
touch_to_fill_controller_ = std::move(controller);
}
+ void SetKeyboardReplacingSurfaceVisibilityControllerForTesting(
+ std::unique_ptr<
+ password_manager::KeyboardReplacingSurfaceVisibilityController>
+ controller) {
+ keyboard_replacing_surface_visibility_controller_ = std::move(controller);
+ }
#endif // BUILDFLAG(IS_ANDROID)
#endif // defined(UNIT_TEST)
diff --git a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
index 491d0bc6..c305809e 100644
--- a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
+++ b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
@@ -2539,4 +2539,68 @@
true));
}
+TEST_F(ChromePasswordManagerClientAndroidTest,
+ DoNotShowKeyboardReplacingSurfaceWhenCannotBeShownOnDeferredArrival) {
+ webauthn::WebAuthnCredManDelegate::override_cred_man_support_for_testing(
+ webauthn::CredManSupport::DISABLED);
+ base::test::ScopedFeatureList features(
+ password_manager::features::
+ kDelaySuggestionsOnAutofocusWaitingForPasskeys);
+ CreateManualFillingController(web_contents());
+
+ auto visibility_controller = std::make_unique<
+ password_manager::MockKeyboardReplacingSurfaceVisibilityController>();
+ EXPECT_CALL(*visibility_controller, CanBeShown())
+ .WillOnce(Return(true))
+ .WillRepeatedly(Return(false));
+ password_manager::MockKeyboardReplacingSurfaceVisibilityController*
+ raw_visibility_controller = visibility_controller.get();
+ GetClient()->SetKeyboardReplacingSurfaceVisibilityControllerForTesting(
+ std::move(visibility_controller));
+
+ auto owned_ttf_controller =
+ std::make_unique<MockTouchToFillPasswordManagerController>(
+ profile(), raw_visibility_controller->AsWeakPtr());
+ MockTouchToFillPasswordManagerController* ttf_controller =
+ owned_ttf_controller.get();
+ GetClient()->SetTouchToFillPasswordManagerControllerForTesting(
+ std::move(owned_ttf_controller));
+
+ // TTF controller InitData and Show should not be called because CanBeShown()
+ // returns false when passkeys arrive.
+ EXPECT_CALL(*ttf_controller, InitData).Times(0);
+ EXPECT_CALL(*ttf_controller, Show).Times(0);
+
+ constexpr char kUrl[] = "https://www.foo.com/login.html";
+ NavigateAndCommit(GURL(kUrl));
+ ContentAutofillDriver* autofill_driver =
+ ContentAutofillDriver::GetForRenderFrameHost(main_rfh());
+ EXPECT_TRUE(autofill_driver);
+
+ std::vector<FormFieldData> fields = {CreateTestFormField(
+ "Username:", "username", "", FormControlType::kInputText, "webauthn")};
+ FormData form =
+ CreateFormDataForRenderFrameHost(*main_rfh(), std::move(fields));
+ {
+ autofill::TestAutofillManagerWaiter waiter(
+ autofill_driver->GetAutofillManager(),
+ {autofill::AutofillManagerEvent::kFormsSeen});
+ autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
+ /*removed_forms=*/{});
+ EXPECT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
+ }
+
+ GetClient()->ShowKeyboardReplacingSurface(
+ ContentPasswordManagerDriver::GetForRenderFrameHost(main_rfh()),
+ GetFocusedFieldSuggestionRequest(form));
+
+ // Passkeys arrive after the surface can no longer be shown.
+ ChromeWebAuthnCredentialsDelegateFactory::GetFactory(web_contents())
+ ->GetDelegateForFrame(main_rfh())
+ ->OnCredentialsReceived(
+ /*credentials=*/{},
+ ChromeWebAuthnCredentialsDelegate::SecurityKeyOrHybridFlowAvailable(
+ true));
+}
+
#endif // BUILDFLAG(IS_ANDROID)
Regression Test / PoC
diff --git a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
index 491d0bc6..c305809e 100644
--- a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
+++ b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
@@ -2539,4 +2539,68 @@
true));
}
+TEST_F(ChromePasswordManagerClientAndroidTest,
+ DoNotShowKeyboardReplacingSurfaceWhenCannotBeShownOnDeferredArrival) {
+ webauthn::WebAuthnCredManDelegate::override_cred_man_support_for_testing(
+ webauthn::CredManSupport::DISABLED);
+ base::test::ScopedFeatureList features(
+ password_manager::features::
+ kDelaySuggestionsOnAutofocusWaitingForPasskeys);
+ CreateManualFillingController(web_contents());
+
+ auto visibility_controller = std::make_unique<
+ password_manager::MockKeyboardReplacingSurfaceVisibilityController>();
+ EXPECT_CALL(*visibility_controller, CanBeShown())
+ .WillOnce(Return(true))
+ .WillRepeatedly(Return(false));
+ password_manager::MockKeyboardReplacingSurfaceVisibilityController*
+ raw_visibility_controller = visibility_controller.get();
+ GetClient()->SetKeyboardReplacingSurfaceVisibilityControllerForTesting(
+ std::move(visibility_controller));
+
+ auto owned_ttf_controller =
+ std::make_unique<MockTouchToFillPasswordManagerController>(
+ profile(), raw_visibility_controller->AsWeakPtr());
+ MockTouchToFillPasswordManagerController* ttf_controller =
+ owned_ttf_controller.get();
+ GetClient()->SetTouchToFillPasswordManagerControllerForTesting(
+ std::move(owned_ttf_controller));
+
+ // TTF controller InitData and Show should not be called because CanBeShown()
+ // returns false when passkeys arrive.
+ EXPECT_CALL(*ttf_controller, InitData).Times(0);
+ EXPECT_CALL(*ttf_controller, Show).Times(0);
+
+ constexpr char kUrl[] = "https://www.foo.com/login.html";
+ NavigateAndCommit(GURL(kUrl));
+ ContentAutofillDriver* autofill_driver =
+ ContentAutofillDriver::GetForRenderFrameHost(main_rfh());
+ EXPECT_TRUE(autofill_driver);
+
+ std::vector<FormFieldData> fields = {CreateTestFormField(
+ "Username:", "username", "", FormControlType::kInputText, "webauthn")};
+ FormData form =
+ CreateFormDataForRenderFrameHost(*main_rfh(), std::move(fields));
+ {
+ autofill::TestAutofillManagerWaiter waiter(
+ autofill_driver->GetAutofillManager(),
+ {autofill::AutofillManagerEvent::kFormsSeen});
+ autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
+ /*removed_forms=*/{});
+ EXPECT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
+ }
+
+ GetClient()->ShowKeyboardReplacingSurface(
+ ContentPasswordManagerDriver::GetForRenderFrameHost(main_rfh()),
+ GetFocusedFieldSuggestionRequest(form));
+
+ // Passkeys arrive after the surface can no longer be shown.
+ ChromeWebAuthnCredentialsDelegateFactory::GetFactory(web_contents())
+ ->GetDelegateForFrame(main_rfh())
+ ->OnCredentialsReceived(
+ /*credentials=*/{},
+ ChromeWebAuthnCredentialsDelegate::SecurityKeyOrHybridFlowAvailable(
+ true));
+}
+
#endif // BUILDFLAG(IS_ANDROID)
Original Bug Report
Android CredMan: Missing visibility gate recheck in deferred callback allows credential theft
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: A potential vulnerability in Chromium’s Android Credential Manager / Touch-to-Fill integration allows a compromised cross-origin iframe to hijack credential filling when a victim’s credential sheet is displayed. Due to a missing visibility gate re-check in ContinueShowKeyboardReplacingSurface, late-arriving passkey notifications can overwrite the per-WebContents filler_ object. This can lead to the victim’s plaintext credentials being filled and exfiltrated to the attacker’s renderer.
Affected files:
chrome/browser/password_manager/chrome_password_manager_client.ccchrome/browser/password_manager/android/cred_man_controller.ccchrome/browser/touch_to_fill/password_manager/touch_to_fill_controller.cc
Estimated timestamp from git blame: 2025-06-05
Potential Vulnerability: Cross-Origin Credential Theft via Missing Visibility Gate Recheck on Android
A logic flaw in Chrome’s Android Credential Manager (CredMan) / Touch-to-Fill (TTF) integration allows a compromised cross-origin iframe (Frame A) to hijack credential filling initiated by another honest cross-origin frame (Frame V) within the same WebContents. This enables the attacker’s renderer to read the victim’s plaintext credentials.
Root Cause Analysis
In chrome/browser/password_manager/chrome_password_manager_client.cc, the synchronous entry point ShowKeyboardReplacingSurface guards re-entry with a per-WebContents visibility check:
if (keyboard_replacing_surface_visibility_controller_ &&
!keyboard_replacing_surface_visibility_controller_->CanBeShown()) {
...
return;
}
However, the deferred entry point ContinueShowKeyboardReplacingSurface — reachable via wait_for_passkeys_timer_ and WebAuthnCredManDelegate::credentials_available_closure_ — does not re-evaluate this visibility gate.
When a frame request is delayed (transitioning from kNotReady to kHasPasskeys), the CredManController::Show method is called. If the victim’s sheet is currently visible, calling visibility_controller_->SetVisible(A_driver) safely returns early without updating the active visible driver. However, the controller unconditionally overwrites the per-WebContents filler_ member with the attacker’s filler:
// chrome/browser/password_manager/android/cred_man_controller.cc
visibility_controller_->SetVisible(std::move(frame_driver)); // Returns early if already visible (retains Victim driver)
filler_ = std::move(filler); // Overwrites with Attacker's filler!
When the user selects a credential from the visible sheet, the victim’s delegate still invokes the shared CredManController::TriggerFilling callback. Because the shared filler_ was overwritten by the late-arriving frame, the plaintext username and password are routed through the attacker’s filler, delivering them directly to the attacker’s renderer via Mojo (mojom::PasswordAutofillAgent::FillPasswordSuggestion).
Potential Steps to Trigger the Vulnerability
(Note: These are suggested/potential steps based on C++ source review; our tooling does not yet have the capability to execute this code live or verify device-specific Credential Manager queuing dynamics.)
- Preconditions: The user is running Android U+ with
CredManModeset tokAllCredMan. The attacker’s domain (attacker.com) has a pre-existing registered passkey. The experimentkShowSuggestionsOnAutofocusis active (enabled in current Android Finch field trials). - Environment Setup: An attacker-controlled top-level page embeds two cross-origin frames: Frame A (
attacker.com, compromised renderer) and Frame V (victim.com, honest). Frame A is granted thepublickey-credentials-getpolicy. - Trigger Delayed Flow: Frame A initiates a conditional WebAuthn request and issues an autofill suggestion request with
trigger_source == kPasswordManagerProcessedFocusedField. This starts Chrome’s 4-secondwait_for_passkeys_timer_bound to Frame A’s continuation closure. - Initiate Victim Sheet: Frame V finishes page load and its autofocus autofill request is processed. Since the visibility gate is still
kCanBeShown, the gate is bypassed. The autofocus trigger branch skips stopping the active timer. The legimate credential sheet forvictim.comis rendered, and the sharedfiller_is set to V’s filler. - Overwrite the Active Filler: Within the 4-second window, Frame A’s passkey enumeration finishes.
delegate_Atriggers its credentials-ready notification, stopping the timer and callingContinueShowKeyboardReplacingSurfacefor Frame A. Since the visibility gate is not re-checked, it callsCredManController::Show, which unconditionally overwrites the sharedfiller_with A’s filler. - Credential Exfiltration: The user selects a credential on V’s sheet. The filling callback is invoked, which routes the credentials to the overwritten
filler_(Frame A). The plaintext credentials are then transmitted via Mojo to Frame A’s compromised renderer.
Suggested Remediation
Add a robust visibility/origin check in ContinueShowKeyboardReplacingSurface before delegating to CredManController or initiating Touch-To-Fill.
Alternatively, inside CredManController::Show, do not overwrite the shared filler_ and callbacks if visibility_controller_->SetVisible(...) fails to activate visibility for the requesting driver. Ensure that filler_ operations are scoped strictly to the currently visible frame driver. For example:
if (visibility_controller_->IsVisible() && visibility_controller_->GetVisibleDriver() != frame_driver) {
return false; // Prevent passive hijacking
}
Evaluated with Chrome root at commit: 87214e6721f6c34afd9181b80769a24c0c601c50
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.