CVE-2026-78985
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fstorage/browser/file_system/local_file_util_unittest.cc |
modified |
Files Changed
storage/browser/file_system/local_file_util.ccstorage/browser/file_system/local_file_util_unittest.ccstorage/browser/test/test_file_system_backend.cc
Patch
From 835fc99abf93cf8213729a7801604f284f5fa709 Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <elkurin@google.com>
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 <leimy@chromium.org>
Commit-Queue: Mingyu Lei <leimy@chromium.org>
Auto-Submit: Eriko Kurimoto <elkurin@chromium.org>
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
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.