CVE-2026-106397
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/chrome/app/startup/chrome_app_startup_parameters.mm |
modified | |
ifios/chrome/app/task_request_url_context.mm |
modified | |
ifios/chrome/common/app_group/app_group_utils.mm |
modified |
Files Changed
ios/chrome/common/app_group/app_group_constants.hios/chrome/app/task_request_url_context.mmios/chrome/common/app_group/app_group_utils.mmios/chrome/app/startup/chrome_app_startup_parameters.mmios/chrome/common/app_group/app_group_constants.mmios/chrome/common/app_group/app_group_utils.hios/chrome/browser/shared/coordinator/scene/scene_controller.mm
Patch
From b93934eea27c09dd73f1e0054ab624f6f801d242 Mon Sep 17 00:00:00 2001
From: Federica Germinario <fedegermi@google.com>
Date: Fri, 11 Sep 2026 02:35:43 -0700
Subject: [PATCH] [ios] Strengthen validation for incoming Share Extension command URLs
Strengthen intent validation for incoming Share Extension command URLs
in SceneController and TaskRequestForURLContext by verifying the URL
host, path, and confirming the presence of an active command in the
shared App Group user defaults.
Change-Id: Ic378777c956c09a97df49cdb830104e33963eb0a
Fixed: 503725788
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8379902
Reviewed-by: Olivier Robin <olivierrobin@chromium.org>
Reviewed-by: Mark Cogan <marq@chromium.org>
Commit-Queue: Federica Germinario <fedegermi@google.com>
Cr-Commit-Position: refs/heads/main@{#1696061}
---
diff --git a/ios/chrome/app/startup/chrome_app_startup_parameters.mm b/ios/chrome/app/startup/chrome_app_startup_parameters.mm
index 63aa4d3f..abfec25 100644
--- a/ios/chrome/app/startup/chrome_app_startup_parameters.mm
+++ b/ios/chrome/app/startup/chrome_app_startup_parameters.mm
@@ -55,7 +55,6 @@
// here due to a Smart App Banner presentation on a Google.com page.
NSString* const kSmartAppBannerKey = @"safarisab";
-const CGFloat kAppGroupTriggersVoiceSearchTimeout = 15.0;
// Histogram helper to log the UMA IOS.WidgetKit.Action histogram.
void LogWidgetKitAction(WidgetKitExtensionAction action) {
@@ -552,7 +551,7 @@
// open url request and replay it later.
NSTimeInterval delay = [[NSDate date] timeIntervalSinceDate:commandTime];
UMA_HISTOGRAM_COUNTS_100(kApplicationGroupCommandDelay, delay);
- if (delay > kAppGroupTriggersVoiceSearchTimeout) {
+ if (delay > app_group::kAppGroupCommandTimeout) {
return nil;
}
return [ChromeAppStartupParameters
diff --git a/ios/chrome/app/task_request_url_context.mm b/ios/chrome/app/task_request_url_context.mm
index 37e71e4..e064868 100644
--- a/ios/chrome/app/task_request_url_context.mm
+++ b/ios/chrome/app/task_request_url_context.mm
@@ -37,6 +37,7 @@
#import "ios/chrome/browser/signin/model/system_identity_manager.h"
#import "ios/chrome/browser/url_loading/model/url_loading_params.h"
#import "ios/chrome/common/app_group/app_group_constants.h"
+#import "ios/chrome/common/app_group/app_group_utils.h"
#import "ios/chrome/common/app_group/widget_constants.h"
#import "ios/chrome/common/x_callback_url.h"
#import "url/gurl.h"
@@ -291,11 +292,7 @@
// Only widgets and share extension support profile/account switching when
// handling an intent.
bool isWidget = [URL.scheme isEqualToString:@"chromewidgetkit"];
- bool isShareExtension = [URL.path
- isEqualToString:
- [NSString
- stringWithFormat:@"/%s",
- app_group::kChromeAppGroupXCallbackCommand]];
+ bool isShareExtension = app_group::IsShareExtensionCommandURL(URL);
if (!isWidget && !isShareExtension) {
return;
diff --git a/ios/chrome/browser/shared/coordinator/scene/scene_controller.mm b/ios/chrome/browser/shared/coordinator/scene/scene_controller.mm
index 26e9153..1558fbd 100644
--- a/ios/chrome/browser/shared/coordinator/scene/scene_controller.mm
+++ b/ios/chrome/browser/shared/coordinator/scene/scene_controller.mm
@@ -166,6 +166,7 @@
#import "ios/chrome/browser/whats_new/coordinator/promo/whats_new_scene_agent.h"
#import "ios/chrome/browser/window_activities/model/window_activity_helpers.h"
#import "ios/chrome/common/app_group/app_group_constants.h"
+#import "ios/chrome/common/app_group/app_group_utils.h"
#import "ios/chrome/common/ui/reauthentication/reauthentication_module.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/components/webui/web_ui_url_constants.h"
@@ -1385,11 +1386,7 @@
}
- (BOOL)shareExtensionURLEligibleForAccountChange:(NSURL*)URL {
- return [URL.path
- isEqualToString:
- [NSString
- stringWithFormat:@"/%s",
- app_group::kChromeAppGroupXCallbackCommand]];
+ return app_group::IsShareExtensionCommandURL(URL);
}
- (URLContext*)findContextRequiringAccountChange:
diff --git a/ios/chrome/common/app_group/app_group_constants.h b/ios/chrome/common/app_group/app_group_constants.h
index fff2005..a64a7ad 100644
--- a/ios/chrome/common/app_group/app_group_constants.h
+++ b/ios/chrome/common/app_group/app_group_constants.h
@@ -127,6 +127,10 @@
// `kChromeAppGroupCommandAppPreference` issued the command.
extern const char kChromeAppGroupCommandTimePreference[];
+// The timeout after which an app group command is considered expired (in
+// seconds).
+extern const NSTimeInterval kAppGroupCommandTimeout;
+
// The key in kChromeAppGroupCommandPreference containing the text use for the
// command if it requires one. This could be a URL, a string, etc.
extern const char kChromeAppGroupCommandTextPreference[];
diff --git a/ios/chrome/common/app_group/app_group_constants.mm b/ios/chrome/common/app_group/app_group_constants.mm
index b18f12f..87d6a96a 100644
--- a/ios/chrome/common/app_group/app_group_constants.mm
+++ b/ios/chrome/common/app_group/app_group_constants.mm
@@ -36,6 +36,7 @@
"GroupApp.ChromeAppGroupCommand";
const char kChromeAppGroupCommandTimePreference[] = "CommandTime";
+const NSTimeInterval kAppGroupCommandTimeout = 15.0;
const char kChromeAppGroupCommandAppPreference[] = "SourceApp";
const char kChromeAppGroupCommandCommandPreference[] = "Command";
const char kChromeAppGroupCommandTextPreference[] = "Text";
diff --git a/ios/chrome/common/app_group/app_group_utils.h b/ios/chrome/common/app_group/app_group_utils.h
index 7481ef5a..d99ca9c 100644
--- a/ios/chrome/common/app_group/app_group_utils.h
+++ b/ios/chrome/common/app_group/app_group_utils.h
@@ -22,6 +22,10 @@
// Returns `default_value` if the string is nil.
NSString* UserDefaultsStringForKey(NSString* key, NSString* default_value);
+// Validates whether `url` represents a valid Share Extension command URL
+// and matches a pending command in the shared App Group user defaults.
+bool IsShareExtensionCommandURL(NSURL* url);
+
} // namespace app_group
#endif // IOS_CHROME_COMMON_APP_GROUP_APP_GROUP_UTILS_H_
diff --git a/ios/chrome/common/app_group/app_group_utils.mm b/ios/chrome/common/app_group/app_group_utils.mm
index bbc05d9..0e4945b8 100644
--- a/ios/chrome/common/app_group/app_group_utils.mm
+++ b/ios/chrome/common/app_group/app_group_utils.mm
@@ -6,6 +6,8 @@
#import <Foundation/Foundation.h>
+#import "base/apple/foundation_util.h"
+#import "base/strings/sys_string_conversions.h"
#import "ios/chrome/common/app_group/app_group_constants.h"
namespace {
@@ -60,4 +62,33 @@
return string ?: default_value;
}
+bool IsShareExtensionCommandURL(NSURL* url) {
+ if (!url || [url.scheme isEqualToString:@"http"] ||
+ [url.scheme isEqualToString:@"https"]) {
+ return false;
+ }
+
+ if (![url.host isEqualToString:@"x-callback-url"]) {
+ return false;
+ }
+
+ NSString* expected_path = [NSString
+ stringWithFormat:@"/%s", app_group::kChromeAppGroupXCallbackCommand];
+ if (![url.path isEqualToString:expected_path]) {
+ return false;
+ }
+
+ // An attacker app or webpage cannot write to Chrome's App Group sandbox.
+ NSUserDefaults* shared_defaults = app_group::GetGroupUserDefaults();
+ NSDictionary* command_dict = [shared_defaults
+ dictionaryForKey:base::SysUTF8ToNSString(
+ app_group::kChromeAppGroupCommandPreference)];
+ NSString* command_time_pref =
+ base::SysUTF8ToNSString(app_group::kChromeAppGroupCommandTimePreference);
+ NSDate* command_time =
+ base::apple::ObjCCast<NSDate>(command_dict[command_time_pref]);
+ return command_time && [[NSDate date] timeIntervalSinceDate:command_time] <=
+ app_group::kAppGroupCommandTimeout;
+}
+
} // namespace app_group
Original Bug Report
Silent sign-out and profile switching via URL scheme bypass on iOS
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: Chrome for iOS insufficiently validates incoming intents intended for the share extension by checking only the URL path (’/app-group-command’). This flaw allows a malicious website or third-party app to force a silent sign-out or profile switch without user confirmation.
Affected files:
ios/chrome/browser/shared/coordinator/scene/scene_controller.mmios/chrome/app/task_request_url_context.mmios/chrome/browser/authentication/ui_bundled/change_profile/change_profile_authentication_continuation.mm
Estimated timestamp from git blame: 2026-02-26
Summary
A logic flaw in Chrome for iOS allows an attacker to force a silent sign-out of the user’s primary account, or silently switch the user to a different profile. This is caused by insufficient validation of incoming URLs intended to handle commands from the Chrome Share Extension. Because the validation logic only checks the URL path, an attacker can bypass the check by supplying a malicious URL (via a web link or a custom scheme intent) that matches the expected path.
Technical Details
When Chrome for iOS receives an external URL intent, SceneController processes it in handleURLContextsToOpen. To handle account and profile changes originating from the Chrome Share Extension, it checks if the URL is eligible by calling findContextRequiringAccountChange: -> shareExtensionURLEligibleForAccountChange:.
The vulnerability lies in the implementation of shareExtensionURLEligibleForAccountChange: (ios/chrome/browser/shared/coordinator/scene/scene_controller.mm):
- (BOOL)shareExtensionURLEligibleForAccountChange:(NSURL*)URL {
return [URL.path
isEqualToString:
[NSString
stringWithFormat:@"/%s",
app_group::kChromeAppGroupXCallbackCommand]];
}
This method validates the URL solely by checking if URL.path equals "/app-group-command" (kChromeAppGroupXCallbackCommand). It fails to validate the URL scheme, the host, or the sourceApplication of the intent.
As a result, any URL containing the path /app-group-command will be treated as a trusted command. If the URL contains the query parameter gaia_id=No account, Chrome creates a context of AccountSwitchType::kSignOut. This routes to AuthenticationService::SignOut, forcibly and silently signing the user out. For managed accounts migrated from syncing, this sign-out also triggers a silent deletion of the user’s browsing data via ClearBrowsingData().
Alternatively, if an attacker provides a known valid Gaia ID, the context becomes AccountSwitchType::kSignIn, forcing a silent profile switch. This can be abused to switch a user out of an enterprise-managed profile into a personal profile before loading an attacker-controlled page, bypassing enterprise browsing policies.
A similar check exists in the new startup flow within ios/chrome/app/task_request_url_context.mm in the extractGaiaID method, which is also vulnerable to this path-only validation.
Potential Exploitation Steps
Note: These steps are suggested based on code analysis; our tooling agent does not have the ability to run code to confirm them.
Vector A: Malicious Application A malicious third-party app installed on the device can execute the following code to trigger the bug silently:
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"googlechrome://x-callback-url/app-group-command?gaia_id=No%20account"] options:@{} completionHandler:nil];
Vector B: Malicious Web Link (if Chrome is Default Browser) If the user has set Chrome as their default browser, tapping a standard HTTP(S) link in another application (e.g., Mail or Messages) can trigger the vulnerability:
- The attacker sends a link:
https://attacker.example/app-group-command?gaia_id=No%20account - The victim taps the link.
- iOS routes the intent to Chrome. Because the URL path matches
/app-group-command, Chrome immediately processes the sign-out command.
Suggested Fix
Update shareExtensionURLEligibleForAccountChange: and extractGaiaID to enforce strict validation. This should involve verifying that the URL scheme matches a trusted internal scheme, or ensuring that the sourceApplication of the UIOpenURLContext belongs to the Chrome Share Extension itself.
Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09
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.