CVE-2026-6360
Overview
Background
- `FileSystemAccessChangeSource`
- a browser-process object that watches a file-system scope and notifies observers of changes; it is reference-counted/owned externally and can be destroyed from within its own callbacks.
- `DidInitialize`
- the method (named in the commit) that runs once initialization finishes: it records the result and invokes every queued initialization callback.
- `initialization_result_` / `initialization_callbacks_`
- member fields holding, respectively, the stored initialization result and the list of pending
OnceCallbacks to run with that result. - Reentrant self-deletion
- the pattern where running a callback can synchronously
deletethe object owning the loop, so any member access after the call touches freed memory.
Root Cause Analysis
In DidInitialize the result was stored in the member initialization_result_ = std::move(result);, and the pending callbacks were run in a loop that called std::move(callback).Run(initialization_result_->Clone());. The code already recognized that a callback can delete this — it moved initialization_callbacks_ to a stack local before iterating — but it still dereferenced the member initialization_result_ inside the loop body. Once any callback destroys this, the backing storage for initialization_result_ is freed, so the next loop iteration’s initialization_result_->Clone() reads through a dangling member pointer, a use-after-free the new ASAN test TestDestroyFromInitializeCallback reproduces by deleting the source from the first callback.
The fix keeps the result alive on the stack: it clones into the member once (initialization_result_ = result->Clone();) and then, inside the loop, clones from the still-owned stack parameter result->Clone() instead of the member, so no member is touched after a callback may have deleted this.
The restored invariant is that DidInitialize accesses only stack-based objects once it begins invoking callbacks that can free the object.
initialization_callbacks_ against reentrant self-deletion while still dereferencing the member initialization_result_ inside the callback loop; the fix sources the per-callback clone from the stack parameter result so the loop no longer touches any member of a possibly-deleted this.Attack Path
- Register multiple init callbacks
Arrange for a
FileSystemAccessChangeSourceto have more than one pending callback queued ininitialization_callbacks_viaEnsureInitialized. - Have a callback destroy the source
Cause the first invoked callback to synchronously delete the change source (the lifetime relationship the test models with
delete sourcefrom the callback). - Loop continues on freed object
After
thisis deleted,DidInitialize’s loop advances and evaluatesinitialization_result_->Clone(), dereferencing the freed member. - Use-after-free read
The clone reads from released memory; the patch and ASAN test demonstrate the dangling read but no further primitive, so downstream escalation is
(inferred).
Impact Assessment
FileSystemAccessChangeSource triggers a use-after-free read in the browser process. The precondition is reaching this watcher path (File System Access API usage) with a callback whose completion tears down the source; such lifetimes are typically driven by renderer-initiated file-system watch requests. The advisory rates this high severity and reports no in-the-wild exploitation; escalation beyond the UAF read is (inferred).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
Audit Directions
- Member access after reentrant callbacksIn any loop that runs user/observer callbacks capable of deleting
this, verify that every post-call access (not just the callback container) uses stack-local copies. - Partial self-deletion guardsFlag code that moves one member to the stack “because a callback may delete this” but still touches other members afterward; the guard must cover all of them.
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