CVE-2026-113102
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
LocalFileEnumeratorstorage/browser/file_system/local_file_util.h |
modified | |
TEST_Fstorage/browser/file_system/local_file_util_unittest.cc |
modified |
Files Changed
storage/browser/file_system/local_file_util.hstorage/browser/test/test_file_system_backend.ccstorage/browser/file_system/local_file_util_unittest.ccstorage/browser/file_system/dragged_file_util.ccstorage/browser/file_system/local_file_util.cc
Patch
From c087135b113e846a7c80a72960b1426d05ab89ec Mon Sep 17 00:00:00 2001 From: Anna Sato <[email protected]> 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 <[email protected]> Reviewed-by: Mingyu Lei <[email protected]> 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, ®ister_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;
Regression Test / PoC
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, ®ister_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;
Original Bug Report
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.ccstorage/browser/file_system/dragged_file_util.ccbase/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.)
- An attacker provides an archive (e.g., a
.tarfile) containing a symbolic link to a sensitive location (e.g.,ln -s /home/victim /tmp/bait/link). - The victim extracts the archive on a POSIX-compliant system. Extraction tools typically preserve symbolic links.
- The victim is socially engineered into dragging the extracted directory onto the attacker’s website.
- The website’s script calls
entry.getFile('link/.ssh/id_rsa', {}, successCallback), using the known symbolic link namelinkfrom the provided archive. - In the browser process,
LocalFileUtil::GetFileInfois called for the resolved platform path (e.g.,/tmp/bait/link/.ssh/id_rsa). IsHiddenItemcallsbase::IsLink(), which callslstat().lstat()resolves the intermediatelinkand reports the status of the target file (id_rsa). Since the target is a regular file,IsHiddenItemreturnsfalse.- 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.