Medium chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Mobile
DescriptionImproper input validation in Mobile
ComponentMobile
Sub-componentiOS
Bug ClassInput Validation
Tracker504223609
Fix commit4fa3ed22ab21 (chromium/src) +464/-40
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
ios/chrome/browser/context_menu/ui_bundled/context_menu_egtest.mm
modified
if
ios/chrome/browser/shared/ui/util/image/image_saver.mm
modified
FakeImageFetchTabHelper
ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm
modified
ImageSaverTest
ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm
modified
ImageSaverTest
ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm
modified
ASSERT_TRUE
ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm
modified

Files Changed

  • ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm
  • ios/chrome/browser/context_menu/ui_bundled/BUILD.gn
  • ios/chrome/browser/shared/ui/util/image/image_saver.mm
  • ios/chrome/browser/shared/ui/util/image/BUILD.gn
  • ios/chrome/browser/context_menu/ui_bundled/context_menu_egtest.mm
From 4fa3ed22ab21e94e6e687b92ff50b2966f58e27c Mon Sep 17 00:00:00 2001
From: Quentin Pubert <[email protected]>
Date: Fri, 11 Sep 2026 05:29:05 -0700
Subject: [PATCH] [iOS] Avoid in-process image decoding when saving images

Previously, ImageSaver called `[UIImage imageWithData:data]` in
`didGetImageData:` to validate image data before saving, which caused
the entire image to be decoded in the browser process.

Handing raw NSData directly to PhotoKit via `PHAssetCreationRequest`
avoids unnecessary image decoding and extra memory overhead in the
browser process while preserving the original format and metadata. Any
corrupt or unsupported image data is handled gracefully by PhotoKit's
error callbacks.

This CL:
1. Eliminates `[UIImage imageWithData:]` in `ImageSaver`, passing raw
   NSData directly to PhotoKit via `PHAssetCreationRequest`.
2. Updates `didFinishSavingWithError:` to check the photo library's
   authorization status. If permission was denied or restricted, the
   settings alert is displayed; for all other errors (such as invalid
   data rejected by PhotoKit), the general save error alert
   (`IDS_IOS_SAVE_IMAGE_ERROR`) is shown.
3. Adds unit tests (`ImageSaverTest.*`) with mocked PhotoKit to verify
   handling of invalid data, empty data, permissions, and lifecycle
   cleanup.
4. Adds an EarlGrey integration test
   (`testSaveInvalidImageShowsErrorAlert` in `ContextMenuTestCase`)
   gated behind `BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)`
   to test saving invalid image data against the real photo library.

Fixed: 504223609
Change-Id: I7afa649404e5a63645fbb92722111bca80e920df
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8369962
Commit-Queue: Quentin Pubert <[email protected]>
Auto-Submit: Quentin Pubert <[email protected]>
Reviewed-by: Gauthier Ambard <[email protected]>
Reviewed-by: Olivier Robin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1696135}
---
diff --git a/ios/chrome/browser/context_menu/ui_bundled/context_menu_egtest.mm b/ios/chrome/browser/context_menu/ui_bundled/context_menu_egtest.mm
index 43cfc24..8f6870f 100644
--- a/ios/chrome/browser/context_menu/ui_bundled/context_menu_egtest.mm
+++ b/ios/chrome/browser/context_menu/ui_bundled/context_menu_egtest.mm
@@ -28,6 +28,7 @@
 #import "ios/chrome/browser/shared/public/features/features.h"
 #import "ios/chrome/browser/signin/model/fake_system_identity.h"
 #import "ios/chrome/grit/ios_strings.h"
+#import "ios/chrome/test/buildflags.h"
 #import "ios/chrome/test/earl_grey/chrome_actions.h"
 #import "ios/chrome/test/earl_grey/chrome_earl_grey.h"
 #import "ios/chrome/test/earl_grey/chrome_earl_grey_ui.h"
@@ -165,6 +166,12 @@
 
 NSString* const kShortImgTitle = @"Chromium logo with a short title";
 
+#if BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
+const char kInvalidImagePageUrl[] = "/invalidImagePage";
+const char kInvalidImageUrl[] = "/invalid_image.png";
+const char kInvalidImagePageText[] = "Invalid image test page.";
+#endif  // BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
+
 const char kLinkImagePageUrl[] = "/imageLink";
 
 // Template HTML value image test. (Use NSString for easier format printing and
@@ -243,12 +250,20 @@
       IDS_IOS_CONTENT_CONTEXT_OPENLINKINTABGROUP);
 }
 
-// Matcher for the open link in group button in the context menu.
+// Matcher for the copy image button in the context menu.
 id<GREYMatcher> CopyImageButton() {
   return ContextMenuItemWithAccessibilityLabelId(
       IDS_IOS_CONTENT_CONTEXT_COPYIMAGE);
 }
 
+#if BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
+// Matcher for the save image button in the context menu.
+id<GREYMatcher> SaveImageButton() {
+  return ContextMenuItemWithAccessibilityLabelId(
+      IDS_IOS_CONTENT_CONTEXT_SAVEIMAGE);
+}
+#endif  // BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
+
 // Matcher for the copy link button in the context menu.
 id<GREYMatcher> CopyLinkButton() {
   return ContextMenuItemWithAccessibilityLabelId(
@@ -308,6 +323,18 @@
         [NSString stringWithFormat:kLongLinkTestPageTemplateHtml, kLongLinkHref,
                                    kInitialPageDestinationLongLinkText];
     http_response->set_content(base::SysNSStringToUTF8(content));
+#if BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
+  } else if (request.relative_url == kInvalidImagePageUrl) {
+    http_response->set_content(
+        "<html><head><meta name='viewport' content='width=device-width, "
+        "initial-scale=1.0, maximum-scale=1.0, user-scalable=no' "
+        "/></head><body><p>Invalid image test page.</p>"
+        "<img src='/invalid_image.png' alt='Invalid Image' "
+        "width='100' height='100' /></body></html>");
+  } else if (request.relative_url == kInvalidImageUrl) {
+    http_response->set_content_type("image/png");
+    http_response->set_content("not_an_image");
+#endif  // BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
   } else {
     return nullptr;
   }
@@ -335,6 +362,25 @@
   [[AppLaunchManager sharedManager] ensureAppLaunchedWithConfiguration:config];
 }
 
+#if BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
+// Allows system permission if shown on Springboard without using eDO calls.
+void CheckAndAcceptSystemDialog() {
+  XCUIApplication* springboardApp = [[XCUIApplication alloc]
+      initWithBundleIdentifier:@"com.apple.springboard"];
+  XCUIElement* alert = [[springboardApp
+      descendantsMatchingType:XCUIElementTypeAlert] firstMatch];
+  if ([alert waitForExistenceWithTimeout:1]) {
+    XCUIElement* allowButton = alert.buttons[@"Allow"];
+    if (![allowButton exists]) {
+      allowButton = [alert.buttons elementBoundByIndex:1];
+    }
+    if ([allowButton exists]) {
+      [allowButton tap];
+    }
+  }
+}
+#endif  // BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
+
 }  // namespace
 
 // Context menu tests for Chrome.
@@ -1513,4 +1559,39 @@
 }
 #endif  // BUILDFLAG(GOOGLE_CHROME_FOR_TESTING_BRANDING)
 
+#if BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
+// Tests that attempting to save an invalid image from the context menu displays
+// an error alert to the user.
+- (void)testSaveInvalidImageShowsErrorAlert {
+  [ChromeEarlGrey loadURL:self.testServer->GetURL(kInvalidImagePageUrl)];
+  [ChromeEarlGrey waitForWebStateContainingText:kInvalidImagePageText];
+
+  [ChromeEarlGreyUI
+      longPressElementOnWebView:ElementSelectorToLongPressImage()];
+
+  TapOnContextMenuButton(SaveImageButton());
+
+  CheckAndAcceptSystemDialog();
+
+  // Verify that the error alert is presented when saving invalid image data.
+  [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
+                      grey_text(l10n_util::GetNSString(
+                          IDS_IOS_SAVE_IMAGE_PRIVACY_ALERT_TITLE))];
+  [ChromeEarlGrey
+      waitForUIElementToAppearWithMatcher:grey_text(l10n_util::GetNSString(
+                                              IDS_IOS_SAVE_IMAGE_ERROR))];
+
+  // Dismiss the alert.
+  id<GREYMatcher> okButtonMatcher =
+      grey_allOf(chrome_test_util::AlertAction(l10n_util::GetNSString(IDS_OK)),
+                 grey_interactable(), nil);
+  [ChromeEarlGrey waitForUIElementToAppearWithMatcher:okButtonMatcher];
+  [[EarlGrey selectElementWithMatcher:okButtonMatcher]
+      performAction:grey_tap()];
+  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:
+                      grey_text(l10n_util::GetNSString(
+                          IDS_IOS_SAVE_IMAGE_PRIVACY_ALERT_TITLE))];
+}
+#endif  // BUILDFLAG(IOS_CHROME_ENABLE_PROFILE_ALTERING_TESTS)
+
 @end
diff --git a/ios/chrome/browser/shared/ui/util/image/image_saver.mm b/ios/chrome/browser/shared/ui/util/image/image_saver.mm
index c241f6f..9006060b 100644
--- a/ios/chrome/browser/shared/ui/util/image/image_saver.mm
+++ b/ios/chrome/browser/shared/ui/util/image/image_saver.mm
@@ -6,21 +6,12 @@
 
 #import <Photos/Photos.h>
 
-#import "base/feature_list.h"
-#import "base/files/file_path.h"
-#import "base/format_macros.h"
-#import "base/functional/bind.h"
-#import "base/ios/ios_util.h"
-#import "base/strings/sys_string_conversions.h"
-#import "base/task/thread_pool.h"
-#import "base/threading/scoped_blocking_call.h"
+#import "base/check.h"
 #import "components/strings/grit/components_strings.h"
 #import "ios/chrome/browser/shared/model/browser/browser.h"
-#import "ios/chrome/browser/shared/ui/util/image/image_util.h"
 #import "ios/chrome/browser/web/model/image_fetch/image_fetch_tab_helper.h"
 #import "ios/chrome/grit/ios_branded_strings.h"
 #import "ios/chrome/grit/ios_strings.h"
-#import "net/base/mime_util.h"
 #import "ui/base/l10n/l10n_util.h"
 
 @interface ImageSaver ()
@@ -76,17 +67,9 @@
     return;
   }
 
-  // Use -imageWithData to validate `data`, but continue to pass the raw
-  // `data` to -savePhoto to ensure no data loss occurs.
-  UIImage* savedImage = [UIImage imageWithData:data];
-  if (!savedImage) {
-    [self displayPrivacyErrorAlertOnMainQueue:l10n_util::GetNSString(
-                                                  IDS_IOS_SAVE_IMAGE_ERROR)];
-    return;
-  }
-
-  // Dump `data` into the photo library. Requires the usage of
-  // NSPhotoLibraryAddUsageDescription.
+  // Dump `data` into the photo library. Handing raw data directly to
+  // PHPhotoLibrary avoids in-process image decoding while preserving the
+  // original format and metadata.
   __weak ImageSaver* weakSelf = self;
   [[PHPhotoLibrary sharedPhotoLibrary]
       performChanges:^{
@@ -98,9 +81,7 @@
                         options:options];
       }
       completionHandler:^(BOOL success, NSError* error) {
-        [weakSelf image:savedImage
-            didFinishSavingWithError:error
-                         contextInfo:nil];
+        [weakSelf didFinishSavingWithError:error];
       }];
 }
 
@@ -183,27 +164,35 @@
                                           message:errorContent
                                    preferredStyle:UIAlertControllerStyleAlert];
 
-  [_alertController
-      addAction:[UIAlertAction actionWithTitle:l10n_util::GetNSString(IDS_OK)
-                                         style:UIAlertActionStyleDefault
-                                       handler:nil]];
+  UIAlertAction* okAction =
+      [UIAlertAction actionWithTitle:l10n_util::GetNSString(IDS_OK)
+                               style:UIAlertActionStyleDefault
+                             handler:nil];
+  okAction.accessibilityIdentifier =
+      [l10n_util::GetNSString(IDS_OK) stringByAppendingString:@"AlertAction"];
+  [_alertController addAction:okAction];
 
   [self.baseViewController presentViewController:_alertController
                                         animated:YES
                                       completion:nil];
 }
 
-// Called after the system attempts to write the image to the saved photos
-// album.
-- (void)image:(UIImage*)image
-    didFinishSavingWithError:(NSError*)error
-                 contextInfo:(void*)contextInfo {
+// Called after the system attempts to write the image to the photo library.
+- (void)didFinishSavingWithError:(NSError*)error {
   // Was there an error?
   if (error) {
-    // Saving photo failed, likely due to a permissions issue.
-    // This code may be execute outside of the main thread. Make sure to display
-    // the error on the main thread.
-    [self displayImageErrorAlertWithSettingsOnMainQueue];
+    // Check if saving failed due to insufficient permissions.
+    // This code may be executed outside of the main thread. Make sure to
+    // display the error on the main thread.
+    PHAuthorizationStatus status =
+        [PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelAddOnly];
+    if (status == PHAuthorizationStatusDenied ||
+        status == PHAuthorizationStatusRestricted) {
+      [self displayImageErrorAlertWithSettingsOnMainQueue];
+    } else {
+      [self displayPrivacyErrorAlertOnMainQueue:l10n_util::GetNSString(
+                                                    IDS_IOS_SAVE_IMAGE_ERROR)];
+    }
   } else {
     // TODO(crbug.com/41362123): Provide a way for the user to easily reach the
     // photos app.
diff --git a/ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm b/ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm
new file mode 100644
index 0000000..3ff9722
--- /dev/null
+++ b/ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm
@@ -0,0 +1,337 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/browser/shared/ui/util/image/image_saver.h"
+
+#import <Photos/Photos.h>
+#import <UIKit/UIKit.h>
+
+#import "base/apple/foundation_util.h"
+#import "base/functional/bind.h"
+#import "base/memory/raw_ptr.h"
+#import "base/task/sequenced_task_runner.h"
+#import "base/test/run_until.h"
+#import "components/strings/grit/components_strings.h"
+#import "ios/chrome/browser/shared/model/browser/test/test_browser.h"
+#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
+#import "ios/chrome/browser/web/model/image_fetch/image_fetch_tab_helper.h"
+#import "ios/chrome/grit/ios_branded_strings.h"
+#import "ios/chrome/grit/ios_strings.h"
+#import "ios/web/public/test/fakes/fake_web_state.h"
+#import "ios/web/public/test/web_task_environment.h"
+#import "testing/gtest/include/gtest/gtest.h"
+#import "testing/gtest_mac.h"
+#import "testing/platform_test.h"
+#import "third_party/ocmock/OCMock/OCMock.h"
+#import "third_party/ocmock/gtest_support.h"
+#import "ui/base/l10n/l10n_util.h"
+#import "url/gurl.h"
+#import "url/origin.h"
+
+namespace {
+
+const char kTestImageUrl[] = "https://www.example.com/test_image.gif";
+
+// Fake implementation of ImageFetchTabHelper that supplies test image data.
+class FakeImageFetchTabHelper : public ImageFetchTabHelper {
+ public:
+  static FakeImageFetchTabHelper* CreateForWebState(web::WebState* web_state) {
+    auto helper = std::make_unique<FakeImageFetchTabHelper>(web_state);
+    FakeImageFetchTabHelper* helper_ptr = helper.get();
+    web_state->SetUserData(UserDataKey(), std::move(helper));
+    return helper_ptr;
+  }
+
+  explicit FakeImageFetchTabHelper(web::WebState* web_state)
+      : ImageFetchTabHelper(web_state) {}
+  FakeImageFetchTabHelper(const FakeImageFetchTabHelper&) = delete;
+  FakeImageFetchTabHelper& operator=(const FakeImageFetchTabHelper&) = delete;
+  ~FakeImageFetchTabHelper() override = default;
+
+  void SetImageData(NSData* data) { image_data_ = data; }
+
+  bool GetImageDataCalled() const { return get_image_data_called_; }
+
+  void GetImageData(const GURL& url,
+                    const web::Referrer& referrer,
+                    const std::string& frame_id,
+                    const url::Origin& frame_origin,
+                    ImageDataCallback callback) override {
+    get_image_data_called_ = true;
+    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+        FROM_HERE, base::BindOnce(callback, image_data_));
+  }
+
+ private:
+  bool get_image_data_called_ = false;
+  NSData* image_data_ = nil;
+};
+
+}  // namespace
+
+class ImageSaverTest : public PlatformTest {
+ protected:
+  ImageSaverTest() {
+    profile_ = TestProfileIOS::Builder().Build();
+    browser_ = std::make_unique<TestBrowser>(profile_.get());
+    web_state_ = std::make_unique<web::FakeWebState>();
+    tab_helper_ = FakeImageFetchTabHelper::CreateForWebState(web_state_.get());
+
+    mock_photo_library_ = OCMClassMock([PHPhotoLibrary class]);
+    OCMStub([mock_photo_library_ sharedPhotoLibrary])
+        .andReturn(mock_photo_library_);
+
+    mock_base_view_controller_ = OCMClassMock([UIViewController class]);
+    image_saver_ = [[ImageSaver alloc] initWithBrowser:browser_.get()];
+  }
+
+  void TearDown() override {
+    [image_saver_ stop];
+    [mock_photo_library_ stopMocking];
+    [mock_base_view_controller_ stopMocking];
+    PlatformTest::TearDown();
+  }
+
+  void ExpectAlertPresented(NSString* expected_title,
+                            NSString* expected_message) {
+    alert_presented_ = false;
+    OCMExpect([mock_base_view_controller_
+                  presentViewController:[OCMArg checkWithBlock:^BOOL(id obj) {
+                    UIAlertController* alert =
+                        base::apple::ObjCCast<UIAlertController>(obj);
+                    return [alert.title isEqualToString:expected_title] &&
+                           [alert.message isEqualToString:expected_message];
+                  }]
+                               animated:YES
+                             completion:nil])
+        .andDo(^(NSInvocation*) {
+          alert_presented_ = true;
+        });
+  }
+
+  void WaitForAlert() {
+    ASSERT_TRUE(base::test::RunUntil([&]() { return alert_presented_; }));
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm b/ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm
new file mode 100644
index 0000000..3ff9722
--- /dev/null
+++ b/ios/chrome/browser/shared/ui/util/image/image_saver_unittest.mm
@@ -0,0 +1,337 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/browser/shared/ui/util/image/image_saver.h"
+
+#import <Photos/Photos.h>
+#import <UIKit/UIKit.h>
+
+#import "base/apple/foundation_util.h"
+#import "base/functional/bind.h"
+#import "base/memory/raw_ptr.h"
+#import "base/task/sequenced_task_runner.h"
+#import "base/test/run_until.h"
+#import "components/strings/grit/components_strings.h"
+#import "ios/chrome/browser/shared/model/browser/test/test_browser.h"
+#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
+#import "ios/chrome/browser/web/model/image_fetch/image_fetch_tab_helper.h"
+#import "ios/chrome/grit/ios_branded_strings.h"
+#import "ios/chrome/grit/ios_strings.h"
+#import "ios/web/public/test/fakes/fake_web_state.h"
+#import "ios/web/public/test/web_task_environment.h"
+#import "testing/gtest/include/gtest/gtest.h"
+#import "testing/gtest_mac.h"
+#import "testing/platform_test.h"
+#import "third_party/ocmock/OCMock/OCMock.h"
+#import "third_party/ocmock/gtest_support.h"
+#import "ui/base/l10n/l10n_util.h"
+#import "url/gurl.h"
+#import "url/origin.h"
+
+namespace {
+
+const char kTestImageUrl[] = "https://www.example.com/test_image.gif";
+
+// Fake implementation of ImageFetchTabHelper that supplies test image data.
+class FakeImageFetchTabHelper : public ImageFetchTabHelper {
+ public:
+  static FakeImageFetchTabHelper* CreateForWebState(web::WebState* web_state) {
+    auto helper = std::make_unique<FakeImageFetchTabHelper>(web_state);
+    FakeImageFetchTabHelper* helper_ptr = helper.get();
+    web_state->SetUserData(UserDataKey(), std::move(helper));
+    return helper_ptr;
+  }
+
+  explicit FakeImageFetchTabHelper(web::WebState* web_state)
+      : ImageFetchTabHelper(web_state) {}
+  FakeImageFetchTabHelper(const FakeImageFetchTabHelper&) = delete;
+  FakeImageFetchTabHelper& operator=(const FakeImageFetchTabHelper&) = delete;
+  ~FakeImageFetchTabHelper() override = default;
+
+  void SetImageData(NSData* data) { image_data_ = data; }
+
+  bool GetImageDataCalled() const { return get_image_data_called_; }
+
+  void GetImageData(const GURL& url,
+                    const web::Referrer& referrer,
+                    const std::string& frame_id,
+                    const url::Origin& frame_origin,
+                    ImageDataCallback callback) override {
+    get_image_data_called_ = true;
+    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+        FROM_HERE, base::BindOnce(callback, image_data_));
+  }
+
+ private:
+  bool get_image_data_called_ = false;
+  NSData* image_data_ = nil;
+};
+
+}  // namespace
+
+class ImageSaverTest : public PlatformTest {
+ protected:
+  ImageSaverTest() {
+    profile_ = TestProfileIOS::Builder().Build();
+    browser_ = std::make_unique<TestBrowser>(profile_.get());
+    web_state_ = std::make_unique<web::FakeWebState>();
+    tab_helper_ = FakeImageFetchTabHelper::CreateForWebState(web_state_.get());
+
+    mock_photo_library_ = OCMClassMock([PHPhotoLibrary class]);
+    OCMStub([mock_photo_library_ sharedPhotoLibrary])
+        .andReturn(mock_photo_library_);
+
+    mock_base_view_controller_ = OCMClassMock([UIViewController class]);
+    image_saver_ = [[ImageSaver alloc] initWithBrowser:browser_.get()];
+  }
+
+  void TearDown() override {
+    [image_saver_ stop];
+    [mock_photo_library_ stopMocking];
+    [mock_base_view_controller_ stopMocking];
+    PlatformTest::TearDown();
+  }
+
+  void ExpectAlertPresented(NSString* expected_title,
+                            NSString* expected_message) {
+    alert_presented_ = false;
+    OCMExpect([mock_base_view_controller_
+                  presentViewController:[OCMArg checkWithBlock:^BOOL(id obj) {
+                    UIAlertController* alert =
+                        base::apple::ObjCCast<UIAlertController>(obj);
+                    return [alert.title isEqualToString:expected_title] &&
+                           [alert.message isEqualToString:expected_message];
+                  }]
+                               animated:YES
+                             completion:nil])
+        .andDo(^(NSInvocation*) {
+          alert_presented_ = true;
+        });
+  }
+
+  void WaitForAlert() {
+    ASSERT_TRUE(base::test::RunUntil([&]() { return alert_presented_; }));
+  }
+
+  web::WebTaskEnvironment task_environment_;
+  std::unique_ptr<TestProfileIOS> profile_;
+  std::unique_ptr<TestBrowser> browser_;
+  std::unique_ptr<web::FakeWebState> web_state_;
+  raw_ptr<FakeImageFetchTabHelper> tab_helper_ = nullptr;
+  id mock_photo_library_ = nil;
+  id mock_base_view_controller_ = nil;
+  ImageSaver* image_saver_ = nil;
+  bool alert_presented_ = false;
+};
+
+// Tests that attempting to save an invalid image presents the save error alert.
+TEST_F(ImageSaverTest, SaveInvalidImagePresentsErrorAlert) {
+  NSData* invalid_image_data =
+      [@"not_a_valid_image" dataUsingEncoding:NSUTF8StringEncoding];
+  tab_helper_->SetImageData(invalid_image_data);
+
+  OCMStub([mock_photo_library_
+              authorizationStatusForAccessLevel:PHAccessLevelAddOnly])
+      .andReturn(PHAuthorizationStatusAuthorized);
+
+  OCMStub([mock_photo_library_ performChanges:[OCMArg any]
+                            completionHandler:[OCMArg any]])
+      .andDo(^(NSInvocation* invocation) {
+        void (^completion_handler)(BOOL, NSError*);
+        [invocation getArgument:&completion_handler atIndex:3];
+        if (completion_handler) {
+          NSError* error = [NSError errorWithDomain:PHPhotosErrorDomain
+                                               code:PHPhotosErrorInvalidResource
+                                           userInfo:nil];
+          completion_handler(NO, error);
+        }
+      });
+
+  ExpectAlertPresented(
+      l10n_util::GetNSString(IDS_IOS_SAVE_IMAGE_PRIVACY_ALERT_TITLE),
+      l10n_util::GetNSString(IDS_IOS_SAVE_IMAGE_ERROR));
+
+  [image_saver_ saveImageAtURL:GURL(kTestImageUrl)
+                      referrer:web::Referrer()
+                      webState:web_state_.get()
+                       frameID:"frame_1"
+                   frameOrigin:url::Origin()
+            baseViewController:mock_base_view_controller_];
+
+  WaitForAlert();
+  EXPECT_OCMOCK_VERIFY(mock_base_view_controller_);
+}
+
+// Tests that requesting to save an image with empty data presents the no
+// internet alert.
+TEST_F(ImageSaverTest, SaveImageWithEmptyDataPresentsNoInternetAlert) {
+  tab_helper_->SetImageData([NSData data]);
+
+  ExpectAlertPresented(
+      l10n_util::GetNSString(IDS_IOS_SAVE_IMAGE_PRIVACY_ALERT_TITLE),
+      l10n_util::GetNSString(IDS_IOS_SAVE_IMAGE_NO_INTERNET_CONNECTION));
+
+  [image_saver_ saveImageAtURL:GURL(kTestImageUrl)
+                      referrer:web::Referrer()
+                      webState:web_state_.get()
+                       frameID:"frame_1"
+                   frameOrigin:url::Origin()
+            baseViewController:mock_base_view_controller_];
+
+  WaitForAlert();
+  EXPECT_OCMOCK_VERIFY(mock_base_view_controller_);
+  EXPECT_TRUE(tab_helper_->GetImageDataCalled());
+}
+
+// Tests that when saving encounters an error and photo access is denied,
+// the settings alert is presented.
+TEST_F(ImageSaverTest,
+       SaveImageWithErrorWhenAccessDeniedPresentsSettingsAlert) {
+  tab_helper_->SetImageData(
+      [@"image_data" dataUsingEncoding:NSUTF8StringEncoding]);
+
+  OCMStub([mock_photo_library_
+              authorizationStatusForAccessLevel:PHAccessLevelAddOnly])
+      .andReturn(PHAuthorizationStatusDenied);
+
+  OCMStub([mock_photo_library_ performChanges:[OCMArg any]
+                            completionHandler:[OCMArg any]])
+      .andDo(^(NSInvocation* invocation) {
+        void (^completion_handler)(BOOL, NSError*);
+        [invocation getArgument:&completion_handler atIndex:3];
+        if (completion_handler) {
+          NSError* error = [NSError errorWithDomain:@"TestPhotoErrorDomain"
+                                               code:1
+                                           userInfo:nil];
+          completion_handler(NO, error);
+        }
+      });
+
+  ExpectAlertPresented(
+      l10n_util::GetNSString(IDS_IOS_SAVE_IMAGE_PRIVACY_ALERT_TITLE),
+      l10n_util::GetNSString(
+          IDS_IOS_SAVE_IMAGE_PRIVACY_ALERT_MESSAGE_GO_TO_SETTINGS));
+
+  [image_saver_ saveImageAtURL:GURL(kTestImageUrl)
+                      referrer:web::Referrer()
+                      webState:web_state_.get()
+                       frameID:"frame_1"
+                   frameOrigin:url::Origin()
+            baseViewController:mock_base_view_controller_];
+
+  WaitForAlert();
+  EXPECT_OCMOCK_VERIFY(mock_base_view_controller_);
+}
+
+// Tests that when saving encounters an error and photo access is restricted,
+// the settings alert is presented.
+TEST_F(ImageSaverTest,
+       SaveImageWithErrorWhenAccessRestrictedPresentsSettingsAlert) {
+  tab_helper_->SetImageData(
+      [@"image_data" dataUsingEncoding:NSUTF8StringEncoding]);
+
+  OCMStub([mock_photo_library_
+              authorizationStatusForAccessLevel:PHAccessLevelAddOnly])
+      .andReturn(PHAuthorizationStatusRestricted);
+
+  OCMStub([mock_photo_library_ performChanges:[OCMArg any]
+                            completionHandler:[OCMArg any]])
+      .andDo(^(NSInvocation* invocation) {
+        void (^completion_handler)(BOOL, NSError*);
+        [invocation getArgument:&completion_handler atIndex:3];
+        if (completion_handler) {
+          NSError* error = [NSError errorWithDomain:@"TestPhotoErrorDomain"
+                                               code:1
+                                           userInfo:nil];
+          completion_handler(NO, error);
+        }
+      });
+
+  ExpectAlertPresented(
+      l10n_util::GetNSString(IDS_IOS_SAVE_IMAGE_PRIVACY_ALERT_TITLE),
+      l10n_util::GetNSString(
+          IDS_IOS_SAVE_IMAGE_PRIVACY_ALERT_MESSAGE_GO_TO_SETTINGS));
+
+  [image_saver_ saveImageAtURL:GURL(kTestImageUrl)
+                      referrer:web::Referrer()
+                      webState:web_state_.get()
+                       frameID:"frame_1"
+                   frameOrigin:url::Origin()
+            baseViewController:mock_base_view_controller_];
+
+  WaitForAlert();
+  EXPECT_OCMOCK_VERIFY(mock_base_view_controller_);
+}
+
+// Tests that when saving encounters an error but photo access is authorized,
+// the generic save error alert is presented.
+TEST_F(ImageSaverTest,
+       SaveImageWithErrorWhenAccessAuthorizedPresentsGeneralErrorAlert) {
+  tab_helper_->SetImageData(
+      [@"image_data" dataUsingEncoding:NSUTF8StringEncoding]);
+
+  OCMStub([mock_photo_library_
+              authorizationStatusForAccessLevel:PHAccessLevelAddOnly])
+      .andReturn(PHAuthorizationStatusAuthorized);
+
+  OCMStub([mock_photo_library_ performChanges:[OCMArg any]
+                            completionHandler:[OCMArg any]])
+      .andDo(^(NSInvocation* invocation) {
+        void (^completion_handler)(BOOL, NSError*);
+        [invocation getArgument:&completion_handler atIndex:3];
+        if (completion_handler) {
+          NSError* error = [NSError errorWithDomain:@"TestPhotoErrorDomain"
+                                               code:2
+                                           userInfo:nil];
+          completion_handler(NO, error);
+        }
+      });
+
+  ExpectAlertPresented(
+      l10n_util::GetNSString(IDS_IOS_SAVE_IMAGE_PRIVACY_ALERT_TITLE),
+      l10n_util::GetNSString(IDS_IOS_SAVE_IMAGE_ERROR));
+
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

IOS sandbox bypass: 'Save Image' fails to sanitize images, exposing browser process

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: The ‘Save Image’ context menu action in Chrome for iOS fails to sanitize untrusted web images before parsing them. By serving an image with a .gif extension, an attacker can bypass client-side canvas extraction, causing the raw image bytes to be parsed directly by [UIImage imageWithData:] in the privileged browser process. This architectural flaw exposes the main Chrome process to potential memory corruption vulnerabilities within Apple’s ImageIO framework.

Affected files:

  • ios/chrome/browser/shared/ui/util/image/image_saver.mm
  • ios/chrome/browser/web/model/image_fetch/resources/image_fetch.ts
  • ios/chrome/browser/context_menu/ui_bundled/context_menu_configuration_provider.mm
  • ios/chrome/browser/web/model/image_fetch/image_fetch_tab_helper.mm

Estimated timestamp from git blame: 2024-09-10

Description

Chrome for iOS implements a defense-in-depth architecture where untrusted image data obtained from the web should be re-encoded using web::JavaScriptImageTranscoder before being processed by native system APIs. This transcoding happens in a sandboxed WKWebView process, insulating the main browser process from potentially fatal parsing bugs in Apple’s ImageIO framework.

While features like “Search Image with Lens” and “Gemini Remix” correctly utilize this transcoder (via ContextMenuConfigurationProvider::sanitizeImageData:), the “Save Image” action does not.

When a user selects “Save Image”, the flow reaches ImageSaver::saveImageAtURL: which calls ImageFetchTabHelper::GetImageData. This helper attempts to extract the image via JavaScript. However, if the requested URL has a .gif extension, the injected script (image_fetch.ts) intentionally skips canvas extraction (getImageDataByCanvas returns null) to preserve animation frames. It falls back to an XMLHttpRequest (or a native network fetch if XHR fails), returning the raw, unadulterated bytes of the image.

These raw bytes are passed to ImageSaver::didGetImageData:, which attempts to validate the data by calling [UIImage imageWithData:data]. This synchronously invokes the ImageIO parser on the main thread of the highly privileged Chrome browser process. If the image container is malformed to exploit an ImageIO vulnerability, the memory corruption occurs within the browser process, leading to a complete sandbox escape and potential Remote Code Execution (RCE).

Potential Exploitation Steps

(Note: These are potential steps based on code analysis; a full working exploit has not been verified.)

  1. An attacker crafts a malicious image file (e.g., TIFF, HEIF, WebP) designed to exploit a known or zero-day vulnerability in Apple’s ImageIO framework.
  2. The attacker hosts this file on a webpage, ensuring the URL ends with .gif (e.g., <img src="payload.gif">) to bypass canvas-based sanitization in Chrome’s injected scripts.
  3. The attacker lures a victim to the webpage.
  4. The victim long-presses the malicious image and selects “Save Image” from the context menu.
  5. The raw payload is fetched and passed to [UIImage imageWithData:] in ImageSaver::didGetImageData:.
  6. ImageIO parses the malformed container within the Chrome browser process, triggering the memory corruption and compromising the application.

Suggested Fix

Update ImageSaver::didGetImageData: (or the preceding logic in ContextMenuConfigurationProvider where the ‘Save Image’ block is defined) to pass the untrusted NSData through web::JavaScriptImageTranscoder before it is passed to [UIImage imageWithData:] or saved to the photo library. This ensures that only re-encoded, safe image data is parsed by native system frameworks within the browser process.

Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646


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.

View on issue tracker