Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Extensions
DescriptionInsufficient validation of untrusted input in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker500526602
Fix commit722ab1647725 (chromium/src) +81/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TEST_F
extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc
modified

Files Changed

  • extensions/browser/api/file_handlers/app_file_handler_util.cc
  • extensions/browser/api/file_handlers/app_file_handler_util_unittest.cc
From 722ab1647725c36be04d27e03d8c7421c0268e40 Mon Sep 17 00:00:00 2001
From: Giovanni Pezzino <[email protected]>
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 <[email protected]>
Commit-Queue: Giovanni Pezzino <[email protected]>
Auto-Submit: Giovanni Pezzino <[email protected]>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • storage/browser/file_system/local_file_stream_writer.cc
  • extensions/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.

  1. Setup: A user installs a malicious Chrome App with fileSystem write permissions.
  2. 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., evil pointing to /home/chronos/user/.bash_profile).
  3. Selection: The app prompts the user via chrome.fileSystem.chooseEntry({type: 'openDirectory'}) to select the extracted directory.
  4. Escalation: The app calls chrome.fileSystem.getWritableEntry() on the evil symlink.
  5. Trigger: The browser’s flawed check passes, and the POSIX open call creates the .bash_profile target.
  6. 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

  1. Correct the Validation Logic: Do not rely on base::PathExists (which follows symlinks) to guard base::IsLink. Unconditionally check base::IsLink(path) first, or use base::GetFileInfo with lstat to securely verify file attributes without following links. For example:
    if (base::IsLink(path))
      return false;
    
  2. Harden File Creation: Ensure that paths originating from untrusted contexts are opened using the O_NOFOLLOW flag 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.

View on issue tracker