← Chrome — 2026-W41

7443fea9388437006fea100838194ac33efdc7fd  [M155] [FSA] Fix use-after-free in FileSystemAccessLockManager

severity high class UAF confidence 0.96 signal score 11.67 content/browser/ patch-now AI review CONFIRMED
Primitive: reentrant DestroySelf()->ReleaseChild() frees Lock C while SetEvictingSubrootLock() still runs, then writes evicting_subroot_lock_ into freed memory
Triage note: Identical FSA LockManager fix (M155 backport): adopts next_evicting_subroot_lock before EvictPendingSubtree(), gates destruction behind CanDestroySelf(), null-hardens IsEvictingSubroot(). Attacker-reachable via File System Access API + BFCache lock eviction.
Structural signals: security_assert (+6.47), null_guard (+1.2), msg_strong (+3.0), msg_medium (+2.0), area:content/browser/ (+1.0), large_churn (-2.0)

The bug at a glance

Heap use-after-free in the browser process (content/browser/file_system_access), reachable from a sandboxed renderer via the File System Access API and ordinary BFCache lifecycle transitions. The freed object is re-accessed on the stack immediately after free, giving a tight free-then-use window with attacker-influenced timing over lock grant/eviction ordering. UAF in the privileged browser process is the classic sandbox-escape primitive, so this is High severity. No special permissions beyond a user gesture granting a file/directory handle are needed.

M155 release branch (refs/branch-heads/8059, Cr-Branched-From main@{#1697595}); cherry-pick of main commit aa6c477ded (main@{#1707268}). Tracked as crbug 553525515 (branch bug 568219380). This is byte-for-byte the same FileSystemAccessLockManager reentrancy fix as the M150 (20ad69f21b) and M156 (b6217069e0) backports; the three differ only in branch metadata and the pre-image blob hashes (file_system_access_lock_manager.cc 350d682..0425e4d here). The angle: a reentrant DestroySelf()->ReleaseChild() that mutates a parent’s child_locks_ map while an ancestor SetEvictingSubrootLock() frame is still live on the stack, holding a soon-to-be-freed Lock pointer.

Root cause

The manager keeps a tree of Lock nodes per path; a pending lock that is waiting on an evicting (BFCached) lock parks that evicting lock in its evicting_subroot_lock_ unique_ptr. In the vulnerable SetEvictingSubrootLock(), the code first moved the passed lock into this->evicting_subroot_lock_, then extracted the deeper evicting subroot into the stack local next_evicting_subroot_lock, then called EvictPendingSubtree() on the pending lock BEFORE assigning next_evicting_subroot_lock back into evicting_subroot_lock_. During that EvictPendingSubtree() the pending subtree’s callbacks are dropped, releasing the last LockHandle to the underlying evicting Lock A and invoking Lock A->LockHandleDestroyed(); because Lock C had not yet adopted Lock A, Lock A->IsEvictingSubroot() returned false, so Lock A took the active-lock path and called DestroySelf()->ReleaseChild() on root_lock_, erasing Lock C from root_lock_->child_locks_ and freeing it while Lock C’s SetEvictingSubrootLock() frame was still on the stack. When EvictPendingSubtree() returned, the frame wrote evicting_subroot_lock_ = std::move(next_evicting_subroot_lock) into freed memory. The fix reorders the assignment so evicting_subroot_lock_ is adopted BEFORE EvictPendingSubtree() runs, making Lock A’s IsEvictingSubroot() return true so it unwinds safely through pending_lock->RemoveEvictingSubrootLock() instead of ReleaseChild(); it also adds CanDestroySelf() gating (empty handles/children/callbacks, not an evicting subroot, parent still owns it at path_) and a null-check in IsEvictingSubroot() so a detached/superseded lock can never delete an unrelated replacement lock from a former parent.

Key code

SetEvictingSubrootLock(): adopt the underlying evicting lock BEFORE eviction so the reentrant LockHandleDestroyed() sees IsEvictingSubroot()==true

auto next_evicting_subroot_lock =
    evicting_subroot_lock->TakeEvictingSubrootLock();
if (next_evicting_subroot_lock) {
  // Must adopt `next_evicting_subroot_lock` BEFORE calling
  // `EvictPendingSubtree()`. Dropping callbacks in `EvictPendingSubtree()`
  // may release handles to the underlying evicting lock, which relies on
  // `this->evicting_subroot_lock_` to recognize that it is an evicting
  // subroot rather than an active lock.
  evicting_subroot_lock_ = std::move(next_evicting_subroot_lock);
  evicting_subroot_lock->EvictPendingSubtree();
} else {
  evicting_subroot_lock_ = std::move(evicting_subroot_lock);
}

Patch walkthrough

  • content/browser/file_system_access/file_system_access_lock_manager.cc — SetEvictingSubrootLock(): the core fix. Previously the passed lock was moved into evicting_subroot_lock_ immediately, next_evicting_subroot_lock was pulled off it, EvictPendingSubtree() ran on the (now dangling-prone) member, and only afterward was next_evicting_subroot_lock assigned. Now, when there is a deeper subroot, evicting_subroot_lock_ is set to next_evicting_subroot_lock (Lock A) first, and EvictPendingSubtree() is called on the stack-local evicting_subroot_lock (Lock B). This guarantees that any reentrant LockHandleDestroyed() on Lock A sees IsEvictingSubroot()==true and unwinds via RemoveEvictingSubrootLock() rather than ReleaseChild(). A CHECK(evicting_subroot_lock) is added to reject a null argument.
  • content/browser/file_system_access/file_system_access_lock_manager.cc — LockHandleDestroyed(): restructured so the IsEvictingSubroot() branch is taken first (destroying via pending_lock->RemoveEvictingSubrootLock()/PromotePendingToTaken()/EvictPendingSubtree()), and the self-destroy path is now gated on the new CanDestroySelf() predicate instead of a bare !IsEvictingSubroot(). This stops a detached, superseded, or still-anchoring (non-empty child_locks_ / non-empty pending_callbacks_ / holding an evicting_subroot_lock_) lock from calling DestroySelf()->ReleaseChild().
  • content/browser/file_system_access/file_system_access_lock_manager.cc — New CanDestroySelf() helper: returns true only when frame_id_lock_handles_, child_locks_, and pending_callbacks_ are all empty, evicting_subroot_lock_ is null, !IsEvictingSubroot(), and the parent still owns this lock at path_ (parent_lock_->GetChild(path_) == this). The last clause is the key invariant preventing a superseded lock from erasing its replacement.
  • content/browser/file_system_access/file_system_access_lock_manager.cc — ReleaseChild(): after erasing a child path it now adds a CHECK(!CanDestroySelf(), base::NotFatalUntil::M160) plus a DestroySelf() fallback to prune an ancestor kept alive only for now-gone children. The NotFatalUntil CHECK is a telemetry probe to confirm the path is actually unreachable before the fallback is removed (TODO crbug.com/553525515).
  • content/browser/file_system_access/file_system_access_lock_manager.cc — IsEvictingSubroot(): adds a null check on parent_lock_->GetChild(path_). Previously it unconditionally dereferenced the returned child to read evicting_subroot_lock_, which could be null if path_ had already been released or this was detached, giving a second (null-deref) crash path.
  • content/browser/file_system_access/file_system_access_lock_manager.cc — IteratePendingSubtreeCallbacks(): the hard CHECK(pending_callbacks_.size() > 0) is replaced with an if (!pending_callbacks_.empty()) guard, so a lock promoted reentrantly before its callbacks were attached no longer hits a CHECK failure.
  • content/browser/file_system_access/file_system_access_lock_manager_unittest.cc — Adds regression test BFCacheEvictPendingLockReentrantDestroySelf which builds the exact A/B/C three-lock scenario (two BFCached contexts plus an active context on one parent_url) and asserts Lock C is reentrantly promoted and granted, then cleanly released.

Background

FileSystemAccessLockManager — Browser-process component that arbitrates exclusive/shared access to file/directory paths opened via the File System Access API. It maintains a per-path tree of Lock nodes (root_lock_ with child_locks_ keyed by path component).

LockHandle / frame_id_lock_handles_ — Ref-counted handles held by renderer frames that keep a Lock alive. When the last handle is released, LockHandleDestroyed() runs and decides whether/how the Lock node self-destructs.

evicting_subroot_lock_ / pending lock — When a page goes into BFCache, its active lock can be evicted so an active page can acquire the path. A pending lock parks the evicting lock in its evicting_subroot_lock_ unique_ptr and waits for that lock’s handles to drain before being promoted (PromotePendingToTaken).

IsEvictingSubroot() — Tells a Lock whether it is the subroot of an evicting subtree (owned by some pending lock’s evicting_subroot_lock_) versus an ordinary active lock owned by its parent. This single predicate decides the entire destruction path in LockHandleDestroyed(), which is why a stale answer during reentrancy is fatal.

BFCache (Back/Forward Cache) — Keeps whole pages (including their FSA locks) alive after navigation for instant back/forward. Eviction of a BFCached page is the trigger that drops the callbacks holding the only surviving handle to Lock A.

Vulnerability window

  1. Latent — The destruction-ordering flaw lived in SetEvictingSubrootLock()/LockHandleDestroyed(); present on M155 (branch 8059, branched from main@{#1697595}).
  2. Root-cause — Tracked internally as crbug.com/553525515. The commit notes ‘AGY was used to help write this patch,’ indicating automated/assisted analysis of the reentrant free.
  3. Fix landed — Original fix aa6c477ded0f2d8ad56bdabce2820462f9a9f30c landed on main@{#1707268} (CL 8389638), reviewed by [email protected], author [email protected].
  4. Backport — Cherry-picked to the M155 branch on 2026-10-06 via CL 8529026 (rubber-stamper auto-submit), Cr-Commit-Position refs/branch-heads/8059@{#988}, branch bug 568219380.

Proof of concept

The diff adds the exact trigger as unit test BFCacheEvictPendingLockReentrantDestroySelf. It puts a RenderFrameHost into kInBackForwardCache, takes Lock A (BFCache) and Lock B (BFCache) on the same parent_url where B’s grant callback owns the only LockHandle to A, then takes Lock C from an active context. Lock C’s acquisition evicts B’s pending subtree, drops B’s callback, frees A reentrantly, and in the unpatched code writes into freed Lock-C memory. End-to-end reachability (inferred): the same shape is produced from script via the File System Access API (navigator.storage / showDirectoryPicker handles) by acquiring access handles on the same path across two pages that are put into BFCache, then forcing an active page to request the same path, provoking lock eviction. No renderer memory-corruption primitive is needed to reach the bug; only lock grant/eviction ordering is.

// 1. Take Lock A on `parent_url` in BFCache.
auto lock_a = TakeLockSync(bf_cache_context, parent_url, exclusive_lock_type);
// 2. Take Lock B in BFCache, holding Lock A in its grant callback.
auto holding_callback = base::BindOnce(/* ...captures std::move(lock_a)... */,
                                       std::move(lock_a), lock_b_future.GetCallback());
manager_->TakeLock(bf_cache_context, parent_url, exclusive_lock_type,
                   std::move(holding_callback));
// 3. Take Lock C in active_context -> evicts Lock B, drops the holding
//    callback, frees Lock A reentrantly while Lock C's SetEvictingSubrootLock
//    is on the stack.
auto lock_c_future = TakeLockAsync(active_context, parent_url, exclusive_lock_type);
ASSERT_TRUE(lock_c_future->IsReady());

Exploitation

  1. Trigger — Drive the A/B/C lock topology on one path: two BFCached documents hold/await a lock such that the pending lock’s grant callback is the sole owner of the evicting lock’s handle, then an active document requests the same path to start eviction (grounded in the regression test).
  2. Free — EvictPendingSubtree() drops the pending callback, releasing the last handle to Lock A; the unpatched IsEvictingSubroot()==false path runs DestroySelf()->ReleaseChild(), erasing and freeing Lock C from root_lock_->child_locks_. (The precise allocator bucket/size of a Lock object is not shown by the patch — inferred.)
  3. Use — The still-live SetEvictingSubrootLock() frame writes evicting_subroot_lock_ = std::move(next_evicting_subroot_lock) into the freed Lock C — a controlled write of a unique_ptr value into a freed browser-process heap object at a fixed member offset (offset not shown — inferred).
  4. Escalation — (inferred) Classic UAF-to-sandbox-escape: reclaim the freed Lock allocation with an attacker-shaped object (via PartitionAlloc same-bucket reuse / heap grooming from the renderer) so the member write corrupts a vtable/pointer, yielding control-flow or arbitrary write in the browser process. None of this escalation is demonstrated by the patch.

Detection & hunting

For defenders and detection engineers:

  • ASan/UAF crash in FileSystemAccessLockManager —
  • NotFatalUntil CHECK telemetry —
  • Behavioral trigger pattern —

Audit directions

  • Same file - other reentrancy —
  • Ownership-state predicates —
  • Other BFCache-interacting browser caches —
  • unique_ptr move + synchronous callback —

Before / after

Loading diff…