High chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in FileSystem
DescriptionIncorrect authorization in FileSystem
ComponentFileSystem
Sub-componentFSA
Bug ClassAuthorization Error
Tracker562043997
Fix commit64c8e2a1ceda (chromium/src) +192/-7
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
modified
for
chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
modified

Files Changed

  • chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
  • chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
From 64c8e2a1cedafdbcc750abc423ad9b389537c223 Mon Sep 17 00:00:00 2001
From: Ming-Ying Chung <[email protected]>
Date: Thu, 17 Sep 2026 20:48:27 -0700
Subject: [PATCH] [FSA] Block macOS firmlink alias paths to sensitive directories

On macOS 10.15+, the system storage layout splits into a read-only
System volume mounted at "/" and a read-write Data volume mounted at
"/System/Volumes/Data". APFS firmlinks map directories such as /Users,
/Applications, and /Library between the two volumes without modifying
standard POSIX realpath representations.

Previously, ChromeFileSystemAccessPermissionContext matched candidate
file and directory paths lexically against canonical blocklist rules
registered under the "/" root namespace. When a target path was
referenced or resolved via "/System/Volumes/Data/...", component-wise
path matching diverged at the leading path component, failing open and
bypassing sensitive directory protections for targets like ~/.ssh,
~/Library, and Chrome User Data.

To prevent blocklist bypasses while preserving legitimate user access,
this change introduces MaybeStripMacDataVolumePrefix() on macOS to strip
the "/System/Volumes/Data" prefix and fold paths onto the "/" namespace
prior to ancestor evaluation in ShouldBlockAccessToPath(). Additionally,
it registers an explicit kBlockAllChildren rule for /System/Volumes in
GenerateBlockPaths() to block direct access to internal system container
volumes.

Bug: 562043997
Change-Id: Id80dfecafad651d28b691e10d960bc6e3ac5a763
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8410087
Reviewed-by: Mingyu Lei <[email protected]>
Commit-Queue: Ming-Ying Chung <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1700449}
---
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
index 1c82e07..7d834cc 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
@@ -341,6 +341,32 @@
 }
 #endif
 
+#if BUILDFLAG(IS_MAC)
+// Returns a file path with the "/System/Volumes/Data" firmlink prefix stripped,
+// mapping paths on the macOS Data volume back to the root "/" namespace.
+//
+// On macOS 10.15+, storage is split into a read-only System volume mounted at
+// "/" and a read-write Data volume mounted at "/System/Volumes/Data". APFS
+// firmlinks map system directories (e.g., "/Users", "/Applications",
+// "/Library") between the two volumes without modifying standard POSIX
+// `realpath` representations. Stripping this prefix allows blocklist rules
+// defined against root-relative paths to match targets accessed through the
+// Data volume firmlink path.
+base::FilePath MaybeStripMacDataVolumePrefix(const base::FilePath& path) {
+  static constexpr base::FilePath::CharType kDataVolumePrefix[] =
+      FILE_PATH_LITERAL("/System/Volumes/Data");
+  base::FilePath data_volume_prefix(kDataVolumePrefix);
+  if (path == data_volume_prefix) {
+    return base::FilePath(FILE_PATH_LITERAL("/"));
+  }
+  base::FilePath stripped_path(FILE_PATH_LITERAL("/"));
+  if (data_volume_prefix.AppendRelativePath(path, &stripped_path)) {
+    return stripped_path;
+  }
+  return path;
+}
+#endif
+
 // A wrapper around `base::NormalizeFilePath` that returns its result instead of
 // using an out parameter.
 base::FilePath NormalizeFilePath(const base::FilePath& path) {
@@ -366,6 +392,20 @@
   return normalized_path;
 }
 
+// Normalizes `path` for sensitive directory blocklist checking. Resolves
+// symbolic links and canonicalizes the path when `should_normalize_file_path`
+// is true. On macOS, additionally strips the "/System/Volumes/Data" firmlink
+// prefix so paths on the Data volume map onto the root "/" blocklist namespace.
+base::FilePath NormalizeFilePathForBlocklist(const base::FilePath& path,
+                                             bool should_normalize_file_path) {
+  base::FilePath normalized_path =
+      should_normalize_file_path ? NormalizeFilePath(path) : path;
+#if BUILDFLAG(IS_MAC)
+  normalized_path = MaybeStripMacDataVolumePrefix(normalized_path);
+#endif
+  return normalized_path;
+}
+
 using BlockType = ChromeFileSystemAccessPermissionContext::BlockType;
 
 std::unique_ptr<ChromeFileSystemAccessPermissionContext::BlockPathRules>
@@ -430,6 +470,10 @@
 #endif
 #if BUILDFLAG(IS_MAC)
       // Similar Mac specific blocks.
+      // Block access to /System/Volumes (internal system and container
+      // volumes).
+      BlockPath::CreateAbsolute(FILE_PATH_LITERAL("/System/Volumes"),
+                                BlockType::kBlockAllChildren),
       BlockPath::CreateRelative(base::DIR_APP_DATA,
                                 BlockType::kBlockAllChildren),
       // Block access to the current bundle directory.
@@ -546,7 +590,7 @@
     }
 
     block_path_rules->block_path_rules_.emplace_back(
-        should_normalize_file_path ? NormalizeFilePath(path) : path,
+        NormalizeFilePathForBlocklist(path, should_normalize_file_path),
         blocked_path.block_type);
   }
 
@@ -572,12 +616,12 @@
   DCHECK(!path.empty());
   DCHECK(path.IsAbsolute());
 
-  if (should_normalize_file_path) {
-    path = NormalizeFilePath(path);
-    profile_path = NormalizeFilePath(profile_path);
-    for (auto& rule : extra_rules) {
-      rule.path = NormalizeFilePath(rule.path);
-    }
+  path = NormalizeFilePathForBlocklist(path, should_normalize_file_path);
+  profile_path =
+      NormalizeFilePathForBlocklist(profile_path, should_normalize_file_path);
+  for (auto& rule : extra_rules) {
+    rule.path =
+        NormalizeFilePathForBlocklist(rule.path, should_normalize_file_path);
   }
 
 #if BUILDFLAG(IS_WIN)
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
index e7676f9..0c8a2d7 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
@@ -974,6 +974,113 @@
                 HandleType::kDirectory, UserAction::kOpen),
             SensitiveDirectoryResult::kAbort);
 }
+
+// Verifies that sensitive directories and system paths accessed through APFS
+// firmlink aliases under `/System/Volumes/Data` or internal system volumes
+// under `/System/Volumes` are blocked.
+//
+// On macOS 10.15+, user home folders, `/Applications`, and `/Library` reside on
+// the read-write Data volume mounted at `/System/Volumes/Data`. This test
+// verifies that referencing sensitive targets through the
+// `/System/Volumes/Data` alias spelling fails sensitive entry access checks and
+// returns `kAbort`.
+TEST_F(ChromeFileSystemAccessPermissionContextTest,
+       ConfirmSensitiveEntryAccess_BlockFirmlinkedSystemAndUserPaths) {
+  base::FilePath home_dir = temp_dir_.GetPath().AppendASCII("home");
+  ScopedHomeDirOverride home_override = OverrideHomeDir(home_dir);
+  ResetBlockPath();
+
+  const base::FilePath kDataVolumePrefix(
+      FILE_PATH_LITERAL("/System/Volumes/Data"));
+
+  // Verify that `~/.ssh` and its private keys are blocked when accessed via the
+  // data volume alias.
+  base::FilePath ssh_under_data(FILE_PATH_LITERAL("/System/Volumes/Data") +
+                                home_dir.AppendASCII(".ssh").value());
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(ssh_under_data),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+  EXPECT_EQ(
+      ConfirmSensitiveEntryAccessSync(
+          permission_context(), PathInfo(ssh_under_data.AppendASCII("id_rsa")),
+          HandleType::kFile, UserAction::kOpen),
+      SensitiveDirectoryResult::kAbort);
+
+  // Verify that `~/Library` and Chrome user data directories are blocked when
+  // accessed via the data volume alias.
+  base::FilePath library_under_data(FILE_PATH_LITERAL("/System/Volumes/Data") +
+                                    home_dir.AppendASCII("Library").value());
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(library_under_data),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+
+  // Verify that the `/System/Volumes/Data` mount point root itself is blocked.
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(kDataVolumePrefix),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+
+  // Verify that internal system volumes located under `/System/Volumes` such as
+  // the dynamic virtual memory swap volume and preboot volume are blocked.
+  EXPECT_EQ(
+      ConfirmSensitiveEntryAccessSync(
+          permission_context(), PathInfo(FILE_PATH_LITERAL("/System/Volumes")),
+          HandleType::kDirectory, UserAction::kOpen),
+      SensitiveDirectoryResult::kAbort);
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(FILE_PATH_LITERAL("/System/Volumes/VM")),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(FILE_PATH_LITERAL("/System/Volumes/Preboot")),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+}
+
+// Verifies that allowed subdirectories within user home folders (such as
+// `~/Downloads`, `~/Library/CloudStorage`, and `~/Library/Containers`) remain
+// accessible when referenced through `/System/Volumes/Data`.
+//
+// Normalization must preserve descendant exceptions defined under
+// `kDontBlockChildren` so legitimate user workflows succeed regardless of
+// whether the path is referenced via `/` or `/System/Volumes/Data`.
+TEST_F(ChromeFileSystemAccessPermissionContextTest,
+       ConfirmSensitiveEntryAccess_AllowFirmlinkedUserSubdirectories) {
+  base::FilePath home_dir = temp_dir_.GetPath().AppendASCII("home");
+  ScopedHomeDirOverride home_override = OverrideHomeDir(home_dir);
+  ResetBlockPath();
+
+  // Child files and subdirectories inside `~/Downloads` should be permitted.
+  base::FilePath data_downloads(FILE_PATH_LITERAL("/System/Volumes/Data") +
+                                home_dir.AppendASCII("Downloads").value());
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(data_downloads.AppendASCII("report.pdf")),
+                HandleType::kFile, UserAction::kOpen),
+            SensitiveDirectoryResult::kAllowed);
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(data_downloads.AppendASCII("subfolder")),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAllowed);
+
+  // External drives mounted under `/Volumes` should remain accessible.
+  base::FilePath external_volume(
+      FILE_PATH_LITERAL("/Volumes/ExternalUSB/Projects/my_code"));
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(external_volume),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAllowed);
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(external_volume.AppendASCII("main.rs")),
+                HandleType::kFile, UserAction::kOpen),
+            SensitiveDirectoryResult::kAllowed);
+}
 #endif  // BUILDFLAG(IS_MAC)
 
 #if BUILDFLAG(IS_WIN)
@@ -1502,6 +1609,40 @@
 }
 #endif  // BUILDFLAG(IS_WIN)
 
+#if BUILDFLAG(IS_MAC)
+// Verifies that symbolic links resolving into sensitive paths on the data
+// volume (`/System/Volumes/Data`) are caught and blocked by symlink checks.
+TEST_F(ChromeFileSystemAccessPermissionContextSymbolicLinkCheckTest,
+       ConfirmSensitiveEntryAccess_ResolveSymbolicLinkToDataVolume) {
+  // Create a real ~/.ssh directory in the temporary home folder.
+  base::FilePath home_dir = temp_dir_.GetPath().AppendASCII("home");
+  base::FilePath ssh_dir = home_dir.AppendASCII(".ssh");
+  ASSERT_TRUE(base::CreateDirectory(ssh_dir));
+
+  ScopedHomeDirOverride home_override = OverrideHomeDir(home_dir);
+  ResetBlockPath();
+
+  // On macOS, `temp_dir_` (/private/var/...) physically resides on the Data
+  // volume mounted at `/System/Volumes/Data`.
+  base::FilePath absolute_ssh_dir = base::MakeAbsoluteFilePath(ssh_dir);
+  ASSERT_FALSE(absolute_ssh_dir.empty());
+
+  base::FilePath target_data_ssh(FILE_PATH_LITERAL("/System/Volumes/Data") +
+                                 absolute_ssh_dir.value());
+  ASSERT_TRUE(base::PathExists(target_data_ssh));
+
+  base::FilePath symlink_path =
+      temp_dir_.GetPath().AppendASCII("symlink_to_data_ssh");
+  ASSERT_EQ(CreateSymbolicLinkForTesting(target_data_ssh, symlink_path),
+            CreateSymbolicLinkResult::kSucceeded);
+
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(symlink_path),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+}
+#endif  // BUILDFLAG(IS_MAC)
+
 TEST_F(ChromeFileSystemAccessPermissionContextTest,
        ConfirmSensitiveEntryAccess_DangerousFile) {
   base::FilePath home_dir = temp_dir_.GetPath().AppendASCII("home");
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
index e7676f9..0c8a2d7 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
@@ -974,6 +974,113 @@
                 HandleType::kDirectory, UserAction::kOpen),
             SensitiveDirectoryResult::kAbort);
 }
+
+// Verifies that sensitive directories and system paths accessed through APFS
+// firmlink aliases under `/System/Volumes/Data` or internal system volumes
+// under `/System/Volumes` are blocked.
+//
+// On macOS 10.15+, user home folders, `/Applications`, and `/Library` reside on
+// the read-write Data volume mounted at `/System/Volumes/Data`. This test
+// verifies that referencing sensitive targets through the
+// `/System/Volumes/Data` alias spelling fails sensitive entry access checks and
+// returns `kAbort`.
+TEST_F(ChromeFileSystemAccessPermissionContextTest,
+       ConfirmSensitiveEntryAccess_BlockFirmlinkedSystemAndUserPaths) {
+  base::FilePath home_dir = temp_dir_.GetPath().AppendASCII("home");
+  ScopedHomeDirOverride home_override = OverrideHomeDir(home_dir);
+  ResetBlockPath();
+
+  const base::FilePath kDataVolumePrefix(
+      FILE_PATH_LITERAL("/System/Volumes/Data"));
+
+  // Verify that `~/.ssh` and its private keys are blocked when accessed via the
+  // data volume alias.
+  base::FilePath ssh_under_data(FILE_PATH_LITERAL("/System/Volumes/Data") +
+                                home_dir.AppendASCII(".ssh").value());
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(ssh_under_data),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+  EXPECT_EQ(
+      ConfirmSensitiveEntryAccessSync(
+          permission_context(), PathInfo(ssh_under_data.AppendASCII("id_rsa")),
+          HandleType::kFile, UserAction::kOpen),
+      SensitiveDirectoryResult::kAbort);
+
+  // Verify that `~/Library` and Chrome user data directories are blocked when
+  // accessed via the data volume alias.
+  base::FilePath library_under_data(FILE_PATH_LITERAL("/System/Volumes/Data") +
+                                    home_dir.AppendASCII("Library").value());
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(library_under_data),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+
+  // Verify that the `/System/Volumes/Data` mount point root itself is blocked.
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(kDataVolumePrefix),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+
+  // Verify that internal system volumes located under `/System/Volumes` such as
+  // the dynamic virtual memory swap volume and preboot volume are blocked.
+  EXPECT_EQ(
+      ConfirmSensitiveEntryAccessSync(
+          permission_context(), PathInfo(FILE_PATH_LITERAL("/System/Volumes")),
+          HandleType::kDirectory, UserAction::kOpen),
+      SensitiveDirectoryResult::kAbort);
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(FILE_PATH_LITERAL("/System/Volumes/VM")),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(FILE_PATH_LITERAL("/System/Volumes/Preboot")),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+}
+
+// Verifies that allowed subdirectories within user home folders (such as
+// `~/Downloads`, `~/Library/CloudStorage`, and `~/Library/Containers`) remain
+// accessible when referenced through `/System/Volumes/Data`.
+//
+// Normalization must preserve descendant exceptions defined under
+// `kDontBlockChildren` so legitimate user workflows succeed regardless of
+// whether the path is referenced via `/` or `/System/Volumes/Data`.
+TEST_F(ChromeFileSystemAccessPermissionContextTest,
+       ConfirmSensitiveEntryAccess_AllowFirmlinkedUserSubdirectories) {
+  base::FilePath home_dir = temp_dir_.GetPath().AppendASCII("home");
+  ScopedHomeDirOverride home_override = OverrideHomeDir(home_dir);
+  ResetBlockPath();
+
+  // Child files and subdirectories inside `~/Downloads` should be permitted.
+  base::FilePath data_downloads(FILE_PATH_LITERAL("/System/Volumes/Data") +
+                                home_dir.AppendASCII("Downloads").value());
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(data_downloads.AppendASCII("report.pdf")),
+                HandleType::kFile, UserAction::kOpen),
+            SensitiveDirectoryResult::kAllowed);
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(data_downloads.AppendASCII("subfolder")),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAllowed);
+
+  // External drives mounted under `/Volumes` should remain accessible.
+  base::FilePath external_volume(
+      FILE_PATH_LITERAL("/Volumes/ExternalUSB/Projects/my_code"));
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(external_volume),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAllowed);
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(),
+                PathInfo(external_volume.AppendASCII("main.rs")),
+                HandleType::kFile, UserAction::kOpen),
+            SensitiveDirectoryResult::kAllowed);
+}
 #endif  // BUILDFLAG(IS_MAC)
 
 #if BUILDFLAG(IS_WIN)
@@ -1502,6 +1609,40 @@
 }
 #endif  // BUILDFLAG(IS_WIN)
 
+#if BUILDFLAG(IS_MAC)
+// Verifies that symbolic links resolving into sensitive paths on the data
+// volume (`/System/Volumes/Data`) are caught and blocked by symlink checks.
+TEST_F(ChromeFileSystemAccessPermissionContextSymbolicLinkCheckTest,
+       ConfirmSensitiveEntryAccess_ResolveSymbolicLinkToDataVolume) {
+  // Create a real ~/.ssh directory in the temporary home folder.
+  base::FilePath home_dir = temp_dir_.GetPath().AppendASCII("home");
+  base::FilePath ssh_dir = home_dir.AppendASCII(".ssh");
+  ASSERT_TRUE(base::CreateDirectory(ssh_dir));
+
+  ScopedHomeDirOverride home_override = OverrideHomeDir(home_dir);
+  ResetBlockPath();
+
+  // On macOS, `temp_dir_` (/private/var/...) physically resides on the Data
+  // volume mounted at `/System/Volumes/Data`.
+  base::FilePath absolute_ssh_dir = base::MakeAbsoluteFilePath(ssh_dir);
+  ASSERT_FALSE(absolute_ssh_dir.empty());
+
+  base::FilePath target_data_ssh(FILE_PATH_LITERAL("/System/Volumes/Data") +
+                                 absolute_ssh_dir.value());
+  ASSERT_TRUE(base::PathExists(target_data_ssh));
+
+  base::FilePath symlink_path =
+      temp_dir_.GetPath().AppendASCII("symlink_to_data_ssh");
+  ASSERT_EQ(CreateSymbolicLinkForTesting(target_data_ssh, symlink_path),
+            CreateSymbolicLinkResult::kSucceeded);
+
+  EXPECT_EQ(ConfirmSensitiveEntryAccessSync(
+                permission_context(), PathInfo(symlink_path),
+                HandleType::kDirectory, UserAction::kOpen),
+            SensitiveDirectoryResult::kAbort);
+}
+#endif  // BUILDFLAG(IS_MAC)
+
 TEST_F(ChromeFileSystemAccessPermissionContextTest,
        ConfirmSensitiveEntryAccess_DangerousFile) {
   base::FilePath home_dir = temp_dir_.GetPath().AppendASCII("home");
Loading diff…

Original Bug Report

reported by [email protected]

File System Access sensitive-path blocklist bypass via APFS firmlinks on macOS

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: On macOS 10.15+ every path on the APFS data volume has a second absolute spelling under /System/Volumes/Data (firmlinks). The File System Access sensitive-path blocklist matches its rules lexically against canonical spellings only, and its realpath-based normalization cannot fold firmlinks, so alias-spelled paths bypass every macOS rule — ~/.ssh, ~/Library, /Applications, the Chrome install dir, the live user-data-dir — plus the un-overridable restricted-directory dialog and the default-on symlink-escape re-check. This was demonstrated end-to-end in a live stock build (Chromium 155.0.8055.0, macOS 26.6.2): promptless directory reads and one-benign-prompt writes through alias-spelled drag-and-drop. Practical delivery from web content on current macOS is the limiting factor: the attacker-controlled vectors tested (symlinks in dropped folders; picker navigation) are neutralized by unrelated layers, leaving delivery dependent on a drag source that emits alias-spelled pasteboard URLs.

Affected files:

Estimated timestamp from git blame: 2019-07-18

Summary

Since macOS 10.15, /Users, /Applications and /Library are APFS firmlinks into the data volume mounted at /System/Volumes/Data, so every user file has two absolute spellings. Firmlinks are plain directories to lstat(), and realpath(3) resolves only symlinks and dot components, so no step in the FSA blocklist pipeline folds the alias back to canonical. Every macOS rule and both runtime extra_rules are spelled canonically and matched component-by-component; an alias-spelled path matches nothing and the check fails open.

Demonstrated live on a stock build (macOS 26.6.2, Chromium 155.0.8055.0) via the drag-and-drop entry point:

  • Zero-prompt directory handle to /System/Volumes/Data/Applications (voids the absolute /Applications kBlockAllChildren rule); read Xcode.app/Contents/Info.plist (33 KB).
  • Zero-prompt enumeration of /System/Volumes/Data/Users (real account names listed).
  • Zero-prompt read of the running browser’s own Default/Preferences (11 KB) via the alias spelling of the live --user-data-dir (voids the runtime extra_rules); after one prompt, wrote a marker into the live profile.
  • One benign prompt naming the innocuous folder → wrote files into the Chrome install dir and inside the running Chromium.app bundle (voids DIR_EXE/DIR_MODULE/DIR_OUTER_BUNDLE); the on-disk markers carry a com.apple.quarantine xattr naming Chromium as the writing agent.
  • Differential on the symlink-escape defense: file symlinks to the same blocklisted files were blocked (SecurityError) with canonical targets but passed the blocklist with alias targets.
  • Controls: dropping/picking the same directories canonically spelled always raised the un-overridable restricted-directory dialog (chrome/browser/ui/file_system_access/file_system_access_restricted_directory_dialog.cc:29-63 — TryAgain/Abort only, no allow path).

Two hypothesized delivery vectors were refuted by the same live testing:

  • Symlink-in-ZIP drag (the promptless-read vector as originally proposed) does not work. The storage layer hides symlink leaves (storage/browser/file_system/local_file_util.cc:275-278, IsHiddenItem = base::IsLink): a dropped symlinked folder is typed as an unusable file handle and child symlinks return NotFoundError. No data flows through attacker-created symlinks even though the blocklist re-check itself passes their alias-spelled targets.
  • Picker Go-to-Folder does not work on macOS 26. NSOpenPanel canonicalizes firmlink aliases during navigation (typed absolute/relative paths and column browsing all returned canonical URLs → restricted dialog). Chrome’s dialog code passes url.path verbatim (components/remote_cocoa/app_shim/select_file_dialog_bridge.mm:624-673), but AppKit hands it an already-folded URL on this OS version; older macOS versions were not tested.

What remains reachable: the drag-and-drop pipeline consumes whatever path string the drag source put on the pasteboard, with no symlink/firmlink resolution in Chrome, and mints real FSA tokens for it browser-side (the executed drops were injected at the browser boundary; browser-side processing is byte-identical to a user drop). Whether stock Finder emits alias-spelled pasteboard URLs when the user browses /System/Volumes/Data was not established; any drag-source app controls the URL string it writes. Assuming such a drag source exists, a user induced to drag e.g. /System/Volumes/Data/Users onto a page hands web content promptless read of it — the same drag canonically spelled is hard-blocked with no override.


Root Cause Analysis

  1. Every macOS rule lives in the canonical namespace: chrome_file_system_access_permission_context.cc:374-501 — rules resolve under /Users/<u> or /Applications (DIR_HOME, ~/.ssh, ~/.gnupg, ~/Library, DIR_EXE/DIR_OUTER_BUNDLE, chrome::DIR_USER_DATA, absolute /Applications); runtime extra_rules (:2290-2297) are ~/Library/Application Support-rooted. No rule mentions /System/Volumes.
  2. Normalization cannot fold firmlinks: chrome_file_system_access_permission_context.cc:346-367 → base::NormalizeFilePath → realpath (base/files/file_util_posix.cc:468-480, 1068-1076). realpath resolves symlinks and dot components only; firmlinks are plain directories, so /System/Volumes/Data/... survives verbatim — and a symlink whose target is alias-spelled normalizes to the alias spelling.
  3. Matching is lexical and fails open:
    // chrome_file_system_access_permission_context.cc:600, 673-677
    if (path == block_path || path.IsParent(block_path)) { ... }  // component-wise compare
    ...
    if (nearest_ancestor.empty() ||
        nearest_ancestor_block_type == BlockType::kDontBlockChildren) {
      return false;                    // <-- alias path: nothing matched, allow
    
    [/, System, Volumes, Data, Users, u, ...] diverges from [/, Users, u, ...] at the first named component, so no rule ever matches an alias-spelled path.
  4. The identical bug class is gated on Windows only: MaybeIsLocalUNCPath (chrome_file_system_access_permission_context.cc:284-341, applied at :583-589 under #if BUILDFLAG(IS_WIN)) was added for UNC alternate spellings of the local filesystem (crbug.com/1307930, CVE-2022-2162). macOS has no analogue; tree-wide, firmlink awareness exists only in the seatbelt profile (sandbox/policy/mac/common.sb:145-176).
  5. The symlink-escape defense is structurally void for alias targets: the child re-check (file_system_access_directory_handle_impl.cc:213-232, comment: run “on the resolved path”) realpaths the literal path, obtains the alias spelling, matches nothing, and UserAction::kNone returns allowed silently (chrome_file_system_access_permission_context.cc:2429-2433). Demonstrated live: canonical-target symlinks → SecurityError; alias-target symlinks → blocklist passed.
  6. Drop-time handling compounds the miss: the sensitive-entry check runs only on the dropped root (file_system_access_manager_impl.cc:1036-1057), read is auto-GRANTED for drops including directories (chrome_file_system_access_permission_context.cc:1797-1802), directory traversal performs no re-check (file_system_access_directory_handle_impl.cc:338-380), and descendant writes inherit via the literal-path ancestor grant (chrome_file_system_access_permission_context.cc:1875-1881, 3376-3402).

Reproduction

As executed on macOS 26.6.2 with stock Chromium 155.0.8055.0:

  1. Serve a page with a dropzone that calls DataTransferItem.getAsFileSystemHandle() and traverses/reads the received handle.
  2. Deliver a drop whose pasteboard file URL is alias-spelled, e.g. /System/Volumes/Data/Applications or the alias spelling of the running profile’s user-data-dir. (In the executed run the drop was injected at the browser boundary; browser-side processing is identical to a user drop.)
  3. Optionally call handle.requestPermission({mode:'readwrite'}) — the prompt names the folder’s innocuous basename — then create/write files under blocklisted descendants.
  4. Expected: alias-spelled sensitive directories behave like their canonical twins — un-overridable restricted-directory dialog, no handle, no reads or writes.

Observed: handles granted with zero prompts; Xcode.app/Contents/Info.plist (33 KB) and the live profile’s Default/Preferences (11 KB) read promptless; after one benign prompt, files written into the Chrome install dir, inside the running Chromium.app bundle, and into the live profile (quarantine xattr provenance: Chromium); every canonical-spelling control blocked by the restricted dialog; alias-target symlink probes passed the exact blocklist re-check that blocks canonical-target ones.

Severity

medium — Executed evidence proves a total bypass of a deliberate, default-on security boundary: every macOS blocklist rule (~/.ssh, ~/Library, install dir, live profile), the un-overridable restricted dialog, and the symlink-escape re-check are void for alias spellings, and the capability when delivered is critical-tier (promptless arbitrary user-file read; one-benign-prompt writes to persistence locations and the live profile). However, no attacker-manufacturable delivery survives on current macOS: symlink delivery is neutralized by the storage layer’s leaf filter and NSOpenPanel canonicalizes aliases on macOS 26, so the remaining vector requires the user to drag a sensitive folder from /System/Volumes/Data and assumes — unverified — that a common drag source emits alias-spelled pasteboard URLs. Unusual interaction plus an unverified precondition demotes below High, consistent with Chrome’s Medium ratings for FSA blocklist bypasses (CVE-2023-2311, CVE-2022-4193; the Windows alternate-namespace twin CVE-2022-2162 / crbug.com/1307930 was fixed with a dedicated gate). Re-rate to High if any stock drag source is shown to emit alias-spelled URLs, or if pre-macOS-26 NSOpenPanel accepts alias navigation (untested).

Suggested Fix Direction

Mirror the Windows MaybeIsLocalUNCPath approach on macOS: before rule evaluation, fold a leading /System/Volumes/Data when the residual path exists (the firmlink set is enumerable via /usr/share/firmlinks), or add an absolute /System/Volumes → kBlockAllChildren rule. A rule of the latter shape was verified live to block all alias legs without affecting ordinary folders; no standard macOS UI requires the alias spelling for user data.


POC: macOS FSA sensitive-path blocklist has zero coverage of the APFS firmlink namespace /System/Volumes/Data

Strategy: web-content Bug Type: security-check bypass / path-equivalence (CWE-41), File System Access API sensitive-directory blocklist

Demonstrated end-to-end on a real Apple-Silicon Mac, macOS 26.6.2, with a stock Chromium build (155.0.8055.0, out/mac-arm64, no source modifications, no security features disabled).

What is proven

Every macOS blocklist rule in chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc (GenerateBlockPaths()), and the runtime extra_rules for the live --user-data-dir, is spelled in the canonical namespace and matched lexically. Since macOS 10.15, every path on the data volume has a second absolute spelling under /System/Volumes/Data (APFS firmlinks — plain directories to lstat(), NOT resolved by realpath(), verified live on the target machine). No rule can match the alias spelling, so ShouldBlockAccessToPath() falls through to “allow” for any /System/Volumes/Data/... path.

All results below are from the drag-and-drop entry point (UserAction::kDragAndDrop / UserAction::kNone re-checks) against real, rule-protected directories:

# Action (alias spelling) Rule bypassed Result
1 Drop /System/Volumes/Data/Applications absolute /Applications kBlockAllChildren handle granted, zero prompts; listed apps; read Xcode.app/Contents/Info.plist (33 KB)
2 Drop /System/Volumes/Data/Users (namespace traversal) granted promptless; real account names enumerated
3 Drop alias spelling of the Chromium install dir DIR_EXE/DIR_MODULE/DIR_OUTER_BUNDLE kBlockAllChildren (“The Chrome installation itself should not be modified by the web”) granted promptless; read bundle Info.plist; after ONE benign prompt (“Save changes to app?”) wrote files into the install dir and inside the running Chromium.app bundle (com.apple.quarantine xattr ...;Chromium;... proves the browser wrote them — see write_proof.txt)
4 Drop alias spelling of the live --user-data-dir runtime extra_rules kBlockAllChildren granted promptless; read the running browser’s Default/Preferences (11 KB); after one prompt wrote a marker into the live profile
5 kNone symlink re-check probe: file symlinks to the SAME blocklisted files, canonical vs alias target the default-on symlink-escape defense (kFileSystemAccessSymbolicLinkCheck + kFileSystemAccessDirectoryIterationBlocklistCheck) canonical targets → SecurityError (rule fired on resolved path); alias targets → check passed (subsequent NotFoundError comes from the storage layer’s leaf-symlink filter, not from any security decision)
6 Controls: drop/pick the SAME directories via canonical spellings (/Applications, install dir, user-data-dir, real /Users/<user> home) — un-overridable restricted dialog (“Can’t open this folder … contains system files”, only Cancel / Choose-a-different-folder) every time

The controls prove the defense exists and works for canonical spellings; the alias legs prove one spelling substitution voids it. The ~/.ssh / ~/Library rules use the same base::FilePath::IsParent lexical matching as the rules demonstrated above (single code path, should_block_with_rule()), and leg 2 shows /System/Volumes/Data/Users/<u>/... is reachable — on a victim machine (DIR_HOME = /Users/<u>) the alias spelling of ~/.ssh diverges from the rule at the first component exactly like leg 1. (On the leased lab machine the home rules could only be exercised as stat-only canonical controls, see “Lab constraints” below.)

Delivery-vector findings (differences from the report)

The core blocklist blindness is fully confirmed, but two of the report’s three trigger paths do not survive on the current tree / current macOS:

  1. V2 symlink-in-ZIP drag-and-drop does NOT work. The storage layer (storage::LocalFileUtil::IsHiddenItem → base::IsLink, “We should not follow symbolic links in sandboxed file system”) hides any symlink that appears as the URL leaf: a dropped symlinked folder fails GetMetadata and is typed as a file; getDirectoryHandle() on a child symlink returns NotFoundError. File-leaf symlinks still reach the kNone blocklist re-check before the storage lookup (leg 5 exploits exactly that ordering to isolate the check’s verdict), but no data flows through attacker symlinks. The exploitable shape is therefore an alias-spelled path with no symlink leaf — i.e. the path string itself must carry /System/Volumes/Data.
  2. V1 picker via Go-to-Folder does NOT work on macOS 26. NSOpenPanel’s navigation canonicalizes firmlink aliases: typed absolute paths (Cmd+Shift+G), typed relative paths from an alias-rooted location, and column-browsing through /System/Volumes/Data all produced canonical URLs (each pick hit the restricted dialog; the identical alias path was allowed when delivered via drop, so the folding provably happened in the panel, not in Chrome). Chrome’s dialog code (select_file_dialog_bridge.mm) does pass url.path verbatim — AppKit hands it an already-folded URL on this OS version.
  3. What remains reachable: the drag-and-drop pipeline consumes whatever path string the drag source put on the pasteboard — ui::clipboard_util::FilesFromPasteboard() does no symlink/firmlink resolution — and mints FSA tokens for it browser-side. This POC delivers the drop via the DevTools protocol (Input.dispatchDragEvent), which enters RenderWidgetHostImpl::DragTargetDrop and runs FilterDropData/GrantFileAccessFromDropData/DropDataToDragData — byte-identical browser-side processing to a user drop, with real FileSystemAccessDataTransferTokens. The driver only simulates the OS-level drag delivery; no Chrome security logic is skipped. Whether stock Finder on macOS 26 still yields alias-spelled pasteboard URLs (it demonstrably has the namespace mounted and browsable) was not established on this machine; any drag-source app controls the URL string it writes.

Additional environment fact verified live: NSHomeDirectory() on macOS 26 ignores $HOME (returns the passwd entry), so Chrome’s ~-relative rules always bind to the real account home.

Files

  • poc.html — the attack page. Auto-runs read legs on drop (promptless), write legs after one requestPermission({mode:'readwrite'}) prompt; exfiltrates everything to the server (/log).
  • server.py — serves the page, records exfiltrated data (exfil.log).
  • cdp_drive.py — stdlib-only DevTools client (drop / click / eval / wait / screenshot). Simulates only the user’s OS-level interactions.
  • setup_target_mac.sh — target-machine setup: scratch victim home (fake id_ed25519, Library/LaunchAgents), and the probe_kit folder built as a real attacker ZIP round trip (zip -y → ditto -x -k, symlinks preserved).
  • bot_helpers.sh — launch/screenshot/synthetic-input helpers for the remote GUI session (launchd gui-domain one-shots).
  • bandaid-fix.patch — the fix, verified (see below).
  • Evidence: exfil.log (full leg-by-leg record with file contents), write_proof.txt (on-disk write markers + quarantine xattrs), mac_1_results_vulnerable.png (probe differential + promptless alias reads, incl. the fake SSH key bytes), mac_2_write_prompt_app.png (the single benign prompt, “Save changes to app?”), mac_3_restricted_dialog_canonical.png (un-overridable dialog on the canonical control), mac_4_bandaid_alias_blocked.png / mac_5_bandaid_results.png (patched build blocking the alias legs). Note: the floating “com.apple.sshd-session” dialog visible in some screenshots is a lab artifact of the remote screenshot oracle, unrelated to the POC. exfil.log timeline: entries before 21:01 are an earlier staging iteration; vulnerable run 21:01–21:07; picker attempts 21:07–21:25 (all canonicalized by the panel → AbortError); bandaid run from 21:26.

Reproduction

On any macOS ≥ 10.15 machine (tested: macOS 26.6.2, Apple Silicon) with a Chromium build in $SCRATCH/app/Chromium.app:

export SCRATCH=/tmp/poc-scratch   # any writable dir
bash setup_target_mac.sh $SCRATCH               # builds victim home + probe_kit (ZIP round trip)
python3 server.py 8080 $SCRATCH/exfil.log &     # from the directory containing poc.html
"$SCRATCH/app/Chromium.app/Contents/MacOS/Chromium" \
    --no-first-run --no-default-browser-check --use-mock-keychain \
    --user-data-dir=$SCRATCH/chrome-profile --remote-debugging-port=9222 \
    http://127.0.0.1:8080/poc.html &
CANON=$(cd $SCRATCH && pwd -P)                  # /private/tmp/... spelling

# Promptless reads / probe (a human can equally drag the folders from Finder):
python3 cdp_drive.py 127.0.0.1:9222 drop $SCRATCH/victim-home/Downloads/probe_kit 300 150
python3 cdp_drive.py 127.0.0.1:9222 drop /System/Volumes/Data/Applications 300 150
python3 cdp_drive.py 127.0.0.1:9222 drop /System/Volumes/Data/Users 300 150
python3 cdp_drive.py 127.0.0.1:9222 drop /System/Volumes/Data$CANON/app 300 150

# Write phase: click the page button (user activation), then accept the ONE
# prompt ("Save changes to app?") with a real click:
python3 cdp_drive.py 127.0.0.1:9222 click 760 355
# ... accept the bubble ...
python3 cdp_drive.py 127.0.0.1:9222 eval 'JSON.stringify(window.pocState.results,null,1)'

# Controls (expect the restricted dialog / SecurityError):
python3 cdp_drive.py 127.0.0.1:9222 drop /Applications 300 150
python3 cdp_drive.py 127.0.0.1:9222 drop $CANON/app 300 150

Expected Result

  • Vulnerable build: every alias-spelled drop is granted with zero prompts; probe xcode_canon_link / prefs_canon_link → SecurityError, xcode_alias_link / prefs_alias_link → past the blocklist (NotFoundError from the storage layer); write legs succeed after the single benign prompt; canonical controls all raise the restricted dialog.
  • Patched build (bandaid-fix.patch): alias probes → SecurityError, alias drops → restricted dialog; ordinary folders unaffected.

Output (from the run on the Mac)

Vulnerable build (Chromium 155.0.8055.0, macOS 26.6.2):

probe xcode_canon_link   BLOCKED  SecurityError: ... break through the security policy of the user agent.
probe xcode_alias_link   ERROR    NotFoundError: ... (blocklist re-check PASSED the alias spelling)
probe prefs_canon_link   BLOCKED  SecurityError: ...
probe prefs_alias_link   ERROR    NotFoundError: ... (blocklist re-check PASSED)
drop:Applications                 ALLOWED  directory handle granted (read auto-granted, zero prompts)
drop:Applications read Xcode.app/Contents/Info.plist  ALLOWED  read 33249 bytes: bplist00...
drop:Users list users             ALLOWED  listed 4: Shared/ f-visor-bot-1/ f-visor-admin/ .localized
drop:app read Chromium.app/Contents/Info.plist        ALLOWED  read 18347 bytes: <?xml version="1.0"...
requestPermission on "app"        INFO     result: granted        <-- the ONLY prompt, names "app"
post-grant:app WRITE marker into Chrome install dir   ALLOWED  WROTE 53 bytes
post-grant:app WRITE marker inside Chromium.app bundle ALLOWED WROTE 52 bytes
drop:chrome-profile read LIVE profile Default/Preferences ALLOWED read 10890 bytes: {"accessibility":...
post-grant:chrome-profile WRITE marker into live user-data-dir ALLOWED WROTE 50 bytes
drop /Applications (canonical control) -> restricted dialog -> AbortError

On-disk proof (see write_proof.txt):

-rw-------@ ... app/poc_marker_written_by_web.txt
-rw-------@ ... app/Chromium.app/Contents/Resources/poc_marker_in_bundle.txt
com.apple.quarantine: 0081;6aa7728f;Chromium;558006FA-5B8A-4E9D-89CB-E8FE28D51D20

Patched build:

probe xcode_alias_link   BLOCKED  SecurityError: ...
probe prefs_alias_link   BLOCKED  SecurityError: ...
drop /System/Volumes/Data/Applications -> restricted dialog -> AbortError
drop victim-home (ordinary folder)     -> still ALLOWED (no regression)

Bandaid fix

bandaid-fix.patch adds one absolute rule on macOS: /System/Volumes → kBlockAllChildren. Firmlinks are not symlinks, so normalization keeps alias spellings; blocking the whole alternate namespace closes every alias twin at once (Finder and the file dialogs never require that spelling for user data). Verified on the Mac: alias legs blocked, ordinary paths unaffected. A finer-grained alternative is to fold a leading /System/Volumes/Data before rule evaluation (the firmlink set is enumerable via /usr/share/firmlinks), which would preserve the kDontBlockChildren carve-outs (e.g. ~/Downloads) under alias spellings.

Lab constraints (documented, not security-relevant)

  • The victim home is a scratch directory because the leased test machine’s real operator home is off-limits by lab policy, and macOS 26’s NSHomeDirectory() cannot be redirected via $HOME. Consequently the ~-relative rules were exercised via (a) the identical lexical engine on other rules, and (b) a stat-only canonical control on the real home (restricted dialog, no data touched).
  • Chrome ran from a scratch directory; that makes the DIR_EXE/DIR_MODULE install-dir rules point there, which is precisely what the install-dir write legs target (on a normal install the same legs target /Applications/Google Chrome.app/... via its alias twin).
  • --remote-debugging-port and cdp_drive.py replace only the human’s physical drag/clicks; --use-mock-keychain works around the locked keychain in remote GUI sessions.

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.

View on issue tracker