CVE-2026-95295
Overview
Background
- Shortcuts widget
- an iOS home-screen widget that renders a user’s most-visited site tiles, populated by data Chrome writes into a shared app-group container.
- App-group shared `NSUserDefaults`
- a storage area (obtained via
app_group::GetGroupUserDefaults()) that the main Chrome app and its widget extension both read, keyed by constants such asapp_group::kSuggestedItems,app_group::kSuggestedItemsForMultiprofile,app_group::kDefault, andapp_group::kNoAccount. - Multiprofile tile dictionary
- the
app_group::kSuggestedItemsForMultiprofileentry, a dictionary that segregates tile data per profile using GAIA ID keys, akNoAccountkey, and a sharedkDefaultkey. - `ChromeAccountManagerService`
- the per-profile service whose
GetAllIdentities(),GetProfileName(), and related accessors let code resolve which profile/account owns a given set of tiles.
Root Cause Analysis
The read path ReadSavedMostVisited() took no profile context and unconditionally returned the tiles stored under the legacy app_group::kSuggestedItems key, so any profile (and the widget) received whichever profile last populated that shared slot. On the write path, the tile saver always mirrored the current profile’s tiles into the shared app_group::kDefault key and set its modification date, meaning a non-active profile’s browsing tiles could overwrite the kDefault entry that other profiles fall back to. Because reads were not keyed by GAIA ID or kNoAccount and writes to the shared key were ungated, most-visited data leaked across profile boundaries.
The fix makes ReadSavedMostVisited() accept a ChromeAccountManagerService* and resolve data by iterating the profile’s identities’ GAIA IDs (or kNoAccount for the personal profile) out of kSuggestedItemsForMultiprofile, falling back to kDefault only when no service is supplied. It also gates the kDefault write behind a profileName == lastUsedProfile check using prefs::kLastUsedProfile, and removes the legacy kSuggestedItems write entirely, so the shared slot only ever reflects the last-used profile.
app_group::kSuggestedItems / app_group::kDefault) as global while the app was multi-profile, so reads and writes ignored profile identity; the fix makes reads profile-aware via GAIA ID / kNoAccount lookups and restricts the shared-key write to the last-used profile.Attack Path
- Multiple profiles in use A device has more than one Chrome profile (e.g., a work/managed profile and a personal profile), each browsing distinct sites that Chrome saves as most-visited tiles.
- Cross-profile overwrite of the shared key
Any profile, active or not, writes its tiles into the shared
app_group::kDefaultslot becauseWriteSingleUpdatedTileToDisk/ the disk-write helper always setkDefaultbefore the fix. - Unqualified read returns another profile's tiles
ReadSavedMostVisited(), or the Shortcuts widget reading the shared app-group data, returns the tiles fromkSuggestedItems/kDefaultwithout checking which profile owns them, surfacing one profile’s most-visited sites to another context.(inferred)that the widget extension is the concrete consumer that renders the leaked tiles. - Information disclosure A user viewing the Shortcuts widget (or another profile in-app) observes most-visited site URLs and favicons that belong to a different profile, revealing browsing activity across the profile boundary.
Impact Assessment
(inferred) and not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.mm |
modified |
Files Changed
ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.hios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.mmios/chrome/common/app_group/app_group_constants.hios/chrome/browser/content_suggestions/ui/cells/BUILD.gnios/chrome/browser/shared/model/prefs/BUILD.gnios/chrome/common/app_group/app_group_constants.mmios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mmios/chrome/browser/shared/model/prefs/browser_prefs.mm
Audit Directions
- Shared app-group / cross-extension storage keyed globallyAudit every read and write of
app_groupNSUserDefaultskeys (and other shared containers consumed by widget/extension targets) for whether the key is qualified by profile/GAIA ID rather than a single global slot. - Profile-agnostic accessors in a multi-profile appFlag functions that read or persist user-specific data without a
ChromeAccountManagerService/ profile parameter, especially legacy signatures retained after multi-profile (MIM) support was added. - Ungated writes to fallback/`Default` keysLook for code that unconditionally writes to shared
kDefault-style fallback entries; such writes should be restricted to the last-used or active profile via checks likeprefs::kLastUsedProfile.
Patch
From 6c4b5a5f6d6c3a1a4a850832f80e8272647bc3d3 Mon Sep 17 00:00:00 2001 From: Federica Germinario <[email protected]> Date: Thu, 27 Aug 2026 07:17:15 -0700 Subject: [PATCH] [ios] Fix cross-profile information disclosure in tile saver This CL fixes a vulnerability where most-visited tiles could leak between profiles in the Shortcuts widget. It makes reading profile-aware by using GAIA ID or 'No account' keys in the multi-profile dictionary, and gates writes to the shared 'Default' key to only the last used profile. It also removes the legacy 'SuggestedItems' key usage. Bug: 517596255 Change-Id: I2c880033cf350d09f95e18248a78dc9d5aee648c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7889940 Commit-Queue: Federica Germinario <[email protected]> Reviewed-by: Mark Cogan <[email protected]> Cr-Commit-Position: refs/heads/main@{#1687194} --- diff --git a/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.h b/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.h index f0578b7..3e8b569 100644 --- a/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.h +++ b/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.h @@ -27,7 +27,8 @@ ChromeAccountManagerService* account_manager_service); // Reads the current saved most visited sites from disk. -NSDictionary* ReadSavedMostVisited(); +NSDictionary* ReadSavedMostVisited( + ChromeAccountManagerService* account_manager_service); // Fetches the updated favicon for a single site and saves it in // `favicons_directory`. diff --git a/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.mm b/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.mm index 145d7efe..b1ebbb0 100644 --- a/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.mm +++ b/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.mm @@ -13,11 +13,13 @@ #import "base/threading/scoped_blocking_call.h" #import "components/favicon/core/fallback_url_util.h" #import "components/ntp_tiles/ntp_tile.h" +#import "components/prefs/pref_service.h" #import "components/signin/public/base/signin_pref_names.h" #import "crypto/obsolete/md5.h" #import "google_apis/gaia/gaia_id.h" #import "ios/chrome/browser/favicon/ui_bundled/favicon_attributes_provider.h" #import "ios/chrome/browser/shared/model/application_context/application_context.h" +#import "ios/chrome/browser/shared/model/prefs/pref_names.h" #import "ios/chrome/browser/signin/model/chrome_account_manager_service.h" #import "ios/chrome/browser/signin/model/system_identity.h" #import "ios/chrome/browser/widget_kit/model/features.h" @@ -77,7 +79,8 @@ ChromeAccountManagerService* account_manager_service) { NSMutableDictionary<NSURL*, NTPTile*>* tiles = [[NSMutableDictionary alloc] init]; - NSDictionary<NSURL*, NTPTile*>* old_tiles = ReadSavedMostVisited(); + NSDictionary<NSURL*, NTPTile*>* old_tiles = + ReadSavedMostVisited(account_manager_service); for (size_t i = 0; i < most_visited_data.size(); i++) { const ntp_tiles::NTPTile& ntp_tile = most_visited_data[i]; NSURL* ns_url = net::NSURLWithGURL(ntp_tile.url); @@ -202,7 +205,8 @@ void WriteSingleUpdatedTileToDisk( NTPTile* tile, ChromeAccountManagerService* account_manager_service) { - NSMutableDictionary* tiles = [ReadSavedMostVisited() mutableCopy]; + NSMutableDictionary* tiles = + [ReadSavedMostVisited(account_manager_service) mutableCopy]; if (![tiles objectForKey:tile.URL]) { return; } @@ -235,12 +239,6 @@ NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults(); - // TODO(crbug.com/387971524): To be removed once - // ios_enable_widgets_for_mim is enabled by default. - [sharedDefaults setObject:data forKey:app_group::kSuggestedItems]; - [sharedDefaults setObject:last_modification_date - forKey:app_group::kSuggestedItemsLastModificationDate]; - NSMutableDictionary* suggested_items = [[sharedDefaults objectForKey:app_group::kSuggestedItemsForMultiprofile] mutableCopy]; if (suggested_items == nil) { @@ -268,10 +266,15 @@ forKey:app_group::kNoAccount]; } - // Always update last modification date for "Default" scenario. - [suggested_items setObject:data forKey:app_group::kDefault]; - [last_modification_dates setObject:last_modification_date - forKey:app_group::kDefault]; + // Update "Default" scenario only if this is the last used profile. + std::string lastUsedProfile = + GetApplicationContext()->GetLocalState()->GetString( + prefs::kLastUsedProfile); + if (profileName == lastUsedProfile) { + [suggested_items setObject:data forKey:app_group::kDefault]; + [last_modification_dates setObject:last_modification_date + forKey:app_group::kDefault]; + } // Update stored info for all identities in the current profile. for (id<SystemIdentity> identity in account_manager_service @@ -292,11 +295,39 @@ UpdateShortcutsWidget(); } -NSDictionary* ReadSavedMostVisited() { +NSDictionary* ReadSavedMostVisited( + ChromeAccountManagerService* account_manager_service) { NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults(); - NSDictionary* data = - DecodeData([sharedDefaults objectForKey:app_group::kSuggestedItems]); - return data; + NSDictionary* suggested_items = + [sharedDefaults objectForKey:app_group::kSuggestedItemsForMultiprofile]; + + NSData* data = nil; + + if (!account_manager_service) { + data = suggested_items[app_group::kDefault]; + } else { + // Try to find data for any identity in this profile. + for (id<SystemIdentity> identity in account_manager_service + ->GetAllIdentities()) { + NSString* gaia_id_string = identity.gaiaId.ToNSString(); + if (suggested_items[gaia_id_string]) { + data = suggested_items[gaia_id_string]; + break; + } + } + + // If personal profile and no identity data found, try kNoAccount. + if (!data) { + std::string profileName = account_manager_service->GetProfileName(); + std::string personalProfileName = GetApplicationContext() + ->GetAccountProfileMapper() + ->GetPersonalProfileName(); + if (profileName == personalProfileName) { + data = suggested_items[app_group::kNoAccount]; + } + } + } + return data ? DecodeData(data) : [[NSMutableDictionary alloc] init]; } void UpdateSingleFavicon(const GURL& site_url, @@ -328,7 +359,7 @@ FROM_HERE, {base::MayBlock(), base::TaskPriority::BEST_EFFORT}, std::move(writeImage), base::BindOnce(&UpdateShortcutsWidget)); } else { - NSDictionary* tiles = ReadSavedMostVisited(); + NSDictionary* tiles = ReadSavedMostVisited(account_manager_service); NTPTile* tile = [tiles objectForKey:siteNSURL]; if (!tile) { return; diff --git a/ios/chrome/browser/shared/model/prefs/browser_prefs.mm b/ios/chrome/browser/shared/model/prefs/browser_prefs.mm index 6b1308de..22b1af43 100644 --- a/ios/chrome/browser/shared/model/prefs/browser_prefs.mm +++ b/ios/chrome/browser/shared/model/prefs/browser_prefs.mm @@ -146,6 +146,7 @@ #import "ios/chrome/browser/voice/model/voice_search_prefs_registration.h" #import "ios/chrome/browser/web/model/font_size/font_size_tab_helper.h" #import "ios/chrome/browser/welcome_back/model/welcome_back_prefs.h" +#import "ios/chrome/common/app_group/app_group_constants.h" #import "ios/components/cookie_util/cookie_constants.h" #import "ios/web/common/features.h" #import "ui/base/l10n/l10n_util.h" @@ -1131,4 +1132,9 @@ [defaults removeObjectForKey:@"userHasInteractedWithTailoredFullscreenPromo"]; [defaults removeObjectForKey:@"userHasInteractedWithFirstRunPromo"]; [defaults removeObjectForKey:@"lastTimeUserInteractedWithFullscreenPromo"]; + + // Added 06/2026. + NSUserDefaults* shared_defaults = app_group::GetGroupUserDefaults(); + [shared_defaults removeObjectForKey:@"SuggestedItems"]; + [shared_defaults removeObjectForKey:@"SuggestedItemsLastModificationDate"]; } diff --git a/ios/chrome/common/app_group/app_group_constants.h b/ios/chrome/common/app_group/app_group_constants.h index 7a739140..fff2005 100644 --- a/ios/chrome/common/app_group/app_group_constants.h +++ b/ios/chrome/common/app_group/app_group_constants.h @@ -180,12 +180,6 @@ extern NSString* const kOpenCommandSourceCredentialsExtension; extern NSString* const kOpenCommandSourceOpenExtension; -// The value of the key for the sharedDefaults used by the Shortcuts Widget. -extern NSString* const kSuggestedItems; -// The value of the key for the sharedDefaults last modification date used by -// the Shortcuts Widget. -extern NSString* const kSuggestedItemsLastModificationDate; - // NSUserDefaults key containing a dictionary with most visited sites data for a // given gaiaID. Used by the Shortcuts Widget. extern NSString* const kSuggestedItemsForMultiprofile; diff --git a/ios/chrome/common/app_group/app_group_constants.mm b/ios/chrome/common/app_group/app_group_constants.mm index fc3e522..b18f12f 100644 --- a/ios/chrome/common/app_group/app_group_constants.mm +++ b/ios/chrome/common/app_group/app_group_constants.mm @@ -83,10 +83,6 @@ @"ChromeCredentialsExtension"; NSString* const kOpenCommandSourceOpenExtension = @"ChromeOpenExtension"; -NSString* const kSuggestedItems = @"SuggestedItems"; -NSString* const kSuggestedItemsLastModificationDate = - @"SuggestedItemsLastModificationDate"; - NSString* const kSuggestedItemsForMultiprofile = @"SuggestedItemsForMIM"; NSString* const kSuggestedItemsLastModificationDateForMultiprofile = @"SuggestedItemsLastModificationDateForMIM"; diff --git a/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mm b/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mm index 24a4ca06..72347e1 100644 --- a/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mm +++ b/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mm @@ -9,10 +9,14 @@ #import "base/strings/utf_string_conversions.h" #import "base/test/task_environment.h" #import "components/ntp_tiles/ntp_tile.h" +#import "components/prefs/pref_service.h" #import "ios/chrome/browser/favicon/ui_bundled/favicon_attributes_provider.h" #import "ios/chrome/browser/shared/model/application_context/application_context.h" +#import "ios/chrome/browser/shared/model/prefs/pref_names.h" #import "ios/chrome/browser/signin/model/chrome_account_manager_service.h" #import "ios/chrome/browser/signin/model/chrome_account_manager_service_factory.h" +#import "ios/chrome/browser/signin/model/fake_system_identity.h" +#import "ios/chrome/browser/signin/model/fake_system_identity_manager.h" #import "ios/chrome/common/app_group/app_group_constants.h" #import "ios/chrome/common/ntp_tile/ntp_tile.h" #import "ios/chrome/common/ui/favicon/favicon_attributes.h" @@ -31,8 +35,24 @@ protected: void SetUp() override { BlockCleanupTest::SetUp(); + + NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults(); + [sharedDefaults + removeObjectForKey:app_group::kSuggestedItemsForMultiprofile]; + [sharedDefaults + removeObjectForKey: + app_group::kSuggestedItemsLastModificationDateForMultiprofile]; + account_manager_service_ = std::make_unique<ChromeAccountManagerService>( GetApplicationContext()->GetLocalState(), kProfileName); + GetApplicationContext()->GetLocalState()->SetString(prefs::kLastUsedProfile, + kProfileName); + + FakeSystemIdentity* identity = [FakeSystemIdentity fakeIdentity1]; + FakeSystemIdentityManager* system_identity_manager = + FakeSystemIdentityManager::FromSystemIdentityManager( + GetApplicationContext()->GetSystemIdentityManager()); + system_identity_manager->AddIdentity(identity); if ([[NSFileManager defaultManager] fileExistsAtPath:[TestFaviconDirectory() path]]) { @@ -40,8 +60,6 @@ error:nil]; } CreateMockImage([UIColor blackColor]); - NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults(); - [sharedDefaults removeObjectForKey:app_group::kSuggestedItems]; } void TearDown() override { @@ -50,8 +68,6 @@ [[NSFileManager defaultManager] removeItemAtURL:TestFaviconDirectory() error:nil]; } - NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults(); - [sharedDefaults removeObjectForKey:app_group::kSuggestedItems]; BlockCleanupTest::TearDown(); } @@ -204,7 +220,8 @@ // Read most visited from disk. NSDictionary<NSURL*, NTPTile*>* saved_tiles = - content_suggestions_tile_saver::ReadSavedMostVisited(); + content_suggestions_tile_saver::ReadSavedMostVisited( + account_manager_service_.get()); EXPECT_EQ(saved_tiles.count, 2U); @@ -247,7 +264,8 @@ // Read most visited from disk. NSDictionary<NSURL*, NTPTile*>* saved_tiles = - content_suggestions_tile_saver::ReadSavedMostVisited(); + content_suggestions_tile_saver::ReadSavedMostVisited( + account_manager_service_.get()); EXPECT_EQ(saved_tiles.count, 3U); @@ -278,7 +296,8 @@ // Read most visited from disk. NSDictionary<NSURL*, NTPTile*>* saved_tiles_after_update = - content_suggestions_tile_saver::ReadSavedMostVisited(); + content_suggestions_tile_saver::ReadSavedMostVisited( + account_manager_service_.get()); EXPECT_EQ(saved_tiles_after_update.count, 3U); @@ -318,7 +337,8 @@ scoped_task_evironment_.RunUntilIdle(); NSDictionary<NSURL*, NTPTile*>* saved_tiles = - content_suggestions_tile_saver::ReadSavedMostVisited(); + content_suggestions_tile_saver::ReadSavedMostVisited( + account_manager_service_.get()); NSString* image_title1 = base::SysUTF16ToNSString(image_tile1.title); NSURL* image_url1 = net::NSURLWithGURL(image_tile1.url); NTPTile* saved_tile1 = [saved_tiles objectForKey:image_url1]; @@ -334,7 +354,8 @@ // Wait for all asynchronous tasks to complete. scoped_task_evironment_.RunUntilIdle(); NSDictionary<NSURL*, NTPTile*>* saved_tiles2 = - content_suggestions_tile_saver::ReadSavedMostVisited(); + content_suggestions_tile_saver::ReadSavedMostVisited( + account_manager_service_.get()); NSString* image_title2 = base::SysUTF16ToNSString(image_tile2.title); NSURL* image_url2 = net::NSURLWithGURL(image_tile2.url); NTPTile* saved_tile2 = [saved_tiles2 objectForKey:image_url2]; @@ -375,7 +396,8 @@ // Wait for all asynchronous tasks to complete. scoped_task_evironment_.RunUntilIdle(); NSDictionary<NSURL*, NTPTile*>* saved = - content_suggestions_tile_saver::ReadSavedMostVisited(); + content_suggestions_tile_saver::ReadSavedMostVisited( + account_manager_service_.get()); NTPTile* saved_tile = [saved objectForKey:ns_url]; VerifyWithImage(saved_tile, ns_title, ns_url); @@ -388,7 +410,8 @@ account_manager_service_.get()); // Wait for all asynchronous tasks to complete. scoped_task_evironment_.RunUntilIdle(); - saved = content_suggestions_tile_saver::ReadSavedMostVisited(); + saved = content_suggestions_tile_saver::ReadSavedMostVisited( + account_manager_service_.get()); saved_tile = [saved objectForKey:ns_url]; VerifyWithImage(saved_tile, ns_title, ns_url); @@ -398,7 +421,8 @@ account_manager_service_.get()); // Wait for all asynchronous tasks to complete. scoped_task_evironment_.RunUntilIdle(); - saved = content_suggestions_tile_saver::ReadSavedMostVisited(); + saved = content_suggestions_tile_saver::ReadSavedMostVisited( + account_manager_service_.get()); saved_tile = [saved objectForKey:ns_url]; VerifyWithFallback(saved_tile, ns_title, ns_url); @@ -411,7 +435,8 @@ account_manager_service_.get()); // Wait for all asynchronous tasks to complete. scoped_task_evironment_.RunUntilIdle(); - saved = content_suggestions_tile_saver::ReadSavedMostVisited(); + saved = content_suggestions_tile_saver::ReadSavedMostVisited( + account_manager_service_.get()); saved_tile = [saved objectForKey:ns_url]; // Fallback should still be present. VerifyWithFallbackAndImage(saved_tile, ns_title, ns_url); diff --git a/ios/chrome/browser/content_suggestions/ui/cells/BUILD.gn b/ios/chrome/browser/content_suggestions/ui/cells/BUILD.gn index 5c7d34a..e1de57f 100644 --- a/ios/chrome/browser/content_suggestions/ui/cells/BUILD.gn +++ b/ios/chrome/browser/content_suggestions/ui/cells/BUILD.gn @@ -111,6 +111,7 @@ "//google_apis", "//ios/chrome/browser/favicon/ui_bundled", "//ios/chrome/browser/shared/model/application_context", + "//ios/chrome/browser/shared/model/prefs:pref_names", "//ios/chrome/browser/signin/model", "//ios/chrome/browser/signin/model:system_identity", "//ios/chrome/browser/widget_kit/model:features", @@ -169,6 +170,7 @@ "//ios/chrome/browser/shared/model/application_context", "//ios/chrome/browser/shared/model/browser/test:test_support", "//ios/chrome/browser/shared/model/prefs:browser_prefs", + "//ios/chrome/browser/shared/model/prefs:pref_names", "//ios/chrome/browser/shared/model/profile/test", "//ios/chrome/browser/shared/public/commands", "//ios/chrome/browser/shared/ui/symbols", @@ -176,6 +178,8 @@ "//ios/chrome/browser/signin/model", "//ios/chrome/browser/signin/model:authentication_service", "//ios/chrome/browser/signin/model:authentication_service_factory", + "//ios/chrome/browser/signin/model:fake_system_identity", + "//ios/chrome/browser/signin/model:fake_system_identity_manager", "//ios/chrome/browser/signin/model:test_support", "//ios/chrome/browser/url_loading/model", "//ios/chrome/browser/url_loading/model:test_support", diff --git a/ios/chrome/browser/shared/model/prefs/BUILD.gn b/ios/chrome/browser/shared/model/prefs/BUILD.gn index c586f86..2d93b94 100644 --- a/ios/chrome/browser/shared/model/prefs/BUILD.gn +++ b/ios/chrome/browser/shared/model/prefs/BUILD.gn @@ -161,6 +161,7 @@ "//ios/chrome/browser/web/model/annotations", "//ios/chrome/browser/web/model/font_size", ... (file diff truncated)
Regression Test / PoC
diff --git a/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mm b/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mm
index 24a4ca06..72347e1 100644
--- a/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mm
+++ b/ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mm
@@ -9,10 +9,14 @@
#import "base/strings/utf_string_conversions.h"
#import "base/test/task_environment.h"
#import "components/ntp_tiles/ntp_tile.h"
+#import "components/prefs/pref_service.h"
#import "ios/chrome/browser/favicon/ui_bundled/favicon_attributes_provider.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
+#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/signin/model/chrome_account_manager_service.h"
#import "ios/chrome/browser/signin/model/chrome_account_manager_service_factory.h"
+#import "ios/chrome/browser/signin/model/fake_system_identity.h"
+#import "ios/chrome/browser/signin/model/fake_system_identity_manager.h"
#import "ios/chrome/common/app_group/app_group_constants.h"
#import "ios/chrome/common/ntp_tile/ntp_tile.h"
#import "ios/chrome/common/ui/favicon/favicon_attributes.h"
@@ -31,8 +35,24 @@
protected:
void SetUp() override {
BlockCleanupTest::SetUp();
+
+ NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults();
+ [sharedDefaults
+ removeObjectForKey:app_group::kSuggestedItemsForMultiprofile];
+ [sharedDefaults
+ removeObjectForKey:
+ app_group::kSuggestedItemsLastModificationDateForMultiprofile];
+
account_manager_service_ = std::make_unique<ChromeAccountManagerService>(
GetApplicationContext()->GetLocalState(), kProfileName);
+ GetApplicationContext()->GetLocalState()->SetString(prefs::kLastUsedProfile,
+ kProfileName);
+
+ FakeSystemIdentity* identity = [FakeSystemIdentity fakeIdentity1];
+ FakeSystemIdentityManager* system_identity_manager =
+ FakeSystemIdentityManager::FromSystemIdentityManager(
+ GetApplicationContext()->GetSystemIdentityManager());
+ system_identity_manager->AddIdentity(identity);
if ([[NSFileManager defaultManager]
fileExistsAtPath:[TestFaviconDirectory() path]]) {
@@ -40,8 +60,6 @@
error:nil];
}
CreateMockImage([UIColor blackColor]);
- NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults();
- [sharedDefaults removeObjectForKey:app_group::kSuggestedItems];
}
void TearDown() override {
@@ -50,8 +68,6 @@
[[NSFileManager defaultManager] removeItemAtURL:TestFaviconDirectory()
error:nil];
}
- NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults();
- [sharedDefaults removeObjectForKey:app_group::kSuggestedItems];
BlockCleanupTest::TearDown();
}
@@ -204,7 +220,8 @@
// Read most visited from disk.
NSDictionary<NSURL*, NTPTile*>* saved_tiles =
- content_suggestions_tile_saver::ReadSavedMostVisited();
+ content_suggestions_tile_saver::ReadSavedMostVisited(
+ account_manager_service_.get());
EXPECT_EQ(saved_tiles.count, 2U);
@@ -247,7 +264,8 @@
// Read most visited from disk.
NSDictionary<NSURL*, NTPTile*>* saved_tiles =
- content_suggestions_tile_saver::ReadSavedMostVisited();
+ content_suggestions_tile_saver::ReadSavedMostVisited(
+ account_manager_service_.get());
EXPECT_EQ(saved_tiles.count, 3U);
@@ -278,7 +296,8 @@
// Read most visited from disk.
NSDictionary<NSURL*, NTPTile*>* saved_tiles_after_update =
- content_suggestions_tile_saver::ReadSavedMostVisited();
+ content_suggestions_tile_saver::ReadSavedMostVisited(
+ account_manager_service_.get());
EXPECT_EQ(saved_tiles_after_update.count, 3U);
@@ -318,7 +337,8 @@
scoped_task_evironment_.RunUntilIdle();
NSDictionary<NSURL*, NTPTile*>* saved_tiles =
- content_suggestions_tile_saver::ReadSavedMostVisited();
+ content_suggestions_tile_saver::ReadSavedMostVisited(
+ account_manager_service_.get());
NSString* image_title1 = base::SysUTF16ToNSString(image_tile1.title);
NSURL* image_url1 = net::NSURLWithGURL(image_tile1.url);
NTPTile* saved_tile1 = [saved_tiles objectForKey:image_url1];
@@ -334,7 +354,8 @@
// Wait for all asynchronous tasks to complete.
scoped_task_evironment_.RunUntilIdle();
NSDictionary<NSURL*, NTPTile*>* saved_tiles2 =
- content_suggestions_tile_saver::ReadSavedMostVisited();
+ content_suggestions_tile_saver::ReadSavedMostVisited(
+ account_manager_service_.get());
NSString* image_title2 = base::SysUTF16ToNSString(image_tile2.title);
NSURL* image_url2 = net::NSURLWithGURL(image_tile2.url);
NTPTile* saved_tile2 = [saved_tiles2 objectForKey:image_url2];
@@ -375,7 +396,8 @@
// Wait for all asynchronous tasks to complete.
scoped_task_evironment_.RunUntilIdle();
NSDictionary<NSURL*, NTPTile*>* saved =
- content_suggestions_tile_saver::ReadSavedMostVisited();
+ content_suggestions_tile_saver::ReadSavedMostVisited(
+ account_manager_service_.get());
NTPTile* saved_tile = [saved objectForKey:ns_url];
VerifyWithImage(saved_tile, ns_title, ns_url);
@@ -388,7 +410,8 @@
account_manager_service_.get());
// Wait for all asynchronous tasks to complete.
scoped_task_evironment_.RunUntilIdle();
- saved = content_suggestions_tile_saver::ReadSavedMostVisited();
+ saved = content_suggestions_tile_saver::ReadSavedMostVisited(
+ account_manager_service_.get());
saved_tile = [saved objectForKey:ns_url];
VerifyWithImage(saved_tile, ns_title, ns_url);
@@ -398,7 +421,8 @@
account_manager_service_.get());
// Wait for all asynchronous tasks to complete.
scoped_task_evironment_.RunUntilIdle();
- saved = content_suggestions_tile_saver::ReadSavedMostVisited();
+ saved = content_suggestions_tile_saver::ReadSavedMostVisited(
+ account_manager_service_.get());
saved_tile = [saved objectForKey:ns_url];
VerifyWithFallback(saved_tile, ns_title, ns_url);
@@ -411,7 +435,8 @@
account_manager_service_.get());
// Wait for all asynchronous tasks to complete.
scoped_task_evironment_.RunUntilIdle();
- saved = content_suggestions_tile_saver::ReadSavedMostVisited();
+ saved = content_suggestions_tile_saver::ReadSavedMostVisited(
+ account_manager_service_.get());
saved_tile = [saved objectForKey:ns_url];
// Fallback should still be present.
VerifyWithFallbackAndImage(saved_tile, ns_title, ns_url);
Original Bug Report
Potential cross-profile most-visited URL leak in iOS Shortcuts widget
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The iOS Chrome Shortcuts widget tile saver contains logic flaws that can potentially disclose most-visited browsing history URLs and page titles across profiles. By mixing profile-agnostic reads of legacy shared defaults with per-profile writes, the implementation allows private browsing data to leak between personal and managed profiles under multi-profile configurations. This can potentially affect systems where multiple profiles are active concurrently, such as iPadOS Split View.
Affected files:
ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.mmios/chrome/browser/content_suggestions/most_visited_tiles/coordinator/most_visited_tiles_mediator.mmios/chrome/widget_kit_extension/shortcuts_widget.swiftios/chrome/widget_kit_extension/select_profile_intent.swift
Estimated timestamp from git blame: 2025-02-11
Summary
A potential information disclosure vulnerability exists in iOS Chrome’s Shortcuts widget tile saver (content_suggestions_tile_saver.mm). Under multi-profile configurations (e.g., when the kSeparateProfilesForManagedAccounts feature is enabled), the implementation performs profile-agnostic reads of a legacy shared NSUserDefaults key while performing profile-specific writes, and unconditionally updates a shared kDefault key. This design can potentially allow private browsing history (most-visited URLs and page titles) to leak between personal and managed (work) profiles.
Note: The following scenarios are potential paths identified via static code analysis; our tooling does not currently have the capability to execute code or run a live proof of concept.
Potential Root Causes and Attack Paths
Path A: Last-Writer-Wins on “Default” Key (Deterministic Overwrite)
In WriteSavedMostVisited (ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.mm, line 220), the application updates the shared App-Group NSUserDefaults suite. While personal profile data is specifically gated under the kNoAccount key, the code unconditionally updates the kDefault (i.e., "Default") slot for every profile:
// Always update last modification date for "Default" scenario.
[suggested_items setObject:data forKey:app_group::kDefault];
[last_modification_dates setObject:last_modification_date
forKey:app_group::kDefault];
When multiple profiles are active concurrently (e.g., in iPadOS Split View with a work and a personal profile open in separate windows), both profiles’ MostVisitedTilesMediator instances are live and independently observe site updates. When either profile’s New Tab Page (NTP) updates, it overwrites the kDefault key.
Since the home-screen Shortcuts widget default configuration reads the SuggestedItemsForMIM suite under the "Default" account ID, the widget will render the most-visited tiles of whichever profile happened to write last. This can potentially allow work-profile URLs to display in a personal-intended context or vice versa.
Path B: Cross-Write via Asynchronous Favicon Callback
ReadSavedMostVisited() (content_suggestions_tile_saver.mm, line 295) reads from the legacy, profile-agnostic key kSuggestedItems:
NSDictionary* ReadSavedMostVisited() {
NSUserDefaults* sharedDefaults = app_group::GetGroupUserDefaults();
NSDictionary* data =
DecodeData([sharedDefaults objectForKey:app_group::kSuggestedItems]);
return data;
}
When a profile (e.g., Personal Profile) writes to kSuggestedItems, it overwrites the legacy, global key. If a second profile (e.g., Work Profile) fetches a favicon asynchronously via UpdateSingleFavicon (line 302) and falls back to the monogram/no-image branch (line 330), it reads the current state using ReadSavedMostVisited(), retrieving the Personal Profile’s tile dictionary.
If the Work URL matches a key in the Personal dictionary (e.g., a common domain like google.com), it will call WriteSingleUpdatedTileToDisk using the Work Profile’s account_manager_service. This in turn invokes WriteSavedMostVisited and serializes the loaded (Personal) dictionary into the Work Profile’s GAIA-keyed slot (line 280). Consequently, a widget explicitly bound to the Work account will display the Personal profile’s private browsing history.
Potential Steps to Trigger
- Enable the
kSeparateProfilesForManagedAccountsfeature on an iOS/iPadOS device. - Add a managed (work) Google account alongside a personal account, and open Chrome in Split View.
- Open a New Tab Page (NTP) in both windows to activate both profiles’
MostVisitedTilesMediatorinstances. - To trigger Path A: Set up the home-screen Shortcuts widget with the default “Default” account selection. Observe if the widget displays whichever profile’s most-visited URLs updated last.
- To trigger Path B: Ensure a URL is present in both profiles’ most-visited lists and lacks a cached favicon image (forcing the monogram/color fallback path). Once the asynchronous favicon block triggers, inspect if the
SuggestedItemsForMIMdictionary for the work profile’s GAIA ID contains the personal profile’s URL and title set.
Suggested Remediation
- Modify
ReadSavedMostVisited()to accept a GAIA ID or profile identifier, and read the state from the profile-specific slotkSuggestedItemsForMultiprofile[gaia]rather than the legacy sharedkSuggestedItemskey. - Gate the write to the
kDefaultkey inWriteSavedMostVisitedso that it is only written by the active or last-used profile, rather than unconditionally overwriting it from every active profile.
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.