Medium watch CVSS 6.5 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in FileSystem
DescriptionIncorrect authorization in FileSystem
ComponentFileSystem
Sub-componentFSA
Bug ClassAuthorization Error
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.
Tracker496401361
Fix commitab88b786ad83 (chromium/src) +110/-4
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-26 (per vendor advisory)
Fix landed2026-08-12 (public git)
Disclosed2026-08-25
Patch gap13 days — fix public in source before the advisory

Background

File System Access (FSA) permission grants
ChromeFileSystemAccessPermissionContext keeps per-origin read_grants and write_grants (PermissionGrantImpl objects keyed by path) in active_permissions_map_. Each grant has a status of GRANTED, DENIED or ASK.
`kFileSystemAccessRevokeReadOnRemove`
a feature under which handle.remove() (NotifyEntryRemoved) downgrades the origin’s read grant for that path to DENIED, keeps write access, and records the path in downgraded_read_paths. Content later found there may have been written by someone else.
`MaybeRestoreReadPermission`
restores a downgraded read grant to GRANTED when the origin itself writes content at a path in downgraded_read_paths, on the reasoning that the origin then authored that content.
`NotifyEntryMoved`
called when an FSA handle is moved or renamed (handle.move()). It moves the origin’s grants from old_path to new_path through PermissionGrantImpl::UpdateGrantPath.

Root Cause Analysis

ChromeFileSystemAccessPermissionContext::NotifyEntryMoved moved the active read and write grants from old_path to new_path, but left downgraded_read_paths untouched. It then always called MaybeRestoreReadPermission(origin, new_path.path) when kFileSystemAccessRevokeReadOnRemove was enabled. So after handle.remove() downgraded secret.txt, and another party recreated secret.txt, the origin could move the handle to temp.txt (which carries along the DENIED read grant but leaves the stale secret.txt entry behind) and then move it back. The second move found secret.txt in downgraded_read_paths and restored read access to GRANTED on content the origin never wrote. This broke the invariant that a downgraded read grant is restored only after the origin itself writes.

The fix makes downgraded_read_paths follow the move: it erases old_path and inserts new_path. It also skips MaybeRestoreReadPermission whenever the moved entry was itself downgraded (old_path_was_downgraded). Separately, the patch removes a DCHECK in PermissionGrantImpl::UpdateGrantPath that assumed moved grants are always GRANTED, which crashed Debug builds now that DENIED read grants are kept on purpose.

Key insight
The “downgraded read” marker was tied to a path, not to the grant, so moving an entry away and back left a stale marker that tricked the move-triggered MaybeRestoreReadPermission into re-granting read access. The fix moves the marker with the grant and stops a move from restoring read access to a downgraded entry.

Attack Path

  1. Obtain read+write access A web origin gets FSA read and write grants for a file or directory through the normal picker and permission flow, with kFileSystemAccessRevokeReadOnRemove enabled.
  2. Remove the entry The origin calls handle.remove(). NotifyEntryRemoved downgrades the read grant to DENIED, keeps write access, and records the path in downgraded_read_paths.
  3. Wait for foreign content Another program or the user recreates a file at the same path, with content the origin should not be able to read.
  4. Move away and back The origin calls handle.move() to a temporary name and then back to the original name. Before the patch, the second NotifyEntryMoved found the stale entry in downgraded_read_paths and called MaybeRestoreReadPermission, which set read access back to GRANTED.
  5. Read the recreated file With read access restored, the origin can read content it did not author, which the revoke-on-remove design was meant to prevent.

Impact Assessment

A web origin that the user has already granted FSA read and write access could regain read access to a file recreated by someone else after the origin removed it. That means reading local file contents beyond the intended policy, enforced in the browser process’s permission context. It needs the kFileSystemAccessRevokeReadOnRemove feature, prior user-granted read and write access, and a third party recreating the file at the same path. The removed DCHECK affected only Debug builds (a crash), not release behaviour. The advisory states only “Incorrect authorization in FileSystem” and gives no in-the-wild exploitation detail.

Changed Functions

FunctionChangeNotes
if
chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
modified

Files Changed

  • chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
  • chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc

Audit Directions

  • Path-keyed side tables in permission state
    Look for other per-origin sets or maps keyed by path (downgraded_read_paths, blocked-path caches, recently-removed lists) that NotifyEntryMoved, directory moves (descendant paths) or overwrite-on-move (kFileSystemAccessMoveWithOverwrite) do not update consistently.
  • Restore or upgrade triggers
    Audit every caller of MaybeRestoreReadPermission and similar re-grant helpers, such as NotifyEntryModified, to confirm each trigger really shows the origin authored the content, rather than merely touched the path.
  • Move into an existing downgraded path
    Check the opposite case: moving a readable entry onto a path still in downgraded_read_paths, including case-insensitive path matches, to confirm no other stale-state combination re-grants read.
From ab88b786ad83b611543df884ef7344d7ff92a906 Mon Sep 17 00:00:00 2001
From: Ming-Ying Chung <mych@chromium.org>
Date: Tue, 11 Aug 2026 18:30:26 -0700
Subject: [PATCH] [FSA] Maintain downgraded read permissions across file moves

A state tracking flaw in NotifyEntryMoved() leaves stale entries in
downgraded_read_paths when file entries are moved. When handle.remove()
downgrades a read grant to DENIED, the path is stored in
downgraded_read_paths to track potential origin file recreations.
However, moving the handle away and back leaves the original path in
downgraded_read_paths. Consequently, MaybeRestoreReadPermission()
inappropriately restores read access to GRANTED on a file the origin did
not author.

For example, consider an origin manipulating a target file handle:

(1) Initial state: Read status is GRANTED and write status is GRANTED
    for "secret.txt".
(2) handle.remove(): Read status drops to DENIED, write status remains
    GRANTED, and "secret.txt" is added to downgraded_read_paths.
(3) Victim recreates "secret.txt": Read status for origin remains
    DENIED.
(4) handle.move('temp.txt'): Active grants migrate to "temp.txt", but
    "secret.txt" remains in downgraded_read_paths.
(5) handle.move('secret.txt'): Grants migrate back to secret.txt, and
    MaybeRestoreReadPermission() finds "secret.txt" in
    downgraded_read_paths, incorrectly restoring read status to GRANTED.

Also drop the obsolete DCHECK in PermissionGrantImpl:UpdateGrantPath():
removing it is safe because DENIED read grants now represent a valid
runtime state. PermissionGrantImpl::UpdateGrantPath() asserted that
active grants being moved held GRANTED status, which caused Debug builds
to crash when moving handles with read grants downgraded to DENIED by
handle.remove(). Prior to kFileSystemAccessRevokeReadOnRemove, active
grants being moved were assumed to be GRANTED. Under
kFileSystemAccessRevokeReadOnRemove, revoked grants are retained in
read_grants with status DENIED to track state across operations, so
removing the DCHECK allows PermissionGrantImpl::UpdateGrantPath() to
safely update grant paths without altering permission checks.

In summary, this CL updates NotifyEntryMoved() to carry
downgraded_read_paths entries alongside active grants, drops the
obsolete assertion in PermissionGrantImpl::UpdateGrantPath(), and adds
dedicated unit tests to verify state migration and assertion safety in
Debug builds.

TAG=agy
CONV=214d42c9-1a4f-4404-85d5-800d9b0885eb

Bug: 496401361
Change-Id: I7844d3e7cfe5ec9363bd16e028665a94fc79e620
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8224042
Reviewed-by: Mingyu Lei <leimy@chromium.org>
Commit-Queue: Ming-Ying Chung <mych@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1677704}
---
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
index e0d5734..f2400b7 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
@@ -1223,9 +1223,6 @@
       return;
     }
 
-    DCHECK_EQ(old_path_it->second->GetActivePermissionStatus(),
-              PermissionStatus::GRANTED);
-
     auto* const grant_to_move = old_path_it->second.get();
 
     if (allow_overwrite) {
@@ -2665,6 +2662,7 @@
       features::kFileSystemAccessMoveWithOverwrite);
 
   bool updated = false;
+  bool old_path_was_downgraded = false;
   auto it = active_permissions_map_.find(origin);
   if (it != active_permissions_map_.end()) {
     // TODO(crbug.com/40245144): Consolidate superfluous child grants.
@@ -2672,6 +2670,14 @@
                                          new_path, allow_overwrite);
     PermissionGrantImpl::UpdateGrantPath(it->second.read_grants, old_path,
                                          new_path, allow_overwrite);
+    if (base::FeatureList::IsEnabled(
+            blink::features::kFileSystemAccessRevokeReadOnRemove) &&
+        it->second.downgraded_read_paths.erase(old_path.path)) {
+      // The downgraded read grant moved along with the entry; carry the
+      // downgraded state to `new_path` so a later write there can restore it.
+      it->second.downgraded_read_paths.insert(new_path.path);
+      old_path_was_downgraded = true;
+    }
     updated = true;
   }
   if (base::FeatureList::IsEnabled(
@@ -2702,7 +2708,10 @@
   }
 
   if (base::FeatureList::IsEnabled(
-          blink::features::kFileSystemAccessRevokeReadOnRemove)) {
+          blink::features::kFileSystemAccessRevokeReadOnRemove) &&
+      !old_path_was_downgraded) {
+    // Only restore if the moved entry was readable at `old_path`; otherwise
+    // the origin has not authored the content now at `new_path`.
     MaybeRestoreReadPermission(origin, new_path.path);
   }
 }
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
index 337d442..67f0e25 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
@@ -3795,6 +3795,103 @@
             PermissionStatus::ASK);
 }
 
+// Verifies that moving an entry with a downgraded read grant transfers the
+// downgraded state to the destination path, and moving the entry back to its
+// original path does not restore the read grant.
+TEST_F(ChromeFileSystemAccessPermissionContextTest,
+       NotifyEntryMoved_DowngradedReadGrantFollowsMove) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      blink::features::kFileSystemAccessRevokeReadOnRemove);
+  permission_context()->SetOriginHasExtendedPermissionForTesting(kTestOrigin);
+
+  const auto file_path_info =
+      PathInfo(kTestPathInfo.path.AppendASCII("test_file.txt"));
+  const auto temp_path_info =
+      PathInfo(kTestPathInfo.path.AppendASCII("temp_file.txt"));
+
+  // Initialize read and write permissions for the target file path.
+  auto read_grant = permission_context()->GetReadPermissionGrant(
+      kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave);
+  auto write_grant = permission_context()->GetWritePermissionGrant(
+      kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_EQ(write_grant->GetStatus(), PermissionStatus::GRANTED);
+
+  // Calling `NotifyEntryRemoved()` downgrades the read grant and records the
+  // path in `downgraded_read_paths`.
+  permission_context()->NotifyEntryRemoved(kTestOrigin, file_path_info);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+  EXPECT_EQ(write_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, file_path_info.path));
+
+  // Calling `NotifyEntryMoved()` transfers the downgraded read state to the
+  // destination path.
+  permission_context()->NotifyEntryMoved(kTestOrigin, file_path_info,
+                                         temp_path_info);
+  EXPECT_EQ(read_grant->GetPath(), temp_path_info.path);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+  EXPECT_FALSE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, file_path_info.path));
+  EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, temp_path_info.path));
+
+  // Moving the entry back to its original path retains the `DENIED` read grant
+  // because the origin has not authored new content at the destination.
+  permission_context()->NotifyEntryMoved(kTestOrigin, temp_path_info,
+                                         file_path_info);
+  EXPECT_EQ(read_grant->GetPath(), file_path_info.path);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+  EXPECT_EQ(write_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, file_path_info.path));
+  EXPECT_FALSE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, temp_path_info.path));
+
+  // Calling `NotifyEntryModified()` at the original path restores the read
+  // grant.
+  permission_context()->NotifyEntryModified(kTestOrigin, file_path_info);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_FALSE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, file_path_info.path));
+}
+
+// Verifies that moving a handle with a downgraded read grant successfully
+// updates the grant path without triggering assertion failures.
+TEST_F(ChromeFileSystemAccessPermissionContextTest,
+       NotifyEntryMoved_DowngradedReadGrantMigratesWithoutAssertionFailure) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      blink::features::kFileSystemAccessRevokeReadOnRemove);
+  permission_context()->SetOriginHasExtendedPermissionForTesting(kTestOrigin);
+
+  const auto file_path_info =
+      PathInfo(kTestPathInfo.path.AppendASCII("test_file.txt"));
+  const auto temp_path_info =
+      PathInfo(kTestPathInfo.path.AppendASCII("temp_file.txt"));
+
+  // Initialize read and write grants.
+  auto read_grant = permission_context()->GetReadPermissionGrant(
+      kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave);
+  auto write_grant = permission_context()->GetWritePermissionGrant(
+      kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_EQ(write_grant->GetStatus(), PermissionStatus::GRANTED);
+
+  // Downgrade the read grant to `DENIED` via `NotifyEntryRemoved()`.
+  permission_context()->NotifyEntryRemoved(kTestOrigin, file_path_info);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+
+  // Moving the handle invokes `PermissionGrantImpl::UpdateGrantPath()`.
+  // This operation must successfully update the grant path for a `DENIED`
+  // grant without triggering assertion failures in debug builds.
+  permission_context()->NotifyEntryMoved(kTestOrigin, file_path_info,
+                                         temp_path_info);
+  EXPECT_EQ(read_grant->GetPath(), temp_path_info.path);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+}
+
 // Tests that calling NotifyEntryRemoved with a directory path also revokes
 // read permission grants for descendants whose stored path differs only in
 // case from the removed directory. Native file pickers on case-insensitive
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
index 337d442..67f0e25 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
@@ -3795,6 +3795,103 @@
             PermissionStatus::ASK);
 }
 
+// Verifies that moving an entry with a downgraded read grant transfers the
+// downgraded state to the destination path, and moving the entry back to its
+// original path does not restore the read grant.
+TEST_F(ChromeFileSystemAccessPermissionContextTest,
+       NotifyEntryMoved_DowngradedReadGrantFollowsMove) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      blink::features::kFileSystemAccessRevokeReadOnRemove);
+  permission_context()->SetOriginHasExtendedPermissionForTesting(kTestOrigin);
+
+  const auto file_path_info =
+      PathInfo(kTestPathInfo.path.AppendASCII("test_file.txt"));
+  const auto temp_path_info =
+      PathInfo(kTestPathInfo.path.AppendASCII("temp_file.txt"));
+
+  // Initialize read and write permissions for the target file path.
+  auto read_grant = permission_context()->GetReadPermissionGrant(
+      kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave);
+  auto write_grant = permission_context()->GetWritePermissionGrant(
+      kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_EQ(write_grant->GetStatus(), PermissionStatus::GRANTED);
+
+  // Calling `NotifyEntryRemoved()` downgrades the read grant and records the
+  // path in `downgraded_read_paths`.
+  permission_context()->NotifyEntryRemoved(kTestOrigin, file_path_info);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+  EXPECT_EQ(write_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, file_path_info.path));
+
+  // Calling `NotifyEntryMoved()` transfers the downgraded read state to the
+  // destination path.
+  permission_context()->NotifyEntryMoved(kTestOrigin, file_path_info,
+                                         temp_path_info);
+  EXPECT_EQ(read_grant->GetPath(), temp_path_info.path);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+  EXPECT_FALSE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, file_path_info.path));
+  EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, temp_path_info.path));
+
+  // Moving the entry back to its original path retains the `DENIED` read grant
+  // because the origin has not authored new content at the destination.
+  permission_context()->NotifyEntryMoved(kTestOrigin, temp_path_info,
+                                         file_path_info);
+  EXPECT_EQ(read_grant->GetPath(), file_path_info.path);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+  EXPECT_EQ(write_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_TRUE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, file_path_info.path));
+  EXPECT_FALSE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, temp_path_info.path));
+
+  // Calling `NotifyEntryModified()` at the original path restores the read
+  // grant.
+  permission_context()->NotifyEntryModified(kTestOrigin, file_path_info);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_FALSE(permission_context()->IsPathInDowngradedReadPathsForTesting(
+      kTestOrigin, file_path_info.path));
+}
+
+// Verifies that moving a handle with a downgraded read grant successfully
+// updates the grant path without triggering assertion failures.
+TEST_F(ChromeFileSystemAccessPermissionContextTest,
+       NotifyEntryMoved_DowngradedReadGrantMigratesWithoutAssertionFailure) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      blink::features::kFileSystemAccessRevokeReadOnRemove);
+  permission_context()->SetOriginHasExtendedPermissionForTesting(kTestOrigin);
+
+  const auto file_path_info =
+      PathInfo(kTestPathInfo.path.AppendASCII("test_file.txt"));
+  const auto temp_path_info =
+      PathInfo(kTestPathInfo.path.AppendASCII("temp_file.txt"));
+
+  // Initialize read and write grants.
+  auto read_grant = permission_context()->GetReadPermissionGrant(
+      kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave);
+  auto write_grant = permission_context()->GetWritePermissionGrant(
+      kTestOrigin, file_path_info, HandleType::kFile, UserAction::kSave);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::GRANTED);
+  EXPECT_EQ(write_grant->GetStatus(), PermissionStatus::GRANTED);
+
+  // Downgrade the read grant to `DENIED` via `NotifyEntryRemoved()`.
+  permission_context()->NotifyEntryRemoved(kTestOrigin, file_path_info);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+
+  // Moving the handle invokes `PermissionGrantImpl::UpdateGrantPath()`.
+  // This operation must successfully update the grant path for a `DENIED`
+  // grant without triggering assertion failures in debug builds.
+  permission_context()->NotifyEntryMoved(kTestOrigin, file_path_info,
+                                         temp_path_info);
+  EXPECT_EQ(read_grant->GetPath(), temp_path_info.path);
+  EXPECT_EQ(read_grant->GetStatus(), PermissionStatus::DENIED);
+}
+
 // Tests that calling NotifyEntryRemoved with a directory path also revokes
 // read permission grants for descendants whose stored path differs only in
 // case from the removed directory. Native file pickers on case-insensitive
Loading diff…

Original Bug Report

reported by vm...@google.com

Read grant restoration bypass via rename round-trip in File System Access API

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A logic error in ChromeFileSystemAccessPermissionContext fails to clear downgraded_read_paths when a file is renamed. An attacker who previously deleted a file can exploit this stale state by renaming a newly created file at the same path away and back again. This inadvertently triggers MaybeRestoreReadPermission, restoring the attacker’s revoked read access to the user’s new file.

Affected files:

  • chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
  • content/browser/file_system_access/file_system_access_handle_base.cc
  • content/browser/file_system_access/file_system_access_file_handle_impl.cc

Estimated timestamp from git blame: 2026-01-25

Overview

The kFileSystemAccessRevokeReadOnRemove feature protects user privacy by revoking an origin’s read access (downgrading to DENIED) when the origin deletes a file via handle.remove(). However, the path is stored in downgraded_read_paths to allow restoration if the file is immediately recreated.

A logic flaw exists in how renames (move()) interact with this state. ChromeFileSystemAccessPermissionContext::NotifyEntryMoved updates the paths for permission grants but fails to clear the original path from the downgraded_read_paths set. An attacker can exploit this stale state using a “round-trip” rename to bypass the revocation and read a newly created file at the original path.

Note: The following are potential steps to trigger the vulnerability, as our setup does not currently have the ability to run code to produce a fully working PoC.

Potential Attacker Steps

  1. Initial Setup & Removal: An attacker origin obtains read/write access to a user-selected file (e.g., secret.txt). The attacker calls handle.remove().
    • Result: The file is deleted. NotifyEntryRemoved downgrades the read grant to DENIED (write stays GRANTED) and adds secret.txt to downgraded_read_paths.
  2. User Recreates File: The user independently creates or saves a new file at the exact same path (secret.txt). The origin’s handle now points to this new file, but cannot read it due to the DENIED read grant.
  3. First Rename (Move Away): Using a transient user activation (e.g., a single click), the attacker calls handle.move('temp.txt').
    • Result: The file is renamed. NotifyEntryMoved migrates the DENIED read grant and GRANTED write grant to temp.txt.
    • Bug: NotifyEntryMoved fails to remove secret.txt from downgraded_read_paths.
    • Note: In debug builds, this step crashes due to DCHECK_EQ(old_path_it->second->GetActivePermissionStatus(), PermissionStatus::GRANTED); inside UpdateGrantPath. In release builds, the DCHECK compiles out, and the DENIED grant is successfully migrated.
  4. Second Rename (Move Back): Still within the same transient user activation window, the attacker calls handle.move('secret.txt').
    • Result: The file is renamed back. NotifyEntryMoved migrates the grants back to secret.txt. At the end of the function, it calls MaybeRestoreReadPermission(origin, 'secret.txt').
  5. Bypass Achieved: MaybeRestoreReadPermission checks if secret.txt is in downgraded_read_paths. Because it was left stale in step 3, it is found. The function restores the read grant to GRANTED. The attacker can now read the new user-created file.

Suggested Fix

  1. In ChromeFileSystemAccessPermissionContext::NotifyEntryMoved, explicitly check and manage downgraded_read_paths. If old_path.path exists in the set, it should either be removed or migrated to new_path.path, depending on the intended product behavior for tracking restored files across moves.
  2. Re-evaluate the DCHECK_EQ in PermissionGrantImpl::UpdateGrantPath (line 1077). If DENIED grants are legitimately expected to be moved (or if they shouldn’t be), the logic handling DENIED grants during moves should be made robust in production builds, rather than implicitly relying on the DCHECK compiling out.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker