CVE-2026-78985
Overview
Background
- `LocalFileUtil`
- the
storage::FileSystemFileUtilimplementation instorage/browser/file_system/local_file_util.cc. It maps aFileSystemURLto a real native path and runs file operations (CreateOrOpen,GetFileInfo,EnsureFileExists,Touch,Truncate,CopyOrMoveFile,CopyInForeignFile) throughNativeFileUtilin the browser process. - `IsHiddenItem()`
- a helper that, per the commit message, reports whether a path is a symbolic link.
LocalFileUtiluses it to deliberately hide symlinks, reporting them asbase::File::FILE_ERROR_NOT_FOUND. - `GetLocalFilePath()`
- the virtual resolver that turns a
FileSystemURLinto a nativebase::FilePath. Every URL-based operation in the class calls it before touching the disk, and subclasses (such as the test backend intest_file_system_backend.cc) can override it. - Symlink following
- native file APIs follow symbolic links by default. An operation on a link path therefore acts on the link’s target, which can be outside the directory the caller was granted.
Root Cause Analysis
By design, LocalFileUtil does not expose symlinks, but before the patch the IsHiddenItem() check sat only in CreateOrOpen() and GetFileInfo() (the removed lines) and in the enumerator. As the commit message says, EnsureFileExists(), Touch(), Truncate(), CopyOrMoveFile() and CopyInForeignFile() resolved the URL and passed the native path straight to NativeFileUtil without the check. Those write operations would therefore follow a symlink inside the exposed directory and create, change timestamps on, truncate or overwrite whatever file the link pointed to. The regression tests show this: before the fix, Truncate, a copy onto a symlink, and CopyInForeignFile onto a symlink would change symlink_target.
The fix moves the check into the shared resolver: right after *local_file_path = url.path(); the resolver now returns FILE_ERROR_NOT_FOUND when IsHiddenItem(*local_file_path) is true. Every URL-based operation, for both source and destination URLs, therefore rejects symlinks, and the now-redundant per-function checks in CreateOrOpen() / GetFileInfo() are removed. In one function, identified only by its body (it resolves only dest_url and always copies with true /* copy */, which matches CopyInForeignFile; the hunk does not show the name), the patch also adds an IsHiddenItem(src_file_path) check on the native source path before calling NativeFileUtil::CopyOrMoveFile. This is a tightening overall. One side effect: subclasses that override GetLocalFilePath() must now do the check themselves, which is why test_file_system_backend.cc’s override was updated too.
IsHiddenItem() inside path resolution, which covers every operation at once.Attack Path
- Symlink in an exposed directory
A directory exposed to a client through a
LocalFileUtil-backed file system contains a symbolic link pointing at a file outside the intended scope. How the link gets there is not shown in the patch (inferred: for example extracted content, or a link placed by another local actor). - Client gets write access
A client with write access to that file system, such as an app or extension with file-system write permission or a platform file manager (inferred; the patch does not name the frontend), issues
Truncate,Touch,EnsureFileExistsor a copy targeting the link’sFileSystemURL. - Resolution without symlink check
Before the fix, path resolution returned the link path with
FILE_OK, and these operations passed it toNativeFileUtil, which follows the link. - Out-of-scope file modified
The link target is truncated, has its timestamps changed, or is overwritten by copied data, as the tests’
GetSize(target_name)assertions encode. Copying from a symlink source could likewise read data from outside the scope into an accessible file (inferred from the “copying from a symlink should fail” test).
Impact Assessment
LocalFileUtil-backed file system make the browser process read or write files outside the granted directory by following symlinks. This is a confused-deputy file-integrity issue, with a possible confidentiality issue through copy-from-symlink (inferred). It needs a symlink already present inside an exposed directory and a caller allowed to run write operations there. The patch does not show ordinary web content reaching these write paths, so web reachability is not established (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 9.6.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fstorage/browser/file_system/local_file_util_unittest.cc |
modified |
Files Changed
storage/browser/test/test_file_system_backend.ccstorage/browser/file_system/local_file_util_unittest.ccstorage/browser/file_system/local_file_util.cc
Audit Directions
- Overrides of `GetLocalFilePath()`Because the checks were removed from
CreateOrOpen()andGetFileInfo()and centralized in the resolver, any production subclass ofLocalFileUtilthat overridesGetLocalFilePath()without callingIsHiddenItem()would now let symlinks through even on those previously protected operations. Audit every override. - Check-then-use races on native paths
IsHiddenItem()checks the path, and theNativeFileUtilcall then reopens it by name. Look for time-of-check/time-of-use windows where a regular file can be swapped for a symlink between the two, and for intermediate path components that are links (inferred residual risk). - Other path-mapping file utilsReview other
FileSystemFileUtil/AsyncFileUtilimplementations and backends (isolated, native, external or mounted, File System Access) for operations that resolve a URL to a native path and then follow links with defaultbase::Filesemantics.
Patch
From 835fc99abf93cf8213729a7801604f284f5fa709 Mon Sep 17 00:00:00 2001 From: Eriko Kurimoto <[email protected]> Date: Wed, 08 Jul 2026 00:25:14 -0700 Subject: [PATCH] storage: Reject symlinks in remaining LocalFileUtil operations LocalFileUtil::IsHiddenItem() (which checks for symbolic links) was applied in CreateOrOpen(), GetFileInfo() and the file enumerator, but not in EnsureFileExists(), Touch(), Truncate(), CopyOrMoveFile() or CopyInForeignFile(). This made those operations inconsistent with the rest of the class, since symlinks are intentionally not exposed through this util. Add the missing IsHiddenItem() checks so that all write operations consistently return FILE_ERROR_NOT_FOUND for symbolic links, matching the existing behaviour of CreateOrOpen() and GetFileInfo(). Bug: 500038021 Change-Id: I6802cfd66f9ca6607c36e1c60d457b8fb52b5867 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8033621 Reviewed-by: Mingyu Lei <[email protected]> Commit-Queue: Mingyu Lei <[email protected]> Auto-Submit: Eriko Kurimoto <[email protected]> Cr-Commit-Position: refs/heads/main@{#1658543} --- diff --git a/storage/browser/file_system/local_file_util.cc b/storage/browser/file_system/local_file_util.cc index 9f280b41..766cf3e 100644 --- a/storage/browser/file_system/local_file_util.cc +++ b/storage/browser/file_system/local_file_util.cc @@ -104,8 +104,6 @@ base::File::Error error = GetLocalFilePath(context, url, &file_path); if (error != base::File::FILE_OK) return base::File(error); - if (IsHiddenItem(file_path)) - return base::File(base::File::FILE_ERROR_NOT_FOUND); return NativeFileUtil::CreateOrOpen(file_path, file_flags); } @@ -142,8 +140,6 @@ base::File::Error error = GetLocalFilePath(context, url, &file_path); if (error != base::File::FILE_OK) return error; - if (IsHiddenItem(file_path)) - return base::File::FILE_ERROR_NOT_FOUND; error = NativeFileUtil::GetFileInfo(file_path, file_info); if (error == base::File::FILE_OK) @@ -175,6 +171,9 @@ return base::File::FILE_ERROR_ACCESS_DENIED; } *local_file_path = url.path(); + if (IsHiddenItem(*local_file_path)) { + return base::File::FILE_ERROR_NOT_FOUND; + } return base::File::FILE_OK; } @@ -232,6 +231,9 @@ GetLocalFilePath(context, dest_url, &dest_file_path); if (error != base::File::FILE_OK) return error; + if (IsHiddenItem(src_file_path)) { + return base::File::FILE_ERROR_NOT_FOUND; + } return NativeFileUtil::CopyOrMoveFile( src_file_path, dest_file_path, FileSystemOperation::CopyOrMoveOptionSet(), NativeFileUtil::CopyOrMoveModeForDestination(dest_url, true /* copy */)); diff --git a/storage/browser/file_system/local_file_util_unittest.cc b/storage/browser/file_system/local_file_util_unittest.cc index 58c3247..e35865828 100644 --- a/storage/browser/file_system/local_file_util_unittest.cc +++ b/storage/browser/file_system/local_file_util_unittest.cc @@ -163,6 +163,124 @@ ASSERT_FALSE(file.IsValid()); EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, file.error_details()); } + +TEST_F(LocalFileUtilTest, EnsureFileExistsFailForSymlink) { + const char* target_name = "symlink_target"; + base::File target_file = CreateFile(target_name); + ASSERT_TRUE(target_file.IsValid()); + ASSERT_TRUE(target_file.created()); + base::FilePath target_path = LocalPath(target_name); + + 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)); + + bool created = false; + EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, + EnsureFileExists(symlink_name, &created)); + EXPECT_FALSE(created); +} + +TEST_F(LocalFileUtilTest, TouchFailForSymlink) { + const char* target_name = "symlink_target"; + base::File target_file = CreateFile(target_name); + ASSERT_TRUE(target_file.IsValid()); + ASSERT_TRUE(target_file.created()); + base::FilePath target_path = LocalPath(target_name); + + 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)); + + std::unique_ptr<FileSystemOperationContext> context(NewContext()); + EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, + file_util()->Touch(context.get(), CreateURL(symlink_name), + base::Time::Now(), base::Time::Now())); +} + +TEST_F(LocalFileUtilTest, TruncateFailForSymlink) { + const char* target_name = "symlink_target"; + base::File target_file = CreateFile(target_name); + ASSERT_TRUE(target_file.IsValid()); + ASSERT_TRUE(target_file.created()); + base::FilePath target_path = LocalPath(target_name); + + 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)); + + std::unique_ptr<FileSystemOperationContext> context(NewContext()); + EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, + file_util()->Truncate(context.get(), CreateURL(symlink_name), 1)); + EXPECT_EQ(0, GetSize(target_name)); +} + +TEST_F(LocalFileUtilTest, CopyOrMoveFileFailForSymlink) { + const char* target_name = "symlink_target"; + base::File target_file = CreateFile(target_name); + ASSERT_TRUE(target_file.IsValid()); + ASSERT_TRUE(target_file.created()); + base::FilePath target_path = LocalPath(target_name); + + 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)); + + const char* other_name = "other_file"; + bool created; + ASSERT_EQ(base::File::FILE_OK, EnsureFileExists(other_name, &created)); + ASSERT_TRUE(created); + + std::unique_ptr<FileSystemOperationContext> context; + context = NewContext(); + ASSERT_EQ(base::File::FILE_OK, + file_util()->Truncate(context.get(), CreateURL(other_name), 1020)); + + // Copying onto a symlink should fail. + context = NewContext(); + EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, + file_util()->CopyOrMoveFile( + context.get(), CreateURL(other_name), CreateURL(symlink_name), + FileSystemFileUtil::CopyOrMoveOptionSet(), true /* copy */)); + EXPECT_EQ(0, GetSize(target_name)); + + // Copying from a symlink should fail. + context = NewContext(); + EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, + file_util()->CopyOrMoveFile( + context.get(), CreateURL(symlink_name), CreateURL(other_name), + FileSystemFileUtil::CopyOrMoveOptionSet(), true /* copy */)); + EXPECT_EQ(1020, GetSize(other_name)); +} + +TEST_F(LocalFileUtilTest, CopyInForeignFileFailForSymlink) { + const char* target_name = "symlink_target"; + base::File target_file = CreateFile(target_name); + ASSERT_TRUE(target_file.IsValid()); + ASSERT_TRUE(target_file.created()); + base::FilePath target_path = LocalPath(target_name); + + 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)); + + base::ScopedTempDir foreign_dir; + ASSERT_TRUE(foreign_dir.CreateUniqueTempDir()); + base::FilePath foreign_path = + foreign_dir.GetPath().AppendASCII("foreign_file"); + ASSERT_TRUE(base::WriteFile(foreign_path, "data")); + + std::unique_ptr<FileSystemOperationContext> context(NewContext()); + EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, + file_util()->CopyInForeignFile(context.get(), foreign_path, + CreateURL(symlink_name))); + EXPECT_EQ(0, GetSize(target_name)); +} #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 97d0171..eab02c9 100644 --- a/storage/browser/test/test_file_system_backend.cc +++ b/storage/browser/test/test_file_system_backend.cc @@ -47,6 +47,9 @@ 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)) { + 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 58c3247..e35865828 100644
--- a/storage/browser/file_system/local_file_util_unittest.cc
+++ b/storage/browser/file_system/local_file_util_unittest.cc
@@ -163,6 +163,124 @@
ASSERT_FALSE(file.IsValid());
EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND, file.error_details());
}
+
+TEST_F(LocalFileUtilTest, EnsureFileExistsFailForSymlink) {
+ const char* target_name = "symlink_target";
+ base::File target_file = CreateFile(target_name);
+ ASSERT_TRUE(target_file.IsValid());
+ ASSERT_TRUE(target_file.created());
+ base::FilePath target_path = LocalPath(target_name);
+
+ 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));
+
+ bool created = false;
+ EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+ EnsureFileExists(symlink_name, &created));
+ EXPECT_FALSE(created);
+}
+
+TEST_F(LocalFileUtilTest, TouchFailForSymlink) {
+ const char* target_name = "symlink_target";
+ base::File target_file = CreateFile(target_name);
+ ASSERT_TRUE(target_file.IsValid());
+ ASSERT_TRUE(target_file.created());
+ base::FilePath target_path = LocalPath(target_name);
+
+ 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));
+
+ std::unique_ptr<FileSystemOperationContext> context(NewContext());
+ EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+ file_util()->Touch(context.get(), CreateURL(symlink_name),
+ base::Time::Now(), base::Time::Now()));
+}
+
+TEST_F(LocalFileUtilTest, TruncateFailForSymlink) {
+ const char* target_name = "symlink_target";
+ base::File target_file = CreateFile(target_name);
+ ASSERT_TRUE(target_file.IsValid());
+ ASSERT_TRUE(target_file.created());
+ base::FilePath target_path = LocalPath(target_name);
+
+ 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));
+
+ std::unique_ptr<FileSystemOperationContext> context(NewContext());
+ EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+ file_util()->Truncate(context.get(), CreateURL(symlink_name), 1));
+ EXPECT_EQ(0, GetSize(target_name));
+}
+
+TEST_F(LocalFileUtilTest, CopyOrMoveFileFailForSymlink) {
+ const char* target_name = "symlink_target";
+ base::File target_file = CreateFile(target_name);
+ ASSERT_TRUE(target_file.IsValid());
+ ASSERT_TRUE(target_file.created());
+ base::FilePath target_path = LocalPath(target_name);
+
+ 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));
+
+ const char* other_name = "other_file";
+ bool created;
+ ASSERT_EQ(base::File::FILE_OK, EnsureFileExists(other_name, &created));
+ ASSERT_TRUE(created);
+
+ std::unique_ptr<FileSystemOperationContext> context;
+ context = NewContext();
+ ASSERT_EQ(base::File::FILE_OK,
+ file_util()->Truncate(context.get(), CreateURL(other_name), 1020));
+
+ // Copying onto a symlink should fail.
+ context = NewContext();
+ EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+ file_util()->CopyOrMoveFile(
+ context.get(), CreateURL(other_name), CreateURL(symlink_name),
+ FileSystemFileUtil::CopyOrMoveOptionSet(), true /* copy */));
+ EXPECT_EQ(0, GetSize(target_name));
+
+ // Copying from a symlink should fail.
+ context = NewContext();
+ EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+ file_util()->CopyOrMoveFile(
+ context.get(), CreateURL(symlink_name), CreateURL(other_name),
+ FileSystemFileUtil::CopyOrMoveOptionSet(), true /* copy */));
+ EXPECT_EQ(1020, GetSize(other_name));
+}
+
+TEST_F(LocalFileUtilTest, CopyInForeignFileFailForSymlink) {
+ const char* target_name = "symlink_target";
+ base::File target_file = CreateFile(target_name);
+ ASSERT_TRUE(target_file.IsValid());
+ ASSERT_TRUE(target_file.created());
+ base::FilePath target_path = LocalPath(target_name);
+
+ 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));
+
+ base::ScopedTempDir foreign_dir;
+ ASSERT_TRUE(foreign_dir.CreateUniqueTempDir());
+ base::FilePath foreign_path =
+ foreign_dir.GetPath().AppendASCII("foreign_file");
+ ASSERT_TRUE(base::WriteFile(foreign_path, "data"));
+
+ std::unique_ptr<FileSystemOperationContext> context(NewContext());
+ EXPECT_EQ(base::File::FILE_ERROR_NOT_FOUND,
+ file_util()->CopyInForeignFile(context.get(), foreign_path,
+ CreateURL(symlink_name)));
+ EXPECT_EQ(0, GetSize(target_name));
+}
#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 97d0171..eab02c9 100644
--- a/storage/browser/test/test_file_system_backend.cc
+++ b/storage/browser/test/test_file_system_backend.cc
@@ -47,6 +47,9 @@
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)) {
+ return base::File::FILE_ERROR_NOT_FOUND;
+ }
return base::File::FILE_OK;
}
Original Bug Report
Arbitrary file overwrite via symlink bypass in LocalFileUtil
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.
Overview: LocalFileUtil fails to validate symbolic links in operations like EnsureFileExists and Truncate. By executing a stream copy onto a symlink, a malicious app can potentially overwrite arbitrary host files. This bypasses the sandbox and could lead to Remote Code Execution.
Affected files:
storage/browser/file_system/local_file_util.ccstorage/browser/file_system/local_file_stream_writer.ccstorage/browser/file_system/native_file_util.cc
Estimated timestamp from git blame: 2024-08-07
Verdict: Potential arbitrary file overwrite leading to Sandbox Escape.
Suggested Fix: Apply the IsHiddenItem() validation to EnsureFileExists, Truncate, CopyOrMoveFile, and CopyInForeignFile within storage/browser/file_system/local_file_util.cc. Additionally, enforce O_NOFOLLOW equivalents during file initialization in LocalFileStreamWriter.
Technical Details
Initial logic and routing parameters in the FileSystem API are validated; operations correctly resolve through IsolatedFileSystemBackend down to LocalFileUtil. While methods like GetFileInfo properly invoke IsHiddenItem() to block symlinks in sandboxed contexts, the remaining execution path completely bypasses this boundary.
During a stream copy operation (StreamCopyOrMoveImpl) onto an existing symlink, the system jumps directly to executing EnsureFileExists followed by Truncate. These functions instantiate base::File using base::File::FLAG_OPEN | base::File::FLAG_WRITE. Because this directly translates to standard POSIX open calls without O_NOFOLLOW, the underlying target file is silently followed, truncated, and directly overwritten with the stream’s payload by the browser process.
Suggested Reproduction Steps
(Note: These are potential steps; our tooling cannot run code to verify a working proof of concept.)
- Standard processing applied: The user extracts and selects an attacker-provided directory containing a symlink (e.g., pointing to
~/.bashrc) and a malicious payload file. - The attacker’s web or Platform App invokes the
copyTo()API to copy the payload over the symlink. - The browser process natively follows the symlink without validation, arbitrarily overwriting the sensitive host file.
Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.