CVE-2026-78898
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 | |
ifcomponents/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 bef9afc5c8bec12e0055064da8281aa76e20dcd1 Mon Sep 17 00:00:00 2001
From: Joe Mason <joenotcharles@google.com>
Date: Thu, 23 Jul 2026 10:32:26 -0700
Subject: [PATCH] Treat download files that differ only by case as SAME_AS_SOURCE
An existing comment says: "Assume that downloading a file onto another
file that differs only by case is not enough of a legitimate edge case
to justify determining the case sensitivity of the underlying
filesystem," then treats files that differ only by case as "different"
on all systems. This flips that logic and treats them as "the same" on
all systems. That can lead to false positives where file downloads are
incorrectly denied on case-sensitive systems, but that's safer than a
false negative on case-insensitive systems.
Since the comparison uses the full file path, not just the name, this
only affects the extreme edge case of a download source that's a file://
url pointing into the download directory, that's saved to the same path
with a different case. That's dangerous on case-insensitive file systems
since the output file could overwrite the input. On case-sensitive file
systems it would work, but seems reasonable to block.
Also adds some unit tests for more operations that could be affected by
case conflicts. These are already handled correctly but weren't
explicitly tested.
Fixed: 499423269
Change-Id: I99073a086eefb57fc091aca9a004da75ded2f0b6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8135087
Reviewed-by: Min Qin <qinmin@chromium.org>
Commit-Queue: Min Qin <qinmin@chromium.org>
Auto-Submit: Joe Mason <joenotcharles@google.com>
Cr-Commit-Position: refs/heads/main@{#1667233}
---
diff --git a/components/download/internal/common/download_path_reservation_tracker.cc b/components/download/internal/common/download_path_reservation_tracker.cc
index 2e8490d..9baf8e18 100644
--- a/components/download/internal/common/download_path_reservation_tracker.cc
+++ b/components/download/internal/common/download_path_reservation_tracker.cc
@@ -103,8 +103,9 @@
iter != g_reservation_map->end(); ++iter) {
if ((!item || iter->first != item) &&
base::FilePath::CompareEqualIgnoreCase(iter->second.value(),
- path.value()))
+ path.value())) {
return true;
+ }
}
return false;
}
@@ -324,8 +325,10 @@
// onto another file that differs only by case is not enough of a legitimate
// edge case to justify determining the case sensitivity of the underlying
// filesystem.
- if (*target_path == info.source_path)
+ if (base::FilePath::CompareEqualIgnoreCase(target_path->value(),
+ info.source_path.value())) {
return PathValidationResult::SAME_AS_SOURCE;
+ }
if (!IsPathInUse(*target_path))
return PathValidationResult::SUCCESS;
@@ -369,7 +372,11 @@
if (DownloadCollectionBridge::ShouldPublishDownload(target_path)) {
PathValidationResult result = PathValidationResult::SUCCESS;
// Disallow downloading a file onto itself. Assume that downloading a file
- if (target_path == info.source_path) {
+ // onto another file that differs only by case is not enough of a legitimate
+ // edge case to justify determining the case sensitivity of the underlying
+ // filesystem.
+ if (base::FilePath::CompareEqualIgnoreCase(target_path.value(),
+ info.source_path.value())) {
result = PathValidationResult::SAME_AS_SOURCE;
} else if (IsPathInUse(target_path)) {
// If the download is written to a content URI, put file name in the
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 749b51a..b4aa382a 100644
--- a/components/download/internal/common/download_path_reservation_tracker_unittest.cc
+++ b/components/download/internal/common/download_path_reservation_tracker_unittest.cc
@@ -314,6 +314,47 @@
EXPECT_FALSE(IsPathInUse(path1));
}
+// As above, but checks for existing files that differ only by case.
+TEST_F(DownloadPathReservationTrackerTest, CaseConflictingFiles) {
+ std::unique_ptr<MockDownloadItem> item = CreateDownloadItem(1);
+
+ base::FilePath path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("foo.txt")));
+ base::FilePath target_path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("FOO.txt")));
+ base::FilePath path1(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("FOO (1).txt")));
+ bool use_download_collection = false;
+#if BUILDFLAG(IS_ANDROID)
+ if (DownloadCollectionBridge::ShouldPublishDownload(path)) {
+ use_download_collection = true;
+ DownloadCollectionBridge::AddExistingFileNameForTesting(path.BaseName());
+ }
+#endif // BUILDFLAG(IS_ANDROID)
+ if (!use_download_collection) {
+ // Create a file at |path|, and a .crdownload file at |path1|.
+ ASSERT_TRUE(base::WriteFile(path, ""));
+ ASSERT_TRUE(base::WriteFile(
+ base::FilePath(path1.value() + FILE_PATH_LITERAL(".crdownload")), ""));
+ }
+
+ ASSERT_TRUE(IsPathInUse(path));
+
+ // Whether this counts as a conflict depends on whether the filesystem is
+ // actually case-sensitive.
+ CreateReservation(
+ item.get(), target_path, DownloadPathReservationTracker::UNIQUIFY,
+ IsPathInUse(target_path) ? PathValidationResult::SUCCESS_RESOLVED_CONFLICT
+ : PathValidationResult::SUCCESS,
+ IsPathInUse(target_path) ? path1 : target_path);
+
+ SetDownloadItemState(item.get(), DownloadItem::COMPLETE);
+ item.reset();
+ RunUntilIdle();
+ EXPECT_TRUE(IsPathInUse(path));
+ EXPECT_FALSE(IsPathInUse(path1));
+}
+
// If there are conflicting files on the file system, an overwriting reservation
// should succeed without altering the target path.
TEST_F(DownloadPathReservationTrackerTest, ConflictingFiles_Overwrite) {
@@ -341,6 +382,38 @@
RunUntilIdle();
}
+// As above, but checks for existing files that differ only by case. On a
+// case-sensitive filesystem this is redundant, since the files won't actually
+// conflict, but it's easier to run the test everywhere than to check the
+// filesystem type.
+TEST_F(DownloadPathReservationTrackerTest, CaseConflictingFiles_Overwrite) {
+ std::unique_ptr<MockDownloadItem> item = CreateDownloadItem(1);
+ base::FilePath path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("foo.txt")));
+ base::FilePath target_path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("FOO.txt")));
+ bool use_download_collection = false;
+#if BUILDFLAG(IS_ANDROID)
+ if (DownloadCollectionBridge::ShouldPublishDownload(path)) {
+ use_download_collection = true;
+ DownloadCollectionBridge::AddExistingFileNameForTesting(path.BaseName());
+ }
+#endif // BUILDFLAG(IS_ANDROID)
+ if (!use_download_collection) {
+ // Create a file at |path|.
+ ASSERT_TRUE(base::WriteFile(path, ""));
+ }
+ ASSERT_TRUE(IsPathInUse(path));
+
+ CreateReservation(item.get(), target_path,
+ DownloadPathReservationTracker::OVERWRITE,
+ PathValidationResult::SUCCESS, target_path);
+
+ SetDownloadItemState(item.get(), DownloadItem::COMPLETE);
+ item.reset();
+ RunUntilIdle();
+}
+
// If the source is a file:// URL that is in the download directory, then Chrome
// could download the file onto itself. Test that this is flagged by DPRT.
TEST_F(DownloadPathReservationTrackerTest, ConflictWithSource) {
@@ -369,6 +442,37 @@
RunUntilIdle();
}
+// As above, but check for a file:// URL that differs only by case. This should
+// be flagged on all file systems, even case-sensitive ones, for safety.
+TEST_F(DownloadPathReservationTrackerTest, CaseConflictWithSource) {
+ std::unique_ptr<MockDownloadItem> item = CreateDownloadItem(1);
+ base::FilePath path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("foo.txt")));
+ base::FilePath target_path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("FOO.txt")));
+ bool use_download_collection = false;
+#if BUILDFLAG(IS_ANDROID)
+ if (DownloadCollectionBridge::ShouldPublishDownload(path)) {
+ use_download_collection = true;
+ DownloadCollectionBridge::AddExistingFileNameForTesting(path.BaseName());
+ }
+#endif // BUILDFLAG(IS_ANDROID)
+ if (!use_download_collection) {
+ ASSERT_TRUE(base::WriteFile(path, ""));
+ }
+ ASSERT_TRUE(IsPathInUse(path));
+ EXPECT_CALL(*item, GetURL())
+ .WillRepeatedly(ReturnRefOfCopy(net::FilePathToFileURL(path)));
+
+ CreateReservation(item.get(), target_path,
+ DownloadPathReservationTracker::UNIQUIFY,
+ PathValidationResult::SAME_AS_SOURCE, target_path);
+
+ SetDownloadItemState(item.get(), DownloadItem::COMPLETE);
+ item.reset();
+ RunUntilIdle();
+}
+
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 749b51a..b4aa382a 100644
--- a/components/download/internal/common/download_path_reservation_tracker_unittest.cc
+++ b/components/download/internal/common/download_path_reservation_tracker_unittest.cc
@@ -314,6 +314,47 @@
EXPECT_FALSE(IsPathInUse(path1));
}
+// As above, but checks for existing files that differ only by case.
+TEST_F(DownloadPathReservationTrackerTest, CaseConflictingFiles) {
+ std::unique_ptr<MockDownloadItem> item = CreateDownloadItem(1);
+
+ base::FilePath path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("foo.txt")));
+ base::FilePath target_path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("FOO.txt")));
+ base::FilePath path1(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("FOO (1).txt")));
+ bool use_download_collection = false;
+#if BUILDFLAG(IS_ANDROID)
+ if (DownloadCollectionBridge::ShouldPublishDownload(path)) {
+ use_download_collection = true;
+ DownloadCollectionBridge::AddExistingFileNameForTesting(path.BaseName());
+ }
+#endif // BUILDFLAG(IS_ANDROID)
+ if (!use_download_collection) {
+ // Create a file at |path|, and a .crdownload file at |path1|.
+ ASSERT_TRUE(base::WriteFile(path, ""));
+ ASSERT_TRUE(base::WriteFile(
+ base::FilePath(path1.value() + FILE_PATH_LITERAL(".crdownload")), ""));
+ }
+
+ ASSERT_TRUE(IsPathInUse(path));
+
+ // Whether this counts as a conflict depends on whether the filesystem is
+ // actually case-sensitive.
+ CreateReservation(
+ item.get(), target_path, DownloadPathReservationTracker::UNIQUIFY,
+ IsPathInUse(target_path) ? PathValidationResult::SUCCESS_RESOLVED_CONFLICT
+ : PathValidationResult::SUCCESS,
+ IsPathInUse(target_path) ? path1 : target_path);
+
+ SetDownloadItemState(item.get(), DownloadItem::COMPLETE);
+ item.reset();
+ RunUntilIdle();
+ EXPECT_TRUE(IsPathInUse(path));
+ EXPECT_FALSE(IsPathInUse(path1));
+}
+
// If there are conflicting files on the file system, an overwriting reservation
// should succeed without altering the target path.
TEST_F(DownloadPathReservationTrackerTest, ConflictingFiles_Overwrite) {
@@ -341,6 +382,38 @@
RunUntilIdle();
}
+// As above, but checks for existing files that differ only by case. On a
+// case-sensitive filesystem this is redundant, since the files won't actually
+// conflict, but it's easier to run the test everywhere than to check the
+// filesystem type.
+TEST_F(DownloadPathReservationTrackerTest, CaseConflictingFiles_Overwrite) {
+ std::unique_ptr<MockDownloadItem> item = CreateDownloadItem(1);
+ base::FilePath path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("foo.txt")));
+ base::FilePath target_path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("FOO.txt")));
+ bool use_download_collection = false;
+#if BUILDFLAG(IS_ANDROID)
+ if (DownloadCollectionBridge::ShouldPublishDownload(path)) {
+ use_download_collection = true;
+ DownloadCollectionBridge::AddExistingFileNameForTesting(path.BaseName());
+ }
+#endif // BUILDFLAG(IS_ANDROID)
+ if (!use_download_collection) {
+ // Create a file at |path|.
+ ASSERT_TRUE(base::WriteFile(path, ""));
+ }
+ ASSERT_TRUE(IsPathInUse(path));
+
+ CreateReservation(item.get(), target_path,
+ DownloadPathReservationTracker::OVERWRITE,
+ PathValidationResult::SUCCESS, target_path);
+
+ SetDownloadItemState(item.get(), DownloadItem::COMPLETE);
+ item.reset();
+ RunUntilIdle();
+}
+
// If the source is a file:// URL that is in the download directory, then Chrome
// could download the file onto itself. Test that this is flagged by DPRT.
TEST_F(DownloadPathReservationTrackerTest, ConflictWithSource) {
@@ -369,6 +442,37 @@
RunUntilIdle();
}
+// As above, but check for a file:// URL that differs only by case. This should
+// be flagged on all file systems, even case-sensitive ones, for safety.
+TEST_F(DownloadPathReservationTrackerTest, CaseConflictWithSource) {
+ std::unique_ptr<MockDownloadItem> item = CreateDownloadItem(1);
+ base::FilePath path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("foo.txt")));
+ base::FilePath target_path(
+ GetPathInDownloadsDirectory(FILE_PATH_LITERAL("FOO.txt")));
+ bool use_download_collection = false;
+#if BUILDFLAG(IS_ANDROID)
+ if (DownloadCollectionBridge::ShouldPublishDownload(path)) {
+ use_download_collection = true;
+ DownloadCollectionBridge::AddExistingFileNameForTesting(path.BaseName());
+ }
+#endif // BUILDFLAG(IS_ANDROID)
+ if (!use_download_collection) {
+ ASSERT_TRUE(base::WriteFile(path, ""));
+ }
+ ASSERT_TRUE(IsPathInUse(path));
+ EXPECT_CALL(*item, GetURL())
+ .WillRepeatedly(ReturnRefOfCopy(net::FilePathToFileURL(path)));
+
+ CreateReservation(item.get(), target_path,
+ DownloadPathReservationTracker::UNIQUIFY,
+ PathValidationResult::SAME_AS_SOURCE, target_path);
+
+ SetDownloadItemState(item.get(), DownloadItem::COMPLETE);
+ item.reset();
+ RunUntilIdle();
+}
+
// Multiple reservations for the same path should uniquify around each other.
TEST_F(DownloadPathReservationTrackerTest, ConflictingReservations) {
std::unique_ptr<MockDownloadItem> item1 = CreateDownloadItem(1);
Original Bug Report
Potential MotW bypass via case-sensitive path comparison
Flapjack, 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 potential Mark-of-the-Web (MotW) bypass exists because DownloadPathReservationTracker uses case-sensitive path comparisons on case-insensitive filesystems. An attacker can use a mismatched-case file:// URL to bypass the self-download block and strip the MotW. This results in a copied executable lacking Gatekeeper or SmartScreen protections.
Affected files:
components/download/internal/common/download_path_reservation_tracker.cc
Estimated timestamp from git blame: 2021-10-21
Vulnerability Summary
A potential Mark-of-the-Web (MotW) bypass exists in DownloadPathReservationTracker::ValidatePathAndResolveConflicts. By exploiting a case mismatch on a case-insensitive filesystem (such as macOS APFS/HFS+ or Windows NTFS), an attacker can bypass the security check designed to prevent a file from being downloaded onto itself. This results in the creation of a duplicate file with its internet MotW stripped.
Technical Details
When reserving a download path, Chrome attempts to prevent MotW stripping by blocking downloads where the source and target paths are identical:
// components/download/internal/common/download_path_reservation_tracker.cc
if (*target_path == info.source_path)
return PathValidationResult::SAME_AS_SOURCE;
The vulnerability occurs because base::FilePath::operator== performs a strict, case-sensitive string comparison on macOS (and for path bodies on Windows). However, the underlying filesystems on these operating systems are typically case-insensitive.
If a download is initiated from a file:// URL where the path case is artificially altered (e.g., PAYLOAD.EXE instead of payload.exe), the *target_path == info.source_path check evaluates to false.
Standard processing then continues: IsPathInUse correctly detects the file’s existence on disk (as base::PathExists relies on the case-insensitive OS API), and Chrome resolves this “conflict” by uniquifying the target filename (e.g., payload (1).exe).
Because the source scheme is file://, Chrome’s quarantine service (e.g., in quarantine_mac.mm) applies a local attribute such as kLSQuarantineTypeOtherDownload rather than kLSQuarantineTypeWebDownload. The resulting copy is treated as a locally sourced file, effectively stripping the restrictive internet MotW.
Potential Exploitation Steps
Note: These are suggested steps based on static code analysis; our tooling agent does not execute live proof-of-concept code to verify.
- A victim downloads a malicious file (e.g.,
payload.exe) to their Downloads folder, which correctly receives an internet MotW from the OS. - The victim opens a locally saved attacker HTML file (
file://origin), which has the necessary privileges to request otherfile://URLs. - The HTML file triggers a programmatic download of the payload using an altered-case source URL but a normal-case target:
<a href="file:///Users/user/Downloads/PAYLOAD.EXE" download="payload.exe">. - The
SAME_AS_SOURCEcheck is bypassed due to the case-sensitive string comparison in Chrome. - Chrome creates a copy of the payload (e.g.,
payload (1).exe) and assigns it a local quarantine attribute. - The new copy can be executed by the user without triggering Gatekeeper or SmartScreen internet-download warnings.
Suggested Fix
Path equality checks in ValidatePathAndResolveConflicts should account for the case sensitivity of the underlying filesystem. Use a case-insensitive comparison like base::FilePath::CompareEqualIgnoreCase on affected platforms, or preferably, compare the underlying filesystem identity (e.g., checking device and inode numbers) to robustly verify if the source and target represent the exact same physical file.
Evaluated with Chrome root at commit: 09ec9e7cc4d24823d20b6d37cf3d282734f6bf0f
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.