CVE-2026-79014
Overview
Background
- `AskForValuesToFill`
- the renderer-to-browser Autofill IPC that reports a focused or clicked form field and asks the browser for suggestions. It updates the active query state in the browser-side delegate.
- `AutofillSuggestionDelegate`
- the interface implemented by
AutofillExternalDelegate,PasswordAutofillManager,PasswordManualFallbackFlowandOmniboxAutofillDelegate. Each tracks which field the current suggestions are for and handles what happens when the user accepts a suggestion. - `ChromeAutofillClient::ShowAutofillSuggestions` / `ShowAutofillSuggestionsImpl`
- the first is called synchronously and posts the second to the UI task runner (to let an IPH bubble hide first). The second actually builds and shows the popup, leaving an asynchronous gap between them.
- `FieldGlobalId`
- a (
LocalFrameToken,FieldRendererId) pair that identifies a form field across frames.last_query_.field_idinAutofillExternalDelegaterecords the field currently being queried.
Root Cause Analysis
ChromeAutofillClient::ShowAutofillSuggestions captured open_args (the suggestions and anchor bounds built for one query) and a weak delegate pointer, then posted ShowAutofillSuggestionsImpl to run later. In the gap, a renderer could send more AskForValuesToFill IPCs for another field, possibly in another frame. That changed the delegate’s live state, for example AutofillExternalDelegate::last_query_. When the posted task ran, it showed a popup built for field A while the delegate now pointed at field B. Accepting a suggestion would then fill based on field B’s query state. This broke the rule that the popup the user sees and the field the delegate will fill must refer to the same query.
The fix adds GetQueriedFieldId() to the delegate interface, records it as expected_field_id when the task is posted, and has ShowAutofillSuggestionsImpl return early, before building the popup, if the snapshot is non-null and the delegate is gone or now reports a different field. The check is behind kAutofillCheckTriggeringFieldDoesNotChangeDuringFilling, which is enabled by default.
FieldGlobalId again just before showing closes the time-of-check/time-of-use gap.Attack Path
- Trigger a legitimate suggestion query
A field (frame A) sends
AskForValuesToFill, and the delegate callsChromeAutofillClient::ShowAutofillSuggestions, which postsShowAutofillSuggestionsImplwithopen_argsfor that field. - Race a second query
Before the posted task runs, an attacker-controlled renderer sends more
AskForValuesToFillIPCs for another field (frame B), overwriting the delegate’s queried field (e.g.last_query_). The commit describes this as an “attacker renderer” spamming IPCs, which suggests a compromised renderer or at least precise control over IPC timing (inferred). - Popup shown with stale content
Before the fix,
ShowAutofillSuggestionsImplshowed the popup built for frame A without re-checking, so the user sees suggestions and an anchor that look like they belong to field A. - User accepts a suggestion The fill is resolved against the delegate’s current query, which is frame B’s field. Autofill data (addresses, payment or password data, depending on the delegate) could then go to a frame the user did not mean to fill (inferred).
Impact Assessment
AskForValuesToFill IPCs at the right moment could make the browser show an Autofill/password popup for one field while an accepted suggestion goes to another, possibly cross-frame, field. The likely result is autofilled user data reaching an unintended frame (inferred). Exploitation requires renderer-side IPC control (as the commit describes) and a user who accepts a suggestion. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3.Changed Functions
| Function | Change | Notes |
|---|---|---|
GetQueriedFormAndFieldcomponents/autofill/core/browser/ui/autofill_external_delegate.cc |
modified | |
PasswordManagerDrivercomponents/autofill/core/browser/ui/autofill_suggestion_delegate.h |
modified | |
MockAutofillSuggestionDelegatecomponents/autofill/core/browser/ui/mock_autofill_suggestion_delegate.cc |
modified | |
ChromeAutofillClientTestWithMockWindowchrome/browser/ui/autofill/chrome_autofill_client_unittest.cc |
modified | |
ChromeAutofillClientTestWithMockWindowchrome/browser/ui/autofill/chrome_autofill_client_unittest.cc |
modified |
Files Changed
components/autofill/core/common/autofill_features.ccchrome/browser/ui/autofill/chrome_autofill_client_unittest.cccomponents/password_manager/core/browser/password_autofill_manager.ccchrome/browser/ui/autofill/chrome_autofill_client.cccomponents/autofill/core/common/autofill_features.hcomponents/autofill/core/browser/ui/payments/omnibox_autofill_delegate.hcomponents/autofill/core/browser/ui/payments/omnibox_autofill_delegate.cccomponents/autofill/core/browser/ui/autofill_external_delegate.hcomponents/autofill/core/browser/ui/autofill_suggestion_delegate.hchrome/browser/ui/autofill/chrome_autofill_client.hcomponents/autofill/core/browser/ui/mock_autofill_suggestion_delegate.hcomponents/password_manager/core/browser/password_autofill_manager.hcomponents/password_manager/core/browser/password_manual_fallback_flow.cccomponents/autofill/core/browser/ui/mock_autofill_suggestion_delegate.cccomponents/password_manager/core/browser/password_manual_fallback_flow.hcomponents/autofill/core/browser/ui/autofill_external_delegate.cc
Audit Directions
- Posted UI tasks capturing renderer-derived stateLook for other
PostTaskhops in Autofill/password UI (e.g.UpdatePopup, preview/fill on accept, the Touch-To-Fill and fast-checkout sheets) where the displayed data is snapshotted but the action reads live delegate state that later IPCs can change. - Null-ID bypass of the new guardThe check is skipped when
expected_field_idis null. Check whether any real delegate can return a nullFieldGlobalId(e.g. beforelast_field_id_orlast_query_is set) and so avoid the protection. - Accept-time re-validationEven with the show-time check, a query can be changed after the popup is visible. Check that suggestion acceptance (
DidAcceptSuggestionpaths) confirms the fill target still matches the field and frame the popup was anchored to.
Patch
From db5774f14bba682c4f6db634928d48ab6746a98e Mon Sep 17 00:00:00 2001
From: Bruno Braga <brunobraga@google.com>
Date: Thu, 13 Aug 2026 02:07:12 -0700
Subject: [PATCH] [Autofill] Verify queried field before showing suggestions popup
Between posting ShowAutofillSuggestionsImpl to the UI task runner and
its execution, an attacker renderer could spam AskForValuesToFill IPCs
for another field. This mutates the delegate's active query state (e.g.
last_query_ in AutofillExternalDelegate) prior to UI popup creation,
leading to a desync where the displayed popup was generated for frame A
but selecting a suggestion queries last_query_ holding frame B's field
ID.
This CL introduces GetQueriedFieldId() to AutofillSuggestionDelegate and
snapshots the expected field ID inside ShowAutofillSuggestions(). Upon
executing ShowAutofillSuggestionsImpl(), we verify that the delegate's
current queried field ID still matches expected_field_id before showing
the popup. If it changed in the interim, display is aborted.
Bug: 536568319
Change-Id: I7d879924e0f1593048ec1a79fa58f344e854a670
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8233280
Reviewed-by: Viktor Semeniuk <vsemeniuk@google.com>
Reviewed-by: Jan Keitel <jkeitel@google.com>
Commit-Queue: Bruno Braga <brunobraga@google.com>
Cr-Commit-Position: refs/heads/main@{#1678702}
---
diff --git a/chrome/browser/ui/autofill/chrome_autofill_client.cc b/chrome/browser/ui/autofill/chrome_autofill_client.cc
index 4dee2cb0..d9cdce6 100644
--- a/chrome/browser/ui/autofill/chrome_autofill_client.cc
+++ b/chrome/browser/ui/autofill/chrome_autofill_client.cc
@@ -877,13 +877,16 @@
// guarantees the IPH will be hidden by the time the Autofill Popup will
// attempt to open. This works because the tasks of hiding the IPH and showing
// the Autofill Popup are posted on the same thread (UI thread).
+ const FieldGlobalId expected_field_id =
+ delegate ? delegate->GetQueriedFieldId() : FieldGlobalId();
+
const SuggestionUiSessionId session_id =
AutofillSuggestionController::GenerateSuggestionUiSessionId();
base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE,
base::BindOnce(&ChromeAutofillClient::ShowAutofillSuggestionsImpl,
weak_ptr_factory_.GetWeakPtr(), session_id, open_args,
- delegate));
+ delegate, expected_field_id));
return session_id;
}
@@ -1376,7 +1379,15 @@
void ChromeAutofillClient::ShowAutofillSuggestionsImpl(
SuggestionUiSessionId session_id,
const PopupOpenArgs& open_args,
- base::WeakPtr<AutofillSuggestionDelegate> delegate) {
+ base::WeakPtr<AutofillSuggestionDelegate> delegate,
+ FieldGlobalId expected_field_id) {
+ if (expected_field_id &&
+ (!delegate || delegate->GetQueriedFieldId() != expected_field_id) &&
+ base::FeatureList::IsEnabled(
+ features::kAutofillCheckTriggeringFieldDoesNotChangeDuringFilling)) {
+ return;
+ }
+
// Convert element_bounds to be in screen space.
const gfx::Rect client_area = web_contents()->GetContainerBounds();
const gfx::RectF element_bounds_in_screen_space =
diff --git a/chrome/browser/ui/autofill/chrome_autofill_client.h b/chrome/browser/ui/autofill/chrome_autofill_client.h
index b1a34704..e7a20d0 100644
--- a/chrome/browser/ui/autofill/chrome_autofill_client.h
+++ b/chrome/browser/ui/autofill/chrome_autofill_client.h
@@ -393,7 +393,8 @@
void ShowAutofillSuggestionsImpl(
SuggestionUiSessionId session_id,
const PopupOpenArgs& open_args,
- base::WeakPtr<AutofillSuggestionDelegate> delegate);
+ base::WeakPtr<AutofillSuggestionDelegate> delegate,
+ FieldGlobalId expected_field_id);
// Called when an actor task is created or an existing one changes state. It
// may be called for actors unrelated to the current tab. If an update is
diff --git a/components/autofill/core/browser/ui/autofill_external_delegate.cc b/components/autofill/core/browser/ui/autofill_external_delegate.cc
index 40c289f..e8cb74b 100644
--- a/components/autofill/core/browser/ui/autofill_external_delegate.cc
+++ b/components/autofill/core/browser/ui/autofill_external_delegate.cc
@@ -434,6 +434,10 @@
return GetQueriedFormAndField().second;
}
+FieldGlobalId AutofillExternalDelegate::GetQueriedFieldId() const {
+ return last_query_.field_id;
+}
+
std::pair<const FormStructure*, const AutofillField*>
AutofillExternalDelegate::GetQueriedFormAndField() const {
auto [form, field] =
diff --git a/components/autofill/core/browser/ui/autofill_external_delegate.h b/components/autofill/core/browser/ui/autofill_external_delegate.h
index bd4c2c0..e39cb45 100644
--- a/components/autofill/core/browser/ui/autofill_external_delegate.h
+++ b/components/autofill/core/browser/ui/autofill_external_delegate.h
@@ -97,6 +97,8 @@
void OnTabSelected(TabbedPaneTabType tab_type) override;
+ FieldGlobalId GetQueriedFieldId() const override;
+
// Called when the renderer posts an Autofill query to the browser. We might
// not want to display the warning if a website has disabled Autocomplete
// because they have their own popup, and showing our popup on to of theirs
diff --git a/components/autofill/core/browser/ui/autofill_suggestion_delegate.h b/components/autofill/core/browser/ui/autofill_suggestion_delegate.h
index 43bc802..0402ce6d 100644
--- a/components/autofill/core/browser/ui/autofill_suggestion_delegate.h
+++ b/components/autofill/core/browser/ui/autofill_suggestion_delegate.h
@@ -14,6 +14,7 @@
#include "components/autofill/core/browser/suggestions/suggestion.h"
#include "components/autofill/core/browser/suggestions/suggestion_hiding_reason.h"
#include "components/autofill/core/browser/ui/tabbed_pane_enums.h"
+#include "components/autofill/core/common/unique_ids.h"
namespace password_manager {
class PasswordManagerDriver;
@@ -111,6 +112,9 @@
// Called when `tab_type` is opened in the tabbed pane config of the autofill
// dropdown.
virtual void OnTabSelected(TabbedPaneTabType tab_type) = 0;
+
+ // Returns the global ID of the field for which suggestions are being queried.
+ virtual FieldGlobalId GetQueriedFieldId() const = 0;
};
} // namespace autofill
diff --git a/components/autofill/core/browser/ui/mock_autofill_suggestion_delegate.cc b/components/autofill/core/browser/ui/mock_autofill_suggestion_delegate.cc
index 0105981c..ed50187 100644
--- a/components/autofill/core/browser/ui/mock_autofill_suggestion_delegate.cc
+++ b/components/autofill/core/browser/ui/mock_autofill_suggestion_delegate.cc
@@ -6,7 +6,10 @@
namespace autofill {
-MockAutofillSuggestionDelegate::MockAutofillSuggestionDelegate() = default;
+MockAutofillSuggestionDelegate::MockAutofillSuggestionDelegate() {
+ ON_CALL(*this, GetQueriedFieldId)
+ .WillByDefault(testing::Return(FieldGlobalId()));
+}
MockAutofillSuggestionDelegate::~MockAutofillSuggestionDelegate() = default;
diff --git a/components/autofill/core/browser/ui/mock_autofill_suggestion_delegate.h b/components/autofill/core/browser/ui/mock_autofill_suggestion_delegate.h
index 01c0ab2c..a6708c1 100644
--- a/components/autofill/core/browser/ui/mock_autofill_suggestion_delegate.h
+++ b/components/autofill/core/browser/ui/mock_autofill_suggestion_delegate.h
@@ -54,6 +54,7 @@
MOCK_METHOD(void, ClearPreviewedForm, (), (override));
MOCK_METHOD(FillingProduct, GetMainFillingProduct, (), (const override));
MOCK_METHOD(void, OnTabSelected, (TabbedPaneTabType tab_type), (override));
+ MOCK_METHOD(FieldGlobalId, GetQueriedFieldId, (), (const override));
base::WeakPtr<MockAutofillSuggestionDelegate> GetWeakPtr();
diff --git a/components/autofill/core/browser/ui/payments/omnibox_autofill_delegate.cc b/components/autofill/core/browser/ui/payments/omnibox_autofill_delegate.cc
index d7dc1c0..7fb635c 100644
--- a/components/autofill/core/browser/ui/payments/omnibox_autofill_delegate.cc
+++ b/components/autofill/core/browser/ui/payments/omnibox_autofill_delegate.cc
@@ -435,6 +435,10 @@
NOTREACHED();
}
+FieldGlobalId OmniboxAutofillDelegate::GetQueriedFieldId() const {
+ return trigger_field_global_id_;
+}
+
void OmniboxAutofillDelegate::OnFieldBecameVisible() {
// Log that the field became visible to the user's viewport.
LogOmniboxAutofillShowChipDecisionPart2(
diff --git a/components/autofill/core/browser/ui/payments/omnibox_autofill_delegate.h b/components/autofill/core/browser/ui/payments/omnibox_autofill_delegate.h
index 4838812..cd24a2c9 100644
--- a/components/autofill/core/browser/ui/payments/omnibox_autofill_delegate.h
+++ b/components/autofill/core/browser/ui/payments/omnibox_autofill_delegate.h
@@ -68,6 +68,7 @@
void ClearPreviewedForm() override;
FillingProduct GetMainFillingProduct() const override;
void OnTabSelected(TabbedPaneTabType tab_type) override;
+ FieldGlobalId GetQueriedFieldId() const override;
// mojom::AutofillVisibilityObserver:
void OnFieldBecameVisible() override;
diff --git a/components/autofill/core/common/autofill_features.cc b/components/autofill/core/common/autofill_features.cc
index cc58d45..6f21f22 100644
--- a/components/autofill/core/common/autofill_features.cc
+++ b/components/autofill/core/common/autofill_features.cc
@@ -566,6 +566,13 @@
BASE_FEATURE(kAutofillBetterLocalHeuristicPlaceholderSupport,
base::FEATURE_DISABLED_BY_DEFAULT);
+// Check that between the time a filling flow is triggered and the popup being
+// displayed, the field id to anchor the popup on did not change.
+// TODO(crbug.com/8233280): This is a kill-switch feature, remove once we are
+// sure no bug was introduced.
+BASE_FEATURE(kAutofillCheckTriggeringFieldDoesNotChangeDuringFilling,
+ base::FEATURE_ENABLED_BY_DEFAULT);
+
// When enabled, deduce country of a new address profile based on the phone
// number if not explicitly observed.
BASE_FEATURE(kAutofillComplementCountryUsingPhoneNumber,
diff --git a/components/autofill/core/common/autofill_features.h b/components/autofill/core/common/autofill_features.h
index 87b8351..e07a08d4b 100644
--- a/components/autofill/core/common/autofill_features.h
+++ b/components/autofill/core/common/autofill_features.h
@@ -219,6 +219,8 @@
COMPONENT_EXPORT(AUTOFILL)
BASE_DECLARE_FEATURE(kAutofillBetterLocalHeuristicPlaceholderSupport);
COMPONENT_EXPORT(AUTOFILL)
+BASE_DECLARE_FEATURE(kAutofillCheckTriggeringFieldDoesNotChangeDuringFilling);
+COMPONENT_EXPORT(AUTOFILL)
BASE_DECLARE_FEATURE(kAutofillComplementCountryUsingPhoneNumber);
COMPONENT_EXPORT(AUTOFILL)
BASE_DECLARE_FEATURE(kAutofillCreditCardUserPerceptionSurvey);
diff --git a/components/password_manager/core/browser/password_autofill_manager.cc b/components/password_manager/core/browser/password_autofill_manager.cc
index 340cb12b..714fc2e 100644
--- a/components/password_manager/core/browser/password_autofill_manager.cc
+++ b/components/password_manager/core/browser/password_autofill_manager.cc
@@ -516,6 +516,10 @@
return false;
}
+autofill::FieldGlobalId PasswordAutofillManager::GetQueriedFieldId() const {
+ return last_field_id_;
+}
+
void PasswordAutofillManager::OnAddPasswordFillData(
const autofill::PasswordFormFillData& fill_data) {
if (!autofill::IsValidPasswordFormFillData(fill_data)) {
@@ -775,6 +779,7 @@
autofill::AutofillSuggestionTriggerSource::kPasswordManager,
/*form_control_ax_id=*/0, autofill::PopupAnchorType::kField);
}
+ last_field_id_ = field_id;
last_session_id_ = autofill_client_->ShowAutofillSuggestions(
last_popup_open_args_, weak_ptr_factory_.GetWeakPtr());
return true;
diff --git a/components/password_manager/core/browser/password_autofill_manager.h b/components/password_manager/core/browser/password_autofill_manager.h
index 31c2abc..30ce1a3 100644
--- a/components/password_manager/core/browser/password_autofill_manager.h
+++ b/components/password_manager/core/browser/password_autofill_manager.h
@@ -95,6 +95,7 @@
autofill::FillingProduct GetMainFillingProduct() const override;
void OnTabSelected(autofill::TabbedPaneTabType tab_type) override;
bool IsSearching() const override;
+ autofill::FieldGlobalId GetQueriedFieldId() const override;
// Invoked when a password mapping is added.
void OnAddPasswordFillData(const autofill::PasswordFormFillData& fill_data);
@@ -271,6 +272,7 @@
// The arguments of the last ShowPopup() call and UpdatePopup(), to be re-used
// by OnUnlockReauthCompleted().
autofill::AutofillClient::PopupOpenArgs last_popup_open_args_;
+ autofill::FieldGlobalId last_field_id_;
// Used to track a requested favicon.
base::CancelableTaskTracker favicon_tracker_;
diff --git a/components/password_manager/core/browser/password_manual_fallback_flow.cc b/components/password_manager/core/browser/password_manual_fallback_flow.cc
index fd34b18..601666a 100644
--- a/components/password_manager/core/browser/password_manual_fallback_flow.cc
+++ b/components/password_manager/core/browser/password_manual_fallback_flow.cc
@@ -410,6 +410,10 @@
return false;
}
+autofill::FieldGlobalId PasswordManualFallbackFlow::GetQueriedFieldId() const {
+ return field_id_;
+}
+
void PasswordManualFallbackFlow::RunFlowImpl(
const gfx::RectF& bounds,
base::i18n::TextDirection text_direction) {
diff --git a/components/password_manager/core/browser/password_manual_fallback_flow.h b/components/password_manager/core/browser/password_manual_fallback_flow.h
index 1b4a5471..0cc74428 100644
--- a/components/password_manager/core/browser/password_manual_fallback_flow.h
+++ b/components/password_manager/core/browser/password_manual_fallback_flow.h
@@ -86,6 +86,7 @@
autofill::FillingProduct GetMainFillingProduct() const override;
void OnTabSelected(autofill::TabbedPaneTabType tab_type) override;
bool IsSearching() const override;
+ autofill::FieldGlobalId GetQueriedFieldId() const override;
private:
// Is used to track whether the flow was invoked and whether the passwords
diff --git a/chrome/browser/ui/autofill/chrome_autofill_client_unittest.cc b/chrome/browser/ui/autofill/chrome_autofill_client_unittest.cc
index f6e8e9b..9914e79c 100644
--- a/chrome/browser/ui/autofill/chrome_autofill_client_unittest.cc
+++ b/chrome/browser/ui/autofill/chrome_autofill_client_unittest.cc
@@ -630,6 +630,58 @@
testing::Mock::VerifyAndClearExpectations(autofill_field_promo_controller());
}
+TEST_F(ChromeAutofillClientTest,
+ ShowAutofillSuggestions_AbortsIfQueriedFieldChanges) {
+ auto delegate = std::make_unique<MockAutofillSuggestionDelegate>();
+
+ FieldGlobalId field1(LocalFrameToken(base::UnguessableToken::Create()),
+ FieldRendererId(1));
+ FieldGlobalId field2(LocalFrameToken(base::UnguessableToken::Create()),
+ FieldRendererId(2));
+
+ ON_CALL(*delegate, GetQueriedFieldId).WillByDefault(Return(field1));
+
+ // Because ShowAutofillSuggestionsImpl() early-returns upon detecting a
+ // queried field mismatch (field2 != field1), it aborts before creating or
+ // showing an AutofillSuggestionController. Thus, no popup session is
+ // created and delegate->OnSuggestionsHidden() must not be called (0 times).
+ EXPECT_CALL(*delegate, OnSuggestionsHidden).Times(0);
+
+ client()->ShowAutofillSuggestions(AutofillClient::PopupOpenArgs(),
+ delegate->GetWeakPtr());
+
+ ON_CALL(*delegate, GetQueriedFieldId).WillByDefault(Return(field2));
+
+ {
+ base::RunLoop run_loop;
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, run_loop.QuitClosure());
+ run_loop.Run();
+ }
+
+ testing::Mock::VerifyAndClearExpectations(delegate.get());
+
+ // When the queried field ID matches (field2 == field2),
+ // ShowAutofillSuggestionsImpl() passes the guard check and proceeds to
+ // create AutofillSuggestionController and call Show(). In this headless unit
+ // test environment without window focus, Show() immediately dismisses the
+ // popup with kNoFrameHasFocus, triggering delegate->OnSuggestionsHidden()
+ // exactly once.
+ EXPECT_CALL(*delegate,
+ OnSuggestionsHidden(SuggestionHidingReason::kNoFrameHasFocus))
+ .Times(1);
+
+ client()->ShowAutofillSuggestions(AutofillClient::PopupOpenArgs(),
+ delegate->GetWeakPtr());
+
+ {
+ base::RunLoop run_loop;
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, run_loop.QuitClosure());
+ run_loop.Run();
+ }
+}
+
class ChromeAutofillClientTestWithMockWindow : public ChromeAutofillClientTest {
public:
ChromeAutofillClientTestWithMockWindow() {
Regression Test / PoC
diff --git a/chrome/browser/ui/autofill/chrome_autofill_client_unittest.cc b/chrome/browser/ui/autofill/chrome_autofill_client_unittest.cc
index f6e8e9b..9914e79c 100644
--- a/chrome/browser/ui/autofill/chrome_autofill_client_unittest.cc
+++ b/chrome/browser/ui/autofill/chrome_autofill_client_unittest.cc
@@ -630,6 +630,58 @@
testing::Mock::VerifyAndClearExpectations(autofill_field_promo_controller());
}
+TEST_F(ChromeAutofillClientTest,
+ ShowAutofillSuggestions_AbortsIfQueriedFieldChanges) {
+ auto delegate = std::make_unique<MockAutofillSuggestionDelegate>();
+
+ FieldGlobalId field1(LocalFrameToken(base::UnguessableToken::Create()),
+ FieldRendererId(1));
+ FieldGlobalId field2(LocalFrameToken(base::UnguessableToken::Create()),
+ FieldRendererId(2));
+
+ ON_CALL(*delegate, GetQueriedFieldId).WillByDefault(Return(field1));
+
+ // Because ShowAutofillSuggestionsImpl() early-returns upon detecting a
+ // queried field mismatch (field2 != field1), it aborts before creating or
+ // showing an AutofillSuggestionController. Thus, no popup session is
+ // created and delegate->OnSuggestionsHidden() must not be called (0 times).
+ EXPECT_CALL(*delegate, OnSuggestionsHidden).Times(0);
+
+ client()->ShowAutofillSuggestions(AutofillClient::PopupOpenArgs(),
+ delegate->GetWeakPtr());
+
+ ON_CALL(*delegate, GetQueriedFieldId).WillByDefault(Return(field2));
+
+ {
+ base::RunLoop run_loop;
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, run_loop.QuitClosure());
+ run_loop.Run();
+ }
+
+ testing::Mock::VerifyAndClearExpectations(delegate.get());
+
+ // When the queried field ID matches (field2 == field2),
+ // ShowAutofillSuggestionsImpl() passes the guard check and proceeds to
+ // create AutofillSuggestionController and call Show(). In this headless unit
+ // test environment without window focus, Show() immediately dismisses the
+ // popup with kNoFrameHasFocus, triggering delegate->OnSuggestionsHidden()
+ // exactly once.
+ EXPECT_CALL(*delegate,
+ OnSuggestionsHidden(SuggestionHidingReason::kNoFrameHasFocus))
+ .Times(1);
+
+ client()->ShowAutofillSuggestions(AutofillClient::PopupOpenArgs(),
+ delegate->GetWeakPtr());
+
+ {
+ base::RunLoop run_loop;
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, run_loop.QuitClosure());
+ run_loop.Run();
+ }
+}
+
class ChromeAutofillClientTestWithMockWindow : public ChromeAutofillClientTest {
public:
ChromeAutofillClientTestWithMockWindow() {
Original Bug Report
Bypass of Autofill cross-origin security via race condition in pending ShowAutofillSuggestions
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 logic flaw in ChromeAutofillClient and AutofillExternalDelegate could potentially allow a compromised renderer to bypass Autofill cross-origin security checks. By exploiting a task boundary between the asynchronous execution of ShowAutofillSuggestions and ShowAutofillSuggestionsImpl, an attacker can corrupt the stored delegate state. This can result in sensitive cross-origin payment or profile suggestions being filled into attacker-controlled fields upon user acceptance.
Affected files:
chrome/browser/ui/autofill/chrome_autofill_client.cccomponents/autofill/core/browser/ui/autofill_external_delegate.cc
Estimated timestamp from git blame: 2024-04-15
Detailed Analysis
A potential logic vulnerability exists in the asynchronous suggestion-showing pipeline in ChromeAutofillClient and the way session state is managed in AutofillExternalDelegate. This flaw could enable a compromised renderer to hijack autofill suggestions intended for a cross-origin iframe and redirect the filled values into its own fields.
Root Cause and Mechanism
-
Task Boundary and No-Op Hiding: When suggestions are shown,
ChromeAutofillClient::ShowAutofillSuggestionsschedules the creation and showing of the popup on the UI thread via aPostTask:// chrome/browser/ui/autofill/chrome_autofill_client.cc base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( FROM_HERE, base::BindOnce(&ChromeAutofillClient::ShowAutofillSuggestionsImpl, ...));During the interval between this
PostTaskand the execution ofShowAutofillSuggestionsImpl,ChromeAutofillClient::suggestion_controller_remainsnullptr. Consequently, any call toChromeAutofillClient::HideSuggestions()in this window results in a silent early return (no-op) without canceling the pending showing task:// chrome/browser/ui/autofill/chrome_autofill_client.cc void ChromeAutofillClient::HideSuggestions(...) { if (!suggestion_controller_) { return; // No-op in the gap before ShowAutofillSuggestionsImpl executes } ... } -
Delegate Hijacking: In a cross-origin form hierarchy (e.g., origin A embedding cross-origin payment iframe M), the browser flattens the frame tree into a single browser form within the root
FormForest(components/autofill/core/browser/foundations/autofill_driver_router.cc). Thus,AskForValuesToFillrequests from both A and M are routed to the root frame’s (A)BrowserAutofillManagerand handled by the sameAutofillExternalDelegate.If the user triggers autofill inside M, a suggestion generation task is run, culminating in
ShowAutofillSuggestionsbeing called on root-A’s delegate. This posts the T1 task (ShowAutofillSuggestionsImpl).Before T1 executes on the UI thread, a compromised renderer A can interleave its own
AskForValuesToFillIPC. When A’s request is handled:AutofillExternalDelegate::OnQueryis invoked, overwritinglast_query_with A’s field ID.- A can intentionally supply
is_focusable = falsein its Mojo request. This triggers the!is_focusablecheck inAttemptToDisplayAutofillSuggestions(), which callsHideSuggestions(SuggestionHidingReason::kStaleData). - Since
suggestion_controller_is stillnullptr, this hiding attempt is a silent no-op. The pending T1 task is not canceled, butlast_query_has now been corrupted with A’s field ID.
-
Bypassing the IsSafeToFill Security Check: When T1 eventually executes, M’s suggestions (containing sensitive credit card numbers or address profiles) are displayed. When the user accepts a suggestion:
AutofillExternalDelegate::DidAcceptSuggestionqueries the form and field viaGetQueriedFormAndField(), which uses the corruptedlast_query_.field_id(pointing to A’s field).- In
FormFiller::FillOrPreviewForm, the browser checksIsSafeToFill(field, ..., trigger_field.origin()). - Because
trigger_fieldwas resolved using the corruptedlast_query_.field_id(which belongs to A),trigger_field.origin()resolves to A’s origin. The destination field also belongs to A, so the origin checkfield.origin() == trigger_originpasses. - The browser then fills the cross-origin card or profile values into A’s fields, disclosing the sensitive data to the compromised renderer.
Potential Attack Steps
- Establish the Form Structure: A page on origin A (hosted by a compromised renderer) embeds a cross-origin payment iframe M. In
FormsSeen, both forms are linked together so theFormForesttreats them as part of a single tree. Origin A marks its fieldafas focusable so that it is cached as such. - Spam IPCs: The compromised renderer A spams
mojom::AutofillDriver::AskForValuesToFillfor fieldaf, but setsis_focusable = falsespecifically within the Mojo parameter structure. - User Interaction: The user clicks on M’s payment field
ccinside the iframe. - Race Trigger: The browser handles the click on
cc, generates suggestions, and schedulesShowAutofillSuggestionsImpl. One of A’s queuedAskForValuesToFilltasks executes in the task boundary, corruptinglast_query_to point toafwithout dismissing the scheduled show task. - Accepting Suggestion: The user accepts a suggestion from the popup (which is anchored on M’s field).
- Information Disclosure: The browser’s safety check is bypassed because
last_query_points toaf. The sensitive data is filled into A’s frame and captured by the compromised renderer.
Note: These steps are based on static analysis and code tracing; our security team has not executed a live proof-of-concept.
Suggested Remediation
To mitigate this issue, ensure that any pending showing tasks are canceled when suggestions are hidden or when the active query changes.
- Cancel Outstanding Show Tasks on Hide:
Track the pending showing task via a cancellable task or token, and cancel it when
HideSuggestionsis called, even ifsuggestion_controller_has not been instantiated yet. - Implement Generation/Session ID Validation:
Validate that the
SuggestionUiSessionIdor active field ID matches the delegate’s currentlast_query_state whenShowAutofillSuggestionsImplis run, aborting the show operation if a mismatch is detected.
Evaluated with Chrome root at commit: bf775e5d75cb9e1767e2cd02cc93efa0077d14a5
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.