CVE-2026-11047
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fbase/files/file_util_unittest.cc |
modified |
Files Changed
base/features.ccbase/features.hbase/files/file_util.hbase/files/file_util_unittest.cc
Patch
From aed056dc0487e9abd001ec7e7fcc189f6b5954be Mon Sep 17 00:00:00 2001 From: Sangbaek Park <[email protected]> Date: Tue, 28 Apr 2026 12:54:06 -0700 Subject: [PATCH] Reland "Do not follow reparse points in base::DeletePathRecursively on Windows" This is a reland of commit 4ca74d8dadca777b82b1016b564cdbd737ec6344 Original change's description: > Do not follow reparse points in base::DeletePathRecursively on Windows > > To successfully delete a directory tree on Windows, directories must be > empty before ::RemoveDirectory is called. To achieve this necessary > post-order traversal, base::DeletePathRecursively uses manual recursion > via base::FileEnumerator with recursive=false. > > However, this manual recursion bypassed FileEnumerator's built-in > safeguard that normally prevents following reparse points (like directory > junctions or symlinks) during recursive enumerations. As a result, the > deletion could traverse into reparse points, deleting their contents > outside the target directory. > > This CL adds an explicit check in DoDeleteFile and DeleteFileRecursive > to prevent following reparse points, making it consistent with POSIX > behavior where symlinks are not followed during recursive deletion. > > During directory enumeration, we explicitly call ::GetFileAttributes() > via base::IsLink() because the attributes cached in FileEnumerator's > find_data() may not reliably contain the FILE_ATTRIBUTE_REPARSE_POINT > bit on Windows. We also ensure that the junction itself is successfully > deleted via ::RemoveDirectory even when traversing its contents is > skipped. > > Test: base_unittests FileUtilTest.{Delete*Junctions*,IsLink} > > Bug: 498768132 > Change-Id: I682f8134e4517a424e464762b87b2bb0977e7e40 > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7735007 > Reviewed-by: Will Harris <[email protected]> > Reviewed-by: Mark Mentovai <[email protected]> > Commit-Queue: Sangbaek Park <[email protected]> > Reviewed-by: Greg Thompson <[email protected]> > Cr-Commit-Position: refs/heads/main@{#1617744} Bug: 498768132 Change-Id: I577731b92aa9e52e33b9f69786336819e46d6a62 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7785464 Reviewed-by: Will Harris <[email protected]> Reviewed-by: Mark Mentovai <[email protected]> Reviewed-by: Greg Thompson <[email protected]> Commit-Queue: Sangbaek Park <[email protected]> Cr-Commit-Position: refs/heads/main@{#1621980} --- diff --git a/base/features.cc b/base/features.cc index 09e7dee..c38e13a 100644 --- a/base/features.cc +++ b/base/features.cc @@ -199,6 +199,11 @@ BASE_FEATURE(kRetryCreateFileMappingOnCommitLimit, FEATURE_DISABLED_BY_DEFAULT); BASE_FEATURE(kPumpPeekMessageWithObserver, FEATURE_DISABLED_BY_DEFAULT); + +// Prevents base::DeletePathRecursively on Windows from traversing NTFS reparse +// points (such as directory junctions). This protects against TOCTOU +// vulnerabilities and prevents deleting files outside the target directory. +BASE_FEATURE(kPreventReparsePointTraversal, FEATURE_ENABLED_BY_DEFAULT); #endif // BUILDFLAG(IS_WIN) #if BUILDFLAG(IS_POSIX) diff --git a/base/features.h b/base/features.h index 6aa8c74..196f4fb 100644 --- a/base/features.h +++ b/base/features.h @@ -73,6 +73,8 @@ BASE_EXPORT BASE_DECLARE_FEATURE(kRetryCreateFileMappingOnCommitLimit); BASE_EXPORT BASE_DECLARE_FEATURE(kPumpPeekMessageWithObserver); + +BASE_EXPORT BASE_DECLARE_FEATURE(kPreventReparsePointTraversal); #endif #if BUILDFLAG(IS_POSIX) diff --git a/base/files/file_util.h b/base/files/file_util.h index 70f47be..fd29b65 100644 --- a/base/files/file_util.h +++ b/base/files/file_util.h @@ -98,8 +98,8 @@ // Returns true if successful, false otherwise. It is considered successful to // attempt to delete a file that does not exist. // -// In POSIX environment and if |path| is a symbolic link, this deletes only -// the symlink. (even if the symlink points to a non-existent file) +// It does not traverse symlinks or Windows reparse points (e.g., directory +// junctions), but instead deletes the link or reparse point itself. BASE_EXPORT bool DeleteFile(const FilePath& path); // Deletes the given path, whether it's a file or a directory. @@ -108,8 +108,8 @@ // Returns true if successful, false otherwise. It is considered successful // to attempt to delete a file that does not exist. // -// In POSIX environment and if |path| is a symbolic link, this deletes only -// the symlink. (even if the symlink points to a non-existent file) +// It does not traverse symlinks or Windows reparse points (e.g., directory +// junctions), but instead deletes the link or reparse point itself. // // WARNING: USING THIS EQUIVALENT TO "rm -rf", SO USE WITH CAUTION. BASE_EXPORT bool DeletePathRecursively(const FilePath& path); diff --git a/base/files/file_util_unittest.cc b/base/files/file_util_unittest.cc index 82fb8584..a57bbd3 100644 --- a/base/files/file_util_unittest.cc +++ b/base/files/file_util_unittest.cc @@ -21,6 +21,7 @@ #include "base/command_line.h" #include "base/compiler_specific.h" #include "base/environment.h" +#include "base/features.h" #include "base/files/file.h" #include "base/files/file_enumerator.h" #include "base/files/file_path.h" @@ -38,6 +39,7 @@ #include "base/test/bind.h" #include "base/test/metrics/histogram_tester.h" #include "base/test/multiprocess_test.h" +#include "base/test/scoped_feature_list.h" #include "base/test/scoped_logging_settings.h" #include "base/test/task_environment.h" #include "base/test/test_file_util.h" @@ -2308,6 +2310,135 @@ #endif } +#if BUILDFLAG(IS_WIN) +TEST_F(FileUtilTest, DeletePathRecursively_DoesNotFollowJunctions) { + // Force the feature ON for this test so it always verifies the secure + // behavior. + test::ScopedFeatureList scoped_feature_list; + scoped_feature_list.InitAndEnableFeature( + features::kPreventReparsePointTraversal); + + // Create a target directory with a file. + FilePath target_dir = temp_dir_.GetPath().Append(FPL("target_dir")); + ASSERT_TRUE(CreateDirectory(target_dir)); + FilePath target_file = target_dir.Append(FPL("target_file.txt")); + CreateTextFile(target_file, bogus_content); + ASSERT_TRUE(PathExists(target_file)); + + // Create a directory to be deleted. + FilePath deletion_dir = temp_dir_.GetPath().Append(FPL("deletion_dir")); + ASSERT_TRUE(CreateDirectory(deletion_dir)); + + // Create a junction in the deletion directory pointing to the target + // directory. + FilePath junction_path = deletion_dir.Append(FPL("junction")); + ASSERT_TRUE(CreateDirectory(junction_path)); + std::optional<test::FilePathReparsePoint> reparse_point = + test::FilePathReparsePoint::Create(junction_path, target_dir); + ASSERT_TRUE(reparse_point.has_value()); + ASSERT_TRUE(PathExists(junction_path.Append(FPL("target_file.txt")))); + + // Delete the directory containing the junction. + EXPECT_TRUE(DeletePathRecursively(deletion_dir)); + + // Verify that the deletion directory is gone. + EXPECT_FALSE(PathExists(deletion_dir)); + + // Verify that the target directory and its contents were NOT deleted. + EXPECT_TRUE(PathExists(target_dir)); + EXPECT_TRUE(PathExists(target_file)); +} + +TEST_F(FileUtilTest, DeleteFile_DoesNotFollowJunctions) { + // Force the feature ON for this test so it always verifies the secure + // behavior. + test::ScopedFeatureList scoped_feature_list; + scoped_feature_list.InitAndEnableFeature( + features::kPreventReparsePointTraversal); + + // Create a target directory with a file. + FilePath target_dir = temp_dir_.GetPath().Append(FPL("target_dir")); + ASSERT_TRUE(CreateDirectory(target_dir)); + FilePath target_file = target_dir.Append(FPL("target_file.txt")); + CreateTextFile(target_file, bogus_content); + ASSERT_TRUE(PathExists(target_file)); + + // Create a junction pointing to the target directory. + FilePath junction_path = temp_dir_.GetPath().Append(FPL("junction")); + ASSERT_TRUE(CreateDirectory(junction_path)); + std::optional<test::FilePathReparsePoint> reparse_point = + test::FilePathReparsePoint::Create(junction_path, target_dir); + ASSERT_TRUE(reparse_point.has_value()); + ASSERT_TRUE(PathExists(junction_path.Append(FPL("target_file.txt")))); + + // Delete the junction using DeleteFile. + EXPECT_TRUE(DeleteFile(junction_path)); + + // Verify that the junction is gone, but the target remains intact. + EXPECT_FALSE(PathExists(junction_path)); + EXPECT_TRUE(PathExists(target_file)); +}
Regression Test / PoC
diff --git a/base/files/file_util_unittest.cc b/base/files/file_util_unittest.cc
index 82fb8584..a57bbd3 100644
--- a/base/files/file_util_unittest.cc
+++ b/base/files/file_util_unittest.cc
@@ -21,6 +21,7 @@
#include "base/command_line.h"
#include "base/compiler_specific.h"
#include "base/environment.h"
+#include "base/features.h"
#include "base/files/file.h"
#include "base/files/file_enumerator.h"
#include "base/files/file_path.h"
@@ -38,6 +39,7 @@
#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/multiprocess_test.h"
+#include "base/test/scoped_feature_list.h"
#include "base/test/scoped_logging_settings.h"
#include "base/test/task_environment.h"
#include "base/test/test_file_util.h"
@@ -2308,6 +2310,135 @@
#endif
}
+#if BUILDFLAG(IS_WIN)
+TEST_F(FileUtilTest, DeletePathRecursively_DoesNotFollowJunctions) {
+ // Force the feature ON for this test so it always verifies the secure
+ // behavior.
+ test::ScopedFeatureList scoped_feature_list;
+ scoped_feature_list.InitAndEnableFeature(
+ features::kPreventReparsePointTraversal);
+
+ // Create a target directory with a file.
+ FilePath target_dir = temp_dir_.GetPath().Append(FPL("target_dir"));
+ ASSERT_TRUE(CreateDirectory(target_dir));
+ FilePath target_file = target_dir.Append(FPL("target_file.txt"));
+ CreateTextFile(target_file, bogus_content);
+ ASSERT_TRUE(PathExists(target_file));
+
+ // Create a directory to be deleted.
+ FilePath deletion_dir = temp_dir_.GetPath().Append(FPL("deletion_dir"));
+ ASSERT_TRUE(CreateDirectory(deletion_dir));
+
+ // Create a junction in the deletion directory pointing to the target
+ // directory.
+ FilePath junction_path = deletion_dir.Append(FPL("junction"));
+ ASSERT_TRUE(CreateDirectory(junction_path));
+ std::optional<test::FilePathReparsePoint> reparse_point =
+ test::FilePathReparsePoint::Create(junction_path, target_dir);
+ ASSERT_TRUE(reparse_point.has_value());
+ ASSERT_TRUE(PathExists(junction_path.Append(FPL("target_file.txt"))));
+
+ // Delete the directory containing the junction.
+ EXPECT_TRUE(DeletePathRecursively(deletion_dir));
+
+ // Verify that the deletion directory is gone.
+ EXPECT_FALSE(PathExists(deletion_dir));
+
+ // Verify that the target directory and its contents were NOT deleted.
+ EXPECT_TRUE(PathExists(target_dir));
+ EXPECT_TRUE(PathExists(target_file));
+}
+
+TEST_F(FileUtilTest, DeleteFile_DoesNotFollowJunctions) {
+ // Force the feature ON for this test so it always verifies the secure
+ // behavior.
+ test::ScopedFeatureList scoped_feature_list;
+ scoped_feature_list.InitAndEnableFeature(
+ features::kPreventReparsePointTraversal);
+
+ // Create a target directory with a file.
+ FilePath target_dir = temp_dir_.GetPath().Append(FPL("target_dir"));
+ ASSERT_TRUE(CreateDirectory(target_dir));
+ FilePath target_file = target_dir.Append(FPL("target_file.txt"));
+ CreateTextFile(target_file, bogus_content);
+ ASSERT_TRUE(PathExists(target_file));
+
+ // Create a junction pointing to the target directory.
+ FilePath junction_path = temp_dir_.GetPath().Append(FPL("junction"));
+ ASSERT_TRUE(CreateDirectory(junction_path));
+ std::optional<test::FilePathReparsePoint> reparse_point =
+ test::FilePathReparsePoint::Create(junction_path, target_dir);
+ ASSERT_TRUE(reparse_point.has_value());
+ ASSERT_TRUE(PathExists(junction_path.Append(FPL("target_file.txt"))));
+
+ // Delete the junction using DeleteFile.
+ EXPECT_TRUE(DeleteFile(junction_path));
+
+ // Verify that the junction is gone, but the target remains intact.
+ EXPECT_FALSE(PathExists(junction_path));
+ EXPECT_TRUE(PathExists(target_file));
+}
+
+TEST_F(FileUtilTest,
+ DeletePathRecursively_FollowsJunctionsWhenFeatureDisabled) {
+ // Force the feature OFF for this test to verify the legacy fallback behavior.
+ test::ScopedFeatureList scoped_feature_list;
+ scoped_feature_list.InitAndDisableFeature(
+ features::kPreventReparsePointTraversal);
+
+ // Create a target directory with a file.
+ FilePath target_dir = temp_dir_.GetPath().Append(FPL("target_dir"));
+ ASSERT_TRUE(CreateDirectory(target_dir));
+ FilePath target_file = target_dir.Append(FPL("target_file.txt"));
+ CreateTextFile(target_file, bogus_content);
+ ASSERT_TRUE(PathExists(target_file));
+
+ // Create a directory to be deleted.
+ FilePath deletion_dir = temp_dir_.GetPath().Append(FPL("deletion_dir"));
+ ASSERT_TRUE(CreateDirectory(deletion_dir));
+
+ // Create a junction pointing to the target directory.
+ FilePath junction_path = deletion_dir.Append(FPL("junction"));
+ ASSERT_TRUE(CreateDirectory(junction_path));
+ std::optional<test::FilePathReparsePoint> reparse_point =
+ test::FilePathReparsePoint::Create(junction_path, target_dir);
+ ASSERT_TRUE(reparse_point.has_value());
+ ASSERT_TRUE(PathExists(junction_path.Append(FPL("target_file.txt"))));
+
+ // Delete the directory containing the junction.
+ EXPECT_TRUE(DeletePathRecursively(deletion_dir));
+
+ // Verify the legacy behavior: the junction is followed, deleting the target
+ // file.
+ EXPECT_FALSE(PathExists(deletion_dir));
+ EXPECT_TRUE(PathExists(target_dir));
+ EXPECT_FALSE(PathExists(target_file));
+}
+
+TEST_F(FileUtilTest, IsLink) {
+ // Create a target directory with a file.
+ FilePath target_dir = temp_dir_.GetPath().Append(FPL("target_dir"));
+ ASSERT_TRUE(CreateDirectory(target_dir));
+ FilePath target_file = target_dir.Append(FPL("target_file.txt"));
+ CreateTextFile(target_file, bogus_content);
+ ASSERT_TRUE(PathExists(target_file));
+
+ // File and directory are not links.
+ EXPECT_FALSE(IsLink(target_dir));
+ EXPECT_FALSE(IsLink(target_file));
+
+ // Create a junction pointing to the target directory.
+ FilePath junction_path = temp_dir_.GetPath().Append(FPL("junction"));
+ ASSERT_TRUE(CreateDirectory(junction_path));
+ std::optional<test::FilePathReparsePoint> reparse_point =
+ test::FilePathReparsePoint::Create(junction_path, target_dir);
+ ASSERT_TRUE(reparse_point.has_value());
+
+ // The junction is a link.
+ EXPECT_TRUE(IsLink(junction_path));
+}
+#endif // BUILDFLAG(IS_WIN)
+
#if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_CHROMEOS)
// This test will validate that files which would block when read result in a
// failure on a call to ReadFileToStringNonBlocking. To accomplish this we will
Original Bug Report
Potential arbitrary recursive delete via junction following in DeleteMediaFoundationCdmData
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 without the security team.
Overview: A compromised MediaFoundation CDM LPAC utility process can create an NTFS junction in its storage directory. When browsing data is cleared, a logic flaw in Windows base::DeletePathRecursively causes the browser process to follow the junction, recursively deleting the targeted directory’s contents.
Affected files:
chrome/browser/media/cdm_document_service_impl.ccbase/files/file_util_win.ccbase/files/file_enumerator_win.cc
Estimated timestamp from git blame: 2024-12-27
Summary
A potential vulnerability exists in the MediaFoundation CDM data deletion logic. A compromised utility process can trigger an arbitrary recursive directory deletion executed by the browser process. By placing an NTFS junction in its storage directory, the utility process can redirect a cleanup task to any directory the user has permissions to delete.
Vulnerability Details
-
Privileged File Access in LPAC: The MediaFoundation CDM utility process runs in an LPAC (Less Privileged AppContainer) sandbox on Windows. In
cdm_document_service_impl.cc,CreateCdmStorePathRootAndGrantAccessIfNeededgrants the LPAC SIDFILE_GENERIC_WRITEandDELETEpermissions (with inheritance) on its storage directory:<profile>/MediaFoundationCdmStore/<arch>. -
Junction Creation: Because
FILE_GENERIC_WRITEincludesFILE_WRITE_DATA, the compromised utility process can create an NTFS mount-point (junction) within its storage directory pointing to an arbitrary target directory (e.g., the Chrome User Data directory). Note that creating junctions on Windows requires write access to the directory, but does not require the restrictedSeCreateSymbolicLinkPrivilege. -
Insecure Deletion Trigger: When a user clears their browsing data (e.g., via “Cookies and other site data” in Settings),
ChromeBrowsingDataRemoverDelegate::RemoveEmbedderDataposts a task toDeleteMediaFoundationCdmDatain the browser process. -
Orphaned Folder Deletion: In
cdm_document_service_impl.cc,DeleteMediaFoundationCdmDataenumerates subdirectories of the CDM store. If a subdirectory name (e.g., the attacker’s junction namedevil) is not found in theorigin_id_mappingfrom preferences, it immediately callsbase::DeletePathRecursively(file_path)to clean up what it assumes is orphaned data. -
Bypassing Reparse Point Checks in
base::DeletePathRecursively: The Windows implementation ofbase::DeletePathRecursively(inbase/files/file_util_win.cc) is vulnerable to following directory junctions:DoDeleteFilechecksGetFileAttributesbut only verifiesFILE_ATTRIBUTE_DIRECTORY, ignoringFILE_ATTRIBUTE_REPARSE_POINT.- It then calls the internal helper
DeleteFileRecursive, which usesbase::FileEnumeratorwith its constructor parameterrecursive=false. base::FileEnumeratoronly skips reparse points when its internalrecursive_flag istrue. Since it is initialized tofalsehere, it returns the contents of the target directory whenFindFirstFileExis called on the junction wildcard.DeleteFileRecursivemanually recurses over the returned entries. The browser process then proceeds to recursively delete every file and subdirectory in the target tree using the user’s full privileges.- Finally,
DoDeleteFileremoves the junction itself.
Impact
A compromised MediaFoundation utility process can achieve a partial sandbox escape, allowing an attacker to delete any file or directory the user has write/delete access to. This results in permanent data loss (e.g., wiping the entire Chrome profile or the user’s Documents folder).
Suggested Reproduction Steps
Note: These are potential steps to trigger the vulnerability. Our tooling agent does not run code to verify them.
- Compromise the MediaFoundation LPAC utility process (precondition).
- Call
CdmDocumentService::GetMediaFoundationCdmData()via Mojo to ensure the storage path exists and appropriate ACEs are applied. - In the LPAC process, create a directory at
<profile>/MediaFoundationCdmStore/<arch>/evil. - Set a reparse point on the
evildirectory (IO_REPARSE_TAG_MOUNT_POINT) targeting an arbitrary user directory (e.g.,C:\Users\<user>\Documents). - Wait for the user to trigger a “Clear browsing data” action for “Cookies and other site data” in Chrome’s Settings.
- Observe that the browser process follows the junction and recursively deletes the contents of the targeted directory.
Suggested Fix
Modify DeleteFileRecursive in base/files/file_util_win.cc to explicitly check for and skip reparse points.
Before recursing or deleting contents, check if the current directory is a reparse point:
DWORD attributes = ::GetFileAttributes(path.value().c_str());
if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_REPARSE_POINT)) {
return ::RemoveDirectory(path.value().c_str()) ? ERROR_SUCCESS : ReturnLastErrorOrSuccessOnNotFound();
}
Alternatively, base::FileEnumerator’s logic could be updated to ensure that Next() always skips reparse points unless explicitly requested, preventing accidental traversal during manual recursion.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
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.