Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in Downloads
DescriptionRace in Downloads
ComponentDownloads
Bug ClassRace
Tracker519996040
Fix commit44d45b1212be (chromium/src) +133/-22
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TEST_F
base/files/file_util_unittest.cc
modified
for
base/files/file_util_unittest.cc
modified

Files Changed

  • base/files/file_util.h
  • base/files/file_util_unittest.cc
  • base/files/file_util_win.cc
  • components/download/internal/common/base_file.cc
  • components/download/internal/common/base_file_unittest.cc
From 44d45b1212bef2b09204120d87f6612681388198 Mon Sep 17 00:00:00 2001
From: Min Qin <[email protected]>
Date: Mon, 15 Jun 2026 17:51:23 -0700
Subject: [PATCH] [base] Create temp download file in an atomic operation

This CL fixes an issue that currently the temporary file are created
through create->close->reopen-by-path patten. As a result of this CL,
the file is no longer closed during the process.

Splitted the CreateAndOpenTemporaryFileInDir() into 2 separate methods.
For download, it needs to remove the default FLAG_WIN_EXCLUSIVE_READ
flag as downloaded file needs to allow antivirus software to scan them.
So it cannot use the current additional_flags param.

Bug: 519996040
Change-Id: I4897c465d6e6804f213ae66bd0962e9954a168dd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7906416
Reviewed-by: Wez <[email protected]>
Commit-Queue: Min Qin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1647223}
---

diff --git a/base/files/file_util.h b/base/files/file_util.h
index 3691d2ad..f9e72c7 100644
--- a/base/files/file_util.h
+++ b/base/files/file_util.h
@@ -414,6 +414,19 @@
                                                  FilePath* temp_file,
                                                  uint32_t additional_flags = 0);
 
+#if BUILDFLAG(IS_WIN)
+// Similar to `CreateAndOpenTemporaryFileInDir`, but allows the caller to
+// specify custom `base::File::Flags` (defined in base/files/file.h) when
+// opening the file.
+// The `base::File::FLAG_CREATE` flag is automatically added to ensure atomic
+// creation (i.e. it will fail if the file already exists).
+// These custom |flags| completely replace the default flags used by
+// `CreateAndOpenTemporaryFileInDir`.
+BASE_EXPORT File CreateAndOpenTemporaryFileInDirWithFlags(const FilePath& dir,
+                                                          FilePath* temp_file,
+                                                          uint32_t flags);
+#endif
+
 // Creates a temporary file. The full path is placed in `path`, and the
 // function returns true if was successful in creating the file. The file will
 // be empty and all handles closed after this function returns.
diff --git a/base/files/file_util_unittest.cc b/base/files/file_util_unittest.cc
index a57bbd3..d9fa92f 100644
--- a/base/files/file_util_unittest.cc
+++ b/base/files/file_util_unittest.cc
@@ -3368,6 +3368,26 @@
 #endif
 }
 
+#if BUILDFLAG(IS_WIN)
+TEST_F(FileUtilTest, CreateAndOpenTemporaryFileInDirWithFlags) {
+  // Create a temporary file with flags that allow sharing for read and delete.
+  FilePath path;
+  uint32_t flags = File::FLAG_READ | File::FLAG_WRITE |
+                   File::FLAG_WIN_EXCLUSIVE_WRITE | File::FLAG_WIN_SHARE_DELETE;
+  File file = CreateAndOpenTemporaryFileInDirWithFlags(temp_dir_.GetPath(),
+                                                       &path, flags);
+  ASSERT_TRUE(file.IsValid());
+  EXPECT_FALSE(path.empty());
+
+  // Try to open another handle to it for reading.
+  File file2(path,
+             File::FLAG_OPEN | File::FLAG_READ | File::FLAG_WIN_SHARE_DELETE);
+  // On all platforms (including Windows), this should succeed because we
+  // did not set FLAG_WIN_EXCLUSIVE_READ.
+  EXPECT_TRUE(file2.IsValid());
+}
+#endif
+
 TEST_F(FileUtilTest, CreateTemporaryFileTest) {
   std::array<FilePath, 3> temp_files;
   for (auto& i : temp_files) {
diff --git a/base/files/file_util_win.cc b/base/files/file_util_win.cc
index 145e618..0a387dc 100644
--- a/base/files/file_util_win.cc
+++ b/base/files/file_util_win.cc
@@ -779,24 +779,12 @@
   return FilePath(FILE_PATH_LITERAL("C:\\"));
 }
 
-File CreateAndOpenTemporaryFileInDir(const FilePath& dir,
-                                     FilePath* temp_file,
-                                     uint32_t additional_flags) {
+File CreateAndOpenTemporaryFileInDirWithFlags(const FilePath& dir,
+                                              FilePath* temp_file,
+                                              uint32_t flags) {
   ScopedBlockingCall scoped_blocking_call(FROM_HERE, BlockingType::MAY_BLOCK);
 
-  // Open the file with exclusive r/w/d access, and allow the caller to decide
-  // to mark it for deletion upon close after the fact.
-  uint32_t flags = File::FLAG_CREATE | File::FLAG_READ | File::FLAG_WRITE |
-                   File::FLAG_WIN_EXCLUSIVE_READ |
-                   File::FLAG_WIN_EXCLUSIVE_WRITE |
-                   File::FLAG_CAN_DELETE_ON_CLOSE | additional_flags;
-
-  // Use GUID instead of ::GetTempFileName() to generate unique file names.
-  // "Due to the algorithm used to generate file names, GetTempFileName can
-  // perform poorly when creating a large number of files with the same prefix.
-  // In such cases, it is recommended that you construct unique file names based
-  // on GUIDs."
-  // https://msdn.microsoft.com/library/windows/desktop/aa364991.aspx
+  flags |= File::FLAG_CREATE;
 
   FilePath temp_name;
   File file;
@@ -831,6 +819,16 @@
   return file;
 }
 
+File CreateAndOpenTemporaryFileInDir(const FilePath& dir,
+                                     FilePath* temp_file,
+                                     uint32_t additional_flags) {
+  constexpr uint32_t default_flags =
+      File::FLAG_READ | File::FLAG_WRITE | File::FLAG_WIN_EXCLUSIVE_READ |
+      File::FLAG_WIN_EXCLUSIVE_WRITE | File::FLAG_CAN_DELETE_ON_CLOSE;
+  return CreateAndOpenTemporaryFileInDirWithFlags(
+      dir, temp_file, default_flags | additional_flags);
+}
+
 bool CreateTemporaryFileInDir(const FilePath& dir, FilePath* temp_file) {
   return CreateAndOpenTemporaryFileInDir(dir, temp_file).IsValid();
 }
diff --git a/components/download/internal/common/base_file.cc b/components/download/internal/common/base_file.cc
index fc570b5d..185b2b8 100644
--- a/components/download/internal/common/base_file.cc
+++ b/components/download/internal/common/base_file.cc
@@ -131,17 +131,48 @@
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
   DCHECK(!detached_);
 
+#if BUILDFLAG(IS_WIN)
+  constexpr uint32_t kTempFileFlags =
+      base::File::FLAG_READ | base::File::FLAG_WRITE |
+      base::File::FLAG_WIN_EXCLUSIVE_WRITE | base::File::FLAG_WIN_SHARE_DELETE;
+#endif
+
   if (full_path.empty()) {
     base::FilePath temp_file;
-    if ((default_directory.empty() ||
-         !base::CreateTemporaryFileInDir(default_directory, &temp_file)) &&
-        !base::CreateTemporaryFile(&temp_file)) {
-      return LogInterruptReason("Unable to create", 0,
-                                DOWNLOAD_INTERRUPT_REASON_FILE_FAILED);
+    base::File temp_base_file;
+    if (!default_directory.empty()) {
+#if BUILDFLAG(IS_WIN)
+      temp_base_file = base::CreateAndOpenTemporaryFileInDirWithFlags(
+          default_directory, &temp_file, kTempFileFlags);
+#else
+      temp_base_file =
+          base::CreateAndOpenTemporaryFileInDir(default_directory, &temp_file);
+#endif
+    }
+
+    if (!temp_base_file.IsValid()) {
+      base::FilePath system_temp_dir;
+      if (!base::GetTempDir(&system_temp_dir)) {
+        return LogInterruptReason("Unable to find temp directory", 0,
+                                  DOWNLOAD_INTERRUPT_REASON_FILE_FAILED);
+      }
+#if BUILDFLAG(IS_WIN)
+      temp_base_file = base::CreateAndOpenTemporaryFileInDirWithFlags(
+          system_temp_dir, &temp_file, kTempFileFlags);
+#else
+      temp_base_file =
+          base::CreateAndOpenTemporaryFileInDir(system_temp_dir, &temp_file);
+#endif
+      if (!temp_base_file.IsValid()) {
+        return LogInterruptReason("Unable to create temporary file", 0,
+                                  DOWNLOAD_INTERRUPT_REASON_FILE_FAILED);
+      }
     }
     full_path_ = temp_file;
+    file_ = std::move(temp_base_file);
   } else {
     full_path_ = full_path;
+    file_ = std::move(file);
   }
 
   bytes_so_far_ = bytes_so_far;
@@ -150,7 +181,6 @@
   // Sparse file doesn't validate hash.
   if (is_sparse_file_)
     secure_hash_.reset();
-  file_ = std::move(file);
 
   return Open(hash_so_far, bytes_wasted);
 }
diff --git a/components/download/internal/common/base_file_unittest.cc b/components/download/internal/common/base_file_unittest.cc
index 001f9805..5ed6bf95 100644
--- a/components/download/internal/common/base_file_unittest.cc
+++ b/components/download/internal/common/base_file_unittest.cc
@@ -745,6 +745,56 @@
   base_file_->Finish();
 }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/base/files/file_util_unittest.cc b/base/files/file_util_unittest.cc
index a57bbd3..d9fa92f 100644
--- a/base/files/file_util_unittest.cc
+++ b/base/files/file_util_unittest.cc
@@ -3368,6 +3368,26 @@
 #endif
 }
 
+#if BUILDFLAG(IS_WIN)
+TEST_F(FileUtilTest, CreateAndOpenTemporaryFileInDirWithFlags) {
+  // Create a temporary file with flags that allow sharing for read and delete.
+  FilePath path;
+  uint32_t flags = File::FLAG_READ | File::FLAG_WRITE |
+                   File::FLAG_WIN_EXCLUSIVE_WRITE | File::FLAG_WIN_SHARE_DELETE;
+  File file = CreateAndOpenTemporaryFileInDirWithFlags(temp_dir_.GetPath(),
+                                                       &path, flags);
+  ASSERT_TRUE(file.IsValid());
+  EXPECT_FALSE(path.empty());
+
+  // Try to open another handle to it for reading.
+  File file2(path,
+             File::FLAG_OPEN | File::FLAG_READ | File::FLAG_WIN_SHARE_DELETE);
+  // On all platforms (including Windows), this should succeed because we
+  // did not set FLAG_WIN_EXCLUSIVE_READ.
+  EXPECT_TRUE(file2.IsValid());
+}
+#endif
+
 TEST_F(FileUtilTest, CreateTemporaryFileTest) {
   std::array<FilePath, 3> temp_files;
   for (auto& i : temp_files) {
diff --git a/components/download/internal/common/base_file_unittest.cc b/components/download/internal/common/base_file_unittest.cc
index 001f9805..5ed6bf95 100644
--- a/components/download/internal/common/base_file_unittest.cc
+++ b/components/download/internal/common/base_file_unittest.cc
@@ -745,6 +745,56 @@
   base_file_->Finish();
 }
 
+// Test that a temporary file is created in the system temporary directory
+// when no default directory is provided.
+TEST_F(BaseFileTest, CreatedInSystemTempDirectory) {
+  ASSERT_TRUE(base_file_->full_path().empty());
+  DownloadInterruptReason result = base_file_->Initialize(
+      base::FilePath(), base::FilePath(), base::File(), 0, std::string(),
+      std::unique_ptr<crypto::SecureHash>(), false, &kTestDataBytesWasted);
+  ASSERT_EQ(DOWNLOAD_INTERRUPT_REASON_NONE, result);
+  EXPECT_FALSE(base_file_->full_path().empty());
+  EXPECT_TRUE(base_file_->in_progress());
+
+  base::FilePath system_temp_dir;
+  ASSERT_TRUE(base::GetTempDir(&system_temp_dir));
+  EXPECT_TRUE(system_temp_dir.IsParent(base_file_->full_path()));
+  base_file_->Finish();
+}
+
+// Test that a temporary file is created in the system temporary directory
+// when the default directory is unwritable (fallback mechanism).
+#if BUILDFLAG(IS_FUCHSIA)
+// TODO(crbug.com/40221266): Re-enable when MakeFileUnwritable works on Fuchsia.
+#define MAYBE_CreatedInSystemTempDirectoryFallback \
+  DISABLED_CreatedInSystemTempDirectoryFallback
+#else
+#define MAYBE_CreatedInSystemTempDirectoryFallback \
+  CreatedInSystemTempDirectoryFallback
+#endif
+TEST_F(BaseFileTest, MAYBE_CreatedInSystemTempDirectoryFallback) {
+  base::FilePath unwritable_dir(
+      temp_dir_.GetPath().AppendASCII("UnwritableDir"));
+  ASSERT_TRUE(base::CreateDirectory(unwritable_dir));
+
+  base::FilePermissionRestorer restore_permissions(unwritable_dir);
+  ASSERT_TRUE(base::MakeFileUnwritable(unwritable_dir));
+
+  ASSERT_TRUE(base_file_->full_path().empty());
+  DownloadInterruptReason result = base_file_->Initialize(
+      base::FilePath(), unwritable_dir, base::File(), 0, std::string(),
+      std::unique_ptr<crypto::SecureHash>(), false, &kTestDataBytesWasted);
+  ASSERT_EQ(DOWNLOAD_INTERRUPT_REASON_NONE, result);
+  EXPECT_FALSE(base_file_->full_path().empty());
+  EXPECT_TRUE(base_file_->in_progress());
+
+  EXPECT_FALSE(unwritable_dir.IsParent(base_file_->full_path()));
+  base::FilePath system_temp_dir;
+  ASSERT_TRUE(base::GetTempDir(&system_temp_dir));
+  EXPECT_TRUE(system_temp_dir.IsParent(base_file_->full_path()));
+  base_file_->Finish();
+}
+
 TEST_F(BaseFileTest, NoDoubleDeleteAfterCancel) {
   ASSERT_TRUE(InitializeFile());
   base::FilePath full_path = base_file_->full_path();
Loading diff…

Original Bug Report

reported by [email protected]

Potential macOS Sandbox Escape via SaveFile/BaseFile Temporary File TOCTOU Race

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in macOS ‘Save Page As’ operations. The browser creates a temporary file in the shared user temporary directory and immediately closes its file descriptor before later reopening it by path. A compromised sandboxed process with write access to the temporary directory could potentially race to replace the temporary file with a symlink, allowing the unsandboxed browser process to write attacker-controlled bytes to an arbitrary target file.

Affected files:

  • components/download/internal/common/base_file.cc
  • content/browser/download/save_file.cc

Estimated timestamp from git blame: 2008-07-26

Description

A potential filesystem Time-of-Check to Time-of-Use (TOCTOU) vulnerability has been identified in the macOS ‘Save Page As’ implementation. This issue could potentially allow a compromised sandboxed process (such as the GPU, Network, or On-Device Model Execution process) to escape its sandbox by racing the browser process to replace a temporary file with a symbolic link.

Root Cause Analysis

  1. Temporary File Generation without FD Retention: In content/browser/download/save_file.cc, SaveFile::Initialize() initializes the underlying BaseFile with empty paths:

    download::DownloadInterruptReason reason = file_.Initialize(
        /*full_path=*/base::FilePath(), /*default_directory=*/base::FilePath(),
        /*file=*/base::File(), ...);
    

    In components/download/internal/common/base_file.cc, because the path is empty, BaseFile::Initialize creates a temporary file via base::CreateTemporaryFile(&temp_file):

    if (full_path.empty()) {
      base::FilePath temp_file;
      if (... && !base::CreateTemporaryFile(&temp_file)) {
        return LogInterruptReason(...);
      }
      full_path_ = temp_file;
    }
    

    The base::CreateTemporaryFile helper calls mkstemp and immediately closes the resulting file descriptor when the ScopedFD goes out of scope in CreateTemporaryFileInDir (base/files/file_util_posix.cc). The file descriptor is discarded, and only the string path is retained.

  2. Reopening by Path Without O_NOFOLLOW: BaseFile::Initialize then calls BaseFile::Open(), which reopens the file by path via InitializeFile():

    file->Initialize(
        file_path,
        base::File::FLAG_OPEN_ALWAYS | base::File::FLAG_WRITE |
        base::File::FLAG_READ | ...);
    

    On POSIX platforms, FLAG_OPEN_ALWAYS maps to standard open(path, O_RDWR) or open(path, O_CREAT | O_RDWR) calls inside base/files/file_posix.cc. Crucially, these calls do not utilize the O_NOFOLLOW flag, meaning the system call will traverse symbolic links.

  3. Shared Temp Directory Trust Boundary on macOS: On macOS, the temporary file is created in _CS_DARWIN_USER_TEMP_DIR. The macOS Seatbelt sandbox profiles for several sandboxed processes (such as gpu.sb, network.sb, and on_device_model_execution.sb) explicitly allow directory scanning, unlinking, and writing in this exact directory parameter (darwin-user-temp-dir).

Potential Attack Scenario

Because our testing environment is purely analytical and does not have the ability to execute live exploit code, the following steps represent a potential attack path:

  1. An attacker gains remote code execution within a sandboxed helper process (e.g., the GPU or Network process).
  2. The user navigates to an attacker-controlled page and initiates a ‘Save Page As’ (‘Webpage, Complete’) command.
  3. The attacker’s page serves hundreds of small subresources to maximize the number of save operations and thus increase the likelihood of winning the race.
  4. For each subresource, the browser creates a temporary file under _CS_DARWIN_USER_TEMP_DIR and closes the file descriptor.
  5. The compromised sandboxed process, polling the directory, quickly calls unlink() on the newly created temporary file and replaces it with a symbolic link (e.g., pointing to ~/Library/LaunchAgents/pwn.plist).
  6. The browser process executes the open() call, follows the symlink, and truncates/writes the incoming attacker-controlled subresource bytes directly to the targeted launch agent plist.
  7. Upon completion, the rename operation moves the symlink itself (since rename does not follow symlinks), leaving the written launch agent payload in place outside the sandbox.

Suggested Fix

Instead of closing the file descriptor immediately after creation and reopening it by path, the browser should retain the open file descriptor from the initial creation step.

We recommend modifying BaseFile::Initialize to utilize base::CreateAndOpenTemporaryFileInDir or a similar wrapper that keeps the file descriptor open and passes it directly to BaseFile, removing the window of opportunity for a TOCTOU symlink swap. This pattern was previously applied successfully to resolve a similar issue in chrome/services/file_util/public/cpp/temporary_file_getter.cc.

Evaluated with Chrome root at commit: 57b021e1fdae94a215627d29aeb1ccf2eb5b3e91


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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