Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Downloads
DescriptionInsufficient validation of untrusted input in Downloads
ComponentDownloads
Bug ClassLogic Error
Tracker503850012
Fix commit3cf661046fb6 (chromium/src) +59/-3
CISA KEVNot listed
CreditedRon Masas (Imperva)
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
components/download/internal/common/download_path_reservation_tracker.cc
modified
TEST_F
components/download/internal/common/download_path_reservation_tracker_unittest.cc
modified

Files Changed

  • components/download/internal/common/download_path_reservation_tracker.cc
  • components/download/internal/common/download_path_reservation_tracker_unittest.cc
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
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by ro...@gmail.com

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-supplied filename as a lexically safe relative path in chrome/browser/extensions/api/downloads/downloads_api.cc:1163-1170.
  • downloads.onDeterminingFilename suggestions are validated the same way in chrome/browser/extensions/api/downloads/downloads_api.cc:2001-2015.
  • That validation relies on net::IsSafePortableRelativePath() in net/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 in components/download/internal/common/download_path_reservation_tracker.cc:219-225 and :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

  1. Download the attached extension files (manifest.json, service-worker.js, open.html, open.js, and zip_builder.js) and place them together in a local directory named install-download-open.
  2. Open chrome://extensions, enable Developer mode, click Load unpacked, and select that install-download-open directory.
  3. When open.html opens, click the button.
  4. 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)

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

View on issue tracker