CVE-2026-87624
Overview
Background
- Keyboard replacing surface
- an Android autofill surface (Touch To Fill / TTF or the CredMan credential picker) that replaces the on-screen keyboard to let the user pick a saved password or passkey for a focused field.
- `ContinueShowKeyboardReplacingSurface`
- the
ChromePasswordManagerClientcontinuation that runs asynchronously after credentials (including passkeys) have been fetched and actually shows the surface and marks its visibility. - `CanShowAutofillUi()`
- a driver method that reports whether the driver’s
RenderFrameHostis currently in a state allowed to display autofill UI (i.e. an active frame), returningfalsefor inactive frames such as one left behind after a navigation (inferred: the exact active-frame check is implied by the fix and the test’sIsActive()assertion). - `weak_driver`
- a
base::WeakPtrto the password-manager driver that requested the surface; it survives even when the underlying frame becomes inactive, so its mere non-null validity does not prove the frame is still showable.
Root Cause Analysis
ShowKeyboardReplacingSurface starts an asynchronous flow that requests credentials/passkeys, and only later invokes ContinueShowKeyboardReplacingSurface once results arrive. The pre-patch continuation guarded only against a destroyed driver with if (!weak_driver) { return; }, treating a still-valid weak_driver as sufficient to proceed to show TTF/CredMan and set the surface visible. Between the request and the credentials callback, the user can navigate away, leaving the requesting RenderFrameHost inactive while its weak_driver remains alive; the continuation then displayed the password surface for a frame that no longer owned the visible page, violating the invariant that a keyboard-replacing surface is only shown for the active, foreground frame.
The fix tightens the guard to if (!weak_driver || !weak_driver->CanShowAutofillUi()), so the continuation returns early when the frame is no longer eligible to show autofill UI, suppressing the surface after navigation. Because the guard runs before any InitData/Show call and before visibility is set, no surface is presented for the stale frame, as the regression test’s Times(0) expectations confirm.
!weak_driver) rather than its eligibility to show UI, so an asynchronous credentials callback could paint a password surface for a frame the user had already navigated away from; adding !weak_driver->CanShowAutofillUi() to the early-return condition ties the surface to an active frame.Attack Path
- Focus a credential field
The victim focuses a password/passkey (
webauthn) field on an attacker-influenced page, triggeringShowKeyboardReplacingSurfaceand an asynchronous request for credentials. - Race the async fetch with a navigation
Before the passkey/credential results arrive, the page (or the user) causes a navigation to another origin, leaving the original
RenderFrameHostinactive while itsweak_driverstays valid. - Credentials arrive for the stale frame
The delayed
OnCredentialsReceivedcallback fires and invokesContinueShowKeyboardReplacingSurfacefor the now-inactive frame, which passes the old!weak_driver-only guard. - Surface shown over the wrong context Pre-patch, TTF/CredMan is initialized and shown and its visibility is set even though the requesting frame is no longer the active page, misrepresenting which page/origin the credential prompt belongs to (inferred: that this yields a user-deceiving spoof; the patch itself only demonstrates the surface being shown for an inactive frame).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/password_manager/chrome_password_manager_client.cc |
modified |
Files Changed
chrome/browser/password_manager/chrome_password_manager_client_unittest.ccchrome/browser/password_manager/chrome_password_manager_client.cc
Audit Directions
- Async UI continuations validated only for object livenessAudit callbacks/continuations that capture a
WeakPtrand later show UI; check they re-verify frame/tab activity (e.g.CanShowAutofillUi(),IsActive()), not merely that the weak pointer is non-null. - Navigation-race between request and resultLook for surfaces or dialogs (autofill, permissions, credential pickers) requested on focus/interaction but presented after an async fetch, where a navigation in the interim can leave a stale but valid driver eligible to display.
- Inactive `RenderFrameHost` reachabilitySearch for password/autofill paths that operate on a frame’s driver after prerender activation, BFCache, or cross-document navigation, ensuring each show/visibility sink is gated on the frame still being active.
Patch
From 10c3ba83d3674d727b18f64e239bda152bb32e9e Mon Sep 17 00:00:00 2001 From: Friedrich Horschig <[email protected]> Date: Thu, 30 Jul 2026 07:59:30 -0700 Subject: [PATCH] [Autofill] Check driver CanShowAutofillUi before showing TTF This CL ensures that ChromePasswordManagerClient::ContinueShowKeyboardReplacingSurface checks weak_driver->CanShowAutofillUi() before proceeding to show Touch to Fill or CredMan UI and setting the keyboard replacing surface visibility. Fixed: 533044125 Change-Id: If69b90f8fbb48aadaefbdca4800ffd99344452ae Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8174004 Reviewed-by: Ioana Treib <[email protected]> Commit-Queue: Friedrich Hauser <[email protected]> Cr-Commit-Position: refs/heads/main@{#1671066} --- diff --git a/chrome/browser/password_manager/chrome_password_manager_client.cc b/chrome/browser/password_manager/chrome_password_manager_client.cc index 1df9f1f..7fd808b4 100644 --- a/chrome/browser/password_manager/chrome_password_manager_client.cc +++ b/chrome/browser/password_manager/chrome_password_manager_client.cc @@ -653,7 +653,7 @@ // without being called. auto split_delay_callback = base::SplitOnceCallback(std::move(delay_callback)); - if (!weak_driver) { + if (!weak_driver || !weak_driver->CanShowAutofillUi()) { return; } password_manager::ContentPasswordManagerDriver* driver = 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 220675f..738ca2fb 100644 --- a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc +++ b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc @@ -92,6 +92,7 @@ #include "content/public/common/url_constants.h" #include "content/public/test/mock_navigation_handle.h" #include "content/public/test/navigation_simulator.h" +#include "content/public/test/prerender_test_util.h" #include "content/public/test/web_contents_tester.h" #include "mojo/public/cpp/bindings/associated_receiver.h" #include "mojo/public/cpp/bindings/pending_associated_receiver.h" @@ -2590,4 +2591,54 @@ base::RunLoop().RunUntilIdle(); } +TEST_F(ChromePasswordManagerClientAndroidTest, + DoNotShowKeyboardReplacingSurfaceForInactiveFrame) { + webauthn::WebAuthnCredManDelegate::override_cred_man_support_for_testing( + webauthn::CredManSupport::DISABLED); + CreateManualFillingController(web_contents()); + + auto* ttf_controller = MakeMockTouchToFillPasswordManagerController(); + 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)); + + content::RenderFrameHostWrapper old_rfh(main_rfh()); + ContentAutofillDriver* autofill_driver = + ContentAutofillDriver::GetForRenderFrameHost(old_rfh.get()); + ASSERT_TRUE(autofill_driver); + + std::vector<FormFieldData> fields = {CreateTestFormField( + "Username:", "username", "", FormControlType::kInputText, "webauthn")}; + FormData form = + CreateFormDataForRenderFrameHost(*old_rfh.get(), std::move(fields)); + { + autofill::TestAutofillManagerWaiter waiter( + autofill_driver->GetAutofillManager(), + {autofill::AutofillManagerEvent::kFormsSeen}); + autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form}, + /*removed_forms=*/{}); + ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1)); + } + + auto* old_driver = + ContentPasswordManagerDriver::GetForRenderFrameHost(old_rfh.get()); + + GetClient()->ShowKeyboardReplacingSurface( + old_driver, GetFocusedFieldSuggestionRequest(form)); + + // Navigate to another site so old_rfh becomes inactive. + NavigateAndCommit(GURL("https://www.bar.com/page2.html")); + ASSERT_FALSE(old_rfh.get()->IsActive()); + + // Passkeys arrive for the old driver/frame after page navigation. + ChromeWebAuthnCredentialsDelegateFactory::GetFactory(web_contents()) + ->GetDelegateForFrame(old_rfh.get()) + ->OnCredentialsReceived( + std::vector<password_manager::PasskeyCredential>(), + 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 220675f..738ca2fb 100644
--- a/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
+++ b/chrome/browser/password_manager/chrome_password_manager_client_unittest.cc
@@ -92,6 +92,7 @@
#include "content/public/common/url_constants.h"
#include "content/public/test/mock_navigation_handle.h"
#include "content/public/test/navigation_simulator.h"
+#include "content/public/test/prerender_test_util.h"
#include "content/public/test/web_contents_tester.h"
#include "mojo/public/cpp/bindings/associated_receiver.h"
#include "mojo/public/cpp/bindings/pending_associated_receiver.h"
@@ -2590,4 +2591,54 @@
base::RunLoop().RunUntilIdle();
}
+TEST_F(ChromePasswordManagerClientAndroidTest,
+ DoNotShowKeyboardReplacingSurfaceForInactiveFrame) {
+ webauthn::WebAuthnCredManDelegate::override_cred_man_support_for_testing(
+ webauthn::CredManSupport::DISABLED);
+ CreateManualFillingController(web_contents());
+
+ auto* ttf_controller = MakeMockTouchToFillPasswordManagerController();
+ 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));
+
+ content::RenderFrameHostWrapper old_rfh(main_rfh());
+ ContentAutofillDriver* autofill_driver =
+ ContentAutofillDriver::GetForRenderFrameHost(old_rfh.get());
+ ASSERT_TRUE(autofill_driver);
+
+ std::vector<FormFieldData> fields = {CreateTestFormField(
+ "Username:", "username", "", FormControlType::kInputText, "webauthn")};
+ FormData form =
+ CreateFormDataForRenderFrameHost(*old_rfh.get(), std::move(fields));
+ {
+ autofill::TestAutofillManagerWaiter waiter(
+ autofill_driver->GetAutofillManager(),
+ {autofill::AutofillManagerEvent::kFormsSeen});
+ autofill_driver->renderer_events().FormsSeen(/*updated_forms=*/{form},
+ /*removed_forms=*/{});
+ ASSERT_TRUE(waiter.Wait(/*num_expected_relevant_events=*/1));
+ }
+
+ auto* old_driver =
+ ContentPasswordManagerDriver::GetForRenderFrameHost(old_rfh.get());
+
+ GetClient()->ShowKeyboardReplacingSurface(
+ old_driver, GetFocusedFieldSuggestionRequest(form));
+
+ // Navigate to another site so old_rfh becomes inactive.
+ NavigateAndCommit(GURL("https://www.bar.com/page2.html"));
+ ASSERT_FALSE(old_rfh.get()->IsActive());
+
+ // Passkeys arrive for the old driver/frame after page navigation.
+ ChromeWebAuthnCredentialsDelegateFactory::GetFactory(web_contents())
+ ->GetDelegateForFrame(old_rfh.get())
+ ->OnCredentialsReceived(
+ std::vector<password_manager::PasskeyCredential>(),
+ ChromeWebAuthnCredentialsDelegate::SecurityKeyOrHybridFlowAvailable(
+ true));
+}
+
#endif // BUILDFLAG(IS_ANDROID)