Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Base
DescriptionInsufficient validation of untrusted input in Base
ComponentBase
Bug ClassLogic Error
Tracker498768132
Fix commitaed056dc0487 (chromium/src) +190/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
TEST_F
base/files/file_util_unittest.cc
modified

Files Changed

  • base/features.cc
  • base/features.h
  • base/files/file_util.h
  • base/files/file_util_unittest.cc
From aed056dc0487e9abd001ec7e7fcc189f6b5954be Mon Sep 17 00:00:00 2001
From: Sangbaek Park <sangbaekpark@google.com>
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 <wfh@chromium.org>
> Reviewed-by: Mark Mentovai <mark@chromium.org>
> Commit-Queue: Sangbaek Park <sangbaekpark@chromium.org>
> Reviewed-by: Greg Thompson <grt@chromium.org>
> 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 <wfh@chromium.org>
Reviewed-by: Mark Mentovai <mark@chromium.org>
Reviewed-by: Greg Thompson <grt@chromium.org>
Commit-Queue: Sangbaek Park <sangbaekpark@chromium.org>
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));
+}
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by vm...@google.com

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.cc
  • base/files/file_util_win.cc
  • base/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

  1. 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, CreateCdmStorePathRootAndGrantAccessIfNeeded grants the LPAC SID FILE_GENERIC_WRITE and DELETE permissions (with inheritance) on its storage directory: <profile>/MediaFoundationCdmStore/<arch>.

  2. Junction Creation: Because FILE_GENERIC_WRITE includes FILE_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 restricted SeCreateSymbolicLinkPrivilege.

  3. Insecure Deletion Trigger: When a user clears their browsing data (e.g., via “Cookies and other site data” in Settings), ChromeBrowsingDataRemoverDelegate::RemoveEmbedderData posts a task to DeleteMediaFoundationCdmData in the browser process.

  4. Orphaned Folder Deletion: In cdm_document_service_impl.cc, DeleteMediaFoundationCdmData enumerates subdirectories of the CDM store. If a subdirectory name (e.g., the attacker’s junction named evil) is not found in the origin_id_mapping from preferences, it immediately calls base::DeletePathRecursively(file_path) to clean up what it assumes is orphaned data.

  5. Bypassing Reparse Point Checks in base::DeletePathRecursively: The Windows implementation of base::DeletePathRecursively (in base/files/file_util_win.cc) is vulnerable to following directory junctions:

    • DoDeleteFile checks GetFileAttributes but only verifies FILE_ATTRIBUTE_DIRECTORY, ignoring FILE_ATTRIBUTE_REPARSE_POINT.
    • It then calls the internal helper DeleteFileRecursive, which uses base::FileEnumerator with its constructor parameter recursive=false.
    • base::FileEnumerator only skips reparse points when its internal recursive_ flag is true. Since it is initialized to false here, it returns the contents of the target directory when FindFirstFileEx is called on the junction wildcard.
    • DeleteFileRecursive manually 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, DoDeleteFile removes 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.

  1. Compromise the MediaFoundation LPAC utility process (precondition).
  2. Call CdmDocumentService::GetMediaFoundationCdmData() via Mojo to ensure the storage path exists and appropriate ACEs are applied.
  3. In the LPAC process, create a directory at <profile>/MediaFoundationCdmStore/<arch>/evil.
  4. Set a reparse point on the evil directory (IO_REPARSE_TAG_MOUNT_POINT) targeting an arbitrary user directory (e.g., C:\Users\<user>\Documents).
  5. Wait for the user to trigger a “Clear browsing data” action for “Cookies and other site data” in Chrome’s Settings.
  6. 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.

View on issue tracker