CVE-2026-17749
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fextensions/browser/api/file_handlers/app_file_handler_util_unittest.cc |
modified |
Files Changed
extensions/browser/api/file_handlers/app_file_handler_util.ccextensions/browser/api/file_handlers/app_file_handler_util_unittest.cc
Patch
From 722ab1647725c36be04d27e03d8c7421c0268e40 Mon Sep 17 00:00:00 2001
From: Giovanni Pezzino <giovax@google.com>
Date: Mon, 29 Jun 2026 01:44:41 -0700
Subject: [PATCH] Reject dangling symlinks in PrepareNativeLocalFileForWritableApp.
PrepareNativeLocalFileForWritableApp() rejects symlinks, but the check
was guarded by base::PathExists(), which follows symlinks and returns
false for a dangling one. As a result the IsLink() check was skipped and
the subsequent FLAG_OPEN_ALWAYS open would create the file at the link
target.
base::IsLink() is lstat-based and already returns false for paths that
do not exist, so the PathExists() guard is unnecessary. Drop it so
dangling symlinks are rejected as well, and add unit-test coverage for
both the dangling and resolved symlink cases.
Also added base::File::FLAG_NO_FOLLOW to the creation flags in
PrepareNativeLocalFileForWritableApp as defense-in-depth against TOCTOU
races, and added a test case for directory symlinks.
BUG=500526602
TEST=PrepareFilesForWritableAppTest.*
TAG=agy
Change-Id: Icd5ad10bebb2a29cf32af1c08db471590fa30bd1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8011453
Reviewed-by: Cassy Chun-Crogan <cassycc@google.com>
Commit-Queue: Giovanni Pezzino <giovax@google.com>
Auto-Submit: Giovanni Pezzino <giovax@google.com>
Cr-Commit-Position: refs/heads/main@{#1653917}
---
diff --git a/extensions/browser/api/file_handlers/app_file_handler_util.cc b/extensions/browser/api/file_handlers/app_file_handler_util.cc
index f644757a..07a922b 100644
--- a/extensions/browser/api/file_handlers/app_file_handler_util.cc
+++ b/extensions/browser/api/file_handlers/app_file_handler_util.cc
@@ -122,14 +122,18 @@
bool PrepareNativeLocalFileForWritableApp(const base::FilePath& path,
bool is_directory) {
// Don't allow links.
- if (base::PathExists(path) && base::IsLink(path))
+ if (base::IsLink(path)) {
return false;
+ }
if (is_directory)
return base::DirectoryExists(path);
// Create the file if it doesn't already exist.
- int creation_flags = base::File::FLAG_OPEN_ALWAYS | base::File::FLAG_READ;
+ // Use FLAG_NO_FOLLOW to prevent TOCTOU races where a path is replaced with a
+ // symlink after the IsLink() check above.
+ int creation_flags = base::File::FLAG_OPEN_ALWAYS | base::File::FLAG_READ |
+ base::File::FLAG_NO_FOLLOW;
base::File file(path, creation_flags);
return file.IsValid();
diff --git a/extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc b/extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc
index afe2000..8b86f00c 100644
--- a/extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc
+++ b/extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc
@@ -6,6 +6,7 @@
#include "base/files/file.h"
#include "base/files/file_path.h"
+#include "base/files/file_util.h"
#include "base/run_loop.h"
#include "base/test/gtest_util.h"
#include "base/test/mock_callback.h"
@@ -427,5 +428,79 @@
#endif
+#if BUILDFLAG(IS_POSIX)
+TEST_F(PrepareFilesForWritableAppTest, SymlinkToExistingFile) {
+ base::FilePath target = file1;
+ base::FilePath symlink =
+ file1.DirName().Append(FILE_PATH_LITERAL("symlink.txt"));
+ ASSERT_TRUE(base::CreateSymbolicLink(target, symlink));
+
+ testing::StrictMock<base::MockOnceCallback<void()>> success_callback;
+ testing::StrictMock<base::MockOnceCallback<void(const base::FilePath& path)>>
+ fail_callback;
+
+ base::RunLoop run_loop;
+ EXPECT_CALL(fail_callback, Run)
+ .WillOnce([&run_loop, &symlink](const base::FilePath& path) {
+ EXPECT_EQ(symlink, path);
+ run_loop.Quit();
+ });
+
+ PrepareFilesForWritableApp({symlink}, &context_, {}, success_callback.Get(),
+ fail_callback.Get());
+ run_loop.Run();
+}
+
+TEST_F(PrepareFilesForWritableAppTest, DanglingSymlink) {
+ base::FilePath target =
+ file1.DirName().Append(FILE_PATH_LITERAL("non_existent.txt"));
+ base::FilePath symlink =
+ file1.DirName().Append(FILE_PATH_LITERAL("dangling.txt"));
+ ASSERT_TRUE(base::CreateSymbolicLink(target, symlink));
+
+ testing::StrictMock<base::MockOnceCallback<void()>> success_callback;
+ testing::StrictMock<base::MockOnceCallback<void(const base::FilePath& path)>>
+ fail_callback;
+
+ base::RunLoop run_loop;
+ EXPECT_CALL(fail_callback, Run)
+ .WillOnce([&run_loop, &symlink](const base::FilePath& path) {
+ EXPECT_EQ(symlink, path);
+ run_loop.Quit();
+ });
+
+ PrepareFilesForWritableApp({symlink}, &context_, {}, success_callback.Get(),
+ fail_callback.Get());
+ run_loop.Run();
+
+ // Verify that the target of the dangling symlink was not created.
+ EXPECT_FALSE(base::PathExists(target));
+}
+
+TEST_F(PrepareFilesForWritableAppTest, SymlinkToExistingDirectory) {
+ base::FilePath target =
+ file1.DirName().Append(FILE_PATH_LITERAL("target_dir"));
+ ASSERT_TRUE(base::CreateDirectory(target));
+ base::FilePath symlink =
+ file1.DirName().Append(FILE_PATH_LITERAL("symlink_dir"));
+ ASSERT_TRUE(base::CreateSymbolicLink(target, symlink));
+
+ testing::StrictMock<base::MockOnceCallback<void()>> success_callback;
+ testing::StrictMock<base::MockOnceCallback<void(const base::FilePath& path)>>
+ fail_callback;
+
+ base::RunLoop run_loop;
+ EXPECT_CALL(fail_callback, Run)
+ .WillOnce([&run_loop, &symlink](const base::FilePath& path) {
+ EXPECT_EQ(symlink, path);
+ run_loop.Quit();
+ });
+
+ PrepareFilesForWritableApp({symlink}, &context_, {symlink},
+ success_callback.Get(), fail_callback.Get());
+ run_loop.Run();
+}
+#endif
+
} // namespace app_file_handler_util
} // namespace extensions
Regression Test / PoC
diff --git a/extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc b/extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc
index afe2000..8b86f00c 100644
--- a/extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc
+++ b/extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc
@@ -6,6 +6,7 @@
#include "base/files/file.h"
#include "base/files/file_path.h"
+#include "base/files/file_util.h"
#include "base/run_loop.h"
#include "base/test/gtest_util.h"
#include "base/test/mock_callback.h"
@@ -427,5 +428,79 @@
#endif
+#if BUILDFLAG(IS_POSIX)
+TEST_F(PrepareFilesForWritableAppTest, SymlinkToExistingFile) {
+ base::FilePath target = file1;
+ base::FilePath symlink =
+ file1.DirName().Append(FILE_PATH_LITERAL("symlink.txt"));
+ ASSERT_TRUE(base::CreateSymbolicLink(target, symlink));
+
+ testing::StrictMock<base::MockOnceCallback<void()>> success_callback;
+ testing::StrictMock<base::MockOnceCallback<void(const base::FilePath& path)>>
+ fail_callback;
+
+ base::RunLoop run_loop;
+ EXPECT_CALL(fail_callback, Run)
+ .WillOnce([&run_loop, &symlink](const base::FilePath& path) {
+ EXPECT_EQ(symlink, path);
+ run_loop.Quit();
+ });
+
+ PrepareFilesForWritableApp({symlink}, &context_, {}, success_callback.Get(),
+ fail_callback.Get());
+ run_loop.Run();
+}
+
+TEST_F(PrepareFilesForWritableAppTest, DanglingSymlink) {
+ base::FilePath target =
+ file1.DirName().Append(FILE_PATH_LITERAL("non_existent.txt"));
+ base::FilePath symlink =
+ file1.DirName().Append(FILE_PATH_LITERAL("dangling.txt"));
+ ASSERT_TRUE(base::CreateSymbolicLink(target, symlink));
+
+ testing::StrictMock<base::MockOnceCallback<void()>> success_callback;
+ testing::StrictMock<base::MockOnceCallback<void(const base::FilePath& path)>>
+ fail_callback;
+
+ base::RunLoop run_loop;
+ EXPECT_CALL(fail_callback, Run)
+ .WillOnce([&run_loop, &symlink](const base::FilePath& path) {
+ EXPECT_EQ(symlink, path);
+ run_loop.Quit();
+ });
+
+ PrepareFilesForWritableApp({symlink}, &context_, {}, success_callback.Get(),
+ fail_callback.Get());
+ run_loop.Run();
+
+ // Verify that the target of the dangling symlink was not created.
+ EXPECT_FALSE(base::PathExists(target));
+}
+
+TEST_F(PrepareFilesForWritableAppTest, SymlinkToExistingDirectory) {
+ base::FilePath target =
+ file1.DirName().Append(FILE_PATH_LITERAL("target_dir"));
+ ASSERT_TRUE(base::CreateDirectory(target));
+ base::FilePath symlink =
+ file1.DirName().Append(FILE_PATH_LITERAL("symlink_dir"));
+ ASSERT_TRUE(base::CreateSymbolicLink(target, symlink));
+
+ testing::StrictMock<base::MockOnceCallback<void()>> success_callback;
+ testing::StrictMock<base::MockOnceCallback<void(const base::FilePath& path)>>
+ fail_callback;
+
+ base::RunLoop run_loop;
+ EXPECT_CALL(fail_callback, Run)
+ .WillOnce([&run_loop, &symlink](const base::FilePath& path) {
+ EXPECT_EQ(symlink, path);
+ run_loop.Quit();
+ });
+
+ PrepareFilesForWritableApp({symlink}, &context_, {symlink},
+ success_callback.Get(), fail_callback.Get());
+ run_loop.Run();
+}
+#endif
+
} // namespace app_file_handler_util
} // namespace extensions
Original Bug Report
Arbitrary file write via dangling symlink bypass in chrome.fileSystem API
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: A logic error in the chrome.fileSystem API allows a malicious Chrome App to bypass symlink validation using a dangling symlink. Because the validation check short-circuits when a symlink target does not exist, the browser process inadvertently creates and grants write access to the target file. This enables an attacker to perform an arbitrary file write outside their sandboxed directory, potentially leading to persistent remote code execution.
Affected files:
extensions/browser/api/file_handlers/app_file_handler_util.ccstorage/browser/file_system/local_file_stream_writer.ccextensions/browser/api/file_system/file_system_api.cc
Estimated timestamp from git blame: 2015-03-17
Summary
A logic flaw exists in extensions/browser/api/file_handlers/app_file_handler_util.cc that allows a malicious Chrome platform app to bypass symbolic link checks during a chrome.fileSystem.getWritableEntry() call. By leveraging a dangling symlink, an attacker can trick the browser process into creating a file outside the user-selected directory and granting the app write access to it.
Technical Details
The vulnerability stems from the symlink validation logic in PrepareNativeLocalFileForWritableApp:
// Don't allow links.
if (base::PathExists(path) && base::IsLink(path))
return false;
On POSIX systems, base::PathExists() uses the access() system call, which attempts to resolve and follow symlinks. If the provided path is a dangling symlink (i.e., its target does not yet exist), access() fails and base::PathExists() returns false. Due to C++ short-circuit evaluation, base::IsLink() is never executed, and the dangling symlink completely bypasses the security check.
The function then proceeds to ensure the file exists:
int creation_flags = base::File::FLAG_OPEN_ALWAYS | base::File::FLAG_READ;
base::File file(path, creation_flags);
When base::File processes FLAG_OPEN_ALWAYS on POSIX, it eventually falls back to open(path, open_flags | O_CREAT, mode). Calling open() with O_CREAT on a dangling symlink (without the O_NOFOLLOW flag) causes the OS to follow the symlink and create an empty file at the target location.
The browser process then grants the renderer write access to this path. When the renderer later writes its payload, the storage layer (LocalFileStreamWriter) opens the file—again without O_NOFOLLOW—successfully writing attacker-controlled data to the out-of-bounds target file.
Potential Exploitation Steps
Note: These are suggested steps based on static analysis, as our tooling does not yet execute proof-of-concept exploits.
- Setup: A user installs a malicious Chrome App with
fileSystemwrite permissions. - Delivery: The attacker convinces the user to download and extract an archive (using a native OS tool that preserves symlinks) containing a dangling symlink (e.g.,
evilpointing to/home/chronos/user/.bash_profile). - Selection: The app prompts the user via
chrome.fileSystem.chooseEntry({type: 'openDirectory'})to select the extracted directory. - Escalation: The app calls
chrome.fileSystem.getWritableEntry()on theevilsymlink. - Trigger: The browser’s flawed check passes, and the POSIX
opencall creates the.bash_profiletarget. - Write: The app writes a malicious bash payload to the writable entry, achieving an arbitrary file write out of bounds and potential persistent sandbox escape.
Suggested Fix
- Correct the Validation Logic: Do not rely on
base::PathExists(which follows symlinks) to guardbase::IsLink. Unconditionally checkbase::IsLink(path)first, or usebase::GetFileInfowithlstatto securely verify file attributes without following links. For example:if (base::IsLink(path)) return false; - Harden File Creation: Ensure that paths originating from untrusted contexts are opened using the
O_NOFOLLOWflag on POSIX to prevent symlink traversal attacks at the storage layer.
Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234
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.