Medium watch CVSS 4.6 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Mobile
DescriptionInformation leak in Mobile
ComponentMobile
Sub-componentiOS
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517596255
Fix commit6c4b5a5f6d6c (chromium/src) +99/-41
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-28 (per vendor advisory)
Fix landed2026-08-27 (public git)
Disclosed2026-09-22
Patch gap26 days — fix public in source before the advisory

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 as app_group::kSuggestedItems, app_group::kSuggestedItemsForMultiprofile, app_group::kDefault, and app_group::kNoAccount.
Multiprofile tile dictionary
the app_group::kSuggestedItemsForMultiprofile entry, a dictionary that segregates tile data per profile using GAIA ID keys, a kNoAccount key, and a shared kDefault key.
`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.

Key insight
The core mistake was treating a single shared storage key (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

  1. 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.
  2. Cross-profile overwrite of the shared key Any profile, active or not, writes its tiles into the shared app_group::kDefault slot because WriteSingleUpdatedTileToDisk / the disk-write helper always set kDefault before the fix.
  3. Unqualified read returns another profile's tiles ReadSavedMostVisited(), or the Shortcuts widget reading the shared app-group data, returns the tiles from kSuggestedItems / kDefault without 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.
  4. 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

An attacker or observer with access to the device UI gains disclosure of another Chrome profile’s most-visited site tiles (URLs and favicons) through the Shortcuts widget or cross-profile in-app reads, revealing private browsing activity. The precondition is a multi-profile configuration where more than one profile has populated tile data on the same device; no remote interaction is described. The advisory classifies this as an information leak in Mobile and states no exploited-in-the-wild status; any escalation beyond tile-metadata disclosure is (inferred) and not demonstrated by the patch.

Changed Functions

FunctionChangeNotes
if
ios/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.h
  • ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver.mm
  • ios/chrome/common/app_group/app_group_constants.h
  • ios/chrome/browser/content_suggestions/ui/cells/BUILD.gn
  • ios/chrome/browser/shared/model/prefs/BUILD.gn
  • ios/chrome/common/app_group/app_group_constants.mm
  • ios/chrome/browser/content_suggestions/ui/cells/content_suggestions_tile_saver_unittest.mm
  • ios/chrome/browser/shared/model/prefs/browser_prefs.mm

Audit Directions

  • Shared app-group / cross-extension storage keyed globally
    Audit every read and write of app_group NSUserDefaults keys (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 app
    Flag 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` keys
    Look 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 like prefs::kLastUsedProfile.
From 6c4b5a5f6d6c3a1a4a850832f80e8272647bc3d3 Mon Sep 17 00:00:00 2001
From: Federica Germinario <fedegermi@google.com>
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 <fedegermi@google.com>
Reviewed-by: Mark Cogan <marq@chromium.org>
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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);
Loading diff…

Original Bug Report

reported by vm...@google.com

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.mm
  • ios/chrome/browser/content_suggestions/most_visited_tiles/coordinator/most_visited_tiles_mediator.mm
  • ios/chrome/widget_kit_extension/shortcuts_widget.swift
  • ios/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

  1. Enable the kSeparateProfilesForManagedAccounts feature on an iOS/iPadOS device.
  2. Add a managed (work) Google account alongside a personal account, and open Chrome in Split View.
  3. Open a New Tab Page (NTP) in both windows to activate both profiles’ MostVisitedTilesMediator instances.
  4. 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.
  5. 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 SuggestedItemsForMIM dictionary for the work profile’s GAIA ID contains the personal profile’s URL and title set.

Suggested Remediation

  1. Modify ReadSavedMostVisited() to accept a GAIA ID or profile identifier, and read the state from the profile-specific slot kSuggestedItemsForMultiprofile[gaia] rather than the legacy shared kSuggestedItems key.
  2. Gate the write to the kDefault key in WriteSavedMostVisited so 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.

View on issue tracker