Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient data validation in Chrome for iOS
DescriptionInsufficient data validation in Chrome for iOS
ComponentChrome for iOS
Bug ClassLogic Error
Tracker504221510
Fix commit99afbdce24d3 (chromium/src) +261/-104
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
for
ios/chrome/app/credential_provider_migrator_app_agent.mm
modified
if
ios/chrome/app/credential_provider_migrator_app_agent.mm
modified

Files Changed

  • ios/chrome/app/BUILD.gn
  • ios/chrome/app/credential_provider_migrator_app_agent.mm
From 99afbdce24d30bd74c1e88a52c139eefd6b413c2 Mon Sep 17 00:00:00 2001
From: Alexis Hétu <sugoi@chromium.org>
Date: Wed, 06 May 2026 06:46:35 -0700
Subject: [PATCH] [iOS] Filter credentials by GAIA ID and serialize migration

This CL updates the Credential Provider Migrator to ensure that
credentials are only migrated to the profile they belong to, and
that migration across multiple profiles happens sequentially.

Changes:
1) CredentialProviderMigrator:
   Added a `gaiaID` parameter to the constructor and implemented
   filtering logic. Any credential in the temporal store that has a
   GAIA ID not matching the target profile's GAIA ID is now skipped
2) CredentialProviderMigratorAppAgent:
   - Updated to retrieve the primary identity's GAIA ID via
     AuthenticationService and pass it to the migrator
   - Implemented a serialization queue (std::deque) to manage
     migration for multiple loaded profiles. Migration for a
     profile now strictly waits for the previous profile's migration
     and cleanup to complete
   - Added necessary sign-in model dependencies to the build target

Also added new test cases to verify that credentials are correctly
filtered by GAIA ID and that nil GAIA IDs are handled appropriately.

Bug: 504221510
Change-Id: I2f23f3881c46f0e531b13dbeef0a406cbdb0b0bb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7803815
Reviewed-by: Mark Cogan <marq@chromium.org>
Commit-Queue: Alexis Hétu <sugoi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1626133}
---

diff --git a/ios/chrome/app/BUILD.gn b/ios/chrome/app/BUILD.gn
index 1ecac5f..3330126 100644
--- a/ios/chrome/app/BUILD.gn
+++ b/ios/chrome/app/BUILD.gn
@@ -376,6 +376,8 @@
       "//ios/chrome/browser/shared/model/application_context",
       "//ios/chrome/browser/shared/model/profile",
       "//ios/chrome/browser/shared/model/profile:features",
+      "//ios/chrome/browser/signin/model:authentication_service",
+      "//ios/chrome/browser/signin/model:authentication_service_factory",
       "//ios/chrome/browser/sync/model",
       "//ios/chrome/browser/webauthn/model",
       "//ios/chrome/common/app_group",
diff --git a/ios/chrome/app/credential_provider_migrator_app_agent.mm b/ios/chrome/app/credential_provider_migrator_app_agent.mm
index e07244be..d0ee7a9 100644
--- a/ios/chrome/app/credential_provider_migrator_app_agent.mm
+++ b/ios/chrome/app/credential_provider_migrator_app_agent.mm
@@ -4,6 +4,8 @@
 
 #import "ios/chrome/app/credential_provider_migrator_app_agent.h"
 
+#import <algorithm>
+#import <deque>
 #import <map>
 
 #import "base/functional/bind.h"
@@ -30,6 +32,8 @@
 #import "ios/chrome/browser/shared/model/profile/features.h"
 #import "ios/chrome/browser/shared/model/profile/profile_ios.h"
 #import "ios/chrome/browser/shared/model/profile/profile_manager_ios.h"
+#import "ios/chrome/browser/signin/model/authentication_service.h"
+#import "ios/chrome/browser/signin/model/authentication_service_factory.h"
 #import "ios/chrome/browser/sync/model/sync_service_factory.h"
 #import "ios/chrome/browser/webauthn/model/ios_passkey_model_factory.h"
 #import "ios/chrome/common/app_group/app_group_constants.h"
@@ -76,6 +80,9 @@
   // Maps profile name to the CredentialProviderMigrator responsible for the
   // profile's migration.
   std::map<std::string, CredentialProviderMigrator*, std::less<>> _migratorMap;
+
+  // Queue of profile names waiting for migration.
+  std::deque<std::string> _pendingMigrationProfileNames;
 }
 
 #pragma mark - SceneObservingAppAgent
@@ -127,13 +134,7 @@
       });
 
   if (iter != loadedProfiles.end()) {
-    NSString* key = AppGroupUserDefaultsCredentialProviderNewCredentials();
-    NSUserDefaults* userDefaults = app_group::GetGroupUserDefaults();
-
-    [self migrateCredentialForProfile:*iter
-                         passKeyModel:passkeyModel
-                                  key:key
-                         userDefaults:userDefaults];
+    [self migrateNextProfile];
   }
 }
 
@@ -199,42 +200,56 @@
     return;
   }
 
-  NSString* key = AppGroupUserDefaultsCredentialProviderNewCredentials();
-  NSUserDefaults* userDefaults = app_group::GetGroupUserDefaults();
-
   const std::vector<ProfileIOS*> loadedProfiles =
       GetApplicationContext()->GetProfileManager()->GetLoadedProfiles();
 
   for (ProfileIOS* profile : loadedProfiles) {
-    webauthn::PasskeyModel* passkeyModel =
-        IOSPasskeyModelFactory::GetForProfile(profile);
-
-    [self migrateCredentialForProfile:profile
-                         passKeyModel:passkeyModel
-                                  key:key
-                         userDefaults:userDefaults];
+    std::string profileName = profile->GetProfileName();
+    if (_migratorMap.contains(profileName)) {
+      continue;
+    }
+    if (std::ranges::find(_pendingMigrationProfileNames, profileName) !=
+        _pendingMigrationProfileNames.end()) {
+      continue;
+    }
+    _pendingMigrationProfileNames.push_back(profileName);
   }
+  [self migrateNextProfile];
 }
 
-// Migrate the credential for the given profile and model.
-- (void)migrateCredentialForProfile:(ProfileIOS*)profile
-                       passKeyModel:(webauthn::PasskeyModel*)passkeyModel
-                                key:(NSString*)key
-                       userDefaults:(NSUserDefaults*)userDefaults {
+// Starts the next pending migration if possible.
+- (void)migrateNextProfile {
   // Only attempt to start migrations while the app is foregrounded or fully
   // initialized.
   if (![self canMigrate]) {
     return;
   }
 
-  CHECK(profile);
-  // Do nothing if the migration for the profile already started.
-  if (_migratorMap.contains(profile->GetProfileName())) {
+  // If a migration is already running, wait for it to finish.
+  if (!_migratorMap.empty()) {
     return;
   }
 
-  // If the passkey model isn't ready, delay the migration of passkeys until
-  // it is ready.
+  if (_pendingMigrationProfileNames.empty()) {
+    return;
+  }
+
+  std::string profileName = _pendingMigrationProfileNames.front();
+  ProfileIOS* profile =
+      GetApplicationContext()->GetProfileManager()->GetProfileWithName(
+          profileName);
+
+  if (!profile) {
+    _pendingMigrationProfileNames.pop_front();
+    [self migrateNextProfile];
+    return;
+  }
+
+  webauthn::PasskeyModel* passkeyModel =
+      IOSPasskeyModelFactory::GetForProfile(profile);
+
+  // If the passkey model isn't ready, delay the migration until it is ready.
+  // The profile remains at the head of the queue.
   if (passkeyModel && !passkeyModel->IsReady()) {
     if (![self isObservingPasskeyModel:passkeyModel]) {
       [self addObserverForPasskeyModel:passkeyModel];
@@ -242,6 +257,18 @@
     return;
   }
 
+  _pendingMigrationProfileNames.pop_front();
+  [self migrateProfile:profile passkeyModel:passkeyModel];
+}
+
+// Migrates a specific profile.
+- (void)migrateProfile:(ProfileIOS*)profile
+          passkeyModel:(webauthn::PasskeyModel*)passkeyModel {
+  CHECK(profile);
+
+  NSString* key = AppGroupUserDefaultsCredentialProviderNewCredentials();
+  NSUserDefaults* userDefaults = app_group::GetGroupUserDefaults();
+
   password_manager::PasswordForm::Store defaultStore =
       password_manager::features_util::IsAccountStorageActive(
           SyncServiceFactory::GetForProfile(profile))
@@ -254,9 +281,15 @@
           : IOSChromeProfilePasswordStoreFactory::GetForProfile(
                 profile, ServiceAccessType::IMPLICIT_ACCESS);
 
+  AuthenticationService* authService =
+      AuthenticationServiceFactory::GetForProfile(profile);
+  id<SystemIdentity> identity = authService->GetPrimaryIdentity();
+  NSString* gaiaID = identity ? identity.gaiaId.ToNSString() : nil;
+
   CredentialProviderMigrator* migrator =
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/chrome/browser/credential_provider/model/credential_provider_migrator_unittest.mm b/ios/chrome/browser/credential_provider/model/credential_provider_migrator_unittest.mm
index 0ad2c1e..da6c8a1 100644
--- a/ios/chrome/browser/credential_provider/model/credential_provider_migrator_unittest.mm
+++ b/ios/chrome/browser/credential_provider/model/credential_provider_migrator_unittest.mm
@@ -5,10 +5,10 @@
 #import "ios/chrome/browser/credential_provider/model/credential_provider_migrator.h"
 
 #import "base/strings/sys_string_conversions.h"
-#import "base/test/ios/wait_util.h"
 #import "base/test/metrics/histogram_tester.h"
 #import "base/test/scoped_feature_list.h"
 #import "base/test/task_environment.h"
+#import "base/test/test_future.h"
 #import "components/password_manager/core/browser/password_form.h"
 #import "components/password_manager/core/browser/password_store/mock_password_store_interface.h"
 #import "components/webauthn/core/browser/test_passkey_model.h"
@@ -24,9 +24,10 @@
 
 constexpr int64_t kJan1st2024 = 1704085200;
 
+NSString* const kMatchingGaia = @"123456";
+NSString* const kMismatchingGaia = @"654321";
+
 using ::base::SysNSStringToUTF8;
-using ::base::test::ios::kWaitForFileOperationTimeout;
-using ::base::test::ios::WaitUntilConditionOrTimeout;
 using ::password_manager::MockPasswordStoreInterface;
 using ::password_manager::PasswordForm;
 using ::testing::_;
@@ -39,14 +40,14 @@
   return [NSData dataWithBytes:str.data() length:str.length()];
 }
 
-ArchivableCredential* TestPasswordCredential() {
+ArchivableCredential* TestPasswordCredential(NSString* gaia = nil) {
   NSString* username = @"username_value";
   NSString* password = @"qwerty123";
   NSString* url = @"http://www.alpha.example.com/path/and?args=8";
   NSString* recordIdentifier = @"recordIdentifier";
   NSString* note = @"note";
   return [[ArchivableCredential alloc] initWithFavicon:nil
-                                                  gaia:nil
+                                                  gaia:gaia
                                               password:password
                                                   rank:1
                                       recordIdentifier:recordIdentifier
@@ -58,17 +59,17 @@
                                           lastUsedTime:0];
 }
 
-ArchivableCredential* TestPasskeyCredential(bool valid = true) {
+ArchivableCredential* TestPasskeyCredential(NSString* rpId, NSString* gaia) {
   return [[ArchivableCredential alloc]
        initWithFavicon:nil
-                  gaia:nil
+                  gaia:gaia
       recordIdentifier:@"recordIdentifier"
                 syncId:StringToData("syncIdOfLength16")
               username:@"username"
        userDisplayName:@"userDisplayName"
                 userId:StringToData("userId")
           credentialId:StringToData("credentialId_16_")
-                  rpId:valid ? @"rpId" : nil
+                  rpId:rpId
             privateKey:StringToData("privateKey")
              encrypted:StringToData("encrypted")
           creationTime:kJan1st2024
@@ -94,7 +95,7 @@
   // Mocking time is required for password notes since they are created with the
   // creation_date metadata, which is compared in AddLogin() call expectations.
   base::test::SingleThreadTaskEnvironment task_environment_{
-      base::test::TaskEnvironment::MainThreadType::IO,
+      base::test::TaskEnvironment::MainThreadType::UI,
       base::test::TaskEnvironment::TimeSource::MOCK_TIME};
 };
 
@@ -116,6 +117,7 @@
   CredentialProviderMigrator* migrator =
       [[CredentialProviderMigrator alloc] initWithUserDefaults:user_defaults_
                                                            key:store_key_
+                                                          gaia:nil
                                                  passwordStore:mock_store_
                                                   passkeyStore:nil];
   ASSERT_TRUE(migrator);
@@ -123,16 +125,15 @@
   // Start migration.
   PasswordForm expected = PasswordFormFromCredential(credential);
   EXPECT_CALL(*mock_store_, AddLogin(expected, _));
-  __block BOOL blockWaitCompleted = false;
+  base::test::TestFuture<BOOL, NSError*> future;
+  auto* future_ptr = &future;
   [migrator startMigrationWithCompletion:^(BOOL success, NSError* error) {
-    EXPECT_TRUE(success);
-    EXPECT_TRUE(error == nil)
-        << SysNSStringToUTF8([error localizedDescription]);
-    blockWaitCompleted = true;
+    future_ptr->SetValue(success, error);
   }];
-  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForFileOperationTimeout, ^bool {
-    return blockWaitCompleted;
-  }));
+  auto [migration_success, migration_error] = future.Take();
+  EXPECT_TRUE(migration_success);
+  EXPECT_TRUE(migration_error == nil)
+      << SysNSStringToUTF8([migration_error localizedDescription]);
 
   // Reload temp store.
   store =
@@ -148,7 +149,7 @@
   UserDefaultsCredentialStore* store =
       [[UserDefaultsCredentialStore alloc] initWithUserDefaults:user_defaults_
                                                             key:store_key_];
-  id<Credential> credential = TestPasskeyCredential();
+  id<Credential> credential = TestPasskeyCredential(@"rpId", kMatchingGaia);
   [store addCredential:credential];
   [store saveDataWithCompletion:^(NSError* error) {
     EXPECT_TRUE(error == nil)
@@ -160,6 +161,7 @@
   CredentialProviderMigrator* migrator = [[CredentialProviderMigrator alloc]
       initWithUserDefaults:user_defaults_
                        key:store_key_
+                      gaia:kMatchingGaia
              passwordStore:mock_store_
               passkeyStore:&test_passkey_model_];
   ASSERT_TRUE(migrator);
@@ -170,16 +172,15 @@
   // Start migration.
   sync_pb::WebauthnCredentialSpecifics expected =
       PasskeyFromCredential(credential);
-  __block BOOL blockWaitCompleted = false;
+  base::test::TestFuture<BOOL, NSError*> future;
+  auto* future_ptr = &future;
   [migrator startMigrationWithCompletion:^(BOOL success, NSError* error) {
-    EXPECT_TRUE(success);
-    EXPECT_TRUE(error == nil)
-        << SysNSStringToUTF8([error localizedDescription]);
-    blockWaitCompleted = true;
+    future_ptr->SetValue(success, error);
   }];
-  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForFileOperationTimeout, ^bool {
-    return blockWaitCompleted;
-  }));
+  auto [migration_success, migration_error] = future.Take();
+  EXPECT_TRUE(migration_success);
+  EXPECT_TRUE(migration_error == nil)
+      << SysNSStringToUTF8([migration_error localizedDescription]);
 
   histogram_tester_.ExpectBucketCount(
       "Passkeys.IOSMigration", PasskeysMigrationStatus::kPasskeyCreated, 1);
@@ -217,23 +218,23 @@
   histogram_tester_.ExpectBucketCount(
       "Passkeys.IOSMigration", PasskeysMigrationStatus::kPasskeyUpdated, 0);
 
-  blockWaitCompleted = false;
+  base::test::TestFuture<BOOL, NSError*> update_future;
+  auto* update_future_ptr = &update_future;
 
   migrator = [[CredentialProviderMigrator alloc]
       initWithUserDefaults:user_defaults_
                        key:store_key_
+                      gaia:kMatchingGaia
              passwordStore:mock_store_
               passkeyStore:&test_passkey_model_];
 
   [migrator startMigrationWithCompletion:^(BOOL success, NSError* error) {
-    EXPECT_TRUE(success);
-    EXPECT_TRUE(error == nil)
-        << SysNSStringToUTF8([error localizedDescription]);
-    blockWaitCompleted = true;
+    update_future_ptr->SetValue(success, error);
   }];
-  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForFileOperationTimeout, ^bool {
-    return blockWaitCompleted;
-  }));
+  auto [update_success, update_error] = update_future.Take();
+  EXPECT_TRUE(update_success);
+  EXPECT_TRUE(update_error == nil)
+      << SysNSStringToUTF8([update_error localizedDescription]);
 
   histogram_tester_.ExpectBucketCount(
       "Passkeys.IOSMigration", PasskeysMigrationStatus::kPasskeyUpdated, 1);
@@ -260,7 +261,8 @@
   UserDefaultsCredentialStore* store =
       [[UserDefaultsCredentialStore alloc] initWithUserDefaults:user_defaults_
                                                             key:store_key_];
-  id<Credential> invalidCredential = TestPasskeyCredential(/*valid=*/false);
+  id<Credential> invalidCredential =
+      TestPasskeyCredential(/*rpId=*/nil, kMatchingGaia);
 
   [store addCredential:invalidCredential];
   [store saveDataWithCompletion:^(NSError* error) {
@@ -273,6 +275,7 @@
   CredentialProviderMigrator* migrator = [[CredentialProviderMigrator alloc]
       initWithUserDefaults:user_defaults_
                        key:store_key_
+                      gaia:kMatchingGaia
              passwordStore:mock_store_
               passkeyStore:&test_passkey_model_];
   ASSERT_TRUE(migrator);
@@ -281,16 +284,15 @@
       "Passkeys.IOSMigration", PasskeysMigrationStatus::kInvalidPasskey, 0);
 
   // Start migration.
-  __block BOOL blockWaitCompleted = false;
+  base::test::TestFuture<BOOL, NSError*> future;
+  auto* future_ptr = &future;
   [migrator startMigrationWithCompletion:^(BOOL success, NSError* error) {
-    EXPECT_TRUE(success);
-    EXPECT_TRUE(error == nil)
-        << SysNSStringToUTF8([error localizedDescription]);
-    blockWaitCompleted = true;
+    future_ptr->SetValue(success, error);
   }];
-  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForFileOperationTimeout, ^bool {
-    return blockWaitCompleted;
-  }));
+  auto [migration_success, migration_error] = future.Take();
+  EXPECT_TRUE(migration_success);
+  EXPECT_TRUE(migration_error == nil)
+      << SysNSStringToUTF8([migration_error localizedDescription]);
 
   histogram_tester_.ExpectBucketCount(
       "Passkeys.IOSMigration", PasskeysMigrationStatus::kInvalidPasskey, 1);
@@ -326,7 +328,8 @@
                                                             key:store_key_];
 
   // `TestPasskeyCredential()` is created with hidden = NO.
-  ArchivableCredential* credential = TestPasskeyCredential();
+  ArchivableCredential* credential =
+      TestPasskeyCredential(@"rpId", kMatchingGaia);
   [store addCredential:credential];
   [store saveDataWithCompletion:^(NSError* error) {
     EXPECT_TRUE(error == nil)
@@ -338,21 +341,21 @@
   CredentialProviderMigrator* migrator = [[CredentialProviderMigrator alloc]
       initWithUserDefaults:user_defaults_
                        key:store_key_
+                      gaia:kMatchingGaia
              passwordStore:mock_store_
               passkeyStore:&test_passkey_model_];
   ASSERT_TRUE(migrator);
 
   // Start initial migration.
-  __block BOOL blockWaitCompleted = false;
+  base::test::TestFuture<BOOL, NSError*> future;
+  auto* future_ptr = &future;
   [migrator startMigrationWithCompletion:^(BOOL success, NSError* error) {
-    EXPECT_TRUE(success);
-    EXPECT_TRUE(error == nil)
-        << SysNSStringToUTF8([error localizedDescription]);
-    blockWaitCompleted = true;
+    future_ptr->SetValue(success, error);
   }];
-  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForFileOperationTimeout, ^bool {
-    return blockWaitCompleted;
-  }));
+  auto [migration_success, migration_error] = future.Take();
+  EXPECT_TRUE(migration_success);
+  EXPECT_TRUE(migration_error == nil)
+      << SysNSStringToUTF8([migration_error localizedDescription]);
 
   // Verify the passkey was migrated and is not hidden.
   std::vector<sync_pb::WebauthnCredentialSpecifics> passkeys =
@@ -377,23 +380,23 @@
   EXPECT_EQ(store.credentials.count, 1u);
 
   // Start migration again.
-  blockWaitCompleted = false;
+  base::test::TestFuture<BOOL, NSError*> update_future;
+  auto* update_future_ptr = &update_future;
 
   migrator = [[CredentialProviderMigrator alloc]
       initWithUserDefaults:user_defaults_
                        key:store_key_
+                      gaia:kMatchingGaia
              passwordStore:mock_store_
               passkeyStore:&test_passkey_model_];
 
   [migrator startMigrationWithCompletion:^(BOOL success, NSError* error) {
-    EXPECT_TRUE(success);
-    EXPECT_TRUE(error == nil)
-        << SysNSStringToUTF8([error localizedDescription]);
-    blockWaitCompleted = true;
+    update_future_ptr->SetValue(success, error);
   }];
-  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForFileOperationTimeout, ^bool {
-    return blockWaitCompleted;
-  }));
+  auto [update_success, update_error] = update_future.Take();
+  EXPECT_TRUE(update_success);
+  EXPECT_TRUE(update_error == nil)
+      << SysNSStringToUTF8([update_error localizedDescription]);
 
   // Verify temporal store is empty again.
   store =
@@ -414,7 +417,8 @@
   UserDefaultsCredentialStore* store =
       [[UserDefaultsCredentialStore alloc] initWithUserDefaults:user_defaults_
                                                             key:store_key_];
-  ArchivableCredential* credential = TestPasskeyCredential();
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential cross-profile credential leak in iOS Credential Provider migration

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. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: In Chrome for iOS, a potential vulnerability exists where credentials saved via the Credential Provider Extension (CPE) may be inadvertently imported into all loaded profiles. This is caused by the migration logic ignoring the credential’s GAIA ID and a race condition during shared storage cleanup. Consequently, personal credentials could be leaked to managed enterprise profiles.

Affected files:

  • ios/chrome/app/credential_provider_migrator_app_agent.mm
  • ios/chrome/browser/credential_provider/model/credential_provider_migrator.mm
  • ios/chrome/browser/credential_provider/model/archivable_credential+password_form.mm
  • ios/chrome/common/credential_provider/archivable_credential+passkey.mm
  • ios/chrome/common/credential_provider/user_defaults_credential_store.mm
  • ios/chrome/common/credential_provider/memory_credential_store.mm

Estimated timestamp from git blame: 2025-12-11

Summary

A potential cross-profile data leak has been identified in the iOS Chrome Credential Provider Extension (CPE) migration logic. When a user saves a password or passkey via the CPE, it is written to a shared App-Group NSUserDefaults store. When Chrome is foregrounded, it attempts to import these credentials into the browser’s internal stores.

However, due to a missing validation check and an asynchronous race condition, the credentials can be deterministically imported into every loaded profile (e.g., both personal and work profiles), rather than just the profile that created them.

Note: These findings are based on static code analysis by an AI tooling agent and represent a potential vulnerability; no live proof-of-concept has been executed.

Technical Details

The issue stems from two intersecting flaws in the codebase:

  1. Missing Profile Validation (gaia tag is ignored): When the CPE saves a credential, it creates an ArchivableCredential tagged with the active profile’s gaia identifier. However, during migration, the conversion functions (PasswordFormFromCredential in archivable_credential+password_form.mm and PasskeyFromCredential in archivable_credential+passkey.mm) entirely ignore this gaia property. The CredentialProviderMigrator unconditionally imports the credential into the profile it was instantiated for, regardless of the credential’s intended account.

  2. Asynchronous Clearing Race Condition: When Chrome enters the foreground, CredentialProviderMigratorAppAgent loops through all loaded profiles synchronously on the main thread, instantiating a CredentialProviderMigrator and calling startMigrationWithCompletion: for each. When a migrator finishes processing a credential, it calls removeCredentialWithRecordIdentifier: and saveDataWithCompletion:. These operations are dispatched asynchronously on a background workingQueue (via MemoryCredentialStore and UserDefaultsCredentialStore). Because the main thread does not wait for this asynchronous cleanup to finish, it immediately instantiates the next profile’s migrator, which synchronously reads the unmodified shared NSUserDefaults storage. Thus, subsequent profiles process and import the exact same credentials.

Impact

In a multi-profile environment (e.g., a personal profile and a managed/work profile), a user saving a personal password or passkey from outside of Chrome will have that credential silently imported into their work profile. If the managed profile has Sync enabled, this plaintext credential will be uploaded to enterprise servers, violating the profile isolation security boundary.

Suggested Steps to Reproduce

  1. Configure Chrome on an iOS device with two profiles: Profile A (Personal) and Profile B (Managed).
  2. Ensure both profiles are loaded into memory (e.g., via iPadOS Split View or recent profile switching).
  3. From a third-party app or Safari, use the Chrome Credential Provider Extension to save a new password or passkey. (This writes to the shared App-Group storage).
  4. Bring the Chrome app to the foreground.
  5. Check chrome://password-manager in both Profile A and Profile B. The newly saved credential will potentially appear in both profiles.

Suggested Fix

  1. Enforce Profile Routing: Update CredentialProviderMigrator to check the gaia property of the ArchivableCredential. If the gaia ID does not match the GAIA ID of the current profile undergoing migration, the migrator should ignore the credential.
  2. Fix the Race Condition: Either serialize the migration process so that a profile’s migration strictly waits for the previous profile’s cleanup to complete before starting, or handle the NSUserDefaults reading/clearing centrally in CredentialProviderMigratorAppAgent before distributing the credentials to the appropriate profile migrators.

Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646


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.

View on issue tracker