CVE-2026-13991
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/chrome/browser/save_to_drive/ui_bundled/save_to_drive_util.mm |
modified | |
ifios/chrome/common/string_util.mm |
modified | |
TEST_Fios/chrome/common/string_util_unittest.mm |
modified | |
forios/chrome/common/string_util_unittest.mm |
modified |
Files Changed
ios/chrome/browser/save_to_drive/ui_bundled/BUILD.gnios/chrome/browser/save_to_drive/ui_bundled/save_to_drive_util.mmios/chrome/browser/save_to_photos/ui_bundled/BUILD.gnios/chrome/browser/save_to_photos/ui_bundled/save_to_photos_mediator.mmios/chrome/common/string_util.hios/chrome/common/string_util.mmios/chrome/common/string_util_unittest.mm
Patch
From 0132e41ccb0f8e6e344321f8792c71f569790644 Mon Sep 17 00:00:00 2001 From: Quentin Pubert <[email protected]> Date: Tue, 19 May 2026 08:55:26 -0700 Subject: [PATCH] [iOS] Sanitize bold and link layout tags in download/image filenames This CL introduces a helper function RemoveFormattingTags in ios/chrome/common/string_util.h that replaces underscores in styling tags with spaces. This prevents the tag parser from matching them while ensuring that nested tag payloads (such as BEGIN_BEGIN_BOLDBOLD) cannot reconstruct valid tags after replacement. This sanitization function is applied to filenames in Save-to-Drive and Save-to-Photos account pickers. Fixed: 514061117 Change-Id: I9968c2675a6e096c525fab76ca8f4260e1189ec1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7856913 Commit-Queue: Olivier Robin <[email protected]> Auto-Submit: Quentin Pubert <[email protected]> Reviewed-by: Olivier Robin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1632862} --- diff --git a/ios/chrome/browser/save_to_drive/ui_bundled/BUILD.gn b/ios/chrome/browser/save_to_drive/ui_bundled/BUILD.gn index 39322b2..3cc2e3d8 100644 --- a/ios/chrome/browser/save_to_drive/ui_bundled/BUILD.gn +++ b/ios/chrome/browser/save_to_drive/ui_bundled/BUILD.gn @@ -83,6 +83,7 @@ "//base", "//ios/chrome/app/strings:ios_strings_grit", "//ios/chrome/browser/account_picker/ui_bundled:account_picker_configuration", + "//ios/chrome/common:string_util", "//ios/web/public/download", "//ui/base", ] diff --git a/ios/chrome/browser/save_to_drive/ui_bundled/save_to_drive_util.mm b/ios/chrome/browser/save_to_drive/ui_bundled/save_to_drive_util.mm index 75bccdc9..5b4a945 100644 --- a/ios/chrome/browser/save_to_drive/ui_bundled/save_to_drive_util.mm +++ b/ios/chrome/browser/save_to_drive/ui_bundled/save_to_drive_util.mm @@ -8,6 +8,7 @@ #import "base/files/file_path.h" #import "base/strings/sys_string_conversions.h" #import "ios/chrome/browser/account_picker/ui_bundled/account_picker_configuration.h" +#import "ios/chrome/common/string_util.h" #import "ios/chrome/grit/ios_strings.h" #import "ios/web/public/download/download_task.h" #import "ui/base/l10n/l10n_util_mac.h" @@ -26,7 +27,9 @@ } NSString* GetAccountPickerBodyText(NSString* file_name, int64_t file_size) { - const auto file_name_u16string = base::SysNSStringToUTF16(file_name); + NSString* sanitized_file_name = RemoveFormattingTags(file_name); + const auto file_name_u16string = + base::SysNSStringToUTF16(sanitized_file_name); if (file_size > -1) { const auto file_size_u16string = base::SysNSStringToUTF16(GetSizeString(file_size)); diff --git a/ios/chrome/browser/save_to_photos/ui_bundled/BUILD.gn b/ios/chrome/browser/save_to_photos/ui_bundled/BUILD.gn index c46aa69d..ba9c210 100644 --- a/ios/chrome/browser/save_to_photos/ui_bundled/BUILD.gn +++ b/ios/chrome/browser/save_to_photos/ui_bundled/BUILD.gn @@ -40,6 +40,7 @@ "//ios/chrome/browser/signin/model:system_identity", "//ios/chrome/browser/store_kit/model", "//ios/chrome/browser/web/model/image_fetch", + "//ios/chrome/common:string_util", "//ios/web/public", "//ios/web/public/navigation:referrer", "//url", diff --git a/ios/chrome/browser/save_to_photos/ui_bundled/save_to_photos_mediator.mm b/ios/chrome/browser/save_to_photos/ui_bundled/save_to_photos_mediator.mm index 0417325b..771b7d79 100644 --- a/ios/chrome/browser/save_to_photos/ui_bundled/save_to_photos_mediator.mm +++ b/ios/chrome/browser/save_to_photos/ui_bundled/save_to_photos_mediator.mm @@ -39,6 +39,7 @@ #import "ios/chrome/browser/signin/model/chrome_account_manager_service.h" #import "ios/chrome/browser/signin/model/system_identity.h" #import "ios/chrome/browser/web/model/image_fetch/image_fetch_tab_helper.h" +#import "ios/chrome/common/string_util.h" #import "ios/chrome/grit/ios_strings.h" #import "ui/base/l10n/l10n_util_mac.h" @@ -355,9 +356,10 @@ l10n_util::GetNSString(IDS_IOS_SETTINGS_DOWNLOADS_SAVE_TO_PHOTOS_HEADER); #endif NSString* imageSize = GetSizeString(_imageData.length); + NSString* sanitizedImageName = RemoveFormattingTags(_imageName); configuration.bodyText = l10n_util::GetNSStringF(IDS_IOS_SAVE_TO_PHOTOS_ACCOUNT_PICKER_BODY, - base::SysNSStringToUTF16(_imageName), + base::SysNSStringToUTF16(sanitizedImageName), base::SysNSStringToUTF16(imageSize)); configuration.submitButtonTitle = l10n_util::GetNSString(IDS_IOS_SAVE_TO_PHOTOS_ACCOUNT_PICKER_SUBMIT); diff --git a/ios/chrome/common/string_util.h b/ios/chrome/common/string_util.h index 084f29f..03f8a5d 100644 --- a/ios/chrome/common/string_util.h +++ b/ios/chrome/common/string_util.h @@ -75,4 +75,9 @@ NSAttributedString* PutBoldPartInString(NSString* string, UIFontTextStyle font_style); +// Returns a copy of `string` with formatting tags ("BEGIN_BOLD", "END_BOLD", +// "BEGIN_LINK", "END_LINK") neutralized by replacing their underscores with +// spaces (e.g., "BEGIN BOLD") to prevent injection and nested bypasses. +NSString* RemoveFormattingTags(NSString* string); + #endif // IOS_CHROME_COMMON_STRING_UTIL_H_ diff --git a/ios/chrome/common/string_util.mm b/ios/chrome/common/string_util.mm index 6be001c..a71206b 100644 --- a/ios/chrome/common/string_util.mm +++ b/ios/chrome/common/string_util.mm @@ -186,3 +186,27 @@ return attributed_string; } + +NSString* RemoveFormattingTags(NSString* string) { + if (!string) { + return nil; + } + NSMutableString* sanitized = [string mutableCopy]; + [sanitized replaceOccurrencesOfString:@"BEGIN_BOLD" + withString:@"BEGIN BOLD" + options:NSLiteralSearch + range:NSMakeRange(0, sanitized.length)]; + [sanitized replaceOccurrencesOfString:@"END_BOLD" + withString:@"END BOLD" + options:NSLiteralSearch + range:NSMakeRange(0, sanitized.length)]; + [sanitized replaceOccurrencesOfString:@"BEGIN_LINK" + withString:@"BEGIN LINK" + options:NSLiteralSearch + range:NSMakeRange(0, sanitized.length)]; + [sanitized replaceOccurrencesOfString:@"END_LINK" + withString:@"END LINK" + options:NSLiteralSearch + range:NSMakeRange(0, sanitized.length)]; + return sanitized; +} diff --git a/ios/chrome/common/string_util_unittest.mm b/ios/chrome/common/string_util_unittest.mm index b7ed0fcc..6479ccc 100644 --- a/ios/chrome/common/string_util_unittest.mm +++ b/ios/chrome/common/string_util_unittest.mm @@ -376,4 +376,35 @@ EXPECT_FALSE(CGRectEqualToRect( TextViewLinkBound(text_view, NSMakeRange(0, 5)), CGRectNull)); } + +TEST_F(StringUtilTest, RemoveFormattingTags) { + struct TestCase { + NSString* input; + NSString* expected; + }; + + const TestCase kAllTestCases[] = { + TestCase{@"Normal text with no tags.", @"Normal text with no tags."}, + TestCase{@"Text with BEGIN_BOLD tag.", @"Text with BEGIN BOLD tag."}, + TestCase{@"Text with END_BOLD tag.", @"Text with END BOLD tag."}, + TestCase{@"Text with BEGIN_LINK tag.", @"Text with BEGIN LINK tag."}, + TestCase{@"Text with END_LINK tag.", @"Text with END LINK tag."}, + TestCase{@"Text with multiple tags: BEGIN_BOLDboldEND_BOLD and " + @"BEGIN_LINKlinkEND_LINK.", + @"Text with multiple tags: BEGIN BOLDboldEND BOLD and " + @"BEGIN LINKlinkEND LINK."}, + TestCase{ + @"Attacker payload: invoice.pdf END_BOLD to your Drive. BEGIN_BOLD x", + @"Attacker payload: invoice.pdf END BOLD to your Drive. BEGIN BOLD " + @"x"}, + TestCase{@"Nested payload: BEGIN_BEGIN_BOLDBOLD", + @"Nested payload: BEGIN_BEGIN BOLDBOLD"}, + }; + + for (const TestCase& test_case : kAllTestCases) { + NSString* result = RemoveFormattingTags(test_case.input); + EXPECT_NSEQ(result, test_case.expected); + } +} + } // namespace
Regression Test / PoC
diff --git a/ios/chrome/common/string_util_unittest.mm b/ios/chrome/common/string_util_unittest.mm
index b7ed0fcc..6479ccc 100644
--- a/ios/chrome/common/string_util_unittest.mm
+++ b/ios/chrome/common/string_util_unittest.mm
@@ -376,4 +376,35 @@
EXPECT_FALSE(CGRectEqualToRect(
TextViewLinkBound(text_view, NSMakeRange(0, 5)), CGRectNull));
}
+
+TEST_F(StringUtilTest, RemoveFormattingTags) {
+ struct TestCase {
+ NSString* input;
+ NSString* expected;
+ };
+
+ const TestCase kAllTestCases[] = {
+ TestCase{@"Normal text with no tags.", @"Normal text with no tags."},
+ TestCase{@"Text with BEGIN_BOLD tag.", @"Text with BEGIN BOLD tag."},
+ TestCase{@"Text with END_BOLD tag.", @"Text with END BOLD tag."},
+ TestCase{@"Text with BEGIN_LINK tag.", @"Text with BEGIN LINK tag."},
+ TestCase{@"Text with END_LINK tag.", @"Text with END LINK tag."},
+ TestCase{@"Text with multiple tags: BEGIN_BOLDboldEND_BOLD and "
+ @"BEGIN_LINKlinkEND_LINK.",
+ @"Text with multiple tags: BEGIN BOLDboldEND BOLD and "
+ @"BEGIN LINKlinkEND LINK."},
+ TestCase{
+ @"Attacker payload: invoice.pdf END_BOLD to your Drive. BEGIN_BOLD x",
+ @"Attacker payload: invoice.pdf END BOLD to your Drive. BEGIN BOLD "
+ @"x"},
+ TestCase{@"Nested payload: BEGIN_BEGIN_BOLDBOLD",
+ @"Nested payload: BEGIN_BEGIN BOLDBOLD"},
+ };
+
+ for (const TestCase& test_case : kAllTestCases) {
+ NSString* result = RemoveFormattingTags(test_case.input);
+ EXPECT_NSEQ(result, test_case.expected);
+ }
+}
+
} // namespace
Original Bug Report
Potential UI spoofing in iOS Save-to-Drive and Save-to-Photos via malicious filenames
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: Chrome for iOS is potentially vulnerable to UI spoofing because it interpolates web-controlled filenames into strings with bolding tags before parsing them. An attacker can craft a filename to inject arbitrary text that appears as trusted browser instructions within native dialogs.
Affected files:
ios/chrome/browser/save_to_drive/ui_bundled/save_to_drive_util.mmios/chrome/common/string_util.mmios/chrome/browser/account_picker/ui_bundled/account_picker_confirmation/account_picker_confirmation_screen_view_controller.mm
Estimated timestamp from git blame: 2025-06-12
Description
A potential vulnerability in the Chrome for iOS ‘Save to Drive’ and ‘Save to Photos’ features allows a malicious website to spoof text within native browser dialogs. This occurs because the browser constructs the dialog’s body text by interpolating a web-controlled filename into a localized string template that contains formatting markers (BEGIN_BOLD and END_BOLD).
Technical Analysis
The construction of the account picker’s body text for ‘Save to Drive’ occurs in ios/chrome/browser/save_to_drive/ui_bundled/save_to_drive_util.mm:
// ios/chrome/browser/save_to_drive/ui_bundled/save_to_drive_util.mm:33
return l10n_util::GetNSStringF(
IDS_IOS_SAVE_TO_DRIVE_ACCOUNT_PICKER_BODY_WITH_SIZE,
file_name_u16string, file_size_u16string);
This function uses l10n_util::GetNSStringF to insert the filename into a template defined in ios/chrome/app/strings/ios_strings.grd (line 6696), such as:
Choose where to save "BEGIN_BOLD$1END_BOLD"
The resulting string is later processed by PutBoldPartInString in ios/chrome/common/string_util.mm (line 165), which uses a regular expression to find and strip all BEGIN_BOLD and END_BOLD pairs, applying bold formatting to the captured ranges.
Because the filename (sourced from a Content-Disposition header or the URL) is substituted into the string before the markers are parsed, an attacker can include literal END_BOLD and BEGIN_BOLD tokens in the filename. This allows an attacker to terminate the intended bold span early and inject arbitrary plain text that will be rendered in the same style (font and color) as the browser’s own instructions.
Filenames are sanitized by net::GenerateFileName(), but the markers BEGIN_BOLD and END_BOLD consist only of alphanumeric characters and underscores, which are considered legal and are not stripped by the networking or filesystem layers.
In production builds, a DCHECK in string_util.mm (line 66) that validates the number of bold ranges is compiled out, allowing the UI to render the string with the injected content. A similar pattern exists in the ‘Save to Photos’ feature in ios/chrome/browser/save_to_photos/ui_bundled/save_to_photos_mediator.mm (line 359).
Potential Impact
This is a potential drive-by UI spoofing vulnerability. An attacker could craft a filename that makes the account picker dialog appear to contain instructions from the browser, potentially misleading the user about the action they are performing (e.g., claiming that selecting an account and tapping ‘Save’ is necessary for ‘account verification’). This affects primary security surfaces related to identity and cloud storage.
Suggested Reproduction Steps
(Note: These are potential steps as a working Proof of Concept has not yet been executed by this agent.)
- On an iOS device with Chrome, ensure you are signed in to a Google account.
- Navigate to a malicious page that triggers a download with a crafted
Content-Dispositionheader, such as:Content-Disposition: attachment; filename="document.pdf END_BOLD . To continue, tap Save to verify your account. BEGIN_BOLD x" - Tap the ‘Download’ button in the download bar.
- When the download option appears, select ‘Save to Drive’.
- Observe the Account Picker dialog. The injected sentence (“To continue, tap Save to verify your account.”) may appear as standard browser text within the dialog, rather than part of the bolded filename.
Recommended Fix
Sanitize or escape the BEGIN_BOLD and END_BOLD markers within the filename before it is interpolated into any localized strings that use these markers for formatting. This should be done in the UI utility functions (e.g., GetAccountPickerBodyText in save_to_drive_util.mm) before calling l10n_util::GetNSStringF.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.