Medium watch CVSS 3.1 chrome Race 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in FileSystem
DescriptionRace condition in FileSystem
ComponentFileSystem
Sub-componentFSA
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker498411599
Fix commitd199c6a071db (chromium/src) +100/-14
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-01 (per vendor advisory)
Fix landed2026-08-05 (public git)
Disclosed2026-09-17
Patch gap43 days — fix public in source before the advisory

Background

FileSystem Access API (FSA)
a web platform API that lets a site read and write files on the user’s disk; writes are staged and then committed to the real target file by the browser process.
Swap file
a temporary file that receives every WriteStream/Truncate operation for a writer; on Close it is atomically moved over the real target file so the target is never seen in a half-written state.
After-write check / safe-move
on close, FileSystemAccessSafeMoveHelper computes the swap file’s SHA-256 hash, submits it to the browser’s after-write security check (e.g. Safe Browsing / enterprise content scanning) via FileSystemAccessWriteItem, and only then moves the swap file into place.
`FileSystemAccessFileWriterImpl`
the browser-process object backing a renderer’s writable file stream; it dispatches asynchronous WriteStream, Truncate, and Close operations onto the file-system operation runner.

Root Cause Analysis

Write and Truncate are dispatched asynchronously to the file-system operation runner and complete later via callbacks (DidWrite when complete is set, and — post-patch — DidTruncate).

Pre-patch, FileSystemAccessFileWriterImpl::CloseImpl immediately constructed a FileSystemAccessSafeMoveHelper and began hashing the swap file and moving it into place, without checking whether any previously dispatched WriteStream or Truncate operations were still in flight. The commit message states the invariant that was violated: the after-write hash and the file moved into place must always reflect the final swap file contents, but a Close that raced ahead of a pending write hashed and moved stale/partial content. The renderer-side FileSystemUnderlyingSink serializes these operations, but a compromised or malicious renderer can issue them out of order, and the browser side did not enforce the ordering.

The fix adds a pending_operations_ counter (incremented in the write and truncate dispatch paths, decremented in DidWrite/DidTruncate) and routes close through MaybeStartClose, which starts the safe-move only once pending_operations_ == 0, so the security check and the committed bytes are guaranteed to reflect the completed writes.

Key insight
The core mistake is that CloseImpl treated close as an operation that could run immediately, ignoring that browser-side write/truncate operations are asynchronous and may still be in flight; the fix defers the safe-move (and its after-write hash) behind an in-flight-operation counter so it can never run against a swap file that a pending write is still modifying.

Attack Path

  1. Open a writer A page (or a compromised renderer) obtains a FileSystemAccessFileWriterImpl for a target file, which allocates a swap file to receive writes.
  2. Dispatch a slow write The renderer calls Write with a data-pipe stream; WriteStreamImpl dispatches the WriteStream operation and increments pending_operations_, but the operation does not complete until data is streamed through the pipe.
  3. Race a close ahead of the write Without waiting for the write callback, the renderer immediately calls Close; pre-patch this made CloseImpl start the safe-move at once, so FileSystemAccessSafeMoveHelper hashed the swap file and ran the after-write security check against content that did not yet include (or differed from) the eventual write.
  4. Commit unchecked content Because the after-write check passed on the stale hash while the actual bytes are written before/around the move, the file that lands at the target does not match what was security-checked, defeating the after-write scan (a TOCTOU over the checked content).
  5. Escalation (inferred) Any consequence beyond bypassing the after-write content check (e.g. persisting content that would have been blocked) is inferred; the patch itself demonstrates only the hash/commit-vs-final-content mismatch, not a memory-corruption or code-execution primitive.

Impact Assessment

An attacker controlling the ordering of FSA mojo calls from a renderer can make the browser process run its after-write security check against a hash that does not match the bytes ultimately committed to the user-visible target file, i.e. bypass the integrity/scanning gate on file writes. The bug is a race (Bug Class: Race) in the browser process, and its precondition is out-of-order Write/Truncate/Close dispatch, which requires a malicious or compromised renderer since the normal renderer-side FileSystemUnderlyingSink enforces the ordering. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1. A severity rating are not stated in the advisory text provided, and any escalation past writing content that evaded the after-write check is (inferred).

Changed Functions

FunctionChangeNotes
if
content/browser/file_system_access/file_system_access_file_writer_impl.cc
modified
FileSystemAccessFileWriterImplTestBase
content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
modified

Files Changed

  • content/browser/file_system_access/file_system_access_file_writer_impl.h
  • content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
  • content/browser/file_system_access/file_system_access_file_writer_impl.cc

Audit Directions

  • Browser-side reliance on renderer-enforced ordering
    Audit other mojo interfaces where the trusted renderer sink serializes calls but the browser handler assumes that ordering; a compromised renderer can interleave dispatch and completion of asynchronous operations.
  • Commit/finalize before async completion
    Look for close/commit/finalize paths that compute a hash, checksum, or size, or move/replace a file, without waiting for all outstanding async I/O to drain, creating a TOCTOU between the security check and the committed bytes.
  • Untracked in-flight operations
    Search for objects that dispatch multiple concurrent async operations without a reference/pending count guarding teardown or finalization, where a terminal operation can run while earlier ones are still mutating shared state.
From d199c6a071dbabc1f6ffaff3df868cfc5f205419 Mon Sep 17 00:00:00 2001
From: Mingyu Lei <[email protected]>
Date: Wed, 05 Aug 2026 04:41:47 -0700
Subject: [PATCH] FSA: Wait for pending writes before closing the file writer

FileSystemAccessFileWriterImpl::CloseImpl started the safe-move of the
swap file (including hashing it for the after-write check) without
verifying that previously dispatched WriteStream or Truncate operations
had completed. The renderer-side FileSystemUnderlyingSink already
enforces this ordering, but the browser side did not.

Track the number of in-flight operations and defer starting the
safe-move until all of them have finished, so that the after-write hash
and the file that is moved into place always reflect the final swap file
contents.

Bug: 498411599
TAG=agy
CONV=e6e19b22-86cc-4787-b702-90949cb1e27f

Change-Id: Ibc5467def0333e183b61511949eb166a85b68dcc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8200284
Commit-Queue: Mingyu Lei <[email protected]>
Reviewed-by: Ming-Ying Chung <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1674036}
---
diff --git a/content/browser/file_system_access/file_system_access_file_writer_impl.cc b/content/browser/file_system_access/file_system_access_file_writer_impl.cc
index 69c43ae5..3314bb0f 100644
--- a/content/browser/file_system_access/file_system_access_file_writer_impl.cc
+++ b/content/browser/file_system_access/file_system_access_file_writer_impl.cc
@@ -180,8 +180,8 @@
     mojo::ScopedDataPipeConsumerHandle stream,
     WriteCallback callback) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
-  DCHECK_EQ(GetEffectiveWritePermissionStatus(),
-            blink::mojom::PermissionStatus::GRANTED);
+  CHECK_EQ(GetEffectiveWritePermissionStatus(),
+           blink::mojom::PermissionStatus::GRANTED);
 
   if (is_close_pending()) {
     std::move(callback).Run(
@@ -192,6 +192,7 @@
     return;
   }
 
+  ++pending_operations_;
   manager()->DoFileSystemOperation(
       FROM_HERE, &FileSystemOperationRunner::WriteStream,
       base::BindRepeating(&FileSystemAccessFileWriterImpl::DidWrite,
@@ -206,20 +207,23 @@
                                               bool complete) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
-  DCHECK(state);
+  CHECK(state);
   state->bytes_written += bytes;
   if (complete) {
+    CHECK_GT(pending_operations_, 0);
+    --pending_operations_;
     std::move(state->callback)
         .Run(file_system_access_error::FromFileError(result),
              state->bytes_written);
+    MaybeStartClose();
   }
 }
 
 void FileSystemAccessFileWriterImpl::TruncateImpl(uint64_t length,
                                                   TruncateCallback callback) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
-  DCHECK_EQ(GetEffectiveWritePermissionStatus(),
-            blink::mojom::PermissionStatus::GRANTED);
+  CHECK_EQ(GetEffectiveWritePermissionStatus(),
+           blink::mojom::PermissionStatus::GRANTED);
 
   if (is_close_pending()) {
     std::move(callback).Run(file_system_access_error::FromStatus(
@@ -228,21 +232,27 @@
     return;
   }
 
+  ++pending_operations_;
   manager()->DoFileSystemOperation(
       FROM_HERE, &FileSystemOperationRunner::Truncate,
-      base::BindOnce(
-          [](TruncateCallback callback, base::File::Error result) {
-            std::move(callback).Run(
-                file_system_access_error::FromFileError(result));
-          },
-          std::move(callback)),
+      base::BindOnce(&FileSystemAccessFileWriterImpl::DidTruncate,
+                     weak_factory_.GetWeakPtr(), std::move(callback)),
       swap_url(), length);
 }
 
+void FileSystemAccessFileWriterImpl::DidTruncate(TruncateCallback callback,
+                                                 base::File::Error result) {
+  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+  CHECK_GT(pending_operations_, 0);
+  --pending_operations_;
+  std::move(callback).Run(file_system_access_error::FromFileError(result));
+  MaybeStartClose();
+}
+
 void FileSystemAccessFileWriterImpl::CloseImpl(CloseCallback callback) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
-  DCHECK_EQ(GetEffectiveWritePermissionStatus(),
-            blink::mojom::PermissionStatus::GRANTED);
+  CHECK_EQ(GetEffectiveWritePermissionStatus(),
+           blink::mojom::PermissionStatus::GRANTED);
   if (is_close_pending()) {
     std::move(callback).Run(file_system_access_error::FromStatus(
         FileSystemAccessStatus::kInvalidState,
@@ -251,6 +261,16 @@
   }
 
   close_callback_ = std::move(callback);
+  MaybeStartClose();
+}
+
+void FileSystemAccessFileWriterImpl::MaybeStartClose() {
+  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+  if (!is_close_pending() || did_start_close_ || pending_operations_ > 0) {
+    return;
+  }
+
+  did_start_close_ = true;
 
   auto file_system_access_safe_move_helper =
       std::make_unique<FileSystemAccessSafeMoveHelper>(
diff --git a/content/browser/file_system_access/file_system_access_file_writer_impl.h b/content/browser/file_system_access/file_system_access_file_writer_impl.h
index 58d09867..fa4eb77 100644
--- a/content/browser/file_system_access/file_system_access_file_writer_impl.h
+++ b/content/browser/file_system_access/file_system_access_file_writer_impl.h
@@ -102,7 +102,9 @@
                 int64_t bytes,
                 bool complete);
   void TruncateImpl(uint64_t length, TruncateCallback callback);
+  void DidTruncate(TruncateCallback callback, base::File::Error result);
   void CloseImpl(CloseCallback callback);
+  void MaybeStartClose();
   void AbortImpl(AbortCallback callback);
   void DidReplaceSwapFile(
       std::unique_ptr<content::FileSystemAccessSafeMoveHelper>
@@ -146,6 +148,9 @@
   bool should_purge_swap_file_on_destruction_
       GUARDED_BY_CONTEXT(sequence_checker_) = true;
 
+  int pending_operations_ GUARDED_BY_CONTEXT(sequence_checker_) = 0;
+  bool did_start_close_ GUARDED_BY_CONTEXT(sequence_checker_) = false;
+
   base::WeakPtr<FileSystemAccessHandleBase> AsWeakPtr() override;
 
   base::WeakPtrFactory<FileSystemAccessFileWriterImpl> weak_factory_
diff --git a/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc b/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
index 65e472a..c8b2e49b 100644
--- a/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
@@ -121,7 +121,8 @@
 class FileSystemAccessFileWriterImplTestBase : public testing::Test {
  public:
   FileSystemAccessFileWriterImplTestBase()
-      : task_environment_(base::test::TaskEnvironment::MainThreadType::IO) {}
+      : task_environment_(base::test::TaskEnvironment::TimeSource::MOCK_TIME,
+                          base::test::TaskEnvironment::MainThreadType::IO) {}
 
   virtual FileSystemAccessPermissionContext* permission_context() {
     return nullptr;
@@ -848,6 +849,66 @@
   EXPECT_TRUE(std::ranges::contains(quarantine_.paths, test_file_url_.path()));
 }
 
+TEST_F(FileSystemAccessFileWriterAfterWriteChecksTest,
+       CloseWaitsForPendingWrite) {
+  mojo::ScopedDataPipeProducerHandle producer_handle;
+  mojo::ScopedDataPipeConsumerHandle consumer_handle;
+  EXPECT_EQ(mojo::CreateDataPipe(nullptr, producer_handle, consumer_handle),
+            MOJO_RESULT_OK);
+
+  std::string expected_hash;
+  EXPECT_TRUE(base::HexStringToString(
+      "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD",
+      &expected_hash));
+
+  EXPECT_CALL(
+      permission_context_,
+      PerformAfterWriteChecks_(
+          AllOf(
+              Field(&FileSystemAccessWriteItem::target_file_path,
+                    Eq(test_file_url_.path())),
+              Field(&FileSystemAccessWriteItem::full_path,
+                    Eq(test_swap_url_.path())),
+              Field(&FileSystemAccessWriteItem::sha256_hash, Eq(expected_hash)),
+              Field(&FileSystemAccessWriteItem::size, Eq(3)),
+              Field(&FileSystemAccessWriteItem::frame_url, Eq(kTestURL)),
+              Field(&FileSystemAccessWriteItem::initiating_frame_id,
+                    Eq(kFrameId)),
+              Field(&FileSystemAccessWriteItem::has_user_gesture, Eq(false))),
+          kFrameId, _))
+      .WillOnce(base::test::RunOnceCallback<2>(
+          FileSystemAccessPermissionContext::AfterWriteCheckResult::kAllow));
+  EXPECT_CALL(permission_context_, NotifyEntryModified(_, _)).Times(1);
+
+  base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr, uint64_t>
+      write_future;
+  handle_->Write(0, std::move(consumer_handle), write_future.GetCallback());
+
+  base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr> close_future;
+  handle_->Close(close_future.GetCallback());
+
+  // Ensure that Close defers after-write checks and swap file replacement
+  // until all pending write operations have completed.
+  task_environment_.FastForwardBy(base::Seconds(1));
+  EXPECT_FALSE(write_future.IsReady());
+  EXPECT_FALSE(close_future.IsReady());
+
+  EXPECT_EQ(producer_handle->WriteAllData(base::byte_span_from_cstring("abc")),
+            MOJO_RESULT_OK);
+  producer_handle.reset();
+
+  EXPECT_EQ(write_future.Get<0>()->status, FileSystemAccessStatus::kOk);
+  EXPECT_EQ(write_future.Get<1>(), 3u);
+  EXPECT_EQ(close_future.Get()->status, FileSystemAccessStatus::kOk);
+
+  EXPECT_FALSE(storage::AsyncFileTestHelper::FileExists(
+      file_system_context_.get(), test_swap_url_,
+      storage::AsyncFileTestHelper::kDontCheckSize));
+  EXPECT_TRUE(storage::AsyncFileTestHelper::FileExists(
+      file_system_context_.get(), test_file_url_, 3));
+  EXPECT_EQ(ReadFile(test_file_url_), "abc");
+}
+
 struct WriteModeTestParams {
   const char* test_name_suffix;
   bool is_feature_enabled;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc b/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
index 65e472a..c8b2e49b 100644
--- a/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
@@ -121,7 +121,8 @@
 class FileSystemAccessFileWriterImplTestBase : public testing::Test {
  public:
   FileSystemAccessFileWriterImplTestBase()
-      : task_environment_(base::test::TaskEnvironment::MainThreadType::IO) {}
+      : task_environment_(base::test::TaskEnvironment::TimeSource::MOCK_TIME,
+                          base::test::TaskEnvironment::MainThreadType::IO) {}
 
   virtual FileSystemAccessPermissionContext* permission_context() {
     return nullptr;
@@ -848,6 +849,66 @@
   EXPECT_TRUE(std::ranges::contains(quarantine_.paths, test_file_url_.path()));
 }
 
+TEST_F(FileSystemAccessFileWriterAfterWriteChecksTest,
+       CloseWaitsForPendingWrite) {
+  mojo::ScopedDataPipeProducerHandle producer_handle;
+  mojo::ScopedDataPipeConsumerHandle consumer_handle;
+  EXPECT_EQ(mojo::CreateDataPipe(nullptr, producer_handle, consumer_handle),
+            MOJO_RESULT_OK);
+
+  std::string expected_hash;
+  EXPECT_TRUE(base::HexStringToString(
+      "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD",
+      &expected_hash));
+
+  EXPECT_CALL(
+      permission_context_,
+      PerformAfterWriteChecks_(
+          AllOf(
+              Field(&FileSystemAccessWriteItem::target_file_path,
+                    Eq(test_file_url_.path())),
+              Field(&FileSystemAccessWriteItem::full_path,
+                    Eq(test_swap_url_.path())),
+              Field(&FileSystemAccessWriteItem::sha256_hash, Eq(expected_hash)),
+              Field(&FileSystemAccessWriteItem::size, Eq(3)),
+              Field(&FileSystemAccessWriteItem::frame_url, Eq(kTestURL)),
+              Field(&FileSystemAccessWriteItem::initiating_frame_id,
+                    Eq(kFrameId)),
+              Field(&FileSystemAccessWriteItem::has_user_gesture, Eq(false))),
+          kFrameId, _))
+      .WillOnce(base::test::RunOnceCallback<2>(
+          FileSystemAccessPermissionContext::AfterWriteCheckResult::kAllow));
+  EXPECT_CALL(permission_context_, NotifyEntryModified(_, _)).Times(1);
+
+  base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr, uint64_t>
+      write_future;
+  handle_->Write(0, std::move(consumer_handle), write_future.GetCallback());
+
+  base::test::TestFuture<blink::mojom::FileSystemAccessErrorPtr> close_future;
+  handle_->Close(close_future.GetCallback());
+
+  // Ensure that Close defers after-write checks and swap file replacement
+  // until all pending write operations have completed.
+  task_environment_.FastForwardBy(base::Seconds(1));
+  EXPECT_FALSE(write_future.IsReady());
+  EXPECT_FALSE(close_future.IsReady());
+
+  EXPECT_EQ(producer_handle->WriteAllData(base::byte_span_from_cstring("abc")),
+            MOJO_RESULT_OK);
+  producer_handle.reset();
+
+  EXPECT_EQ(write_future.Get<0>()->status, FileSystemAccessStatus::kOk);
+  EXPECT_EQ(write_future.Get<1>(), 3u);
+  EXPECT_EQ(close_future.Get()->status, FileSystemAccessStatus::kOk);
+
+  EXPECT_FALSE(storage::AsyncFileTestHelper::FileExists(
+      file_system_context_.get(), test_swap_url_,
+      storage::AsyncFileTestHelper::kDontCheckSize));
+  EXPECT_TRUE(storage::AsyncFileTestHelper::FileExists(
+      file_system_context_.get(), test_file_url_, 3));
+  EXPECT_EQ(ReadFile(test_file_url_), "abc");
+}
+
 struct WriteModeTestParams {
   const char* test_name_suffix;
   bool is_feature_enabled;
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.