CVE-2026-87615
Overview
Background
- `IbanSaveManager`
- The browser-process autofill component that offers to save an IBAN either locally or by uploading it to Google Payments, driving the async prompt/consent and network-request flow.
- Upload-save flow
- For an upload candidate,
AttemptToOfferUploadSavecallsGetIbanUploadDetails, thenOnDidGetUploadDetailsconcurrently shows the user prompt (ConfirmUploadIbanToCloud→OnUserDidDecideOnUploadSave) and loads risk data (LoadRiskData), and only after both doesSendUploadRequestactually callUploadIban. - `base::BarrierClosure(2)`
- A helper that produces a
RepeatingClosurewhich invokes a single wrapped callback once it has itself been run the specified number of times (here, once by the prompt decision and once by risk-data loading). - Consent invariant
- An IBAN must be uploaded to Payments only for the specific candidate the user explicitly accepted in that specific prompt.
Root Cause Analysis
Pre-patch, IbanSaveManager kept the in-flight upload context in mutable instance members — context_token_, upload_request_details_, and user_did_accept_upload_prompt_ — that were shared across all upload flows rather than scoped to one. Because the prompt decision (OnUserDidDecideOnUploadSave) and risk-data completion (OnDidGetUploadRiskData) each independently checked/set those shared fields and then called SendUploadRequest, two overlapping upload prompts could interleave so that one flow’s acceptance state (user_did_accept_upload_prompt_) or context_token_ was consumed by the other flow’s completion callback. The removed reset code and its comment state the exact hazard: risk data and prompt-acceptance state from a previous flow could be re-used, “which could potentially result in saves without the user’s consent.”
The fix makes the class stateless: the candidate IBAN, context_token, client_behavior_signals, and risk data are carried through the callback closures, and a base::BarrierClosure(2) fires SendUploadRequest exactly once after both the (per-flow) prompt decision and risk-data load complete. OnUserDidDecideOnUploadSave now returns a std::unique_ptr<Iban> that is non-null only on kAccepted, and SendUploadRequest early-returns when the candidate value is empty, so an unaccepted flow can never emit an UploadIban request.
user_did_accept_upload_prompt_, context_token_, upload_request_details_) as shared mutable instance members, letting concurrent upload prompts cross-contaminate; the fix binds each flow’s data into its own closures and gates the send on a per-flow BarrierClosure plus a non-empty accepted-candidate check.Attack Path
- Trigger two overlapping upload-save offers
The victim encounters two IBAN upload-save opportunities close together (e.g., successive form submissions), so two
AttemptToOfferUploadSave→OnDidGetUploadDetailsflows are active at once against the sameIbanSaveManager. (inferred trigger detail) - Shared state is overwritten mid-flow
The second flow’s
OnDidGetUploadDetailsresets and re-writes the sharedcontext_token_, clearsupload_request_details_.risk_data, and resetsuser_did_accept_upload_prompt_, clobbering the first flow’s in-progress context. - Consent from one prompt is consumed by the other
When the user accepts one prompt,
OnUserDidDecideOnUploadSavesets the shareduser_did_accept_upload_prompt_ = true; a subsequently-arrivingOnDidGetUploadRiskDatafrom the other flow sees that flag and callsSendUploadRequest, uploading an IBAN/context the user did not accept. - Wrong or unconsented IBAN uploaded
SendUploadRequestreads the sharedcontext_token_and buildsupload_request_details_, so the emittedUploadIbanrequest can pair the wrong IBAN value with the wrong context token, or send an IBAN with no genuine user consent.
Impact Assessment
context_token, all within the trusted browser process where autofill runs. This is a consent/data-integrity and privacy defect rather than a memory-safety primitive; the patch itself demonstrates no out-of-bounds access, read/write primitive, or code execution, so any escalation beyond unauthorized IBAN upload is not supported by the diff (inferred: none). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.4. The user accepting at least one of them; exploited-in-the-wild status and severity are not stated in the provided advisory.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/autofill/core/browser/payments/iban_save_manager.cc |
modified | |
switchcomponents/autofill/core/browser/payments/iban_save_manager.cc |
modified | |
SyncServicecomponents/autofill/core/browser/payments/iban_save_manager.h |
modified |
Files Changed
components/autofill/core/browser/payments/iban_save_manager_test_api.hcomponents/autofill/core/browser/payments/test_payments_autofill_client.hcomponents/autofill/core/browser/payments/iban_save_manager_unittest.cccomponents/autofill/core/browser/payments/test_payments_autofill_client.cccomponents/autofill/core/browser/payments/iban_save_manager.cccomponents/autofill/core/browser/payments/iban_save_manager.h
Audit Directions
- Shared mutable state across async callbacksAudit other autofill/payments managers (card, virtual-card, address save) for per-request context stored as instance members and consumed by independent completion callbacks; scope such data into
BindOnceclosures instead. - Consent-then-network patternsFlag any flow where a boolean “user accepted” flag and the data-to-send live separately and either callback can trigger the network request; require the accepted payload itself to gate the send (as with the empty-value check here).
- Overlapping-prompt reentrancyLook for prompt/deliberation flows that assume a single in-flight instance; test with two concurrent offers to expose interleavings where one flow’s completion consumes another’s state or token.
Patch
From e117fd30d42942cd9243d8c78839840b201481c6 Mon Sep 17 00:00:00 2001
From: Luis Antunes <luisantunes@google.com>
Date: Tue, 04 Aug 2026 07:51:40 -0700
Subject: [PATCH] [autofill] Make IbanSaveManager stateless
Refactor IbanSaveManager to remove all mutable instance member variables
(such as context_token_ and upload_request_details_), making the class
completely stateless.
Key changes:
- Remove mutable instance fields from IbanSaveManager and update helper
functions to accept candidate IBANs by value / const reference appropriately.
- Construct UploadIbanRequestDetails locally inside SendUploadRequest()
rather than storing it as a class member.
- Pass request context (context_token, client_behavior_signals, candidate
IBAN, and risk data) directly through callback closures.
- Synchronize concurrent risk data pre-loading and prompt deliberation
using std::unique_ptr data containers bound to a completion closure and
synchronized by base::BarrierClosure(2), ensuring network requests are
sent only when user consent is granted.
- Update IbanSaveManagerTestApi with default parameters to simplify test
call sites.
- Update TestPaymentsAutofillClient to store active prompt callbacks,
enabling unit tests to simulate multiple overlapping prompts.
- Add unit test coverage in IbanSaveManagerTest verifying that overlapping
save prompts preserve independent candidate IBANs and context tokens.
Fixed: 501643868
Change-Id: I4547eb6c2bf435b4ea095d98a82e3e7144a0f3da
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8087839
Reviewed-by: Xuehui Chen <xuehuichen@google.com>
Reviewed-by: Darwin Yang <darwinyang@chromium.org>
Commit-Queue: Luis Antunes <luisantunes@google.com>
Reviewed-by: Slobodan Pejic <slobodan@chromium.org>
Reviewed-by: Qihui Zhao <qihuizhao@google.com>
Cr-Commit-Position: refs/heads/main@{#1673337}
---
diff --git a/components/autofill/core/browser/payments/iban_save_manager.cc b/components/autofill/core/browser/payments/iban_save_manager.cc
index adadc2e..61d47ca 100644
--- a/components/autofill/core/browser/payments/iban_save_manager.cc
+++ b/components/autofill/core/browser/payments/iban_save_manager.cc
@@ -9,16 +9,19 @@
#include <string>
#include <string_view>
#include <utility>
+#include <vector>
+#include "base/barrier_closure.h"
#include "base/check_deref.h"
#include "base/check_op.h"
#include "base/feature_list.h"
-#include "base/functional/bind.h"
+#include "base/memory/weak_ptr.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/strings/utf_string_conversions.h"
#include "base/values.h"
#include "build/buildflag.h"
+#include "components/autofill/core/browser/data_manager/payments/payments_data_manager.h"
#include "components/autofill/core/browser/data_manager/personal_data_manager.h"
#include "components/autofill/core/browser/data_model/payments/iban.h"
#include "components/autofill/core/browser/foundations/autofill_client.h"
@@ -28,20 +31,35 @@
#include "components/autofill/core/browser/payments/legal_message_line.h"
#include "components/autofill/core/browser/payments/payments_autofill_client.h"
#include "components/autofill/core/browser/payments/payments_network_interface.h"
+#include "components/autofill/core/browser/payments/payments_request_details.h"
#include "components/autofill/core/browser/payments/payments_util.h"
#include "components/autofill/core/browser/strike_databases/payments/iban_save_strike_database.h"
-#include "components/autofill/core/browser/studies/autofill_experiments.h"
#include "components/autofill/core/common/autofill_payments_features.h"
#include "components/autofill/core/common/autofill_regexes.h"
#include "components/autofill/core/common/signatures.h"
#include "components/strike_database/strike_database.h"
#include "components/sync/base/data_type.h"
+#include "components/sync/service/sync_service.h"
#include "components/sync/service/sync_user_settings.h"
namespace autofill {
+namespace {
+
using PaymentsRpcResult = payments::PaymentsAutofillClient::PaymentsRpcResult;
+// Applies `nickname` to `candidate` if `nickname` contains non-whitespace
+// characters.
+void ApplyNicknameIfPresent(Iban& candidate, std::u16string_view nickname) {
+ const std::u16string_view trimmed_nickname =
+ base::TrimWhitespace(nickname, base::TRIM_ALL);
+ if (!trimmed_nickname.empty()) {
+ candidate.set_nickname(std::u16string(trimmed_nickname));
+ }
+}
+
+} // namespace
+
IbanSaveManager::IbanSaveManager(AutofillClient* client)
: client_(CHECK_DEREF(client)) {}
@@ -188,7 +206,7 @@
});
}
-bool IbanSaveManager::AttemptToOfferLocalSave(Iban& import_candidate) {
+bool IbanSaveManager::AttemptToOfferLocalSave(const Iban& import_candidate) {
if (observer_for_testing_) {
observer_for_testing_->OnOfferLocalSave();
}
@@ -208,7 +226,7 @@
return show_save_prompt;
}
-bool IbanSaveManager::AttemptToOfferUploadSave(Iban& import_candidate) {
+bool IbanSaveManager::AttemptToOfferUploadSave(const Iban& import_candidate) {
autofill_metrics::LogUploadIbanMetric(
import_candidate.record_type() == Iban::kLocalIban
? autofill_metrics::UploadIbanOriginMetric::kLocalIban
@@ -216,25 +234,25 @@
autofill_metrics::UploadIbanActionMetric::kOffered);
bool show_save_prompt = !GetIbanSaveStrikeDatabase()->ShouldBlockFeature(
GetPartialIbanHashString(base::UTF16ToUTF8(import_candidate.value())));
+ std::vector<ClientBehaviorConstants> client_behavior_signals;
#if BUILDFLAG(IS_ANDROID)
- upload_request_details_.client_behavior_signals.push_back(
+ client_behavior_signals.push_back(
ClientBehaviorConstants::kShowAccountEmailInLegalMessage);
#else
if (base::FeatureList::IsEnabled(features::kAutofillEnableWalletBrandingV2)) {
- upload_request_details_.client_behavior_signals.push_back(
+ client_behavior_signals.push_back(
ClientBehaviorConstants::kShowAccountEmailInLegalMessage);
}
#endif
client_->GetPaymentsAutofillClient()
->GetPaymentsNetworkInterface()
->GetIbanUploadDetails(
- payments_data_manager().app_locale(),
- upload_request_details_.client_behavior_signals,
+ payments_data_manager().app_locale(), client_behavior_signals,
payments::GetBillingCustomerId(payments_data_manager()),
import_candidate.GetCountryCode(),
base::BindOnce(&IbanSaveManager::OnDidGetUploadDetails,
- weak_ptr_factory_.GetWeakPtr(), show_save_prompt,
- import_candidate));
+ weak_ptr_factory_.GetWeakPtr(), import_candidate,
+ client_behavior_signals, show_save_prompt));
return show_save_prompt;
}
@@ -250,11 +268,7 @@
Iban import_candidate,
payments::PaymentsAutofillClient::SaveIbanOfferUserDecision user_decision,
std::u16string_view nickname) {
- const std::u16string_view trimmed_nickname =
- base::TrimWhitespace(nickname, base::TRIM_ALL);
- if (!trimmed_nickname.empty()) {
- import_candidate.set_nickname(std::u16string(trimmed_nickname));
- }
+ ApplyNicknameIfPresent(import_candidate, nickname);
const std::string& partial_iban_hash =
GetPartialIbanHashString(base::UTF16ToUTF8(import_candidate.value()));
@@ -284,30 +298,21 @@
}
}
-void IbanSaveManager::OnUserDidDecideOnUploadSave(
+std::unique_ptr<Iban> IbanSaveManager::OnUserDidDecideOnUploadSave(
Iban import_candidate,
- bool show_save_prompt,
payments::PaymentsAutofillClient::SaveIbanOfferUserDecision user_decision,
std::u16string_view nickname) {
- CHECK_NE(import_candidate.record_type(), Iban::kServerIban);
- const std::u16string_view trimmed_nickname =
- base::TrimWhitespace(nickname, base::TRIM_ALL);
- if (!trimmed_nickname.empty()) {
- import_candidate.set_nickname(std::u16string(trimmed_nickname));
- }
-
+ const Iban::RecordType record_type = import_candidate.record_type();
+ CHECK_NE(record_type, Iban::kServerIban);
+ ApplyNicknameIfPresent(import_candidate, nickname);
autofill_metrics::UploadIbanActionMetric action_metric;
+ std::unique_ptr<Iban> accepted_candidate;
switch (user_decision) {
case payments::PaymentsAutofillClient::SaveIbanOfferUserDecision::kAccepted:
action_metric = autofill_metrics::UploadIbanActionMetric::kAccepted;
autofill_metrics::LogIbanSaveAcceptedCountry(
import_candidate.GetCountryCode());
- user_did_accept_upload_prompt_ = true;
- if (!upload_request_details_.risk_data.empty()) {
- // Risk data has already been gathered, so the server request can be
- // sent.
- SendUploadRequest(import_candidate, show_save_prompt);
- }
+ accepted_candidate = std::make_unique<Iban>(std::move(import_candidate));
break;
case payments::PaymentsAutofillClient::SaveIbanOfferUserDecision::kIgnored:
action_metric = autofill_metrics::UploadIbanActionMetric::kIgnored;
@@ -324,15 +329,17 @@
break;
}
autofill_metrics::LogUploadIbanMetric(
- import_candidate.record_type() == Iban::kLocalIban
+ record_type == Iban::kLocalIban
? autofill_metrics::UploadIbanOriginMetric::kLocalIban
: autofill_metrics::UploadIbanOriginMetric::kNewIban,
action_metric);
+ return accepted_candidate;
}
void IbanSaveManager::OnDidGetUploadDetails(
+ const Iban& import_candidate,
+ std::vector<ClientBehaviorConstants> client_behavior_signals,
bool show_save_prompt,
- Iban import_candidate,
PaymentsRpcResult result,
const std::u16string& validation_regex,
const std::u16string& context_token,
@@ -351,26 +358,55 @@
LegalMessageLines parsed_legal_message_lines;
if (LegalMessageLine::Parse(*legal_message, &parsed_legal_message_lines,
/*escape_apostrophes=*/true)) {
- // Reset `risk_data` and `user_did_accept_upload_prompt_` so that the risk
- // data and prompt acceptance state from a previous upload IBAN flow are
- // not re-used, which could potentially result in saves without the user's
- // consent.
- upload_request_details_.risk_data.clear();
- upload_request_details_.app_locale.clear();
- upload_request_details_.context_token.clear();
- upload_request_details_.value.clear();
- upload_request_details_.nickname.clear();
- user_did_accept_upload_prompt_ = false;
- context_token_ = context_token;
+ // `risk_data` and `accepted_candidate` are owned by the barrier
+ // completion closure, guaranteeing raw pointers remain valid until all
+ // tasks complete.
+ auto risk_data = std::make_unique<std::string>();
+ std::string* raw_risk_data = risk_data.get();
+ auto accepted_candidate = std::make_unique<Iban>();
+ Iban* raw_accepted_candidate = accepted_candidate.get();
+
+ // Synchronizes completion of ConfirmUploadIbanToCloud and LoadRiskData.
+ // Both currently run on the UI thread, but using a barrier prepares for
+ // future offloading to parallel task runners (crbug.com/537394697).
+ base::RepeatingClosure barrier = base::BarrierClosure(
+ 2, base::BindOnce(&IbanSaveManager::SendUploadRequest,
+ weak_ptr_factory_.GetWeakPtr(),
+ std::move(accepted_candidate), context_token,
+ std::move(client_behavior_signals),
+ show_save_prompt, std::move(risk_data)));
+
client_->GetPaymentsAutofillClient()->ConfirmUploadIbanToCloud(
import_candidate, std::move(parsed_legal_message_lines),
show_save_prompt,
- base::BindOnce(&IbanSaveManager::OnUserDidDecideOnUploadSave,
- weak_ptr_factory_.GetWeakPtr(), import_candidate,
- show_save_prompt));
+ base::BindOnce(
+ [](base::WeakPtr<IbanSaveManager> iban_save_manager,
+ Iban import_candidate, Iban* raw_accepted_candidate,
+ base::RepeatingClosure barrier,
+ payments::PaymentsAutofillClient::SaveIbanOfferUserDecision
+ user_decision,
+ std::u16string_view nickname) {
+ if (iban_save_manager) {
+ std::unique_ptr<Iban> accepted_candidate =
+ iban_save_manager->OnUserDidDecideOnUploadSave(
+ std::move(import_candidate), user_decision, nickname);
+ if (accepted_candidate) {
+ *raw_accepted_candidate = std::move(*accepted_candidate);
+ }
+ }
+ barrier.Run();
+ },
+ weak_ptr_factory_.GetWeakPtr(), import_candidate,
+ raw_accepted_candidate, barrier));
+
client_->GetPaymentsAutofillClient()->LoadRiskData(base::BindOnce(
- &IbanSaveManager::OnDidGetUploadRiskData,
- weak_ptr_factory_.GetWeakPtr(), show_save_prompt, import_candidate));
+ [](std::string* risk_data, base::RepeatingClosure barrier,
+ const std::string& loaded_risk_data) {
+ *risk_data = loaded_risk_data;
+ barrier.Run();
+ },
+ raw_risk_data, barrier));
+
// If `show_save_prompt`'s value is false, desktop builds will still offer
// save in the omnibox without popping-up the bubble.
if (observer_for_testing_) {
@@ -386,42 +422,42 @@
}
}
-void IbanSaveManager::OnDidGetUploadRiskData(bool show_save_prompt,
- const Iban& import_candidate,
- const std::string& risk_data) {
- upload_request_details_.risk_data = risk_data;
- // Populating risk data and offering upload occur asynchronously.
- // If the dialog has already been accepted, send the upload IBAN request.
- // Otherwise, continue to wait for the user to accept the save dialog.
- if (user_did_accept_upload_prompt_) {
- SendUploadRequest(import_candidate, show_save_prompt);
+void IbanSaveManager::SendUploadRequest(
+ std::unique_ptr<Iban> import_candidate,
+ std::u16string context_token,
+ std::vector<ClientBehaviorConstants> client_behavior_signals,
+ bool show_save_prompt,
+ std::unique_ptr<std::string> risk_data) {
+ if (import_candidate->value().empty()) {
+ return;
}
-}
-
-void IbanSaveManager::SendUploadRequest(const Iban& import_candidate,
- bool show_save_prompt) {
if (observer_for_testing_) {
observer_for_testing_->OnSentUploadRequest();
}
- upload_request_details_.app_locale = payments_data_manager().app_locale();
- upload_request_details_.billing_customer_number =
+ payments::UploadIbanRequestDetails upload_request_details;
+ upload_request_details.app_locale = payments_data_manager().app_locale();
+ upload_request_details.billing_customer_number =
payments::GetBillingCustomerId(payments_data_manager());
- upload_request_details_.context_token = context_token_;
- upload_request_details_.value = import_candidate.value();
- upload_request_details_.nickname = import_candidate.nickname();
+ upload_request_details.context_token = std::move(context_token);
+ upload_request_details.value = import_candidate->value();
+ upload_request_details.nickname = import_candidate->nickname();
+ upload_request_details.risk_data = std::move(*risk_data);
+ upload_request_details.client_behavior_signals =
+ std::move(client_behavior_signals);
client_->GetPaymentsAutofillClient()
->GetPaymentsNetworkInterface()
- ->UploadIban(upload_request_details_,
- base::BindOnce(&IbanSaveManager::OnDidUploadIban,
- weak_ptr_factory_.GetWeakPtr(),
- import_candidate, show_save_prompt));
+ ->UploadIban(
+ upload_request_details,
+ base::BindOnce(&IbanSaveManager::OnDidUploadIban,
+ weak_ptr_factory_.GetWeakPtr(),
+ std::move(import_candidate), show_save_prompt));
}
-void IbanSaveManager::OnDidUploadIban(const Iban& import_candidate,
+void IbanSaveManager::OnDidUploadIban(std::unique_ptr<Iban> import_candidate,
bool show_save_prompt,
PaymentsRpcResult result) {
const std::string& partial_iban_hash =
- GetPartialIbanHashString(base::UTF16ToUTF8(import_candidate.value()));
+ GetPartialIbanHashString(base::UTF16ToUTF8(import_candidate->value()));
if (result == PaymentsRpcResult::kSuccess) {
// Clear all IbanSave strikes for this IBAN, so that if it's later removed
// the strike count starts over with respect to re-saving it.
@@ -433,9 +469,9 @@
// If upload save failed, check if the IBAN already exists locally. If not,
// automatically save the IBAN locally so that the IBAN is not left unsaved
// since the user intended to save it.
- bool should_local_save = !MatchesExistingLocalIban(import_candidate);
+ bool should_local_save = !MatchesExistingLocalIban(*import_candidate);
if (should_local_save) {
- payments_data_manager().AddAsLocalIban(import_candidate);
+ payments_data_manager().AddAsLocalIban(*import_candidate);
}
autofill_metrics::LogIbanUploadSaveFailed(should_local_save);
diff --git a/components/autofill/core/browser/payments/iban_save_manager.h b/components/autofill/core/browser/payments/iban_save_manager.h
index a050d38..a9bea48 100644
--- a/components/autofill/core/browser/payments/iban_save_manager.h
+++ b/components/autofill/core/browser/payments/iban_save_manager.h
@@ -6,20 +6,26 @@
#define COMPONENTS_AUTOFILL_CORE_BROWSER_PAYMENTS_IBAN_SAVE_MANAGER_H_
#include <memory>
+#include <optional>
#include <string>
#include <string_view>
+#include <vector>
+#include "base/functional/callback_forward.h"
#include "base/memory/raw_ptr.h"
#include "base/memory/raw_ref.h"
#include "base/memory/weak_ptr.h"
#include "base/values.h"
#include "components/autofill/core/browser/data_model/payments/iban.h"
-#include "components/autofill/core/browser/foundations/autofill_client.h"
#include "components/autofill/core/browser/metrics/autofill_metrics.h"
+#include "components/autofill/core/browser/payments/client_behavior_constants.h"
#include "components/autofill/core/browser/payments/payments_autofill_client.h"
-#include "components/autofill/core/browser/payments/payments_request_details.h"
#include "components/autofill/core/browser/strike_databases/payments/iban_save_strike_database.h"
+namespace syncer {
+class SyncService;
+} // namespace syncer
+
... (file diff truncated)
Regression Test / PoC
diff --git a/components/autofill/core/browser/payments/iban_save_manager_unittest.cc b/components/autofill/core/browser/payments/iban_save_manager_unittest.cc
index 3681087..666c5cbe 100644
--- a/components/autofill/core/browser/payments/iban_save_manager_unittest.cc
+++ b/components/autofill/core/browser/payments/iban_save_manager_unittest.cc
@@ -7,6 +7,7 @@
#include "base/json/json_reader.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/utf_string_conversions.h"
+#include "base/test/gmock_callback_support.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/task_environment.h"
@@ -31,6 +32,10 @@
namespace autofill {
namespace {
+using ::testing::_;
+using ::testing::AllOf;
+using ::testing::Field;
+
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
constexpr char kLegalMessageLines[] =
@@ -543,7 +548,6 @@
EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()->risk_data_loaded());
- EXPECT_TRUE(test_api(GetIbanSaveManager()).HasContextToken());
EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()
->ConfirmUploadIbanToCloudWasCalled());
EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
@@ -580,7 +584,6 @@
SetUpGetIbanUploadDetailsResponse(/*is_successful=*/false);
EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
- EXPECT_FALSE(test_api(GetIbanSaveManager()).HasContextToken());
EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
->ConfirmUploadIbanToCloudWasCalled());
EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()
@@ -601,7 +604,6 @@
/*includes_invalid_legal_message=*/true);
EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
- EXPECT_FALSE(test_api(GetIbanSaveManager()).HasContextToken());
EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
->ConfirmUploadIbanToCloudWasCalled());
EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()
@@ -620,7 +622,6 @@
EXPECT_TRUE(
test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(another_iban));
- EXPECT_TRUE(test_api(GetIbanSaveManager()).HasContextToken());
EXPECT_TRUE(autofill_client_.GetPaymentsAutofillClient()
->ConfirmUploadIbanToCloudWasCalled());
EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
@@ -642,7 +643,6 @@
EXPECT_TRUE(
test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(another_iban));
- EXPECT_FALSE(test_api(GetIbanSaveManager()).HasContextToken());
EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
->ConfirmUploadIbanToCloudWasCalled());
EXPECT_FALSE(autofill_client_.GetPaymentsAutofillClient()
@@ -665,10 +665,10 @@
EXPECT_EQ(1, iban_save_strike_database.GetStrikes(partial_iban_hash));
EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
- test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kAccepted,
- u"My teacher's IBAN");
+ std::move(autofill_client_.GetPaymentsAutofillClient()
+ ->confirm_upload_iban_to_cloud_callbacks()
+ .back())
+ .Run(SaveIbanOfferUserDecision::kAccepted, u"My teacher's IBAN");
// Verify the IBAN's strikes have been cleared.
EXPECT_EQ(0, iban_save_strike_database.GetStrikes(partial_iban_hash));
@@ -690,10 +690,10 @@
EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
- test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kAccepted,
- u"My teacher's IBAN");
+ std::move(autofill_client_.GetPaymentsAutofillClient()
+ ->confirm_upload_iban_to_cloud_callbacks()
+ .back())
+ .Run(SaveIbanOfferUserDecision::kAccepted, u"My teacher's IBAN");
// Verify the IBAN's strikes have been added by 1.
EXPECT_EQ(2, iban_save_strike_database.GetStrikes(partial_iban_hash));
@@ -709,8 +709,7 @@
IbanSaveStrikeDatabase iban_save_strike_database(strike_database_);
test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kDeclined);
+ .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kDeclined);
// Verify the IBAN's strikes have been added by 1.
EXPECT_EQ(1, iban_save_strike_database.GetStrikes(partial_iban_hash));
@@ -728,8 +727,7 @@
IbanSaveStrikeDatabase iban_save_strike_database(strike_database_);
test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kIgnored);
+ .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kIgnored);
// Verify the IBAN's strikes have been added by 1.
EXPECT_EQ(1, iban_save_strike_database.GetStrikes(partial_iban_hash));
@@ -779,8 +777,7 @@
ASSERT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kAccepted,
+ .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kAccepted,
u"My teacher's IBAN");
histogram_tester.ExpectBucketCount(
@@ -802,8 +799,7 @@
ASSERT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kAccepted,
+ .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kAccepted,
u"My teacher's IBAN");
histogram_tester.ExpectBucketCount(
@@ -823,8 +819,7 @@
EXPECT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kDeclined,
+ .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kDeclined,
u"My teacher's IBAN");
histogram_tester.ExpectBucketCount(
@@ -845,8 +840,7 @@
ASSERT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kDeclined,
+ .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kDeclined,
u"My teacher's IBAN");
histogram_tester.ExpectBucketCount(
@@ -866,8 +860,7 @@
EXPECT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kIgnored,
+ .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kIgnored,
u"My teacher's IBAN");
histogram_tester.ExpectBucketCount(
@@ -888,8 +881,7 @@
ASSERT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kIgnored,
+ .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kIgnored,
u"My teacher's IBAN");
histogram_tester.ExpectBucketCount(
@@ -944,8 +936,7 @@
EXPECT_TRUE(GetIbanSaveManager().AttemptToOfferSave(iban));
test_api(GetIbanSaveManager())
- .OnUserDidDecideOnUploadSave(iban, /*show_save_prompt=*/true,
- SaveIbanOfferUserDecision::kAccepted,
+ .OnUserDidDecideOnUploadSave(iban, SaveIbanOfferUserDecision::kAccepted,
u"IBAN nickname");
histogram_tester.ExpectUniqueSample("Autofill.Iban.CountryOfSaveAcceptedIban",
@@ -961,7 +952,7 @@
iban.set_value(std::u16string(test::kIbanValue16));
ASSERT_TRUE(personal_data().payments_data_manager().GetLocalIbans().empty());
test_api(GetIbanSaveManager())
- .OnDidUploadIban(iban, /*show_save_prompt=*/true,
+ .OnDidUploadIban(std::make_unique<Iban>(iban), /*show_save_prompt=*/true,
payments::PaymentsAutofillClient::PaymentsRpcResult::
kPermanentFailure);
@@ -981,7 +972,7 @@
personal_data().payments_data_manager().AddAsLocalIban(iban);
ASSERT_EQ(personal_data().payments_data_manager().GetLocalIbans().size(), 1U);
test_api(GetIbanSaveManager())
- .OnDidUploadIban(iban, /*show_save_prompt=*/true,
+ .OnDidUploadIban(std::make_unique<Iban>(iban), /*show_save_prompt=*/true,
payments::PaymentsAutofillClient::PaymentsRpcResult::
kPermanentFailure);
@@ -1003,7 +994,7 @@
ASSERT_EQ(personal_data().payments_data_manager().GetLocalIbans().size(), 1U);
iban.set_nickname(u"new nickname");
test_api(GetIbanSaveManager())
- .OnDidUploadIban(iban, /*show_save_prompt=*/true,
+ .OnDidUploadIban(std::make_unique<Iban>(iban), /*show_save_prompt=*/true,
payments::PaymentsAutofillClient::PaymentsRpcResult::
kPermanentFailure);
@@ -1015,6 +1006,94 @@
u"");
}
+// Tests that overlapping upload flows preserve independent context tokens and
+// IBAN candidates.
+TEST_F(IbanSaveManagerTest, UploadSaveIban_OverlappingUploadFlows) {
+ Iban iban1;
+ iban1.set_value(std::u16string(test::kIbanValue16));
+ Iban iban2;
+ iban2.set_value(u"CH56 0483 5012 3456 7800 9");
+
+ EXPECT_CALL(*payments_network_interface(), GetIbanUploadDetails)
+ .WillOnce(base::test::RunOnceCallback<4>(
+ payments::PaymentsAutofillClient::PaymentsRpcResult::kSuccess,
+ kCapitalizedIbanRegex, u"token1",
+ std::make_unique<base::DictValue>(*base::JSONReader::ReadDict(
+ kLegalMessageLines, base::JSON_PARSE_CHROMIUM_EXTENSIONS))))
+ .WillOnce(base::test::RunOnceCallback<4>(
+ payments::PaymentsAutofillClient::PaymentsRpcResult::kSuccess,
+ kCapitalizedIbanRegex, u"token2",
+ std::make_unique<base::DictValue>(*base::JSONReader::ReadDict(
+ kLegalMessageLines, base::JSON_PARSE_CHROMIUM_EXTENSIONS))));
+
+ EXPECT_CALL(
+ *payments_network_interface(),
+ UploadIban(AllOf(Field(&payments::UploadIbanRequestDetails::value,
+ iban1.value()),
+ Field(&payments::UploadIbanRequestDetails::context_token,
+ u"token1")),
+ _))
+ .WillOnce(base::test::RunOnceCallback<1>(
+ payments::PaymentsAutofillClient::PaymentsRpcResult::kSuccess));
+
+ EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban1));
+ EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban2));
+ ASSERT_EQ(autofill_client_.GetPaymentsAutofillClient()
+ ->confirm_upload_iban_to_cloud_callbacks()
+ .size(),
+ 2U);
+
+ std::move(autofill_client_.GetPaymentsAutofillClient()
+ ->confirm_upload_iban_to_cloud_callbacks()[0])
+ .Run(payments::PaymentsAutofillClient::SaveIbanOfferUserDecision::
+ kAccepted,
+ u"My IBAN");
+}
+
+// Tests that when the user accepts the upload save prompt BEFORE risk data
+// finishes loading, the upload request is sent cleanly once risk data arrives.
+TEST_F(IbanSaveManagerTest, UploadSaveIban_UserAcceptsBeforeRiskDataReady) {
+ Iban iban;
+ iban.set_value(std::u16string(test::kIbanValue16));
+ SetUpGetIbanUploadDetailsResponse(/*is_successful=*/true);
+
+ autofill_client_.GetPaymentsAutofillClient()
+ ->set_defer_load_risk_data_responses(true);
+
+ EXPECT_CALL(
+ *payments_network_interface(),
+ UploadIban(
+ AllOf(Field(&payments::UploadIbanRequestDetails::value, iban.value()),
+ Field(&payments::UploadIbanRequestDetails::risk_data,
+ "delayed risk data")),
+ _))
+ .WillOnce(base::test::RunOnceCallback<1>(
+ payments::PaymentsAutofillClient::PaymentsRpcResult::kSuccess));
+
+ EXPECT_TRUE(test_api(GetIbanSaveManager()).AttemptToOfferUploadSave(iban));
+
+ ASSERT_EQ(autofill_client_.GetPaymentsAutofillClient()
+ ->confirm_upload_iban_to_cloud_callbacks()
+ .size(),
+ 1U);
+ ASSERT_EQ(autofill_client_.GetPaymentsAutofillClient()
+ ->load_risk_data_callbacks()
+ .size(),
+ 1U);
+
+ // 1. User accepts prompt FIRST (before risk data is ready).
+ std::move(autofill_client_.GetPaymentsAutofillClient()
+ ->confirm_upload_iban_to_cloud_callbacks()[0])
+ .Run(payments::PaymentsAutofillClient::SaveIbanOfferUserDecision::
+ kAccepted,
+ u"My IBAN");
+
+ // 2. Risk data finishes loading SECOND.
+ std::move(autofill_client_.GetPaymentsAutofillClient()
+ ->load_risk_data_callbacks()[0])
+ .Run("delayed risk data");
... (truncated)
Original Bug Report
State confusion in IbanSaveManager allows arbitrary IBAN upload without consent
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 without the Chrome Security team.
Overview: A state confusion vulnerability in IbanSaveManager allows a malicious webpage to swap the IBAN being saved to a user’s Google Payments wallet. By overlapping two IBAN upload flows, an attacker can overwrite shared state, causing the user’s ‘Save’ click on a legitimate prompt to unknowingly upload an attacker-controlled IBAN.
Affected files:
components/autofill/core/browser/payments/iban_save_manager.cccomponents/autofill/core/browser/payments/iban_save_manager.hchrome/browser/ui/autofill/payments/iban_bubble_controller_impl.cc
Estimated timestamp from git blame: 2025-09-03
Summary
A state confusion vulnerability exists in IbanSaveManager where multiple concurrent IBAN upload flows overwrite shared instance-level state. A malicious webpage can exploit this by presenting a legitimate “decoy” IBAN prompt to the user, while asynchronously starting a hidden upload flow for an attacker-controlled IBAN. When the user interacts with the decoy prompt, the overwritten state causes the browser to upload the attacker’s IBAN to the user’s Google Payments wallet without their knowledge or consent.
Vulnerability Details
IbanSaveManager coordinates the asynchronous process of obtaining upload details, displaying a consent bubble, and collecting client risk data. The state for this process—including upload_request_details_, context_token_, and user_did_accept_upload_prompt_—is stored directly in the IbanSaveManager instance. Because the client is per-WebContents, this state is shared across all frames and all concurrent save flows.
An attacker can exploit this shared state using the following sequence:
- The attacker triggers a form submission for a “decoy” IBAN.
IbanSaveManagerfetches upload details, clears the shared state, and sets the decoycontext_token_.- The UI successfully displays the “Save IBAN” bubble for the decoy IBAN.
IbanSaveManagerinitiates an asynchronousLoadRiskDatacall for the decoy IBAN.- While the user views the bubble, the attacker’s script submits a hidden form containing an attacker-controlled IBAN.
IbanSaveManagerfetches upload details for the attacker IBAN. This overwrites the shared state:upload_request_details_.risk_datais cleared,user_did_accept_upload_prompt_is reset tofalse, andcontext_token_is set to the attacker’s token.IbanBubbleControllerImpl::OfferUploadSaveis called for the attacker IBAN. Because the decoy bubble is already visible, it early-returns (iban_bubble_controller_impl.cc:75), suppressing the attacker prompt but leaving the attacker’s state active inIbanSaveManager.IbanSaveManagerinitiates an asynchronousLoadRiskDatacall for the attacker IBAN.- The user clicks “Save” on the visible decoy bubble. The callback sets
user_did_accept_upload_prompt_ = true. However, because the attacker’s flow cleared therisk_datain step 6, the upload is paused pending risk data. - The asynchronous
LoadRiskDatafor the attacker IBAN completes, invokingOnDidGetUploadRiskDatawith the attacker’s IBAN as the parameter. - Because
user_did_accept_upload_prompt_is nowtrue, it immediately callsSendUploadRequest(attacker_iban). SendUploadRequestbuilds the final request using the attacker’s IBAN and the sharedcontext_token_(which is the attacker’s token from step 6). The attacker’s IBAN is successfully saved to the user’s account.
Note: The timing of LoadRiskData can be manipulated by the attacker by artificially stalling the main thread with heavy JavaScript or WebGL operations, ensuring the attacker’s risk data callback fires after the user clicks “Save”.
Impact
An attacker can persist an arbitrary IBAN to a user’s synced Google account without their consent. This saved IBAN will be offered as an autofill suggestion across the user’s devices for future SEPA payment forms, creating a direct vector for misdirected financial transfers.
Potential Reproduction Steps
Note: Our tooling agent cannot execute code; these are suggested steps based on source code analysis.
- Serve a page with a visible form containing a legitimate “decoy” IBAN and a hidden iframe containing an “attacker” IBAN form.
- Trigger a click on the decoy form’s submit button. The “Save IBAN” bubble appears.
- Use JavaScript to temporarily block the main thread to delay
LoadRiskDatacallbacks. - Submit the hidden attacker form.
- Unblock the thread.
- As a user, click the “Save” button on the visible decoy bubble.
- Observe via
chrome://settings/paymentsthat the attacker’s IBAN has been added to the user’s wallet, not the decoy.
Suggested Fix
Remove the instance-level state (context_token_, upload_request_details_, user_did_accept_upload_prompt_) from IbanSaveManager. Instead, encapsulate the state for each upload flow into a distinct struct or object (e.g., UploadIbanRequestContext) that is passed along with the callbacks, or ensure that overlapping requests explicitly cancel and reset the UI rather than silently corrupting the underlying state.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.