Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect reference resolution in Browser
DescriptionIncorrect reference resolution in Browser
ComponentBrowser
Sub-componentMac
Bug ClassLogic Error
Tracker497842821
Fix commit00b2ab5f2031 (chromium/src) +140/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
chrome/browser/file_select_helper_mac.mm
modified
TEST_F
chrome/browser/file_select_helper_unittest.cc
modified

Files Changed

  • chrome/browser/file_select_helper_mac.mm
  • chrome/browser/file_select_helper.h
  • chrome/browser/file_select_helper_unittest.cc
From 00b2ab5f2031240af1b83a8499d1aaef83bb306c Mon Sep 17 00:00:00 2001
From: Alexei Svitkine <asvitkine@chromium.org>
Date: Tue, 08 Sep 2026 20:31:23 -0700
Subject: [PATCH] [Mac] Skip symlinks pointing outside the package when zipping for upload

When a user selects a macOS package (such as an .app bundle) in a file
dialog, FileSelectHelper zips it before upload. Previously,
FileEnumerator did not pass SHOW_SYM_LINKS, which caused it to traverse
symlinked directories and archive the target files of symlinks,
potentially leaking files from outside the package.

This CL sets SHOW_SYM_LINKS on the enumerator and skips symlinks that
resolve to targets outside the package tree (or broken symlinks).
Internal symlinks pointing within the package are preserved (duplicated
into regular entries) to maintain working app bundles. It also rejects
packages where the root itself is a symlink.

TAG=agy
CONV=f9649f57-39b7-437f-b6a5-2e332d10a20a

Bug: 497842821
Change-Id: Ie41fab44bed3ee5e15a81168aa4dd87a302d9bdb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8328367
Reviewed-by: Nico Weber <thakis@chromium.org>
Commit-Queue: Alexei Svitkine <asvitkine@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1694317}
---
diff --git a/chrome/browser/file_select_helper.h b/chrome/browser/file_select_helper.h
index 16e77ae..a2acc4f 100644
--- a/chrome/browser/file_select_helper.h
+++ b/chrome/browser/file_select_helper.h
@@ -88,6 +88,7 @@
 
   FRIEND_TEST_ALL_PREFIXES(FileSelectHelperTest, IsAcceptTypeValid);
   FRIEND_TEST_ALL_PREFIXES(FileSelectHelperTest, ZipPackage);
+  FRIEND_TEST_ALL_PREFIXES(FileSelectHelperTest, ZipPackageSkipsSymlinks);
   FRIEND_TEST_ALL_PREFIXES(FileSelectHelperTest, GetSanitizedFileName);
   FRIEND_TEST_ALL_PREFIXES(FileSelectHelperTest, LastSelectedDirectory);
   FRIEND_TEST_ALL_PREFIXES(FileSelectHelperTest,
diff --git a/chrome/browser/file_select_helper_mac.mm b/chrome/browser/file_select_helper_mac.mm
index 6242ece5..f75046d 100644
--- a/chrome/browser/file_select_helper_mac.mm
+++ b/chrome/browser/file_select_helper_mac.mm
@@ -11,6 +11,7 @@
 
 #include "base/apple/foundation_util.h"
 #include "base/files/file.h"
+#include "base/files/file_enumerator.h"
 #include "base/files/file_path.h"
 #include "base/files/file_util.h"
 #include "base/functional/bind.h"
@@ -53,6 +54,28 @@
 // parent directory.
 std::vector<base::FilePath> RelativePathsForPackage(
     const base::FilePath& package) {
+  // Reject packages where the root itself is a symbolic link.
+  // When a user selects a file or package through the standard file picker
+  // (NSOpenPanel), AppKit's `resolvesAliases` property is enabled by default,
+  // resolving both Finder aliases and symlinks to their target directory before
+  // reaching this code. Therefore, legitimate user selections through the
+  // dialog already have their targets resolved. A raw symlink only arrives here
+  // if the selection bypassed NSOpenPanel (such as via drag-and-drop onto an
+  // <input type="file">). Following a root symlink in that case is dangerous
+  // (e.g. a dropped "Fake.app -> /Users/victim" would cause the entire target
+  // directory to be considered part of the package and zipped).
+  if (base::IsLink(package)) {
+    return {};
+  }
+
+  base::FilePath real_package = base::MakeAbsoluteFilePath(package);
+  // MakeAbsoluteFilePath() uses realpath(3) and returns an empty path if the
+  // package path does not exist on disk, permission is denied, or resolution
+  // fails.
+  if (real_package.empty()) {
+    return {};
+  }
+
   // Get the base directory.
   base::FilePath base_dir = package.DirName();
 
@@ -62,15 +85,27 @@
 
   // Add the components of the package as relative paths.
   base::FileEnumerator file_enumerator(
-      package,
-      true /* recursive */,
-      base::FileEnumerator::FILES | base::FileEnumerator::DIRECTORIES);
+      package, true /* recursive */,
+      base::FileEnumerator::FILES | base::FileEnumerator::DIRECTORIES |
+          base::FileEnumerator::SHOW_SYM_LINKS);
   for (base::FilePath path = file_enumerator.Next(); !path.empty();
        path = file_enumerator.Next()) {
+    // Only include symlinks whose targets reside inside the package tree.
+    // External symlinks (or broken symlinks) are skipped to prevent file
+    // leakage outside the package. Internal symlinks are preserved by having
+    // their target contents copied into the zip archive, preserving working
+    // bundles.
+    if (S_ISLNK(file_enumerator.GetInfo().stat().st_mode)) {
+      base::FilePath target = base::MakeAbsoluteFilePath(path);
+      if (target.empty() || !real_package.IsParent(target)) {
+        continue;
+      }
+    }
     base::FilePath relative_path;
     bool success = base_dir.AppendRelativePath(path, &relative_path);
-    if (success)
+    if (success) {
       relative_paths.push_back(relative_path);
+    }
   }
 
   return relative_paths;
@@ -91,6 +126,10 @@
     return base::FilePath();
 
   std::vector<base::FilePath> files_to_zip(RelativePathsForPackage(path));
+  if (files_to_zip.empty()) {
+    return base::FilePath();
+  }
+
   base::FilePath base_dir = path.DirName();
   bool success = zip::ZipFiles(base_dir, files_to_zip, file.GetPlatformFile());
 
diff --git a/chrome/browser/file_select_helper_unittest.cc b/chrome/browser/file_select_helper_unittest.cc
index 91d498da..cb616dc1 100644
--- a/chrome/browser/file_select_helper_unittest.cc
+++ b/chrome/browser/file_select_helper_unittest.cc
@@ -194,6 +194,102 @@
     EXPECT_TRUE(base::ContentsEqual(orig_file, final_file));
   }
 }
+
+TEST_F(FileSelectHelperTest, ZipPackageSkipsSymlinks) {
+  base::ScopedTempDir temp_dir;
+  ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
+
+  // Set up files outside the package that symlinks will point to.
+  const std::string outside_contents = "outside";
+  base::FilePath outside_file = temp_dir.GetPath().Append("outside_file");
+  ASSERT_TRUE(base::WriteFile(outside_file, outside_contents));
+  base::FilePath outside_dir = temp_dir.GetPath().Append("outside_dir");
+  ASSERT_TRUE(base::CreateDirectory(outside_dir));
+  ASSERT_TRUE(base::WriteFile(outside_dir.Append("secret"), outside_contents));
+
+  // Set up a package containing regular files and symlinks:
+  // - Symlinks pointing to files/dirs outside the package.
+  // - Symlinks pointing to files inside the package.
+  const char app_name[] = "Fake.app";
+  base::FilePath package = temp_dir.GetPath().Append(app_name);
+  base::FilePath contents = package.Append("Contents");
+  ASSERT_TRUE(base::CreateDirectory(contents));
+  const std::string plist_contents = "plist";
+  base::FilePath plist_file = contents.Append("Info.plist");
+  ASSERT_TRUE(base::WriteFile(plist_file, plist_contents));
+
+  // External symlinks (absolute and relative).
+  ASSERT_TRUE(
+      base::CreateSymbolicLink(outside_file, contents.Append("file_link")));
+  ASSERT_TRUE(
+      base::CreateSymbolicLink(base::FilePath("../../outside_file"),
+                               contents.Append("relative_outside_link")));
+  ASSERT_TRUE(
+      base::CreateSymbolicLink(outside_dir, contents.Append("dir_link")));
+  ASSERT_TRUE(
+      base::CreateSymbolicLink(base::FilePath("../../outside_dir"),
+                               contents.Append("relative_outside_dir_link")));
+
+  // Internal symlinks (absolute and relative).
+  ASSERT_TRUE(base::CreateSymbolicLink(plist_file,
+                                       contents.Append("internal_abs_link")));
+  ASSERT_TRUE(base::CreateSymbolicLink(base::FilePath("Info.plist"),
+                                       contents.Append("internal_rel_link")));
+
+  // Zip the package.
+  base::FilePath dest = FileSelectHelper::ZipPackage(package);
+  ASSERT_FALSE(dest.empty());
+  ASSERT_TRUE(base::PathExists(dest));
+
+  // Unzip the package into a temporary directory.
+  base::ScopedTempDir unzip_dir;
+  ASSERT_TRUE(unzip_dir.CreateUniqueTempDir());
+  base::CommandLine cl(base::FilePath("/usr/bin/unzip"));
+  cl.AppendArg(dest.value().c_str());
+  cl.AppendArg("-d");
+  cl.AppendArg(unzip_dir.GetPath().value().c_str());
+  std::string output;
+  EXPECT_TRUE(base::GetAppOutput(cl, &output));
+
+  // The regular file and internal symlinks should be present.
+  base::FilePath unzipped_contents =
+      unzip_dir.GetPath().Append(app_name).Append("Contents");
+  std::string read_contents;
+  ASSERT_TRUE(base::ReadFileToString(unzipped_contents.Append("Info.plist"),
+                                     &read_contents));
+  EXPECT_EQ(plist_contents, read_contents);
+
+  ASSERT_TRUE(base::ReadFileToString(
+      unzipped_contents.Append("internal_abs_link"), &read_contents));
+  EXPECT_EQ(plist_contents, read_contents);
+
+  ASSERT_TRUE(base::ReadFileToString(
+      unzipped_contents.Append("internal_rel_link"), &read_contents));
+  EXPECT_EQ(plist_contents, read_contents);
+
+  // Symlinks pointing outside the package should not be present, neither as
+  // themselves nor as their targets.
+  EXPECT_FALSE(base::PathExists(unzipped_contents.Append("file_link")));
+  EXPECT_FALSE(
+      base::PathExists(unzipped_contents.Append("relative_outside_link")));
+  EXPECT_FALSE(base::PathExists(unzipped_contents.Append("dir_link")));
+  EXPECT_FALSE(
+      base::PathExists(unzipped_contents.Append("dir_link").Append("secret")));
+  EXPECT_FALSE(
+      base::PathExists(unzipped_contents.Append("relative_outside_dir_link")));
+  EXPECT_FALSE(base::PathExists(
+      unzipped_contents.Append("relative_outside_dir_link").Append("secret")));
+
+  // A package that is itself a symlink should be rejected.
+  base::FilePath symlink_package =
+      temp_dir.GetPath().Append("SymlinkPackage.app");
+  ASSERT_TRUE(base::CreateSymbolicLink(outside_dir, symlink_package));
+  EXPECT_TRUE(FileSelectHelper::ZipPackage(symlink_package).empty());
+
+  // A non-existent package should return an empty path.
+  base::FilePath nonexistent = temp_dir.GetPath().Append("Nonexistent.app");
+  EXPECT_TRUE(FileSelectHelper::ZipPackage(nonexistent).empty());
+}
 #endif  // BUILDFLAG(IS_MAC)
 
 TEST_F(FileSelectHelperTest, GetSanitizedFileName) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/file_select_helper_unittest.cc b/chrome/browser/file_select_helper_unittest.cc
index 91d498da..cb616dc1 100644
--- a/chrome/browser/file_select_helper_unittest.cc
+++ b/chrome/browser/file_select_helper_unittest.cc
@@ -194,6 +194,102 @@
     EXPECT_TRUE(base::ContentsEqual(orig_file, final_file));
   }
 }
+
+TEST_F(FileSelectHelperTest, ZipPackageSkipsSymlinks) {
+  base::ScopedTempDir temp_dir;
+  ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
+
+  // Set up files outside the package that symlinks will point to.
+  const std::string outside_contents = "outside";
+  base::FilePath outside_file = temp_dir.GetPath().Append("outside_file");
+  ASSERT_TRUE(base::WriteFile(outside_file, outside_contents));
+  base::FilePath outside_dir = temp_dir.GetPath().Append("outside_dir");
+  ASSERT_TRUE(base::CreateDirectory(outside_dir));
+  ASSERT_TRUE(base::WriteFile(outside_dir.Append("secret"), outside_contents));
+
+  // Set up a package containing regular files and symlinks:
+  // - Symlinks pointing to files/dirs outside the package.
+  // - Symlinks pointing to files inside the package.
+  const char app_name[] = "Fake.app";
+  base::FilePath package = temp_dir.GetPath().Append(app_name);
+  base::FilePath contents = package.Append("Contents");
+  ASSERT_TRUE(base::CreateDirectory(contents));
+  const std::string plist_contents = "plist";
+  base::FilePath plist_file = contents.Append("Info.plist");
+  ASSERT_TRUE(base::WriteFile(plist_file, plist_contents));
+
+  // External symlinks (absolute and relative).
+  ASSERT_TRUE(
+      base::CreateSymbolicLink(outside_file, contents.Append("file_link")));
+  ASSERT_TRUE(
+      base::CreateSymbolicLink(base::FilePath("../../outside_file"),
+                               contents.Append("relative_outside_link")));
+  ASSERT_TRUE(
+      base::CreateSymbolicLink(outside_dir, contents.Append("dir_link")));
+  ASSERT_TRUE(
+      base::CreateSymbolicLink(base::FilePath("../../outside_dir"),
+                               contents.Append("relative_outside_dir_link")));
+
+  // Internal symlinks (absolute and relative).
+  ASSERT_TRUE(base::CreateSymbolicLink(plist_file,
+                                       contents.Append("internal_abs_link")));
+  ASSERT_TRUE(base::CreateSymbolicLink(base::FilePath("Info.plist"),
+                                       contents.Append("internal_rel_link")));
+
+  // Zip the package.
+  base::FilePath dest = FileSelectHelper::ZipPackage(package);
+  ASSERT_FALSE(dest.empty());
+  ASSERT_TRUE(base::PathExists(dest));
+
+  // Unzip the package into a temporary directory.
+  base::ScopedTempDir unzip_dir;
+  ASSERT_TRUE(unzip_dir.CreateUniqueTempDir());
+  base::CommandLine cl(base::FilePath("/usr/bin/unzip"));
+  cl.AppendArg(dest.value().c_str());
+  cl.AppendArg("-d");
+  cl.AppendArg(unzip_dir.GetPath().value().c_str());
+  std::string output;
+  EXPECT_TRUE(base::GetAppOutput(cl, &output));
+
+  // The regular file and internal symlinks should be present.
+  base::FilePath unzipped_contents =
+      unzip_dir.GetPath().Append(app_name).Append("Contents");
+  std::string read_contents;
+  ASSERT_TRUE(base::ReadFileToString(unzipped_contents.Append("Info.plist"),
+                                     &read_contents));
+  EXPECT_EQ(plist_contents, read_contents);
+
+  ASSERT_TRUE(base::ReadFileToString(
+      unzipped_contents.Append("internal_abs_link"), &read_contents));
+  EXPECT_EQ(plist_contents, read_contents);
+
+  ASSERT_TRUE(base::ReadFileToString(
+      unzipped_contents.Append("internal_rel_link"), &read_contents));
+  EXPECT_EQ(plist_contents, read_contents);
+
+  // Symlinks pointing outside the package should not be present, neither as
+  // themselves nor as their targets.
+  EXPECT_FALSE(base::PathExists(unzipped_contents.Append("file_link")));
+  EXPECT_FALSE(
+      base::PathExists(unzipped_contents.Append("relative_outside_link")));
+  EXPECT_FALSE(base::PathExists(unzipped_contents.Append("dir_link")));
+  EXPECT_FALSE(
+      base::PathExists(unzipped_contents.Append("dir_link").Append("secret")));
+  EXPECT_FALSE(
+      base::PathExists(unzipped_contents.Append("relative_outside_dir_link")));
+  EXPECT_FALSE(base::PathExists(
+      unzipped_contents.Append("relative_outside_dir_link").Append("secret")));
+
+  // A package that is itself a symlink should be rejected.
+  base::FilePath symlink_package =
+      temp_dir.GetPath().Append("SymlinkPackage.app");
+  ASSERT_TRUE(base::CreateSymbolicLink(outside_dir, symlink_package));
+  EXPECT_TRUE(FileSelectHelper::ZipPackage(symlink_package).empty());
+
+  // A non-existent package should return an empty path.
+  base::FilePath nonexistent = temp_dir.GetPath().Append("Nonexistent.app");
+  EXPECT_TRUE(FileSelectHelper::ZipPackage(nonexistent).empty());
+}
 #endif  // BUILDFLAG(IS_MAC)
 
 TEST_F(FileSelectHelperTest, GetSanitizedFileName) {
Loading diff…

Original Bug Report

reported by vm...@google.com

Arbitrary file read via macOS file package zipping following symlinks

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: On macOS, when a user selects a file package (e.g., a .app bundle) in a standard file picker, Chrome automatically zips the package. The zipping process inadvertently follows symlinks without validation, potentially allowing a malicious bundle to exfiltrate arbitrary local files when uploaded.

Affected files:

  • chrome/browser/file_select_helper_mac.mm
  • components/remote_cocoa/app_shim/select_file_dialog_bridge.mm
  • content/browser/web_contents/file_chooser_impl.cc
  • third_party/zlib/google/zip.cc

Estimated timestamp from git blame: 2014-10-10

Summary

On macOS, Chrome automatically zips file packages (such as .app bundles) when they are selected via a file picker in kOpen or kOpenMultiple mode. This process, handled by FileSelectHelper::ProcessSelectedFilesMac, fails to properly handle symlinks. An attacker can craft a malicious package containing symlinks to sensitive local files (like SSH keys). When a victim selects this package, Chrome recursively zips the targets of these symlinks, bypassing existing mitigations and allowing the attacker to exfiltrate arbitrary local files at the browser process’s privilege level.

Vulnerability Details

The vulnerability exists because the package-zipping pipeline on macOS follows symlinks during both directory enumeration and file reading:

  1. Symlink Following during Enumeration: When a package is selected, FileSelectHelper::ZipPackage calls RelativePathsForPackage (in chrome/browser/file_select_helper_mac.mm) to get a list of its contents. This function constructs a base::FileEnumerator with recursive=true but without the base::FileEnumerator::SHOW_SYM_LINKS flag. On POSIX systems, omitting this flag causes the enumerator to use stat() instead of lstat(). Consequently, it follows symlinks to directories and recursively enumerates their contents as if they were inside the package.
  2. Lexical Relativization: The enumerated paths are relativized using base::FilePath::AppendRelativePath. This operation is purely lexical and does not resolve symlinks, blindly accepting paths like Evil.app/Contents/loot_ssh/id_rsa as valid relative paths.
  3. Symlink Following during Zip I/O: The resulting list of paths is passed to zip::ZipFiles (in third_party/zlib/google/zip.cc). This function opens files using base::File with FLAG_OPEN | FLAG_READ. On POSIX, base::File translates this to the open() system call without the O_NOFOLLOW flag. Therefore, the kernel follows the symlinks again, and the actual contents of the target files (e.g., ~/.ssh/id_rsa) are read into the zip archive.
  4. Mitigation Bypass: The existing RemoveSymlinks mitigation in FileChooserImpl::FileSelected is skipped for standard file selection (kOpen and kOpenMultiple); it is only enforced for kUploadFolder. Furthermore, because ProcessSelectedFilesMac replaces the original package path with the path to the temporary .zip file (which is a regular file), any subsequent symlink checks on the resulting path would be ineffective.
  5. Package Selection: The macOS UI bridge in components/remote_cocoa/app_shim/select_file_dialog_bridge.mm explicitly permits the selection of file packages even when directory selection is disabled (canChooseDirectories = NO), facilitating this attack via standard <input type="file"> elements.

Potential Reproduction Steps

Note: These steps describe how an attacker might exploit this issue. Our tooling agent cannot run code to verify this PoC end-to-end, but code analysis confirms the behavior.

  1. Create a directory named Evil.app/ with a minimal Contents/Info.plist so macOS recognizes it as a package.
  2. Inside Evil.app/Contents/, create symlinks to sensitive user data, e.g., ln -s ~/.ssh loot_ssh.
  3. Package Evil.app in a .dmg or .zip file to preserve the symlinks upon download and extraction by a victim.
  4. Social-engineer a user to visit a malicious site with an <input type="file"> element and select the Evil.app bundle.
  5. Chrome’s browser process will recursively zip the bundle, following the symlinks and including the contents of the target files (e.g., SSH private keys) in the archive.
  6. The browser process grants the renderer read access to the resulting zip file, which the attacker’s JavaScript can then read and exfiltrate to a remote server.

Suggested Fix

  1. Enumerate symlinks without following them: In chrome/browser/file_select_helper_mac.mm, update RelativePathsForPackage to instantiate base::FileEnumerator with the base::FileEnumerator::SHOW_SYM_LINKS flag. This will ensure it uses lstat() and does not traverse into directory symlinks.
  2. Filter symlinks during zipping: Either manually filter out symlinks returned by the enumerator in RelativePathsForPackage, or add logic to zip::ZipFiles (or a dedicated wrapper) to explicitly reject symlinks or use O_NOFOLLOW when opening files for zipping in this context.
  3. Apply RemoveSymlinks validation: Consider moving or duplicating the RemoveSymlinks validation logic so that it applies to the contents of Mac packages before they are zipped, ensuring consistency with the folder upload mitigations.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from 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