Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in FileSystem
DescriptionOut of bounds read in FileSystem
ComponentFileSystem
Sub-componentBlink>Storage>FileSystem
Bug ClassOOB
Tracker497965748
Fix commitc087135b113e (chromium/src) +139/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
LocalFileEnumerator
storage/browser/file_system/local_file_util.h
modified
TEST_F
storage/browser/file_system/local_file_util_unittest.cc
modified

Files Changed

  • storage/browser/file_system/local_file_util.h
  • storage/browser/test/test_file_system_backend.cc
  • storage/browser/file_system/local_file_util_unittest.cc
  • storage/browser/file_system/dragged_file_util.cc
  • storage/browser/file_system/local_file_util.cc
From c087135b113e846a7c80a72960b1426d05ab89ec Mon Sep 17 00:00:00 2001
From: Anna Sato <annasato@chromium.org>
Date: Wed, 16 Sep 2026 02:38:50 -0700
Subject: [PATCH] Stop following symbolic links in sandboxed file systems.

LocalFileUtil::IsHiddenItem only checks the last component of a path to
see if it is a symbolic link. This allows callers to reach outside the
sandbox by naming a file underneath a symlinked directory.

Introduce LocalFileUtil::IsHiddenItemUnderRoot, which checks all path
components up to (but not including) the filesystem root. This prevents
accessing files under symlinked directories inside the sandbox, while
still allowing the sandbox root itself or parent directories to be
symlinks (which is common in some test environments).

Fixed: 497965748
Change-Id: Iacaf59351f2b55c796d6ce79900bd1ea64100fce
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8361270
Commit-Queue: Anna Sato <annasato@chromium.org>
Reviewed-by: Mingyu Lei <leimy@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1698784}
---
diff --git a/storage/browser/file_system/dragged_file_util.cc b/storage/browser/file_system/dragged_file_util.cc
index ea3277c..93a7a60 100644
--- a/storage/browser/file_system/dragged_file_util.cc
+++ b/storage/browser/file_system/dragged_file_util.cc
@@ -102,7 +102,7 @@
     return base::File::FILE_OK;
   }
   base::File::Error error = NativeFileUtil::GetFileInfo(url.path(), file_info);
-  if (IsHiddenItem(url.path()) && !base::FilePath().IsParent(url.path())) {
+  if (IsHiddenItemUnderRoot(url.path(), GetRootPathForURL(url))) {
     // Don't follow symlinks unless it's the one that are selected by the user.
     return base::File::FILE_ERROR_NOT_FOUND;
   }
diff --git a/storage/browser/file_system/local_file_util.cc b/storage/browser/file_system/local_file_util.cc
index 766cf3e..a203df37c 100644
--- a/storage/browser/file_system/local_file_util.cc
+++ b/storage/browser/file_system/local_file_util.cc
@@ -53,7 +53,7 @@
       if (next.empty()) {
         error_ = file_enum_.GetError();
         return next;
-      } else if (file_util_->IsHiddenItem(next)) {
+      } else if (file_util_->IsHiddenItemUnderRoot(next, platform_root_path_)) {
         continue;
       }
       file_util_info_ = file_enum_.GetInfo();
@@ -97,6 +97,37 @@
 
 LocalFileUtil::~LocalFileUtil() = default;
 
+// static
+base::FilePath LocalFileUtil::GetRootPathForURL(const FileSystemURL& url) {
+  size_t prefix_len = 0;
+  const FileSystemType effective_type =
+      url.mount_type() != kFileSystemTypeUnknown ? url.mount_type()
+                                                 : url.type();
+  if (effective_type == kFileSystemTypeIsolated ||
+      effective_type == kFileSystemTypeDragged) {
+    prefix_len = 2;
+  } else if (effective_type == kFileSystemTypeExternal ||
+             effective_type == kFileSystemTypeLocalForPlatformApp) {
+    prefix_len = 1;
+  } else {
+    // For standard local filesystems (FSA API, unit tests, etc.),
+    // do not walk up to `/`. Only check the target file itself.
+    return url.path().DirName();
+  }
+  std::vector<base::FilePath::StringType> components =
+      VirtualPath::GetComponents(url.virtual_path());
+  size_t relative_components =
+      components.size() > prefix_len ? components.size() - prefix_len : 0;
+
+  base::FilePath root_path = url.path();
+  for (size_t i = 0; i < relative_components && !root_path.empty() &&
+                     root_path != root_path.DirName();
+       ++i) {
+    root_path = root_path.DirName();
+  }
+  return root_path;
+}
+
 base::File LocalFileUtil::CreateOrOpen(FileSystemOperationContext* context,
                                        const FileSystemURL& url,
                                        int file_flags) {
@@ -171,7 +202,7 @@
     return base::File::FILE_ERROR_ACCESS_DENIED;
   }
   *local_file_path = url.path();
-  if (IsHiddenItem(*local_file_path)) {
+  if (IsHiddenItemUnderRoot(*local_file_path, GetRootPathForURL(url))) {
     return base::File::FILE_ERROR_NOT_FOUND;
   }
   return base::File::FILE_OK;
@@ -277,4 +308,17 @@
   return base::IsLink(local_file_path);
 }
 
+bool LocalFileUtil::IsHiddenItemUnderRoot(
+    const base::FilePath& local_file_path,
+    const base::FilePath& root_path) const {
+  for (base::FilePath path = local_file_path;
+       !path.empty() && path != path.DirName() && path != root_path;
+       path = path.DirName()) {
+    if (IsHiddenItem(path)) {
+      return true;
+    }
+  }
+  return false;
+}
+
 }  // namespace storage
diff --git a/storage/browser/file_system/local_file_util.h b/storage/browser/file_system/local_file_util.h
index 6bb0602..4afce36 100644
--- a/storage/browser/file_system/local_file_util.h
+++ b/storage/browser/file_system/local_file_util.h
@@ -34,6 +34,8 @@
 
   ~LocalFileUtil() override;
 
+  static base::FilePath GetRootPathForURL(const FileSystemURL& url);
+
   base::File CreateOrOpen(FileSystemOperationContext* context,
                           const FileSystemURL& url,
                           int file_flags) override;
@@ -90,6 +92,11 @@
   // restriction by overriding this method.
   virtual bool IsHiddenItem(const base::FilePath& local_file_path) const;
 
+  // Checks whether |local_file_path| or any of its ancestors up to
+  // (but not including) |root_path| is a hidden item.
+  virtual bool IsHiddenItemUnderRoot(const base::FilePath& local_file_path,
+                                     const base::FilePath& root_path) const;
+
  private:
   class LocalFileEnumerator;
 };
diff --git a/storage/browser/file_system/local_file_util_unittest.cc b/storage/browser/file_system/local_file_util_unittest.cc
index e35865828..2fee3f72 100644
--- a/storage/browser/file_system/local_file_util_unittest.cc
+++ b/storage/browser/file_system/local_file_util_unittest.cc
@@ -18,6 +18,7 @@
 #include "storage/browser/file_system/file_system_context.h"
 #include "storage/browser/file_system/file_system_file_util.h"
 #include "storage/browser/file_system/file_system_operation_context.h"
+#include "storage/browser/file_system/isolated_context.h"
 #include "storage/browser/file_system/local_file_util.h"
 #include "storage/browser/file_system/native_file_util.h"
 #include "storage/browser/quota/quota_manager_proxy.h"
@@ -281,6 +282,89 @@
                                            CreateURL(symlink_name)));
   EXPECT_EQ(0, GetSize(target_name));
 }
+
+TEST_F(LocalFileUtilTest, AccessFailForFileUnderSymlink) {
+  const char* target_dir_name = "target_dir";
+  base::FilePath target_dir_path = LocalPath(target_dir_name);
+  EXPECT_TRUE(base::CreateDirectory(target_dir_path));
+
+  const char* target_file_name = "target_dir/target_file";
+  base::File target_file = CreateFile(target_file_name);
+  EXPECT_TRUE(target_file.IsValid());
+  EXPECT_TRUE(target_file.created());
+  target_file.Close();
+
+  const char* symlink_dir_name = "symlink_dir";
+  base::FilePath symlink_dir_path = LocalPath(symlink_dir_name);
+  EXPECT_TRUE(base::CreateSymbolicLink(target_dir_path, symlink_dir_path));
+
+  std::unique_ptr<FileSystemOperationContext> context(NewContext());
+  FileSystemURL url = CreateURL("symlink_dir/target_file");
+  int file_flags = base::File::FLAG_OPEN | base::File::FLAG_READ;
+  base::File file = file_util()->CreateOrOpen(context.get(), url, file_flags);
+  EXPECT_FALSE(file.IsValid());
+  EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, file.error_details());
+
+  bool created = false;
+  EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+            EnsureFileExists("symlink_dir/target_file", &created));
+  EXPECT_FALSE(created);
+
+  context = NewContext();
+  EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+            file_util()->Touch(context.get(), url, base::Time::Now(),
+                               base::Time::Now()));
+
+  context = NewContext();
+  EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+            file_util()->Truncate(context.get(), url, 1));
+
+  context = NewContext();
+  base::File::Info file_info;
+  base::FilePath platform_path;
+  EXPECT_EQ(
+      base::File::FILE_ERROR_NOT_FOUND,
+      file_util()->GetFileInfo(context.get(), url, &file_info, &platform_path));
+}
+
+TEST_F(LocalFileUtilTest, OpenSuccessForUserSelectedSymlink) {
+  // Create symlink target file.
+  const char* target_name = "symlink_target";
+  base::File target_file = CreateFile(target_name);
+  ASSERT_TRUE(target_file.IsValid());
+  ASSERT_TRUE(target_file.created());
+  target_file.Close();
+  base::FilePath target_path = LocalPath(target_name);
+
+  // Create symlink where target must be real file.
+  const char* symlink_name = "symlink_file";
+  base::FilePath symlink_path = LocalPath(symlink_name);
+  ASSERT_TRUE(base::CreateSymbolicLink(target_path, symlink_path));
+  ASSERT_TRUE(FileExists(symlink_name));
+
+  // Register user-selected symlink in isolated context.
+  std::string register_name = symlink_name;
+  IsolatedContext::ScopedFSHandle fs =
+      IsolatedContext::GetInstance()->RegisterFileSystemForPath(
+          kFileSystemTypeLocal, std::string(), symlink_path, &register_name);
+  ASSERT_TRUE(fs.is_valid());
+
+  FileSystemURL url = file_system_context()->CreateCrackedFileSystemURL(
+      blink::StorageKey::CreateFromStringForTesting("http://foo/"),
+      kFileSystemTypeIsolated,
+      IsolatedContext::GetInstance()
+          ->CreateVirtualRootPath(fs.id())
+          .Append(base::FilePath::FromUTF8Unsafe(register_name)));
+
+  // Try to open the symlink file which should succeed.
+  std::unique_ptr<FileSystemOperationContext> context(NewContext());
+  int file_flags = base::File::FLAG_OPEN | base::File::FLAG_READ;
+  LocalFileUtil local_file_util;
+  base::File file =
+      local_file_util.CreateOrOpen(context.get(), url, file_flags);
+  ASSERT_TRUE(file.IsValid());
+  EXPECT_EQ(base::File::FILE_OK, file.error_details());
+}
 #endif
 
 TEST_F(LocalFileUtilTest, EnsureFileExists) {
diff --git a/storage/browser/test/test_file_system_backend.cc b/storage/browser/test/test_file_system_backend.cc
index eab02c9..88408ac 100644
--- a/storage/browser/test/test_file_system_backend.cc
+++ b/storage/browser/test/test_file_system_backend.cc
@@ -47,7 +47,7 @@
                                      const FileSystemURL& file_system_url,
                                      base::FilePath* local_file_path) override {
     *local_file_path = base_path_.Append(file_system_url.path());
-    if (IsHiddenItem(*local_file_path)) {
+    if (IsHiddenItemUnderRoot(*local_file_path, base_path_)) {
       return base::File::FILE_ERROR_NOT_FOUND;
     }
     return base::File::FILE_OK;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/storage/browser/file_system/local_file_util_unittest.cc b/storage/browser/file_system/local_file_util_unittest.cc
index e35865828..2fee3f72 100644
--- a/storage/browser/file_system/local_file_util_unittest.cc
+++ b/storage/browser/file_system/local_file_util_unittest.cc
@@ -18,6 +18,7 @@
 #include "storage/browser/file_system/file_system_context.h"
 #include "storage/browser/file_system/file_system_file_util.h"
 #include "storage/browser/file_system/file_system_operation_context.h"
+#include "storage/browser/file_system/isolated_context.h"
 #include "storage/browser/file_system/local_file_util.h"
 #include "storage/browser/file_system/native_file_util.h"
 #include "storage/browser/quota/quota_manager_proxy.h"
@@ -281,6 +282,89 @@
                                            CreateURL(symlink_name)));
   EXPECT_EQ(0, GetSize(target_name));
 }
+
+TEST_F(LocalFileUtilTest, AccessFailForFileUnderSymlink) {
+  const char* target_dir_name = "target_dir";
+  base::FilePath target_dir_path = LocalPath(target_dir_name);
+  EXPECT_TRUE(base::CreateDirectory(target_dir_path));
+
+  const char* target_file_name = "target_dir/target_file";
+  base::File target_file = CreateFile(target_file_name);
+  EXPECT_TRUE(target_file.IsValid());
+  EXPECT_TRUE(target_file.created());
+  target_file.Close();
+
+  const char* symlink_dir_name = "symlink_dir";
+  base::FilePath symlink_dir_path = LocalPath(symlink_dir_name);
+  EXPECT_TRUE(base::CreateSymbolicLink(target_dir_path, symlink_dir_path));
+
+  std::unique_ptr<FileSystemOperationContext> context(NewContext());
+  FileSystemURL url = CreateURL("symlink_dir/target_file");
+  int file_flags = base::File::FLAG_OPEN | base::File::FLAG_READ;
+  base::File file = file_util()->CreateOrOpen(context.get(), url, file_flags);
+  EXPECT_FALSE(file.IsValid());
+  EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, file.error_details());
+
+  bool created = false;
+  EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+            EnsureFileExists("symlink_dir/target_file", &created));
+  EXPECT_FALSE(created);
+
+  context = NewContext();
+  EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+            file_util()->Touch(context.get(), url, base::Time::Now(),
+                               base::Time::Now()));
+
+  context = NewContext();
+  EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+            file_util()->Truncate(context.get(), url, 1));
+
+  context = NewContext();
+  base::File::Info file_info;
+  base::FilePath platform_path;
+  EXPECT_EQ(
+      base::File::FILE_ERROR_NOT_FOUND,
+      file_util()->GetFileInfo(context.get(), url, &file_info, &platform_path));
+}
+
+TEST_F(LocalFileUtilTest, OpenSuccessForUserSelectedSymlink) {
+  // Create symlink target file.
+  const char* target_name = "symlink_target";
+  base::File target_file = CreateFile(target_name);
+  ASSERT_TRUE(target_file.IsValid());
+  ASSERT_TRUE(target_file.created());
+  target_file.Close();
+  base::FilePath target_path = LocalPath(target_name);
+
+  // Create symlink where target must be real file.
+  const char* symlink_name = "symlink_file";
+  base::FilePath symlink_path = LocalPath(symlink_name);
+  ASSERT_TRUE(base::CreateSymbolicLink(target_path, symlink_path));
+  ASSERT_TRUE(FileExists(symlink_name));
+
+  // Register user-selected symlink in isolated context.
+  std::string register_name = symlink_name;
+  IsolatedContext::ScopedFSHandle fs =
+      IsolatedContext::GetInstance()->RegisterFileSystemForPath(
+          kFileSystemTypeLocal, std::string(), symlink_path, &register_name);
+  ASSERT_TRUE(fs.is_valid());
+
+  FileSystemURL url = file_system_context()->CreateCrackedFileSystemURL(
+      blink::StorageKey::CreateFromStringForTesting("http://foo/"),
+      kFileSystemTypeIsolated,
+      IsolatedContext::GetInstance()
+          ->CreateVirtualRootPath(fs.id())
+          .Append(base::FilePath::FromUTF8Unsafe(register_name)));
+
+  // Try to open the symlink file which should succeed.
+  std::unique_ptr<FileSystemOperationContext> context(NewContext());
+  int file_flags = base::File::FLAG_OPEN | base::File::FLAG_READ;
+  LocalFileUtil local_file_util;
+  base::File file =
+      local_file_util.CreateOrOpen(context.get(), url, file_flags);
+  ASSERT_TRUE(file.IsValid());
+  EXPECT_EQ(base::File::FILE_OK, file.error_details());
+}
 #endif
 
 TEST_F(LocalFileUtilTest, EnsureFileExists) {
diff --git a/storage/browser/test/test_file_system_backend.cc b/storage/browser/test/test_file_system_backend.cc
index eab02c9..88408ac 100644
--- a/storage/browser/test/test_file_system_backend.cc
+++ b/storage/browser/test/test_file_system_backend.cc
@@ -47,7 +47,7 @@
                                      const FileSystemURL& file_system_url,
                                      base::FilePath* local_file_path) override {
     *local_file_path = base_path_.Append(file_system_url.path());
-    if (IsHiddenItem(*local_file_path)) {
+    if (IsHiddenItemUnderRoot(*local_file_path, base_path_)) {
       return base::File::FILE_ERROR_NOT_FOUND;
     }
     return base::File::FILE_OK;
Loading diff…

Original Bug Report

reported by vm...@google.com

Intermediate Symlink Bypass in LocalFileUtil on POSIX

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

Overview: A logic flaw in LocalFileUtil::IsHiddenItem on POSIX systems may allow an attacker to bypass symbolic link traversal protections in Isolated File Systems. By constructing a path with an intermediate symbolic link, an attacker could potentially read arbitrary files outside the intended restricted directory.

Affected files:

  • storage/browser/file_system/local_file_util.cc
  • storage/browser/file_system/dragged_file_util.cc
  • base/files/file_util_posix.cc

Estimated timestamp from git blame: 2024-05-03

Description

A potential logic flaw in storage/browser/file_system/local_file_util.cc on POSIX systems allows an attacker to bypass symbolic link traversal protections in Isolated File Systems (such as those created when a user drags and drops a folder into the browser).

The LocalFileUtil::IsHiddenItem method is intended to prevent access to symbolic links within these systems, as they could be used to escape the restricted directory. However, its implementation on POSIX only checks if the final component of a path is a symbolic link.

Specifically, IsHiddenItem calls base::IsLink(), which in turn uses lstat() on the provided platform path. On POSIX, lstat() resolves all intermediate symbolic links in a path and only refuses to dereference the final component.

If an attacker provides a path where an intermediate directory is a symbolic link (e.g., link/sensitive_file, where link points to /home/user), lstat() resolves the intermediate link and retrieves the status of sensitive_file. Since sensitive_file itself is a regular file and not a link, the base::IsLink() check returns false, and the security check passes.

The browser process subsequently opens the file using the full path, which naturally follows the intermediate symbolic link, granting access to the file outside the dropped directory.

Potential Attack Scenario

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

  1. An attacker provides an archive (e.g., a .tar file) containing a symbolic link to a sensitive location (e.g., ln -s /home/victim /tmp/bait/link).
  2. The victim extracts the archive on a POSIX-compliant system. Extraction tools typically preserve symbolic links.
  3. The victim is socially engineered into dragging the extracted directory onto the attacker’s website.
  4. The website’s script calls entry.getFile('link/.ssh/id_rsa', {}, successCallback), using the known symbolic link name link from the provided archive.
  5. In the browser process, LocalFileUtil::GetFileInfo is called for the resolved platform path (e.g., /tmp/bait/link/.ssh/id_rsa).
  6. IsHiddenItem calls base::IsLink(), which calls lstat(). lstat() resolves the intermediate link and reports the status of the target file (id_rsa). Since the target is a regular file, IsHiddenItem returns false.
  7. The browser process considers the file safe and grants the renderer process read access to the path, allowing the attacker to read the victim’s private data.

Vulnerable Code

In storage/browser/file_system/local_file_util.cc:

273: bool LocalFileUtil::IsHiddenItem(const base::FilePath& local_file_path) const {
274:   // We should not follow symbolic links in sandboxed file system.
275:   return base::IsLink(local_file_path);
276: }

In base/files/file_util_posix.cc:

1030: bool IsLink(const FilePath& file_path) {
1031:   stat_wrapper_t st;
1032:   if (File::Lstat(file_path, &st) != 0) {
1033:     return false;
1034:   }
1035:   return S_ISLNK(st.st_mode);
1036: }

Suggested Fix

LocalFileUtil::IsHiddenItem should ensure that no component of the path is a symbolic link. On POSIX, this can be achieved by using base::MakeAbsoluteFilePath (which resolves all symlinks using realpath) and verifying that the resulting resolved path does not diverge from the expected normalized path (or ensuring that the resolved path still resides strictly within the bounds of the isolated file system’s root). Alternatively, base::IsLink could be updated or a new utility function introduced to check all path components iteratively.

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