Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in FileSystem
DescriptionUse after free in FileSystem
ComponentFileSystem
Bug ClassUAF
Tracker505096898
Fix commit01cf5ac83416 (chromium/src) +432/-86
CISA KEVNot listed
CreditedAndrew Boni
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
chrome/browser/sync_file_system/local/local_file_change_tracker.cc
modified
LocalFileChangeTracker
chrome/browser/sync_file_system/local/local_file_change_tracker.h
modified

Files Changed

  • chrome/browser/sync_file_system/local/local_file_change_tracker.cc
  • chrome/browser/sync_file_system/local/local_file_change_tracker.h
From 01cf5ac834163941ed9be27307d1a2b2794dfa49 Mon Sep 17 00:00:00 2001
From: Fergal Daly <[email protected]>
Date: Fri, 22 May 2026 02:11:17 -0700
Subject: [PATCH] Fix UAFs arising from lifetime of File*Observers.

Calls to these observers get posted to multiple sequences and can result
in UAFs if the observer is freed while such a call is still queued.

The fix is to
- Make them all ref-counted so that they are not deleted while calls are queued.
- Add a "Disable" method which is called at the point where they would previously have been freed. Disabling ensures that when the queued call is executed, it is a no-op.

Disabling must be done with a lock held as these observers and the
objects that own them are on different threads.

This also requires some tweaks to
- SandboxQuotaObserver to avoid dangling pointers.
- FileSystemAccessBucketPathWatcher to avoid leaking a cycle of scoped_refptrs. This holds a reference to FileSystemContext via its base class FileSystemAccessChangeSource and FileSystemContext holds a refernce to FileSystemAccessBucketPathWatcher.

Because this is a race, I couldn't make a reliable browser test
possible.

The leak was detected by several unittests, so no extra test is added
for that.

Bug: 486761163,505096898
Change-Id: Ibfb5ac2a85f5d5340e3564d7c8a25571c6fbafb5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7805989
Reviewed-by: Ming-Ying Chung <[email protected]>
Commit-Queue: Fergal Daly <[email protected]>
Auto-Submit: Fergal Daly <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1634809}
---

diff --git a/chrome/browser/sync_file_system/local/local_file_change_tracker.cc b/chrome/browser/sync_file_system/local/local_file_change_tracker.cc
index 630cd06..a0f346e 100644
--- a/chrome/browser/sync_file_system/local/local_file_change_tracker.cc
+++ b/chrome/browser/sync_file_system/local/local_file_change_tracker.cc
@@ -80,7 +80,9 @@
     const base::FilePath& base_path,
     leveldb::Env* env_override,
     base::SequencedTaskRunner* file_task_runner)
-    : initialized_(false),
+    : base::RefCountedDeleteOnSequence<LocalFileChangeTracker>(
+          file_task_runner),
+      initialized_(false),
       file_task_runner_(file_task_runner),
       tracker_db_(std::make_unique<TrackerDB>(base_path, env_override)),
       current_change_seq_number_(0),
@@ -91,8 +93,25 @@
   tracker_db_.reset();
 }
 
+void LocalFileChangeTracker::AddRef() const {
+  base::RefCountedDeleteOnSequence<LocalFileChangeTracker>::AddRef();
+}
+
+void LocalFileChangeTracker::Release() const {
+  base::RefCountedDeleteOnSequence<LocalFileChangeTracker>::Release();
+}
+
+void LocalFileChangeTracker::Disable() {
+  base::AutoLock lock(is_disabled_lock_);
+  is_disabled_ = true;
+}
+
 void LocalFileChangeTracker::OnStartUpdate(const FileSystemURL& url) {
   DCHECK(file_task_runner_->RunsTasksInCurrentSequence());
+  base::AutoLock lock(is_disabled_lock_);
+  if (is_disabled_) {
+    return;
+  }
   if (changes_.contains(url) || demoted_changes_.contains(url)) {
     return;
   }
@@ -100,21 +119,36 @@
   MarkDirtyOnDatabase(url);
 }
 
-void LocalFileChangeTracker::OnEndUpdate(const FileSystemURL& url) {}
+void LocalFileChangeTracker::OnEndUpdate(const FileSystemURL& url) {
+  // If you add code in here, make sure to take the lock and check
+  // `is_disabled_`.
+}
 
 void LocalFileChangeTracker::OnCreateFile(const FileSystemURL& url) {
+  base::AutoLock lock(is_disabled_lock_);
+  if (is_disabled_) {
+    return;
+  }
   RecordChange(url, FileChange(FileChange::FILE_CHANGE_ADD_OR_UPDATE,
                                SYNC_FILE_TYPE_FILE));
 }
 
 void LocalFileChangeTracker::OnCreateFileFrom(const FileSystemURL& url,
                                               const FileSystemURL& src) {
+  base::AutoLock lock(is_disabled_lock_);
+  if (is_disabled_) {
+    return;
+  }
   RecordChange(url, FileChange(FileChange::FILE_CHANGE_ADD_OR_UPDATE,
                                SYNC_FILE_TYPE_FILE));
 }
 
 void LocalFileChangeTracker::OnMoveFileFrom(const FileSystemURL& url,
                                             const FileSystemURL& src) {
+  base::AutoLock lock(is_disabled_lock_);
+  if (is_disabled_) {
+    return;
+  }
   RecordChange(url, FileChange(FileChange::FILE_CHANGE_ADD_OR_UPDATE,
                                SYNC_FILE_TYPE_FILE));
   RecordChange(src,
@@ -122,21 +156,37 @@
 }
 
 void LocalFileChangeTracker::OnRemoveFile(const FileSystemURL& url) {
+  base::AutoLock lock(is_disabled_lock_);
+  if (is_disabled_) {
+    return;
+  }
   RecordChange(url, FileChange(FileChange::FILE_CHANGE_DELETE,
                                SYNC_FILE_TYPE_FILE));
 }
 
 void LocalFileChangeTracker::OnModifyFile(const FileSystemURL& url) {
+  base::AutoLock lock(is_disabled_lock_);
+  if (is_disabled_) {
+    return;
+  }
   RecordChange(url, FileChange(FileChange::FILE_CHANGE_ADD_OR_UPDATE,
                                SYNC_FILE_TYPE_FILE));
 }
 
 void LocalFileChangeTracker::OnCreateDirectory(const FileSystemURL& url) {
+  base::AutoLock lock(is_disabled_lock_);
+  if (is_disabled_) {
+    return;
+  }
   RecordChange(url, FileChange(FileChange::FILE_CHANGE_ADD_OR_UPDATE,
                                SYNC_FILE_TYPE_DIRECTORY));
 }
 
 void LocalFileChangeTracker::OnRemoveDirectory(const FileSystemURL& url) {
+  base::AutoLock lock(is_disabled_lock_);
+  if (is_disabled_) {
+    return;
+  }
   RecordChange(url, FileChange(FileChange::FILE_CHANGE_DELETE,
                                SYNC_FILE_TYPE_DIRECTORY));
 }
diff --git a/chrome/browser/sync_file_system/local/local_file_change_tracker.h b/chrome/browser/sync_file_system/local/local_file_change_tracker.h
index 0ce13ec..910f4d1 100644
--- a/chrome/browser/sync_file_system/local/local_file_change_tracker.h
+++ b/chrome/browser/sync_file_system/local/local_file_change_tracker.h
@@ -12,6 +12,8 @@
 
 #include "base/containers/circular_deque.h"
 #include "base/files/file_path.h"
+#include "base/memory/ref_counted.h"
+#include "base/memory/ref_counted_delete_on_sequence.h"
 #include "base/memory/scoped_refptr.h"
 #include "base/synchronization/lock.h"
 #include "chrome/browser/sync_file_system/file_change.h"
@@ -38,8 +40,10 @@
 // Tracks local file changes for cloud-backed file systems.
 // All methods must be called on the file_task_runner given to the constructor.
 // Owned by FileSystemContext.
-class LocalFileChangeTracker : public storage::FileUpdateObserver,
-                               public storage::FileChangeObserver {
+class LocalFileChangeTracker
+    : public storage::FileUpdateObserver,
+      public storage::FileChangeObserver,
+      public base::RefCountedDeleteOnSequence<LocalFileChangeTracker> {
  public:
   // |file_task_runner| must be the one where the observee file operations run.
   // (So that we can make sure DB operations are done before actual update
@@ -51,9 +55,12 @@
   LocalFileChangeTracker(const LocalFileChangeTracker&) = delete;
   LocalFileChangeTracker& operator=(const LocalFileChangeTracker&) = delete;
 
-  ~LocalFileChangeTracker() override;
-
   // FileUpdateObserver overrides.
+  void AddRef() const override;
+  void Release() const override;
+
+  void Disable() override;
+
   void OnStartUpdate(const storage::FileSystemURL& url) override;
   void OnUpdate(const storage::FileSystemURL& url, int64_t delta) override {}
   void OnEndUpdate(const storage::FileSystemURL& url) override;
@@ -124,6 +131,13 @@
   }
 
  private:
+  friend class base::RefCountedDeleteOnSequence<LocalFileChangeTracker>;
+  friend class base::DeleteHelper<LocalFileChangeTracker>;
+
+  mutable base::Lock is_disabled_lock_;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/file_system_access/file_system_access_watcher_manager_unittest.cc b/content/browser/file_system_access/file_system_access_watcher_manager_unittest.cc
index e60ad3b..f71c5c6 100644
--- a/content/browser/file_system_access/file_system_access_watcher_manager_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_watcher_manager_unittest.cc
@@ -323,7 +323,8 @@
     file_system_context_.reset();
     chrome_blob_context_.reset();
     task_environment_.RunUntilIdle();
-    EXPECT_TRUE(dir_.Delete());
+    // On Windows, a synchronous delete of the directory can fail.
+    GetDeleteFileCallback(dir_.GetPath()).Run();
   }
 
   bool CreateDirectory(const base::FilePath& full_path) {
diff --git a/storage/browser/file_system/sandbox_file_system_backend_delegate_unittest.cc b/storage/browser/file_system/sandbox_file_system_backend_delegate_unittest.cc
index a372ef0..fa83ccf 100644
--- a/storage/browser/file_system/sandbox_file_system_backend_delegate_unittest.cc
+++ b/storage/browser/file_system/sandbox_file_system_backend_delegate_unittest.cc
@@ -24,6 +24,58 @@
 
 namespace {
 
+class MockFileChangeObserver : public FileChangeObserver {
+ public:
+  MockFileChangeObserver() = default;
+  ~MockFileChangeObserver() override = default;
+
+  void AddRef() const override {}
+  void Release() const override {}
+
+  void Disable() override { is_disabled_ = true; }
+
+  void OnCreateFile(const FileSystemURL& url) override {
+    if (is_disabled_) {
+      return;
+    }
+  }
+  void OnCreateFileFrom(const FileSystemURL& url,
+                        const FileSystemURL& src) override {
+    if (is_disabled_) {
+      return;
+    }
+  }
+  void OnMoveFileFrom(const FileSystemURL& url,
+                      const FileSystemURL& src) override {
+    if (is_disabled_) {
+      return;
+    }
+  }
+  void OnRemoveFile(const FileSystemURL& url) override {
+    if (is_disabled_) {
+      return;
+    }
+  }
+  void OnModifyFile(const FileSystemURL& url) override {
+    if (is_disabled_) {
+      return;
+    }
+  }
+  void OnCreateDirectory(const FileSystemURL& url) override {
+    if (is_disabled_) {
+      return;
+    }
+  }
+  void OnRemoveDirectory(const FileSystemURL& url) override {
+    if (is_disabled_) {
+      return;
+    }
+  }
+
+ private:
+  bool is_disabled_ = false;
+};
+
 FileSystemURL CreateFileSystemURL(const char* path) {
   return FileSystemURL::CreateForTest(
       blink::StorageKey::CreateFromStringForTesting("http://foo/"),
@@ -34,6 +86,8 @@
 
 class SandboxFileSystemBackendDelegateTest : public testing::Test {
  protected:
+  std::unique_ptr<SandboxFileSystemBackendDelegate> delegate_;
+
   void SetUp() override {
     ASSERT_TRUE(data_dir_.CreateUniqueTempDir());
     quota_manager_proxy_ = base::MakeRefCounted<MockQuotaManagerProxy>(
@@ -80,7 +134,6 @@
   base::ScopedTempDir data_dir_;
   base::test::TaskEnvironment task_environment_;
   scoped_refptr<MockQuotaManagerProxy> quota_manager_proxy_;
-  std::unique_ptr<SandboxFileSystemBackendDelegate> delegate_;
 
   int callback_count_ = 0;
   base::File::Error last_error_ = base::File::FILE_OK;
diff --git a/storage/browser/test/mock_file_change_observer.cc b/storage/browser/test/mock_file_change_observer.cc
index b9808ec..752d33a 100644
--- a/storage/browser/test/mock_file_change_observer.cc
+++ b/storage/browser/test/mock_file_change_observer.cc
@@ -19,25 +19,39 @@
 
 // static
 ChangeObserverList MockFileChangeObserver::CreateList(
-    MockFileChangeObserver* observer) {
+    scoped_refptr<MockFileChangeObserver> observer) {
   ChangeObserverList list;
   return list.AddObserver(
-      observer, base::SingleThreadTaskRunner::GetCurrentDefault().get());
+      std::move(observer),
+      base::SingleThreadTaskRunner::GetCurrentDefault().get());
+}
+
+void MockFileChangeObserver::Disable() {
+  is_disabled_ = true;
 }
 
 void MockFileChangeObserver::OnCreateFile(const FileSystemURL& url) {
+  if (is_disabled_) {
+    return;
+  }
   create_file_count_++;
   changed_urls_.insert(url);
 }
 
 void MockFileChangeObserver::OnCreateFileFrom(const FileSystemURL& url,
                                               const FileSystemURL& src) {
+  if (is_disabled_) {
+    return;
+  }
   create_file_from_count_++;
   changed_urls_.insert(url);
 }
 
 void MockFileChangeObserver::OnMoveFileFrom(const FileSystemURL& url,
                                             const FileSystemURL& src) {
+  if (is_disabled_) {
+    return;
+  }
   create_file_from_count_++;
   remove_file_count_++;
   changed_urls_.insert(url);
@@ -45,21 +59,33 @@
 }
 
 void MockFileChangeObserver::OnRemoveFile(const FileSystemURL& url) {
+  if (is_disabled_) {
+    return;
+  }
   remove_file_count_++;
   changed_urls_.insert(url);
 }
 
 void MockFileChangeObserver::OnModifyFile(const FileSystemURL& url) {
+  if (is_disabled_) {
+    return;
+  }
   modify_file_count_++;
   changed_urls_.insert(url);
 }
 
 void MockFileChangeObserver::OnCreateDirectory(const FileSystemURL& url) {
+  if (is_disabled_) {
+    return;
+  }
   create_directory_count_++;
   changed_urls_.insert(url);
 }
 
 void MockFileChangeObserver::OnRemoveDirectory(const FileSystemURL& url) {
+  if (is_disabled_) {
+    return;
+  }
   remove_directory_count_++;
   changed_urls_.insert(url);
 }
diff --git a/storage/browser/test/mock_file_change_observer.h b/storage/browser/test/mock_file_change_observer.h
index c8bc562..a9b05db6 100644
--- a/storage/browser/test/mock_file_change_observer.h
+++ b/storage/browser/test/mock_file_change_observer.h
@@ -23,9 +23,13 @@
   ~MockFileChangeObserver() override;
 
   // Creates a ChangeObserverList which only contains given |observer|.
-  static ChangeObserverList CreateList(MockFileChangeObserver* observer);
+  static ChangeObserverList CreateList(
+      scoped_refptr<MockFileChangeObserver> observer);
 
   // FileChangeObserver overrides.
+  void AddRef() const override {}
+  void Release() const override {}
+
   void OnCreateFile(const FileSystemURL& url) override;
   void OnCreateFileFrom(const FileSystemURL& url,
                         const FileSystemURL& src) override;
@@ -36,6 +40,8 @@
   void OnCreateDirectory(const FileSystemURL& url) override;
   void OnRemoveDirectory(const FileSystemURL& url) override;
 
+  void Disable() override;
+
   void ResetCount() {
     create_file_count_ = 0;
     create_file_from_count_ = 0;
@@ -101,6 +107,7 @@
   }
 
  private:
+  bool is_disabled_ = false;
   FileSystemURLSet changed_urls_;
 
   int create_file_count_;
diff --git a/storage/browser/test/mock_file_update_observer.cc b/storage/browser/test/mock_file_update_observer.cc
index 6421d16b..7689855 100644
--- a/storage/browser/test/mock_file_update_observer.cc
+++ b/storage/browser/test/mock_file_update_observer.cc
@@ -15,10 +15,17 @@
 
 // static
 UpdateObserverList MockFileUpdateObserver::CreateList(
-    MockFileUpdateObserver* observer) {
+    scoped_refptr<MockFileUpdateObserver> observer) {
   UpdateObserverList list;
   return list.AddObserver(
-      observer, base::SingleThreadTaskRunner::GetCurrentDefault().get());
+      std::move(observer),
+      base::SingleThreadTaskRunner::GetCurrentDefault().get());
+}
+
+void MockFileUpdateObserver::Disable() {
+  start_update_count_.clear();
+  end_update_count_.clear();
+  is_ready_ = false;
 }
 
 void MockFileUpdateObserver::OnStartUpdate(const FileSystemURL& url) {
diff --git a/storage/browser/test/mock_file_update_observer.h b/storage/browser/test/mock_file_update_observer.h
index 32372ca..133bcc8 100644
--- a/storage/browser/test/mock_file_update_observer.h
+++ b/storage/browser/test/mock_file_update_observer.h
@@ -27,20 +27,20 @@
   ~MockFileUpdateObserver() override;
 
   // Creates a ChangeObserverList which only contains given |observer|.
-  static UpdateObserverList CreateList(MockFileUpdateObserver* observer);
+  static UpdateObserverList CreateList(
+      scoped_refptr<MockFileUpdateObserver> observer);
 
   // FileUpdateObserver overrides.
+  void AddRef() const override {}
+  void Release() const override {}
+
   void OnStartUpdate(const FileSystemURL& url) override;
   void OnUpdate(const FileSystemURL& url, int64_t delta) override;
   void OnEndUpdate(const FileSystemURL& url) override;
 
   void Enable() { is_ready_ = true; }
 
-  void Disable() {
-    start_update_count_.clear();
-    end_update_count_.clear();
-    is_ready_ = false;
-  }
+  void Disable() override;
 
  private:
   std::map<FileSystemURL, int, FileSystemURL::Comparator> start_update_count_;
diff --git a/storage/browser/test/test_file_system_backend.cc b/storage/browser/test/test_file_system_backend.cc
index 3f5d47a..97d0171 100644
--- a/storage/browser/test/test_file_system_backend.cc
+++ b/storage/browser/test/test_file_system_backend.cc
@@ -103,14 +103,31 @@
   }
 
   // FileUpdateObserver overrides.
-  void OnStartUpdate(const FileSystemURL& url) override {}
+  void AddRef() const override {}
+  void Release() const override {}
+
+  void Disable() override { is_disabled_ = true; }
+
+  void OnStartUpdate(const FileSystemURL& url) override {
+    if (is_disabled_) {
+      return;
+    }
+  }
   void OnUpdate(const FileSystemURL& url, int64_t delta) override {
+    if (is_disabled_) {
+      return;
+    }
     usage_ += delta;
   }
-  void OnEndUpdate(const FileSystemURL& url) override {}
+  void OnEndUpdate(const FileSystemURL& url) override {
+    if (is_disabled_) {
+      return;
+    }
+  }
 
  private:
   int64_t usage_;
+  bool is_disabled_ = false;
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Browser-process UAF in FileSystemAccessBucketPathWatcher


Report description

Browser-process UAF in FileSystemAccessBucketPathWatcher


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules


The problem

Please describe the technical details of the vulnerability

Hi team, there’s a web reachable browser process UAF on FileSystemAccessBucketPathWatcher. this one is triggered by closing an incognito window while FileSystemObserver + OPFS writes are in flight. ASan trace attached

summary

heap-UAF in storage::TaskRunnerBoundObserverList<FileChangeObserver>::Notify at task_runner_bound_observer_list.h:64-77. when the incognito profile tears down while an OPFS write is in flight on file_task_runner, ~FileSystemAccessBucketPathWatcher runs synchronously on UI while the Unretained-bound PostTask from the in-flight write is still queued. the next UI drain dispatches through the freed 888-byte watcher slot. READ of size 8 at offset +864. MiraclePtr also fires a dangling-pointer-instantiation report at base/memory/raw_ptr_asan_service.cc:853 just before the UAF, from the same dispatch. chromium 149.0.7806.0 (dev/trunk HEAD 02fe3add0ed5), Ubuntu 24.04, ASan is_asan=true dcheck_always_on=false

repro steps

  1. build ASan Chrome with gn gen out/asan --args='is_asan=true is_debug=false dcheck_always_on=false' && autoninja -C out/asan chrome
  2. put the attached poc.html and server.py in the same directory, then serve on http://127.0.0.1:8765 by running the server (python3 server dot py, Python only per VRP rules)
  3. launch Chrome:
xvfb-run -a env ASAN_OPTIONS='halt_on_error=1:abort_on_error=0:detect_leaks=0:symbolize=1' \
  ./out/asan/chrome \
  --no-sandbox \
  --headless=new \
  --disable-gpu \
  --disable-crashpad-for-testing \
  --disable-breakpad \
  --disable-crash-reporter \
  --disable-in-process-stack-traces \
  --disable-dev-shm-usage \
  --user-data-dir=/tmp/chrome-poc \
  --incognito \
  --enable-blink-features=FileSystemObserver \
  --enable-features=NetworkServiceInProcess \
  --disable-features=HttpsUpgrades,HttpsFirstModeIncognito \
  --no-first-run \
  --no-default-browser-check \
  "http://127.0.0.1:8765/poc.html"

PoC:

  1. navigator.storage.getDirectory() opens OPFS
  2. new FileSystemObserver(cb).observe(root) registers the watcher in change_observers_ through FileSystemAccessObserverHost::ObserveSandboxFileSystemBackendDelegate::AddFileChangeObserver
  3. 40 infinite-loop chains of 4 MiB createWritable().write().close() ops fire, saturating file_task_runner with DidWrite callbacks that each call Notify() and queue base::Unretained(watcher) PostTasks on UI
  4. at 6 seconds the page calls window.close() on itself, which is the last incognito window. BrowserManagerService::DeleteBrowser at browser_manager_service.cc:129 runs ProfileDestroyer::DestroyOTRProfileWhenAppropriateWithTimeoutOffTheRecordProfileImpl::~OffTheRecordProfileImplBrowserContextImpl::ShutdownStoragePartitions~FileSystemAccessManagerImpl~FileSystemAccessWatcherManager~FileSystemAccessBucketPathWatcher
  5. the UI message pump continues draining queued work after the destructors return, and the first such task is the in-flight Unretained(watcher) callback. ASan fires at the vtable load

one local-infra note. on my Xvfb + --no-sandbox + ASan setup, Chromium’s non-component FD-ownership tracker (content/app/content_main.cc:326, base::subtle::EnableFDOwnershipEnforcement(true)) crashes child processes before they load any pages. I had to patch that single line to EnableFDOwnershipEnforcement(false) to get poc.html to load. if your infra doesn’t trip the tracker you can skip the patch. trying the unmodified chrome first is the right move, and only applying the patch if you see Crashing due to FD ownership violation: spam in stderr. the FD-tracker code is absent from every frame of the attached ASan stack (both the UAF dereference and the free side) and the same 888-byte UAF at offset +864 also fires under unpatched content_browsertests via the attached browsertest_snippet.cc, so the patch is a build-env workaround, not a bug-enabler

the attached asan_trace_webdirect.txt is the full trace from real chrome (not browsertests) produced by running the command in the repro section. excerpts:

==4176923==ERROR: MiraclePtr: dangling-pointer-instantiation on address 0x7556754703e0
    #1 base::(anonymous namespace)::Log(...) base/memory/raw_ptr_asan_service.cc:829:3
    #2 base::RawPtrAsanService::CrashOnDanglingInstantiation(...) base/memory/raw_ptr_asan_service.cc:853:3
    #3 storage::TaskRunnerBoundObserverList<storage::FileChangeObserver>::Notify<...>
    #4 storage::ObfuscatedFileUtil::DeleteFile(...) storage/browser/file_system/obfuscated_file_util.cc:814:32
    ...
    #15 base::internal::WorkerThread::RunWorker() base/task/thread_pool/worker_thread.cc:473:36
    #16 base::internal::WorkerThread::RunPooledWorker() base/task/thread_pool/worker_thread.cc:359:3
    #17 base::internal::WorkerThread::ThreadMain() base/task/thread_pool/worker_thread.cc:339:7

==4176923==ERROR: AddressSanitizer: heap-use-after-free on address 0x7556754703e0
READ of size 8 at 0x7556754703e0 thread T0 (chrome)
    #0 base::internal::Invoker<..., UnretainedWrapper<storage::FileChangeObserver,
         base::unretained_traits::MayNotDangle, ...>, ...>::RunOnce(...)
       base/functional/bind_internal.h:740:12
    #1 base::TaskAnnotator::RunTaskImpl(base::PendingTask&)
    #2 base::sequence_manager::...::ThreadControllerWithMessagePumpImpl::DoWorkImpl
    #3 ...::DoWork()
    #4 base::MessagePumpGlib::HandleDispatch() base/message_loop/message_pump_glib.cc:736
    ...
    #11 base::RunLoop::Run(base::Location const&) base/run_loop.cc:135:14
    #12 content::BrowserMainLoop::RunMainMessageLoop() content/browser/browser_main_loop.cc:1103
    ...
    #20 ChromeMain chrome/app/chrome_main.cc:194:12
    #21 __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

0x7556754703e0 is located 864 bytes inside of 888-byte region [0x755675470080,0x7556754703f8)

freed by thread T0 (chrome) here:
    #0 operator delete(void*)
    #1 content::FileSystemAccessWatcherManager::~FileSystemAccessWatcherManager()
    #2 content::FileSystemAccessManagerImpl::~FileSystemAccessManagerImpl()
       content/browser/file_system_access/file_system_access_manager_impl.cc:459
    #5 content::StoragePartitionImpl::~StoragePartitionImpl()
       content/browser/storage_partition_impl.cc:1153
    #9 content::StoragePartitionImplMap::~StoragePartitionImplMap()
    #10 content::BrowserContextImpl::ShutdownStoragePartitions()
    #11 OffTheRecordProfileImpl::~OffTheRecordProfileImpl()
       chrome/browser/profiles/off_the_record_profile_impl.cc:278
    #14 ProfileImpl::DestroyOffTheRecordProfile(Profile*)
    #15 ProfileDestroyer::DestroyOffTheRecordProfileNow(Profile*)
       chrome/browser/profiles/profile_destroyer.cc:251
    #16 ProfileDestroyer::Start(...)
       chrome/browser/profiles/profile_destroyer.cc:452
    #17 ProfileDestroyer::DestroyOTRProfileWhenAppropriateWithTimeout(...)
       chrome/browser/profiles/profile_destroyer.cc:221
    #18 BrowserManagerService::DeleteBrowser(Browser*)
       chrome/browser/ui/browser_manager_service.cc:129
    ...

previously allocated by thread T0 (chrome) here:
    #0 operator new(unsigned long)
    #1 content::FileSystemAccessWatcherManager::FileSystemAccessWatcherManager(...)
    #2 content::FileSystemAccessManagerImpl::FileSystemAccessManagerImpl(...)
       content/browser/file_system_access/file_system_access_manager_impl.cc:450
    #4 content::StoragePartitionImpl::Initialize(...)
       content/browser/storage_partition_impl.cc:1369
    ...
    #17 (anonymous namespace)::GetPrivateProfileIfRequested(...)
       chrome/browser/ui/startup/startup_browser_creator.cc:302
    #18 StartupBrowserCreator::ProcessCmdLineImpl(...)

suggested fix, infra-level so it also covers 504632288:

// storage/browser/file_system/task_runner_bound_observer_list.h
// on the PostTask branch at line 73-76, replace base::Unretained(observer.first)
// with a WeakPtr-based binding, or require observers to expose a cancelable
// token the dispatcher can check at the call site.

or watcher-local, mirroring the cfafd4297bbd2 pattern from the sibling CL:

// ~FileSystemAccessBucketPathWatcher posts a fence on file_task_runner that
// drains in-flight ops before returning. ~FileSystemAccessManagerImpl gates
// on that fence. any pending Notify PostTasks either fire against a still-alive
// watcher or are never posted.

attached:

  • poc.html, the web-direct PoC (OPFS + FileSystemObserver + 40 infinite 4MB write chains + window.close())
  • server.py, tiny Python localhost HTTP server for serving poc.html
  • asan_trace_webdirect.txt, full ASan trace from real chrome (231 lines)
  • browsertest_snippet.cc, optional deterministic browsertest driver that reproduces the same UAF synchronously for debugging convenience

Impact analysis

browser-process heap-UAF on a real production object driven end-to-end from web content. the FileSystemObserver + OPFS prerequisites are status: "stable" on desktop per runtime_enabled_features.json5:2771-2781, reachable from any secure context. the incognito teardown trigger is driven by the same production path an end user hits when closing their last incognito window, because kDestroyProfileOnBrowserClose is default-on on Linux/Mac/Win and the trace bottoms out at BrowserManagerService::DeleteBrowser

mitigation posture in shipping Chrome, to set expectations up front. MiraclePtr/BRP doesn’t cover the observer map because it stores raw FileChangeObserver* rather than raw_ptr<T>. the sibling CL cfafd4297bbd2 for bug 497429850 called this out in its commit message: “Earlier refactor attempts using raw_ptr triggered failures due to widespread reliance on lazy/delayed object destruction in the file system infrastructure.” CFI-vcall applies at the dispatch but two production FileChangeObserver subclasses satisfy the check, LocalFileChangeTracker and FileSystemAccessBucketPathWatcher, both .text-fixed, so a browser-process pointer leak defeats CFI. UnretainedWrapper<..., MayNotDangle> runs raw_ptr<T>::ReportIfDangling() before extraction and kPartitionAllocUnretainedDanglingPtr is FEATURE_ENABLED_BY_DEFAULT with mode kCrash in stable (partition_alloc_features.cc:34-48), and you can see the MiraclePtr-level CrashOnDanglingInstantiation fire ahead of the ASan UAF in the attached trace. on a stable official build that CHECK turns the UAF into a browser-process abort rather than attacker-controlled dispatch

the underlying corruption primitive is still a production UAF on an 888-byte object, just gated behind the dangling-ptr CHECK. I haven’t closed the loop on bypassing that CHECK (allocator partition edges, Lockdown Mode paths, etc.), so the exploit-class ceiling is an open question I’d appreciate a read on. my best guess is the shipping-stable manifestation is a remote browser-process crash from any HTTPS origin with OPFS plus the user closing their last incognito window mid-OPFS-activity

infrastructure overlap with 504632288 is direct, same TaskRunnerBoundObserverList::Notify dispatch and same base::Unretained-outlives-observer pattern. an infra-level fix to the observer-list Notify dispatch closes both bugs. a watcher-local drain would only close this one. 504632288 was closed Not Reproducible because its repro was a unit test that manually orchestrated the free and the Notify. this report drives it from real Chrome via FileSystemObserver.observe() + OPFS writes + the production incognito close path


The cause

What version of Chrome have you found the security issue in?

149.0.7806.0 dev (HEAD 02fe3add0ed5, 2026-04-21)

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption

How would you like to be publicly acknowledged for your report?

Andrew Boni

View on issue tracker