CVE-2026-79155
Overview
Background
- `UrlFetchRequestBase::WriteFileData`
- the
google_apisroutine that opens the caller-suppliedoutput_file_pathand writes downloaded response bytes into it. - `DownloadData::output_file` / `output_file_path`
- a
base::Filehandle and its target path; the file is opened lazily insideWriteFileDatawhen the handle is not already valid. - Symlink following (TOCTOU)
- a time-of-check/time-of-use window where a path created earlier as a regular temp file is replaced by a symbolic link before it is reopened, so the open follows the link to an attacker-chosen target.
- `O_NOFOLLOW`
- a POSIX
openflag that makes the call fail if the final path component is a symbolic link, preventing writes through the link.
Root Cause Analysis
UrlFetchRequestBase::WriteFileData opens download_data->output_file_path only when output_file is not already valid, and this open happens some time after the caller may have created that path as a temporary file. Pre-patch it used base::File::Initialize with FLAG_CREATE_ALWAYS | FLAG_WRITE, which follows symbolic links; if an attacker replaced the final path component with a symlink during that window, the download bytes were written through the link to whatever target the link pointed at. The invariant violated is that a download must only write to the actual file the caller staged, never to a redirected target chosen by a racing process.
The fix, on POSIX, opens the path directly with open(..., O_CREAT | O_TRUNC | O_WRONLY | O_NOFOLLOW | O_CLOEXEC, S_IRUSR | S_IWUSR) under a ScopedBlockingCall; if the final component is now a symlink the open fails, WriteFileData returns false, and the request is cancelled (reported as OTHER_ERROR) rather than writing through the link. The regression test confirms a symlinked output path leaves the link target’s contents untouched and the request reports non-success.
O_NOFOLLOW so a symlinked final component aborts the write instead of following it.Attack Path
- Observe the staged path
A local attacker with access to the download’s output directory identifies the
output_file_pathcreated as a temporary file beforeWriteFileDatareopens it. - Win the race In the window between staging and the (previously symlink-following) reopen, the attacker replaces the final path component with a symbolic link pointing at a sensitive target file.
- Redirect the write
Pre-patch,
base::File::Initializefollows the link, so the downloaded response bytes are written to the attacker-chosen target, overwriting or creating it with attacker-influenced content. - Post-fix failure
With
O_NOFOLLOW, the reopen fails on the symlink,WriteFileDatareturns false, and the request is cancelled withOTHER_ERROR, leaving the link target untouched.
Impact Assessment
google_apis download machinery to write response data through a symlink to a file of the attacker’s choosing, an arbitrary-write/overwrite primitive limited to POSIX platforms and to files writable in that context. The precondition is local access to swap the final path component within the TOCTOU window; the advisory classifies this as a race condition in FileSystem and reports no in-the-wild exploitation. Any escalation from the redirected write (e.g. corrupting a security-relevant file) is beyond what the patch demonstrates (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fgoogle_apis/drive/drive_base_requests_server_unittest.cc |
modified |
Files Changed
google_apis/common/base_requests.ccgoogle_apis/drive/drive_base_requests_server_unittest.cc
Audit Directions
- Reopen-after-stage patternsAudit any code that creates a temp/output path and later reopens it by path (rather than by retained fd), especially with
CREATE_ALWAYS/create-truncate semantics, for symlink-following TOCTOU on POSIX. - Missing `O_NOFOLLOW`Search
base::File::Initializeand rawopencall sites that write attacker-influenced or downloaded data to caller-supplied paths and confirm they refuse symlinked final components. - Cross-platform gapsWhere a POSIX-only
O_NOFOLLOWguard is added, verify the corresponding Windows path (e.g. reparse-point handling) has an equivalent protection or is genuinely not exposed.
Patch
From 8d02bbd914aaf89947d8055e10f41d45eab99fc2 Mon Sep 17 00:00:00 2001 From: Fergal Daly <[email protected]> Date: Thu, 02 Jul 2026 02:33:00 -0700 Subject: [PATCH] google_apis: Do not follow symlinks when writing download output UrlFetchRequestBase::WriteFileData() opens the caller-supplied output path some time after the caller may have created it as a temporary file. On POSIX, open the path with O_NOFOLLOW so that the write fails if the final path component has been replaced by a symbolic link in the meantime, and the request is cancelled with OTHER_ERROR instead of writing through the link. Add a DownloadFileRequestBase regression test that supplies a symlink as the output path and verifies that the link target is left untouched and the request reports failure. Fixed: 522294538 Change-Id: Ia436d9f66fbb36d221d437a3a507c03e8cad9b04 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8029006 Reviewed-by: Mingyu Lei <[email protected]> Auto-Submit: Fergal Daly <[email protected]> Reviewed-by: Fergal Daly <[email protected]> Commit-Queue: Fergal Daly <[email protected]> Cr-Commit-Position: refs/heads/main@{#1655969} --- diff --git a/google_apis/common/base_requests.cc b/google_apis/common/base_requests.cc index 4ac59d3..037901c 100644 --- a/google_apis/common/base_requests.cc +++ b/google_apis/common/base_requests.cc @@ -13,6 +13,7 @@ #include <utility> #include "base/containers/span.h" +#include "base/files/file.h" #include "base/functional/bind.h" #include "base/functional/callback_helpers.h" #include "base/json/json_reader.h" @@ -21,6 +22,7 @@ #include "base/task/sequenced_task_runner.h" #include "base/task/single_thread_task_runner.h" #include "base/values.h" +#include "build/build_config.h" #include "google_apis/common/request_sender.h" #include "google_apis/common/task_util.h" #include "google_apis/credentials_mode.h" @@ -31,6 +33,15 @@ #include "services/network/public/cpp/resource_request.h" #include "services/network/public/mojom/url_response_head.mojom.h" +#if BUILDFLAG(IS_POSIX) +#include <fcntl.h> +#include <sys/stat.h> + +#include "base/files/scoped_file.h" +#include "base/posix/eintr_wrapper.h" +#include "base/threading/scoped_blocking_call.h" +#endif + namespace { // Template for optional OAuth2 authorization HTTP header. @@ -336,9 +347,24 @@ bool UrlFetchRequestBase::WriteFileData(std::string file_data, DownloadData* download_data) { if (!download_data->output_file.IsValid()) { +#if BUILDFLAG(IS_POSIX) + // The output path may refer to a temporary file that was created in + // advance and is being reopened here, so do not follow symbolic links. + base::ScopedBlockingCall scoped_blocking_call( + FROM_HERE, base::BlockingType::MAY_BLOCK); + base::ScopedFD fd( + HANDLE_EINTR(open(download_data->output_file_path.value().c_str(), + O_CREAT | O_TRUNC | O_WRONLY | O_NOFOLLOW | O_CLOEXEC, + S_IRUSR | S_IWUSR))); + if (!fd.is_valid()) { + return false; + } + download_data->output_file = base::File(std::move(fd)); +#else download_data->output_file.Initialize( download_data->output_file_path, base::File::FLAG_CREATE_ALWAYS | base::File::FLAG_WRITE); +#endif if (!download_data->output_file.IsValid()) return false; } diff --git a/google_apis/drive/drive_base_requests_server_unittest.cc b/google_apis/drive/drive_base_requests_server_unittest.cc index 8f3fc55..d80f5c1 100644 --- a/google_apis/drive/drive_base_requests_server_unittest.cc +++ b/google_apis/drive/drive_base_requests_server_unittest.cc @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -#include "google_apis/drive/drive_base_requests.h" - #include <memory> #include "base/files/file_util.h" @@ -11,10 +9,12 @@ #include "base/functional/bind.h" #include "base/run_loop.h" #include "base/test/task_environment.h" +#include "build/build_config.h" #include "google_apis/common/dummy_auth_service.h" #include "google_apis/common/request_sender.h" #include "google_apis/common/task_util.h" #include "google_apis/common/test_util.h" +#include "google_apis/drive/drive_base_requests.h" #include "mojo/public/cpp/bindings/pending_remote.h" #include "mojo/public/cpp/bindings/remote.h" #include "net/test/embedded_test_server/embedded_test_server.h" @@ -166,4 +166,39 @@ // Do not verify the not found message. } +#if BUILDFLAG(IS_POSIX) +TEST_F(BaseRequestsServerTest, DownloadFileRequest_SymlinkOutputPath) { + const std::string kTargetContents = "must not be overwritten"; + const base::FilePath target_path = + GetTestCachedFilePath(base::FilePath::FromUTF8Unsafe("symlink_target")); + ASSERT_TRUE(base::WriteFile(target_path, kTargetContents)); + + const base::FilePath link_path = + GetTestCachedFilePath(base::FilePath::FromUTF8Unsafe("symlink_output")); + ASSERT_TRUE(base::CreateSymbolicLink(target_path, link_path)); + + ApiErrorCode result_code = OTHER_ERROR; + base::FilePath temp_file; + { + base::RunLoop run_loop; + std::unique_ptr<DownloadFileRequestBase> request = + std::make_unique<DownloadFileRequestBase>( + request_sender_.get(), + test_util::CreateQuitCallback( + &run_loop, + test_util::CreateCopyResultCallback(&result_code, &temp_file)), + GetContentCallback(), ProgressCallback(), + test_server_.GetURL("/files/drive/testfile.txt"), link_path); + request_sender_->StartRequestWithAuthRetry(std::move(request)); + run_loop.Run(); + } + + EXPECT_NE(HTTP_SUCCESS, result_code); + + std::string target_contents; + ASSERT_TRUE(base::ReadFileToString(target_path, &target_contents)); + EXPECT_EQ(kTargetContents, target_contents); +} +#endif // BUILDFLAG(IS_POSIX) + } // namespace google_apis
Regression Test / PoC
diff --git a/google_apis/drive/drive_base_requests_server_unittest.cc b/google_apis/drive/drive_base_requests_server_unittest.cc
index 8f3fc55..d80f5c1 100644
--- a/google_apis/drive/drive_base_requests_server_unittest.cc
+++ b/google_apis/drive/drive_base_requests_server_unittest.cc
@@ -2,8 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-#include "google_apis/drive/drive_base_requests.h"
-
#include <memory>
#include "base/files/file_util.h"
@@ -11,10 +9,12 @@
#include "base/functional/bind.h"
#include "base/run_loop.h"
#include "base/test/task_environment.h"
+#include "build/build_config.h"
#include "google_apis/common/dummy_auth_service.h"
#include "google_apis/common/request_sender.h"
#include "google_apis/common/task_util.h"
#include "google_apis/common/test_util.h"
+#include "google_apis/drive/drive_base_requests.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
@@ -166,4 +166,39 @@
// Do not verify the not found message.
}
+#if BUILDFLAG(IS_POSIX)
+TEST_F(BaseRequestsServerTest, DownloadFileRequest_SymlinkOutputPath) {
+ const std::string kTargetContents = "must not be overwritten";
+ const base::FilePath target_path =
+ GetTestCachedFilePath(base::FilePath::FromUTF8Unsafe("symlink_target"));
+ ASSERT_TRUE(base::WriteFile(target_path, kTargetContents));
+
+ const base::FilePath link_path =
+ GetTestCachedFilePath(base::FilePath::FromUTF8Unsafe("symlink_output"));
+ ASSERT_TRUE(base::CreateSymbolicLink(target_path, link_path));
+
+ ApiErrorCode result_code = OTHER_ERROR;
+ base::FilePath temp_file;
+ {
+ base::RunLoop run_loop;
+ std::unique_ptr<DownloadFileRequestBase> request =
+ std::make_unique<DownloadFileRequestBase>(
+ request_sender_.get(),
+ test_util::CreateQuitCallback(
+ &run_loop,
+ test_util::CreateCopyResultCallback(&result_code, &temp_file)),
+ GetContentCallback(), ProgressCallback(),
+ test_server_.GetURL("/files/drive/testfile.txt"), link_path);
+ request_sender_->StartRequestWithAuthRetry(std::move(request));
+ run_loop.Run();
+ }
+
+ EXPECT_NE(HTTP_SUCCESS, result_code);
+
+ std::string target_contents;
+ ASSERT_TRUE(base::ReadFileToString(target_path, &target_contents));
+ EXPECT_EQ(kTargetContents, target_contents);
+}
+#endif // BUILDFLAG(IS_POSIX)
+
} // namespace google_apis
Original Bug Report
Potential macOS RemoteToLocalSyncer TOCTOU allows arbitrary file write and sandbox escape
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 TOCTOU vulnerability in RemoteToLocalSyncer::DownloadFile on macOS allows a compromised sandboxed process to achieve arbitrary file truncation and write operations at browser-process privilege. This occurs because the browser process discards the safe file descriptor of a generated temporary file, subsequently re-opening the path without O_NOFOLLOW. Consequently, a compromised child process with write permissions to the shared temporary directory could swap the file for a symbolic link during the network download round-trip.
Affected files:
chrome/browser/sync_file_system/drive_backend/remote_to_local_syncer.ccgoogle_apis/common/base_requests.ccchrome/browser/sync_file_system/sync_file_system_service.cc
Estimated timestamp from git blame: 2013-11-12
Detailed Description
A potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability has been identified in the RemoteToLocalSyncer::DownloadFile function on macOS. The issue stems from the way temporary download files are managed and subsequently populated with network data.
Potential Vulnerability Mechanism
-
Temporary File Creation and FD Discarding:
RemoteToLocalSyncer::DownloadFilecreates a temporary file under the system temporary directory (which resolves to_CS_DARWIN_USER_TEMP_DIRviaNSTemporaryDirectory()on macOS) by callingCreateTemporaryFile:// chrome/browser/sync_file_system/drive_backend/remote_to_local_syncer.cc storage::ScopedFile file = CreateTemporaryFile(...); base::FilePath path = file.path();The underlying implementation in
base::CreateTemporaryFileInDirgenerates the path, invokesmkstemp(), and immediately closes the safe file descriptor, returning only the raw path to the caller. -
Network Process Round-Trip and Path Re-opening: The naked file path is passed to the Drive download stack (
drive_service()->DownloadFile). TheUrlFetchRequestBasestreams the download via the network service. When data is received, the browser process re-opens the file path on its blocking task runner using thebase::File::FLAG_CREATE_ALWAYSflag:// google_apis/common/base_requests.cc download_data->output_file.Initialize( download_data->output_file_path, base::File::FLAG_CREATE_ALWAYS | base::File::FLAG_WRITE); -
Unsafe POSIX open() Flags: In
base/files/file_posix.cc,FLAG_CREATE_ALWAYSis translated toO_CREAT | O_TRUNC(withO_WRONLY), but does not includeO_NOFOLLOW:if (flags & FLAG_CREATE_ALWAYS) { open_flags = O_CREAT | O_TRUNC; }
Potential Attack Path (Theoretical Analysis)
Because the automated security tooling does not have the capability to execute code or verify runtime behavior, the following is a theoretical sequence of steps an attacker might follow to exploit this behavior:
- An attacker first obtains code execution within a sandboxed child process (such as the Network Process or GPU Process) on macOS.
- Both the unsandboxed browser process and the sandboxed child processes share access to the per-user temporary directory (
_CS_DARWIN_USER_TEMP_DIR). The child processes havefile-write*permissions (which allowunlinkandsymlinkoperations) on this directory. - The attacker monitors the temporary directory for files conforming to the temporary template (e.g.,
.<bundle-id>.XXXXXX). - Upon detection, the attacker unlinks the temporary file and creates a symbolic link at that path pointing to a sensitive file owned by the victim (for example,
~/.zshrc). - When the browser process receives the download data stream, it opens the path via
open(). WithoutO_NOFOLLOW, the kernel follows the symbolic link, leading to the truncation and overwrite of the target user file (~/.zshrc) with the downloaded content.
If the attacker controls the downloaded content (which is the case if they have compromised the Network Process and can spoof or modify response streams), they can achieve arbitrary code execution outside of the sandbox upon the next shell initiation.
Suggested Fix
To resolve this potential vulnerability, the file descriptor returned by the temporary file creation function should be retained and passed directly to the writer, avoiding any name-based re-opening. Alternatively, if name-based re-opening is necessary, the file must be opened using safety flags such as O_NOFOLLOW or the directory must be exclusive to the browser process and inaccessible to sandboxed child processes.
Evaluated with Chrome root at commit: b2fea2e31df308d0f04e4ae47def4c4f939ee141
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.