CVE-2026-13791
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/download/internal/common/download_path_reservation_tracker.cc |
modified | |
TEST_Fcomponents/download/internal/common/download_path_reservation_tracker_unittest.cc |
modified |
Files Changed
components/download/internal/common/download_path_reservation_tracker.cccomponents/download/internal/common/download_path_reservation_tracker_unittest.cc
Patch
From 3cf661046fb67c4e379096bed32ddc5f5a66ea2c Mon Sep 17 00:00:00 2001
From: Min Qin <qinmin@chromium.org>
Date: Thu, 21 May 2026 18:27:18 -0700
Subject: [PATCH] Enforce resolved-path containment for downloads to prevent symlink traversal
This CL changes ValidatePathAndResolveConflicts() to ensure it will only
return a file path that is under default download dir or fallback dir.
Bug: 503850012
Change-Id: I266b04f9621a787df0855e30c305b6c861e740d8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7854993
Reviewed-by: Shakti Sahu <shaktisahu@chromium.org>
Commit-Queue: Min Qin <qinmin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1634653}
---
diff --git a/components/download/internal/common/download_path_reservation_tracker.cc b/components/download/internal/common/download_path_reservation_tracker.cc
index 06310f4a..56aea36 100644
--- a/components/download/internal/common/download_path_reservation_tracker.cc
+++ b/components/download/internal/common/download_path_reservation_tracker.cc
@@ -207,9 +207,11 @@
base::FilePath temporary_path;
base::FilePath fallback_directory; // directory to use when target path
// cannot be used.
- bool create_target_directory;
+ bool create_target_directory = false;
base::Time start_time;
DownloadPathReservationTracker::FilenameConflictAction conflict_action;
+ bool is_transient = false;
+ bool is_forced_path = false;
};
// Check if |target_path| is writable.
@@ -253,8 +255,30 @@
PathValidationResult ValidatePathAndResolveConflicts(
const CreateReservationInfo& info,
base::FilePath* target_path) {
+ // Enforce that the suggested path does not escape the default download
+ // directory via symlink/junction traversal.
+ bool path_escaped = false;
+ if (!info.is_transient && !info.is_forced_path &&
+ !info.default_download_path.empty() &&
+ base::PathExists(info.default_download_path)) {
+ base::FilePath absolute_default_path =
+ base::MakeAbsoluteFilePath(info.default_download_path);
+ base::FilePath absolute_target_dir =
+ base::MakeAbsoluteFilePath(target_path->DirName());
+ if (!absolute_default_path.empty() && !absolute_target_dir.empty()) {
+ if (absolute_target_dir != absolute_default_path &&
+ !absolute_default_path.IsParent(absolute_target_dir)) {
+ DVLOG(1) << "Path escapes default download path via symlink/junction \""
+ << target_path->value() << "\"";
+ *target_path =
+ info.default_download_path.Append(target_path->BaseName());
+ path_escaped = true;
+ }
+ }
+ }
+
// Check writability of the suggested path. If we can't write to it, use
- // the |default_download_path| if it is not empty or |fallback_directory|.
+ // |default_download_path| if it is not empty or |fallback_directory|.
// We'll prompt them in this case. No further amendments are made to the
// filename since the user is going to be prompted.
if (!IsPathWritable(info, *target_path)) {
@@ -268,6 +292,10 @@
return PathValidationResult::PATH_NOT_WRITABLE;
}
+ if (path_escaped) {
+ return PathValidationResult::PATH_NOT_WRITABLE;
+ }
+
int max_path_component_length =
base::GetMaximumPathComponentLength(target_path->DirName());
// Check the limit of file name length if it could be obtained. When the
@@ -513,7 +541,9 @@
fallback_directory,
create_directory,
download_item->GetStartTime(),
- conflict_action};
+ conflict_action,
+ download_item->IsTransient(),
+ !download_item->GetForcedFilePath().empty()};
GetTaskRunner()->PostTaskAndReplyWithResult(
FROM_HERE, base::BindOnce(&CreateReservation, info, reserved_path),
diff --git a/components/download/internal/common/download_path_reservation_tracker_unittest.cc b/components/download/internal/common/download_path_reservation_tracker_unittest.cc
index a107052..026051cc 100644
--- a/components/download/internal/common/download_path_reservation_tracker_unittest.cc
+++ b/components/download/internal/common/download_path_reservation_tracker_unittest.cc
@@ -125,6 +125,9 @@
EXPECT_CALL(*item, GetState())
.WillRepeatedly(Return(DownloadItem::IN_PROGRESS));
EXPECT_CALL(*item, GetURL()).WillRepeatedly(ReturnRefOfCopy(GURL()));
+ EXPECT_CALL(*item, IsTransient()).WillRepeatedly(Return(false));
+ EXPECT_CALL(*item, GetForcedFilePath())
+ .WillRepeatedly(ReturnRefOfCopy(base::FilePath()));
static constexpr base::Time::Exploded kReferenceTime = {.year = 2019,
.month = 1,
@@ -728,4 +731,27 @@
#endif // Platforms that support filename truncation.
+#if BUILDFLAG(IS_POSIX) && !BUILDFLAG(IS_ANDROID)
+TEST_F(DownloadPathReservationTrackerTest, SymlinkTraversingPath) {
+ std::unique_ptr<MockDownloadItem> item = CreateDownloadItem(1);
+ base::ScopedTempDir external_dir;
+ ASSERT_TRUE(external_dir.CreateUniqueTempDir());
+
+ base::FilePath symlink_path =
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("symlink_dir"));
+ ASSERT_TRUE(base::CreateSymbolicLink(external_dir.GetPath(), symlink_path));
+
+ base::FilePath target_path =
+ symlink_path.Append(FILE_PATH_LITERAL("payload.txt"));
+ ASSERT_FALSE(IsPathInUse(target_path));
+
+ CreateReservation(
+ item.get(), target_path, DownloadPathReservationTracker::OVERWRITE,
+ PathValidationResult::PATH_NOT_WRITABLE,
+ default_download_path().Append(FILE_PATH_LITERAL("payload.txt")));
+
+ SetDownloadItemState(item.get(), DownloadItem::COMPLETE);
+}
+#endif
+
} // namespace download
Regression Test / PoC
diff --git a/components/download/internal/common/download_path_reservation_tracker_unittest.cc b/components/download/internal/common/download_path_reservation_tracker_unittest.cc
index a107052..026051cc 100644
--- a/components/download/internal/common/download_path_reservation_tracker_unittest.cc
+++ b/components/download/internal/common/download_path_reservation_tracker_unittest.cc
@@ -125,6 +125,9 @@
EXPECT_CALL(*item, GetState())
.WillRepeatedly(Return(DownloadItem::IN_PROGRESS));
EXPECT_CALL(*item, GetURL()).WillRepeatedly(ReturnRefOfCopy(GURL()));
+ EXPECT_CALL(*item, IsTransient()).WillRepeatedly(Return(false));
+ EXPECT_CALL(*item, GetForcedFilePath())
+ .WillRepeatedly(ReturnRefOfCopy(base::FilePath()));
static constexpr base::Time::Exploded kReferenceTime = {.year = 2019,
.month = 1,
@@ -728,4 +731,27 @@
#endif // Platforms that support filename truncation.
+#if BUILDFLAG(IS_POSIX) && !BUILDFLAG(IS_ANDROID)
+TEST_F(DownloadPathReservationTrackerTest, SymlinkTraversingPath) {
+ std::unique_ptr<MockDownloadItem> item = CreateDownloadItem(1);
+ base::ScopedTempDir external_dir;
+ ASSERT_TRUE(external_dir.CreateUniqueTempDir());
+
+ base::FilePath symlink_path =
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("symlink_dir"));
+ ASSERT_TRUE(base::CreateSymbolicLink(external_dir.GetPath(), symlink_path));
+
+ base::FilePath target_path =
+ symlink_path.Append(FILE_PATH_LITERAL("payload.txt"));
+ ASSERT_FALSE(IsPathInUse(target_path));
+
+ CreateReservation(
+ item.get(), target_path, DownloadPathReservationTracker::OVERWRITE,
+ PathValidationResult::PATH_NOT_WRITABLE,
+ default_download_path().Append(FILE_PATH_LITERAL("payload.txt")));
+
+ SetDownloadItemState(item.get(), DownloadItem::COMPLETE);
+}
+#endif
+
} // namespace download
Original Bug Report
chrome.downloads symlink/junction escape under Downloads can lead to RCE
Report description
chrome.downloads symlink/junction escape under Downloads can lead to RCE
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Summary
An extension can escape the intended Downloads root by supplying a lexically
safe relative filename whose intermediate component is an existing symlink
(POSIX) or junction/reparse-point directory (Windows) inside the Downloads
directory. Chrome accepts the path as syntactically safe and then writes through
the linked directory without enforcing resolved-path containment. Combined with
downloads.open, this can be turned into remote code execution, as shown in the
attached PoC and video: https://drive.google.com/file/d/1rQFgd76v7Djt-_UteD2rRi0UzaXKfjj7/view?usp=sharing
Root Cause
chrome.downloads.download()only validates the extension-suppliedfilenameas a lexically safe relative path inchrome/browser/extensions/api/downloads/downloads_api.cc:1163-1170.downloads.onDeterminingFilenamesuggestions are validated the same way inchrome/browser/extensions/api/downloads/downloads_api.cc:2001-2015.- That validation relies on
net::IsSafePortableRelativePath()innet/base/filename_util_icu.cc:34-44, which rejects absolute paths and traversal tokens but does not resolve symlinks/junctions. - Chrome then roots the path with a plain append in
chrome/browser/download/download_target_determiner.cc:486-487. - The reservation layer explicitly documents that it does not look at symlinks
or mount points in
components/download/public/common/download_path_reservation_tracker.h:107-109, and it uses ordinary writability / directory creation checks incomponents/download/internal/common/download_path_reservation_tracker.cc:219-225and:357-361.
Prerequisites
- macOS.
- A macOS configuration where the logged-in user account running Chrome can
overwrite files under
/Applications/Google Chrome.app. In my testing, this was possible on a default non-enterprise device; managed or locked-down systems may differ.
Steps to Reproduce
- Download the attached extension files (
manifest.json,service-worker.js,open.html,open.js, andzip_builder.js) and place them together in a local directory namedinstall-download-open. - Open
chrome://extensions, enable Developer mode, clickLoad unpacked, and select thatinstall-download-opendirectory. - When
open.htmlopens, click the button. - Wait a few seconds for the sequence to complete.
Expected Result
- Calculator opens.
Impact analysis
A malicious browser extension that has the downloads permission can exploit this issue. For the full chain shown in the PoC, the extension also uses downloads.open. In practice, that means an attacker would need to get a user to install a malicious extension, or compromise an existing extension that already has those permissions. This is not a normal website-only bug.
What the attacker gains is the ability to make Chrome write attacker-controlled files outside the intended Downloads directory by targeting a preexisting symlink or junction under Downloads. That expands the extension’s write primitive beyond the Downloads sandbox. In the PoC, that primitive is strong enough to overwrite an executable target and trigger code execution on macOS.
The cause
What version of Chrome have you found the security issue in?
147.0.7727.56 (Official Build) (arm64)
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Remote Code Execution (RCE)
How would you like to be publicly acknowledged for your report?
Ron Masas