CVE-2026-11232
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcomponents/saved_tab_groups/internal/shared_tab_group_data_sync_bridge.cc |
modified | |
ifcomponents/saved_tab_groups/internal/shared_tab_group_data_sync_bridge.cc |
modified |
Files Changed
components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge.cccomponents/saved_tab_groups/internal/shared_tab_group_data_sync_bridge_unittest.cc
Patch
From f54f81bc87d45e6303654855577db9979ec97b01 Mon Sep 17 00:00:00 2001 From: dljames <[email protected]> Date: Wed, 15 Apr 2026 00:00:01 -0700 Subject: [PATCH] Clear Tabs Missing Groups in SharedTabGroupDataSyncBridge Fixes a bug where the cache of tabs missing groups was not getting properly cleared when ResolveTabMissingGroups was called in the case where a tab finally gets parented to its found group. Change-Id: Iafbd24ca711d545e49bf4df74dc23bfaeda0054c Bug: 495981782 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7763504 Reviewed-by: Shakti Sahu <[email protected]> Commit-Queue: Darryl James <[email protected]> Cr-Commit-Position: refs/heads/main@{#1614960} --- diff --git a/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge.cc b/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge.cc index 79b176be..379609a 100644 --- a/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge.cc +++ b/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge.cc @@ -1601,12 +1601,14 @@ // This method should only be called when there is an ongoing write batch, // for example during a remote update. CHECK(ongoing_write_batch_); - for (const auto& [tab_guid, tab_missing_group] : tabs_missing_groups_) { + auto it = tabs_missing_groups_.begin(); + while (it != tabs_missing_groups_.end()) { + const auto& [tab_guid, tab_missing_group] = *it; base::Uuid group_guid = base::Uuid::ParseLowercase( tab_missing_group.specifics.tab().shared_tab_group_guid()); const SavedTabGroup* group = model_wrapper_->GetGroup(group_guid); if (!group) { - // The group still does not exist in the model. + ++it; continue; } @@ -1623,6 +1625,10 @@ tab_missing_group.modification_time)) { return error; } + + // Cleanup tabs so subsequent calls to ResolveTabsMissingGroups does not add + // stale data. + it = tabs_missing_groups_.erase(it); } return std::nullopt; } diff --git a/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge_unittest.cc b/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge_unittest.cc index 34a7ed0..76542232 100644 --- a/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge_unittest.cc +++ b/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge_unittest.cc @@ -2491,6 +2491,62 @@ } TEST_F(SharedTabGroupDataSyncBridgeTest, + ShouldRemoveResolvedTabsFromMissingGroups) { + const CollaborationId kCollaborationId("collaboration"); + const base::Uuid kMissingGroupGuid = base::Uuid::GenerateRandomV4(); + const base::Uuid kTabGuid = base::Uuid::GenerateRandomV4(); + ASSERT_TRUE(InitializeBridgeAndModel()); + + // 1. Add a tab missing its group remotely. + sync_pb::SharedTabGroupDataSpecifics tab_specifics = + MakeTabSpecifics("tab title", GURL("http://google.com/1"), + kMissingGroupGuid, GenerateRandomUniquePosition()); + tab_specifics.set_guid(kTabGuid.AsLowercaseString()); + tab_specifics.set_version(999); + + ApplySingleEntityChange( + CreateAddEntityChange(tab_specifics, kCollaborationId)); + + // 2. Add the missing group entry remotely. This resolves the tab and should + // remove it from `tabs_missing_groups_`. + sync_pb::SharedTabGroupDataSpecifics group_specifics = + MakeTabGroupSpecifics("group title", sync_pb::SharedTabGroup::CYAN); + group_specifics.set_guid(kMissingGroupGuid.AsLowercaseString()); + ApplySingleEntityChange( + CreateAddEntityChange(group_specifics, kCollaborationId)); + + // Verify the tab is added to the group in the model. + const SavedTabGroup* group = model()->Get(kMissingGroupGuid); + ASSERT_TRUE(group); + EXPECT_THAT(group->saved_tabs(), + ElementsAre(HasTabMetadata("tab title", "http://google.com/1"))); + + // 3. Update the tab locally (simulate a user changing the tab title). + SavedTabGroupTab updated_tab = *group->saved_tabs().begin(); + updated_tab.SetTitle(u"updated local title"); + model()->UpdateTabInGroup(kMissingGroupGuid, updated_tab, + /*notify_observers=*/true); + + // 4. Trigger a completely unrelated remote update. + const base::Uuid kUnrelatedGroupGuid = base::Uuid::GenerateRandomV4(); + sync_pb::SharedTabGroupDataSpecifics unrelated_group_specifics = + MakeTabGroupSpecifics("unrelated group", sync_pb::SharedTabGroup::RED); + unrelated_group_specifics.set_guid(kUnrelatedGroupGuid.AsLowercaseString()); + ApplySingleEntityChange( + CreateAddEntityChange(unrelated_group_specifics, kCollaborationId)); + + // 5. Verify the tab title was not reverted to the original remote title. + // If the bug was present (tab left in `tabs_missing_groups_`), the unrelated + // update would trigger `ResolveTabsMissingGroups` and overwrite the local + // title. + group = model()->Get(kMissingGroupGuid); + ASSERT_TRUE(group); + EXPECT_THAT(group->saved_tabs(), + ElementsAre(HasTabMetadata("updated local title", + "http://google.com/1"))); +} + +TEST_F(SharedTabGroupDataSyncBridgeTest, ShouldTrimAllSupportedFieldsFromRemoteTabGroupSpecifics) { ASSERT_TRUE(InitializeBridgeAndModel());
Regression Test / PoC
diff --git a/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge_unittest.cc b/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge_unittest.cc
index 34a7ed0..76542232 100644
--- a/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge_unittest.cc
+++ b/components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge_unittest.cc
@@ -2491,6 +2491,62 @@
}
TEST_F(SharedTabGroupDataSyncBridgeTest,
+ ShouldRemoveResolvedTabsFromMissingGroups) {
+ const CollaborationId kCollaborationId("collaboration");
+ const base::Uuid kMissingGroupGuid = base::Uuid::GenerateRandomV4();
+ const base::Uuid kTabGuid = base::Uuid::GenerateRandomV4();
+ ASSERT_TRUE(InitializeBridgeAndModel());
+
+ // 1. Add a tab missing its group remotely.
+ sync_pb::SharedTabGroupDataSpecifics tab_specifics =
+ MakeTabSpecifics("tab title", GURL("http://google.com/1"),
+ kMissingGroupGuid, GenerateRandomUniquePosition());
+ tab_specifics.set_guid(kTabGuid.AsLowercaseString());
+ tab_specifics.set_version(999);
+
+ ApplySingleEntityChange(
+ CreateAddEntityChange(tab_specifics, kCollaborationId));
+
+ // 2. Add the missing group entry remotely. This resolves the tab and should
+ // remove it from `tabs_missing_groups_`.
+ sync_pb::SharedTabGroupDataSpecifics group_specifics =
+ MakeTabGroupSpecifics("group title", sync_pb::SharedTabGroup::CYAN);
+ group_specifics.set_guid(kMissingGroupGuid.AsLowercaseString());
+ ApplySingleEntityChange(
+ CreateAddEntityChange(group_specifics, kCollaborationId));
+
+ // Verify the tab is added to the group in the model.
+ const SavedTabGroup* group = model()->Get(kMissingGroupGuid);
+ ASSERT_TRUE(group);
+ EXPECT_THAT(group->saved_tabs(),
+ ElementsAre(HasTabMetadata("tab title", "http://google.com/1")));
+
+ // 3. Update the tab locally (simulate a user changing the tab title).
+ SavedTabGroupTab updated_tab = *group->saved_tabs().begin();
+ updated_tab.SetTitle(u"updated local title");
+ model()->UpdateTabInGroup(kMissingGroupGuid, updated_tab,
+ /*notify_observers=*/true);
+
+ // 4. Trigger a completely unrelated remote update.
+ const base::Uuid kUnrelatedGroupGuid = base::Uuid::GenerateRandomV4();
+ sync_pb::SharedTabGroupDataSpecifics unrelated_group_specifics =
+ MakeTabGroupSpecifics("unrelated group", sync_pb::SharedTabGroup::RED);
+ unrelated_group_specifics.set_guid(kUnrelatedGroupGuid.AsLowercaseString());
+ ApplySingleEntityChange(
+ CreateAddEntityChange(unrelated_group_specifics, kCollaborationId));
+
+ // 5. Verify the tab title was not reverted to the original remote title.
+ // If the bug was present (tab left in `tabs_missing_groups_`), the unrelated
+ // update would trigger `ResolveTabsMissingGroups` and overwrite the local
+ // title.
+ group = model()->Get(kMissingGroupGuid);
+ ASSERT_TRUE(group);
+ EXPECT_THAT(group->saved_tabs(),
+ ElementsAre(HasTabMetadata("updated local title",
+ "http://google.com/1")));
+}
+
+TEST_F(SharedTabGroupDataSyncBridgeTest,
ShouldTrimAllSupportedFieldsFromRemoteTabGroupSpecifics) {
ASSERT_TRUE(InitializeBridgeAndModel());
Original Bug Report
Potential persistent state-locking in Shared Tab Groups via missing group cache
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A logic error in SharedTabGroupDataSyncBridge::ResolveTabsMissingGroups fails to remove orphaned tabs from the tabs_missing_groups_ cache once their parent group arrives. This causes the stale, cached tab data to be repeatedly re-applied to the local model on every subsequent incremental sync update. A malicious collaborator can exploit this to persistently ’lock’ a tab to an attacker-controlled URL for the duration of a browser session, overwriting any user attempts to navigate away.
Affected files:
components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge.cccomponents/saved_tab_groups/public/saved_tab_group_tab.cc
Estimated timestamp from git blame: 2025-06-02
Description
There is a potential logic vulnerability in the SharedTabGroupDataSyncBridge within Chrome’s Saved Tab Groups component. When a shared tab is received via sync before its corresponding group entity (a common race condition in distributed systems), it is temporarily stored in an internal cache map, tabs_missing_groups_.
However, SharedTabGroupDataSyncBridge::ResolveTabsMissingGroups fails to erase entries from tabs_missing_groups_ after they have been successfully applied to the model upon the arrival of the missing group entity. Because ResolveTabsMissingGroups is called unconditionally during every subsequent incremental sync update (via ApplyIncrementalSyncChanges), the stale tab data initially cached in the map is repeatedly re-applied to the local tab model.
During re-application, ApplyRemoteTabUpdate finds the tab already exists in the model and calls SavedTabGroupTab::MergeRemoteTab. This function unconditionally overwrites the tab’s URL, title, and attribution metadata with the stale values from the cache. This effectively ’locks’ the tab’s state to the values received during the initial out-of-order sync event.
In contrast, the non-shared implementation in saved_tab_group_sync_bridge.cc correctly implements an iterator-erase pattern to remove resolved tabs from its internal collection.
Impact
This issue allows a malicious collaborator in a shared tab group to force a persistent phishing state. By deliberately inducing a tab-before-group race condition (e.g., by committing a tab with a malicious URL before its new group GUID), an attacker can ensure their chosen URL is permanently enforced on the victim’s client for the duration of the browser session.
Any attempt by the victim to navigate away from the malicious URL will be reverted to the attacker’s chosen state upon the next unrelated sync event (e.g., a background sync ping or another user updating a different tab). While a browser restart breaks the loop (as the in-memory cache is correctly re-populated only with truly orphaned tabs from disk), the persistence within a single session is a significant security risk for phishing and attribution spoofing.
Potential Attack Steps
Note: These steps are suggested based on code analysis, as our setup does not currently have the ability to run proof-of-concept exploits.
- Attacker Payload: An attacker and victim are in a Shared Tab Group collaboration. The attacker crafts a new Shared Tab Group entity and a malicious Shared Tab entity containing an attacker-controlled URL (e.g.,
https://malicious.example.com). - Induced Race Condition: The attacker sends the sync updates such that the victim’s client receives the malicious tab entity before its parent group entity.
- Cache Orphaned Tab: The victim’s browser receives the tab in
SharedTabGroupDataSyncBridge::ApplyIncrementalSyncChanges. It callsApplyRemoteTabUpdate, fails to find the group in the local model, and caches the tab’s specifics intabs_missing_groups_. - Group Arrives (The Bug): The victim receives the group entity.
ResolveTabsMissingGroupsiterates overtabs_missing_groups_, finds the group, and successfully adds the tab to the local model. However, it fails to erase the tab fromtabs_missing_groups_. - Victim Intervenes: The victim, seeing the unexpected tab, navigates it to a safe URL (e.g.,
https://google.com), updating their local model. - Unrelated Sync Reverts State: Sometime later, an unrelated sync update occurs in the Shared Tab Group.
ApplyIncrementalSyncChangesprocesses it and callsResolveTabsMissingGroupsagain. - Overwriting the Local Model:
ResolveTabsMissingGroupsiterates over thetabs_missing_groups_map, finds the stale, never-erased malicious tab data, and callsApplyRemoteTabUpdate. - Navigation Forced: This cascades down to
SavedTabGroupTab::MergeRemoteTab, which unconditionally overwrites the victim’s safe URL with the stale attacker-controlled URL.TabGroupSyncDelegateDesktopsyncs this model change to the UI, forcing the activeWebContentsto navigate back tohttps://malicious.example.com. This loop (steps 6-8) will repeat indefinitely until the browser is restarted.
Suggested Fix
In components/saved_tab_groups/internal/shared_tab_group_data_sync_bridge.cc, modify ResolveTabsMissingGroups to properly erase entries from tabs_missing_groups_ after a successful resolution.
std::optional<syncer::ModelError>
SharedTabGroupDataSyncBridge::ResolveTabsMissingGroups(
syncer::MetadataChangeList& metadata_change_list) {
CHECK(ongoing_write_batch_);
auto it = tabs_missing_groups_.begin();
while (it != tabs_missing_groups_.end()) {
const auto& [tab_guid, tab_missing_group] = *it;
base::Uuid group_guid = base::Uuid::ParseLowercase(
tab_missing_group.specifics.tab().shared_tab_group_guid());
const SavedTabGroup* group = model_wrapper_->GetGroup(group_guid);
if (!group) {
++it;
continue;
}
if (std::optional<syncer::ModelError> error =
ApplyRemoteTabUpdate(tab_missing_group.specifics,
&metadata_change_list, *ongoing_write_batch_,
/*tab_ids_with_pending_model_update=*/{},
tab_missing_group.collaboration_metadata,
tab_missing_group.creation_time,
tab_missing_group.modification_time)) {
return error;
}
// ERASURE FIX:
it = tabs_missing_groups_.erase(it);
}
return std::nullopt;
}
Additionally, consider implementing a discard threshold (TTL) for shared tabs missing groups, similar to the 30-day limit implemented in SavedTabGroupSyncBridge, to prevent permanent orphaned records accumulating on disk.
Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8
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. Please feel free to reach out to me if you have concerns or feedback.