CVE-2026-6360
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcontent/browser/file_system_access/file_system_access_change_source.cc |
modified | |
MockRawChangeObservercontent/browser/file_system_access/file_system_access_change_source_unittest.cc |
modified | |
FakeChangeSourcecontent/browser/file_system_access/file_system_access_change_source_unittest.cc |
modified | |
FileSystemAccessChangeSourceTestcontent/browser/file_system_access/file_system_access_change_source_unittest.cc |
modified | |
TEST_Fcontent/browser/file_system_access/file_system_access_change_source_unittest.cc |
modified |
Files Changed
content/browser/file_system_access/file_system_access_change_source.cccontent/browser/file_system_access/file_system_access_change_source_unittest.cc
Patch
From c0390bcd64ba1fd6594fbc9f6246a1649662d683 Mon Sep 17 00:00:00 2001 From: Fergal Daly <[email protected]> Date: Mon, 06 Apr 2026 19:49:06 -0700 Subject: [PATCH] Fix UAF in FileSystemAccessChangeSource. `DidInitialize` calls any outstanding initialization callbacks but a callback can delete this. The code guards against this in its access of `initialization_callbacks_` but not `initialization_result_`. This fix keeps a copy of the result on the stack. This also adds a test which fails with ASAN before the fix is applied and passes after. The basic test code was written by Gemini. Fixed: 497880137 Change-Id: I046831db23cb4b8e41964910e2aede9b1be0db7f Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7728464 Auto-Submit: Fergal Daly <[email protected]> Reviewed-by: Ming-Ying Chung <[email protected]> Commit-Queue: Ming-Ying Chung <[email protected]> Cr-Commit-Position: refs/heads/main@{#1610499} --- diff --git a/content/browser/file_system_access/file_system_access_change_source.cc b/content/browser/file_system_access/file_system_access_change_source.cc index 566dc1e..48bd867 100644 --- a/content/browser/file_system_access/file_system_access_change_source.cc +++ b/content/browser/file_system_access/file_system_access_change_source.cc @@ -71,13 +71,14 @@ CHECK(!initialization_result_.has_value()); CHECK(!initialization_callbacks_.empty()); - initialization_result_ = std::move(result); + // The callbacks may cause |this| to be deleted, so we should only use + // stack-based objects below. + initialization_result_ = result->Clone(); - // Move the callbacks to the stack since they may cause |this| to be deleted. auto initialization_callbacks = std::move(initialization_callbacks_); initialization_callbacks_.clear(); for (auto& callback : initialization_callbacks) { - std::move(callback).Run(initialization_result_->Clone()); + std::move(callback).Run(result->Clone()); } } diff --git a/content/browser/file_system_access/file_system_access_change_source_unittest.cc b/content/browser/file_system_access/file_system_access_change_source_unittest.cc new file mode 100644 index 0000000..b0f15909 --- /dev/null +++ b/content/browser/file_system_access/file_system_access_change_source_unittest.cc @@ -0,0 +1,146 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "content/browser/file_system_access/file_system_access_change_source.h" + +#include "base/files/scoped_temp_dir.h" +#include "base/functional/bind.h" +#include "base/memory/scoped_refptr.h" +#include "base/task/sequenced_task_runner.h" +#include "base/test/task_environment.h" +#include "base/test/test_future.h" +#include "content/browser/file_system_access/file_system_access_watch_scope.h" +#include "storage/browser/file_system/file_system_context.h" +#include "storage/browser/file_system/file_system_url.h" +#include "storage/browser/quota/quota_manager_proxy.h" +#include "storage/browser/test/test_file_system_context.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/public/mojom/file_system_access/file_system_access_error.mojom.h" + +namespace content { + +namespace { + +class MockRawChangeObserver + : public FileSystemAccessChangeSource::RawChangeObserver { + public: + MOCK_METHOD(void, + OnRawChange, + (const storage::FileSystemURL& changed_url, + bool error, + const FileSystemAccessChangeSource::ChangeInfo& change_info, + const FileSystemAccessWatchScope& scope), + (override)); + MOCK_METHOD(void, + OnUsageChange, + (size_t old_usage, + size_t new_usage, + const FileSystemAccessWatchScope& scope), + (override)); + MOCK_METHOD(void, + OnSourceBeingDestroyed, + (FileSystemAccessChangeSource * source), + (override)); +}; + +class FakeChangeSource : public FileSystemAccessChangeSource { + public: + FakeChangeSource( + FileSystemAccessWatchScope scope, + scoped_refptr<storage::FileSystemContext> file_system_context) + : FileSystemAccessChangeSource(std::move(scope), + std::move(file_system_context)) {} + ~FakeChangeSource() override = default; + + // FileSystemAccessChangeSource: + void Initialize( + base::OnceCallback<void(blink::mojom::FileSystemAccessErrorPtr)> + on_source_initialized) override { + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::BindOnce(std::move(on_source_initialized), + blink::mojom::FileSystemAccessError::New( + blink::mojom::FileSystemAccessStatus::kOk, + base::File::FILE_OK, ""))); + } + + void Signal(const storage::FileSystemURL& changed_url, + bool error = false, + ChangeInfo change_info = ChangeInfo()) { + NotifyOfChange(changed_url, error, change_info); + } +}; + +} // namespace + +class FileSystemAccessChangeSourceTest : public testing::Test { + public: + FileSystemAccessChangeSourceTest() + : task_environment_(base::test::TaskEnvironment::MainThreadType::IO) {} + + void SetUp() override { + ASSERT_TRUE(dir_.CreateUniqueTempDir()); + file_system_context_ = storage::CreateFileSystemContextForTesting( + /*quota_manager_proxy=*/nullptr, dir_.GetPath()); + } + + protected: + base::test::TaskEnvironment task_environment_; + base::ScopedTempDir dir_; + scoped_refptr<storage::FileSystemContext> file_system_context_; +}; + +TEST_F(FileSystemAccessChangeSourceTest, CreateAndInitialize) { + auto file_path = dir_.GetPath().AppendASCII("file"); + auto file_url = file_system_context_->CreateCrackedFileSystemURL( + blink::StorageKey(), storage::kFileSystemTypeLocal, file_path); + + auto scope = FileSystemAccessWatchScope::GetScopeForFileWatch(file_url); + FakeChangeSource source(scope, file_system_context_); + + base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr> future; + source.EnsureInitialized(future.GetCallback()); + EXPECT_EQ(future.Get()->status, blink::mojom::FileSystemAccessStatus::kOk); +} + +TEST_F(FileSystemAccessChangeSourceTest, NotifyOfChange) { + auto file_path = dir_.GetPath().AppendASCII("file"); + auto file_url = file_system_context_->CreateCrackedFileSystemURL( + blink::StorageKey(), storage::kFileSystemTypeLocal, file_path); + + auto scope = FileSystemAccessWatchScope::GetScopeForFileWatch(file_url); + FakeChangeSource source(scope, file_system_context_); + + MockRawChangeObserver observer; + source.AddObserver(&observer); + + EXPECT_CALL(observer, OnRawChange(testing::Eq(file_url), testing::IsFalse(), + testing::_, testing::Eq(scope))); + source.Signal(file_url); + + source.RemoveObserver(&observer); +} + +// A callback passed to `EnsureInitialized` may result in `this` being +// destroyed. This tests that `DidInitialize` (which calls the callbacks) is +// robust to that situation. See https://crbug.com/497880137. +TEST_F(FileSystemAccessChangeSourceTest, TestDestroyFromInitializeCallback) { + auto file_path = dir_.GetPath().AppendASCII("file"); + auto file_url = file_system_context_->CreateCrackedFileSystemURL( + blink::StorageKey(), storage::kFileSystemTypeLocal, file_path); + + auto scope = FileSystemAccessWatchScope::GetScopeForFileWatch(file_url); + FakeChangeSource* source = new FakeChangeSource(scope, file_system_context_); + + source->EnsureInitialized(base::BindOnce( + [](FakeChangeSource* source, blink::mojom::FileSystemAccessErrorPtr) { + delete source; + }, + base::Unretained(source))); + base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr> future; + source->EnsureInitialized(future.GetCallback()); + EXPECT_EQ(future.Get()->status, blink::mojom::FileSystemAccessStatus::kOk); +} + +} // namespace content diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
Regression Test / PoC
diff --git a/content/browser/file_system_access/file_system_access_change_source_unittest.cc b/content/browser/file_system_access/file_system_access_change_source_unittest.cc
new file mode 100644
index 0000000..b0f15909
--- /dev/null
+++ b/content/browser/file_system_access/file_system_access_change_source_unittest.cc
@@ -0,0 +1,146 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "content/browser/file_system_access/file_system_access_change_source.h"
+
+#include "base/files/scoped_temp_dir.h"
+#include "base/functional/bind.h"
+#include "base/memory/scoped_refptr.h"
+#include "base/task/sequenced_task_runner.h"
+#include "base/test/task_environment.h"
+#include "base/test/test_future.h"
+#include "content/browser/file_system_access/file_system_access_watch_scope.h"
+#include "storage/browser/file_system/file_system_context.h"
+#include "storage/browser/file_system/file_system_url.h"
+#include "storage/browser/quota/quota_manager_proxy.h"
+#include "storage/browser/test/test_file_system_context.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/mojom/file_system_access/file_system_access_error.mojom.h"
+
+namespace content {
+
+namespace {
+
+class MockRawChangeObserver
+ : public FileSystemAccessChangeSource::RawChangeObserver {
+ public:
+ MOCK_METHOD(void,
+ OnRawChange,
+ (const storage::FileSystemURL& changed_url,
+ bool error,
+ const FileSystemAccessChangeSource::ChangeInfo& change_info,
+ const FileSystemAccessWatchScope& scope),
+ (override));
+ MOCK_METHOD(void,
+ OnUsageChange,
+ (size_t old_usage,
+ size_t new_usage,
+ const FileSystemAccessWatchScope& scope),
+ (override));
+ MOCK_METHOD(void,
+ OnSourceBeingDestroyed,
+ (FileSystemAccessChangeSource * source),
+ (override));
+};
+
+class FakeChangeSource : public FileSystemAccessChangeSource {
+ public:
+ FakeChangeSource(
+ FileSystemAccessWatchScope scope,
+ scoped_refptr<storage::FileSystemContext> file_system_context)
+ : FileSystemAccessChangeSource(std::move(scope),
+ std::move(file_system_context)) {}
+ ~FakeChangeSource() override = default;
+
+ // FileSystemAccessChangeSource:
+ void Initialize(
+ base::OnceCallback<void(blink::mojom::FileSystemAccessErrorPtr)>
+ on_source_initialized) override {
+ base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindOnce(std::move(on_source_initialized),
+ blink::mojom::FileSystemAccessError::New(
+ blink::mojom::FileSystemAccessStatus::kOk,
+ base::File::FILE_OK, "")));
+ }
+
+ void Signal(const storage::FileSystemURL& changed_url,
+ bool error = false,
+ ChangeInfo change_info = ChangeInfo()) {
+ NotifyOfChange(changed_url, error, change_info);
+ }
+};
+
+} // namespace
+
+class FileSystemAccessChangeSourceTest : public testing::Test {
+ public:
+ FileSystemAccessChangeSourceTest()
+ : task_environment_(base::test::TaskEnvironment::MainThreadType::IO) {}
+
+ void SetUp() override {
+ ASSERT_TRUE(dir_.CreateUniqueTempDir());
+ file_system_context_ = storage::CreateFileSystemContextForTesting(
+ /*quota_manager_proxy=*/nullptr, dir_.GetPath());
+ }
+
+ protected:
+ base::test::TaskEnvironment task_environment_;
+ base::ScopedTempDir dir_;
+ scoped_refptr<storage::FileSystemContext> file_system_context_;
+};
+
+TEST_F(FileSystemAccessChangeSourceTest, CreateAndInitialize) {
+ auto file_path = dir_.GetPath().AppendASCII("file");
+ auto file_url = file_system_context_->CreateCrackedFileSystemURL(
+ blink::StorageKey(), storage::kFileSystemTypeLocal, file_path);
+
+ auto scope = FileSystemAccessWatchScope::GetScopeForFileWatch(file_url);
+ FakeChangeSource source(scope, file_system_context_);
+
+ base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr> future;
+ source.EnsureInitialized(future.GetCallback());
+ EXPECT_EQ(future.Get()->status, blink::mojom::FileSystemAccessStatus::kOk);
+}
+
+TEST_F(FileSystemAccessChangeSourceTest, NotifyOfChange) {
+ auto file_path = dir_.GetPath().AppendASCII("file");
+ auto file_url = file_system_context_->CreateCrackedFileSystemURL(
+ blink::StorageKey(), storage::kFileSystemTypeLocal, file_path);
+
+ auto scope = FileSystemAccessWatchScope::GetScopeForFileWatch(file_url);
+ FakeChangeSource source(scope, file_system_context_);
+
+ MockRawChangeObserver observer;
+ source.AddObserver(&observer);
+
+ EXPECT_CALL(observer, OnRawChange(testing::Eq(file_url), testing::IsFalse(),
+ testing::_, testing::Eq(scope)));
+ source.Signal(file_url);
+
+ source.RemoveObserver(&observer);
+}
+
+// A callback passed to `EnsureInitialized` may result in `this` being
+// destroyed. This tests that `DidInitialize` (which calls the callbacks) is
+// robust to that situation. See https://crbug.com/497880137.
+TEST_F(FileSystemAccessChangeSourceTest, TestDestroyFromInitializeCallback) {
+ auto file_path = dir_.GetPath().AppendASCII("file");
+ auto file_url = file_system_context_->CreateCrackedFileSystemURL(
+ blink::StorageKey(), storage::kFileSystemTypeLocal, file_path);
+
+ auto scope = FileSystemAccessWatchScope::GetScopeForFileWatch(file_url);
+ FakeChangeSource* source = new FakeChangeSource(scope, file_system_context_);
+
+ source->EnsureInitialized(base::BindOnce(
+ [](FakeChangeSource* source, blink::mojom::FileSystemAccessErrorPtr) {
+ delete source;
+ },
+ base::Unretained(source)));
+ base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr> future;
+ source->EnsureInitialized(future.GetCallback());
+ EXPECT_EQ(future.Get()->status, blink::mojom::FileSystemAccessStatus::kOk);
+}
+
+} // namespace content
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index ec792aa..8071300 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -2676,6 +2676,7 @@
"../browser/fenced_frame/redacted_fenced_frame_config_mojom_traits_unittest.cc",
"../browser/file_system/browser_file_system_helper_unittest.cc",
"../browser/file_system/file_system_operation_runner_unittest.cc",
+ "../browser/file_system_access/file_system_access_change_source_unittest.cc",
"../browser/file_system_access/file_system_access_directory_handle_impl_unittest.cc",
"../browser/file_system_access/file_system_access_file_handle_impl_unittest.cc",
"../browser/file_system_access/file_system_access_file_modification_host_impl_unittest.cc",
Original Bug Report
REFILE: Browser-Process UAF in FileSystemAccessChangeSource::DidInitialize (Reliability-Improved PoC)
Reference: This is a highly-reliable refile of Issue https://issues.chromium.org/issues/497456775.
Why it was previously marked Not Reproducible: The previous PoC relied on a single-shot race condition (Worker Termination vs. IO Thread Execution). Because thread scheduling timings differ across hardware (e.g., M-series vs. Intel, core counts), it missed the race window on the triage machine.
The Fix: I have created a new PoC that acts as a “reliability sweep”. It programmatically sweeps through thousands of attempts using varying profiles of [attempts x fanout x concurrency] (e.g., 1024x24x8). This systematically exhausts the thread scheduler variations, effectively guaranteeing the Use-After-Free race window is hit across different hardware profiles.
A video demonstrating the reliable crash on the latest ASan build is attached.
VULNERABILITY DETAILS
Class: Browser-process heap-use-after-free / memory corruption.
Impact:
A compromised renderer or a malicious web page can trigger a browser-process UAF. Because the vulnerability bypasses MiraclePtr (MiraclePtr Status: NOT PROTECTED), this memory corruption can be exploited for an arbitrary memory read or controlled write in the browser process, leading to sandbox escape/RCE.
Root Cause:
The bug is in content/browser/file_system_access/file_system_access_change_source.cc, in FileSystemAccessChangeSource::DidInitialize(...).
DidInitialize() moves initialization_callbacks_ to a local stack variable because executing the callbacks may cause this to be deleted. However, it still reads this->initialization_result_ from the member variable inside the callback loop:
initialization_result_ = std::move(result);
// Move the callbacks to the stack since they may cause |this| to be deleted.
auto initialization_callbacks = std::move(initialization_callbacks_);
initialization_callbacks_.clear();
for (auto& callback : initialization_callbacks) {
// If the first callback deleted |this|, the next iteration UAFs here:
std::move(callback).Run(initialization_result_->Clone());
}
If the first callback tears down the watcher and deletes the source, the second iteration dereferences freed object state through this->initialization_result_. The implicit this pointer is not protected by BRP/MiraclePtr in this specific execution context.
VERSION
Chrome Version: Chromium 148.0.7761.0 canary (official mac-arm64 ASan prebuilt; reproduced March 31, 2026) Operating System: macOS 15.7.4 (24G517), arm64
REPRODUCTION CASE
Please see the attached individual files:
poc.html(The reliability sweep driver)poc.jsworker.jsasan.log(ASan logs from the run)FSA_UAF_POC.mov(Video evidence of reproduction)
The PoC is a minimized, pure-web reproduction utilizing a reliability sweep:
- Serve the PoC files from a local HTTP server (
python3 -m http.server 8000). - Launch the ASan browser.
- Click Start.
- In the directory picker, choose any writable local directory.
- The PoC will begin a staged sweep (Stage 1: 1024x24x8, Stage 2: 1024x32x8, Stage 3: 768x24x12).
- The browser will crash with a heap-use-after-free during one of these stages.
Exact Command Line from Repro (As recorded in asan.log):
profile_dir=$(mktemp -d /tmp/fsa_profile.XXXXXX)
ASAN_OPTIONS="detect_odr_violation=0:abort_on_error=1:symbolize=1:external_symbolizer_path=/Users/asjidkalam/fuzzing/chrome_research/chromium/src/third_party/llvm-build/Release+Asserts/bin/llvm-symbolizer:log_path=/tmp/fsa_local_context_arm64_default_symbolized_asan" \
open -n "/Users/asjidkalam/fuzzing/chrome_research/chromium/src/.asan_mac_arm64_run/Chromium.app" --args \
--user-data-dir="$profile_dir" \
--window-size=1280,900 \
--window-position=0,0 \
--enable-blink-features=FileSystemObserver \
--test-type \
--no-first-run \
--no-default-browser-check \
"http://127.0.0.1:8000/fsa_change_source_default_web_vrp_bundle/poc.html"
Notes:
- No flags required: This repro does NOT use
--disable-features=PartitionAllocBackupRefPtr. - Timing: If the crash does not occur in Stage 1, please allow the sweep to continue through Stage 3.
SUGGESTED PATCH
Move/Clone the initialization result to a local stack variable before entering the callback loop to decouple it from the object’s lifetime:
auto initialization_callbacks = std::move(initialization_callbacks_);
initialization_callbacks_.clear();
auto result_clone = initialization_result_->Clone();
for (auto& callback : initialization_callbacks) {
std::move(callback).Run(result_clone->Clone());
}
FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION
Type of crash: browser-process ASan heap-use-after-free
Crash State:
- Use path:
content::FileSystemAccessChangeSource::DidInitialize(...) - Callback path:
content::FileSystemAccessLocalPathWatcher::Initialize(...) - Free path:
content::FileSystemAccessWatcherManager::RemoveObserver(...) - Allocation path:
content::FileSystemAccessWatcherManager::CreateOwnedSourceForScope(...)
Summary from attached ASan log:
ERROR: AddressSanitizer: heap-use-after-freeSUMMARY: AddressSanitizer: heap-use-after-free ... in content::FileSystemAccessChangeSource::DidInitialize(...)MiraclePtr Status: NOT PROTECTED
CREDIT INFORMATION
Reporter credit: asjidkalam