CVE-2026-17665
Overview
Background
- BFCache (Back/Forward Cache)
- a Blink feature that keeps a fully constructed page (its
Document,ExecutionContext, and workers) alive but frozen after navigation so it can be instantly restored on history traversal. - `FrameLifecycleState`
- a
mojom::blinkenum describing anExecutionContext’s lifecycle, with values includingkRunning,kPaused, andkFrozen. - Frozen vs. paused context
- a
kPausedcontext is temporarily halted but still resident, while akFrozencontext (as used by BFCache) must not execute any JavaScript at all until it is restored. - `IgnorePause`
- an enum flag passed to callback-invocation helpers indicating whether a paused context should still be allowed to run a given callback.
Root Cause Analysis
The callback gate in generated_code_helper.cc decided whether a queued JavaScript callback may run by consulting only ExecutionContext::IsContextPaused(), treating a paused context as the sole “do not run scripts” state. A context placed in BFCache, however, is kFrozen rather than kPaused, so IsContextPaused() returned false and the gate permitted callbacks to execute against a frozen context. This violated the invariant that a frozen (BFCache’d) context must never run JavaScript until restored: an asynchronously granted callback (for example a Web Locks grant delivered to a dedicated worker) could fire while the page and its objects were meant to be dormant, running script against state that the freezing/eviction machinery may already have torn down and leading to a use-after-free.
The fix introduces ExecutionContext::IsContextFrozen() and rewrites both gates so a callback is refused whenever the context is frozen, or is paused with ignore_pause == IgnorePause::kDontIgnore, restoring the guarantee that frozen contexts execute no callbacks.
kPaused state alone, ignoring the equally-non-executing kFrozen state; the fix adds an explicit IsContextFrozen() check so frozen contexts unconditionally block callbacks.Attack Path
- Prime a BFCache-eligible page
The attacker navigates a same-process page that has a pending asynchronous callback source, such as a dedicated worker waiting on a
navigator.locks.requestgrant held by another context. - Freeze the context into BFCache
Navigating away moves the page and its worker into BFCache, setting their
ExecutionContexttoFrameLifecycleState::kFrozenwhile heap objects begin winding down. - Trigger the deferred callback The awaited resource becomes available (the competing lock is released), so Blink dispatches the worker’s lock-grant callback.
- Bypass the stale gate
The callback gate in
generated_code_helper.ccchecks onlyIsContextPaused(), which isfalsefor a frozen context, so the callback is invoked instead of being deferred. - Execute against freed state The callback runs JavaScript in the frozen context, touching objects that are being or have been torn down for BFCache, yielding a use-after-free in V8/Blink.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/execution_context/execution_context.cc |
modified | |
ifthird_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/back-forward-cache/weblocks-worker.https.window.js |
modified |
Files Changed
third_party/blink/renderer/bindings/core/v8/generated_code_helper.ccthird_party/blink/renderer/core/execution_context/execution_context.ccthird_party/blink/renderer/core/execution_context/execution_context.hthird_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/back-forward-cache/weblocks-worker.https.window.js
Audit Directions
- Incomplete lifecycle-state checksSearch for other guards that test only
IsContextPaused()(orkPaused) and confirm they also account forkFrozenbefore allowing script, timers, or callbacks to run. - Asynchronous callback delivery into BFCacheReview async APIs (Web Locks, storage, IndexedDB, media, permissions) whose grants or events can arrive after a context is frozen, verifying each defers delivery until restore.
- Frozen-state teardown racesAudit BFCache freezing and eviction paths for windows where objects are being released while a callback can still be dispatched, ensuring dispatch is gated on
IsContextFrozenOrPaused().
Patch
From fab48e05e108a252067742ccfb0ae5bd1349595c Mon Sep 17 00:00:00 2001
From: Yuzu Saijo <yuzus@google.com>
Date: Tue, 16 Jun 2026 21:50:18 -0700
Subject: [PATCH] [bfcache] Prevent JS callbacks from running when context is frozen
Before this CL, JavaScript callbacks were only stopped when context is
paused. This CL changes it so that they stop running when context is
frozen, too.
Migrating from `IsContextPaused()` to `IsContextFrozenOrPaused()` is the
general approach we take.
Bug: 511277457
Change-Id: I0b7cc83324d27591b034c31fa190106b61442174
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7894097
Reviewed-by: Kentaro Hara <haraken@chromium.org>
Reviewed-by: Yuki Shiino <yukishiino@chromium.org>
Commit-Queue: Yuzu Saijo <yuzus@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1648072}
---
diff --git a/third_party/blink/renderer/bindings/core/v8/generated_code_helper.cc b/third_party/blink/renderer/bindings/core/v8/generated_code_helper.cc
index ede2e6c..0323e53 100644
--- a/third_party/blink/renderer/bindings/core/v8/generated_code_helper.cc
+++ b/third_party/blink/renderer/bindings/core/v8/generated_code_helper.cc
@@ -42,11 +42,13 @@
relevant_execution_context->IsContextDestroyed()) {
return false;
}
- if (relevant_execution_context->IsContextPaused()) {
- if (ignore_pause == IgnorePause::kDontIgnore)
- return false;
+ // When execution context is frozen, callbacks should never run.
+ // When paused, callbacks should not run except when explicitly set to ignore.
+ if (relevant_execution_context->IsContextFrozen() ||
+ (relevant_execution_context->IsContextPaused() &&
+ ignore_pause == IgnorePause::kDontIgnore)) {
+ return false;
}
-
// TODO(yukishiino): Callback function type value must make the incumbent
// environment alive, i.e. the reference to v8::Context must be strong.
v8::HandleScope handle_scope(incumbent_script_state->GetIsolate());
@@ -66,9 +68,10 @@
incumbent_execution_context->IsContextDestroyed()) {
return false;
}
- if (incumbent_execution_context->IsContextPaused()) {
- if (ignore_pause == IgnorePause::kDontIgnore)
- return false;
+ if (incumbent_execution_context->IsContextFrozen() ||
+ (incumbent_execution_context->IsContextPaused() &&
+ ignore_pause == IgnorePause::kDontIgnore)) {
+ return false;
}
return !incumbent_script_state->World().IsMainWorld() ||
incumbent_execution_context->CanExecuteScripts(kAboutToExecuteScript);
diff --git a/third_party/blink/renderer/core/execution_context/execution_context.cc b/third_party/blink/renderer/core/execution_context/execution_context.cc
index 108ac1b..ba02ed9 100644
--- a/third_party/blink/renderer/core/execution_context/execution_context.cc
+++ b/third_party/blink/renderer/core/execution_context/execution_context.cc
@@ -324,6 +324,10 @@
return lifecycle_state_ == mojom::blink::FrameLifecycleState::kPaused;
}
+bool ExecutionContext::IsContextFrozen() const {
+ return lifecycle_state_ == mojom::blink::FrameLifecycleState::kFrozen;
+}
+
LoaderFreezeMode ExecutionContext::GetLoaderFreezeMode() const {
if (is_in_back_forward_cache_) {
DCHECK_EQ(lifecycle_state_, mojom::blink::FrameLifecycleState::kFrozen);
diff --git a/third_party/blink/renderer/core/execution_context/execution_context.h b/third_party/blink/renderer/core/execution_context/execution_context.h
index d30cdd97..4a73a62 100644
--- a/third_party/blink/renderer/core/execution_context/execution_context.h
+++ b/third_party/blink/renderer/core/execution_context/execution_context.h
@@ -258,6 +258,7 @@
void CountDeprecation(WebFeature feature) override;
bool IsContextPaused() const;
+ bool IsContextFrozen() const;
LoaderFreezeMode GetLoaderFreezeMode() const;
mojom::FrameLifecycleState ContextPauseState() const {
return lifecycle_state_;
diff --git a/third_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/back-forward-cache/weblocks-worker.https.window.js b/third_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/back-forward-cache/weblocks-worker.https.window.js
new file mode 100644
index 0000000..99cdfbf2
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/back-forward-cache/weblocks-worker.https.window.js
@@ -0,0 +1,97 @@
+// META: title=WebLocks granted to a dedicated worker in BFCache should be deferred.
+// META: script=/common/dispatcher/dispatcher.js
+// META: script=/common/get-host-info.sub.js
+// META: script=/common/utils.js
+// META: script=/html/browsers/browsing-the-web/back-forward-cache/resources/rc-helper.js
+// META: script=/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js
+// META: script=/html/browsers/browsing-the-web/remote-context-helper-tests/resources/test-helper.js
+
+'use strict';
+
+promise_test(async t => {
+ const rcHelper = new RemoteContextHelper();
+ // Open a window with noopener so that BFCache will work.
+ const rc1 = await rcHelper.addWindow(
+ /*extraConfig=*/ {}, /*options=*/ { features: 'noopener' });
+
+ // BroadcastChannel to receive the grant notification from worker.
+ const bc = new BroadcastChannel('bfcache_lock_test_channel');
+ let grantedReceived = false;
+ bc.onmessage = t.step_func(e => {
+ if (e.data === 'granted') {
+ grantedReceived = true;
+ }
+ });
+
+ // Acquire the lock in the main test window first.
+ let mainLockResolver;
+ const mainLockPromise = new Promise(resolve => { mainLockResolver = resolve; });
+ navigator.locks.request('bfcache_weblock_test', async lock => {
+ mainLockResolver();
+ // Keep holding the lock until we decide to release it.
+ await new Promise(resolve => {
+ t.add_cleanup(resolve);
+ globalThis.releaseMainLock = resolve;
+ });
+ });
+ await mainLockPromise;
+
+ // Start a worker in the remote context.
+ const worker = await rc1.addWorker(
+ /*workerVar=*/ undefined,
+ {
+ scripts: [],
+ }
+ );
+
+ // In the worker, request the same lock.
+ // Since the main window holds it, this will pend.
+ await worker.executeScript(() => {
+ navigator.locks.request('bfcache_weblock_test', () => {
+ const bc = new BroadcastChannel('bfcache_lock_test_channel');
+ bc.postMessage('granted');
+ });
+ });
+
+ // Prepare rc1 for BFCache.
+ await prepareForBFCache(rc1);
+
+ // Navigate away.
+ const rc1Away = await rc1.navigateToNew();
+ await assertSimplestScriptRuns(rc1Away);
+
+ // Now rc1 and its worker should be in BFCache.
+ // Wait a bit to ensure they are frozen.
+ await new Promise(resolve => t.step_timeout(resolve, 1000));
+
+ // Release the lock in the main window.
+ // If the worker is correctly frozen, it will acquire the lock but will not run the callback.
+ globalThis.releaseMainLock();
+
+ // Wait to see if the worker does not run the callback.
+ await new Promise(resolve => t.step_timeout(resolve, 1000));
+
+ // Assert that we have not received the message yet.
+ assert_false(grantedReceived, "Lock should not be granted while in BFCache");
+
+ // Restore rc1 from BFCache.
+ await rc1Away.historyBack();
+ await assertImplementsBFCacheOptional(rc1);
+
+ // Now that it is restored, the worker should unfreeze and run the callback.
+ // Wait for the message to arrive.
+ if (!grantedReceived) {
+ await new Promise(resolve => {
+ bc.addEventListener('message', t.step_func(e => {
+ if (e.data === 'granted') {
+ resolve();
+ }
+ }));
+ // Set a timeout just in case it never arrives.
+ t.step_timeout(() => resolve(), 2000);
+ });
+ }
+
+ assert_true(grantedReceived, "Lock should be granted after restore");
+
+}, 'WebLocks grant to worker in BFCache is deferred');
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/back-forward-cache/weblocks-worker.https.window.js b/third_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/back-forward-cache/weblocks-worker.https.window.js
new file mode 100644
index 0000000..99cdfbf2
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/back-forward-cache/weblocks-worker.https.window.js
@@ -0,0 +1,97 @@
+// META: title=WebLocks granted to a dedicated worker in BFCache should be deferred.
+// META: script=/common/dispatcher/dispatcher.js
+// META: script=/common/get-host-info.sub.js
+// META: script=/common/utils.js
+// META: script=/html/browsers/browsing-the-web/back-forward-cache/resources/rc-helper.js
+// META: script=/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js
+// META: script=/html/browsers/browsing-the-web/remote-context-helper-tests/resources/test-helper.js
+
+'use strict';
+
+promise_test(async t => {
+ const rcHelper = new RemoteContextHelper();
+ // Open a window with noopener so that BFCache will work.
+ const rc1 = await rcHelper.addWindow(
+ /*extraConfig=*/ {}, /*options=*/ { features: 'noopener' });
+
+ // BroadcastChannel to receive the grant notification from worker.
+ const bc = new BroadcastChannel('bfcache_lock_test_channel');
+ let grantedReceived = false;
+ bc.onmessage = t.step_func(e => {
+ if (e.data === 'granted') {
+ grantedReceived = true;
+ }
+ });
+
+ // Acquire the lock in the main test window first.
+ let mainLockResolver;
+ const mainLockPromise = new Promise(resolve => { mainLockResolver = resolve; });
+ navigator.locks.request('bfcache_weblock_test', async lock => {
+ mainLockResolver();
+ // Keep holding the lock until we decide to release it.
+ await new Promise(resolve => {
+ t.add_cleanup(resolve);
+ globalThis.releaseMainLock = resolve;
+ });
+ });
+ await mainLockPromise;
+
+ // Start a worker in the remote context.
+ const worker = await rc1.addWorker(
+ /*workerVar=*/ undefined,
+ {
+ scripts: [],
+ }
+ );
+
+ // In the worker, request the same lock.
+ // Since the main window holds it, this will pend.
+ await worker.executeScript(() => {
+ navigator.locks.request('bfcache_weblock_test', () => {
+ const bc = new BroadcastChannel('bfcache_lock_test_channel');
+ bc.postMessage('granted');
+ });
+ });
+
+ // Prepare rc1 for BFCache.
+ await prepareForBFCache(rc1);
+
+ // Navigate away.
+ const rc1Away = await rc1.navigateToNew();
+ await assertSimplestScriptRuns(rc1Away);
+
+ // Now rc1 and its worker should be in BFCache.
+ // Wait a bit to ensure they are frozen.
+ await new Promise(resolve => t.step_timeout(resolve, 1000));
+
+ // Release the lock in the main window.
+ // If the worker is correctly frozen, it will acquire the lock but will not run the callback.
+ globalThis.releaseMainLock();
+
+ // Wait to see if the worker does not run the callback.
+ await new Promise(resolve => t.step_timeout(resolve, 1000));
+
+ // Assert that we have not received the message yet.
+ assert_false(grantedReceived, "Lock should not be granted while in BFCache");
+
+ // Restore rc1 from BFCache.
+ await rc1Away.historyBack();
+ await assertImplementsBFCacheOptional(rc1);
+
+ // Now that it is restored, the worker should unfreeze and run the callback.
+ // Wait for the message to arrive.
+ if (!grantedReceived) {
+ await new Promise(resolve => {
+ bc.addEventListener('message', t.step_func(e => {
+ if (e.data === 'granted') {
+ resolve();
+ }
+ }));
+ // Set a timeout just in case it never arrives.
+ t.step_timeout(() => resolve(), 2000);
+ });
+ }
+
+ assert_true(grantedReceived, "Lock should be granted after restore");
+
+}, 'WebLocks grant to worker in BFCache is deferred');
Original Bug Report
Use-After-Free in V8 FutexEmulation via Worker Freeze Re-entrancy
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential re-entrancy vulnerability exists in V8’s Atomics.wait implementation when interrupted by a Blink worker freeze (e.g., BFCache). The freeze spins up a nested message loop that incorrectly allows unpausable tasks like WebLock callbacks to execute JavaScript. This allows the same thread to re-enter Atomics.wait, double-adding the thread’s singleton wait node to the global wait list, corrupting the list and leading to a Use-After-Free of heap-allocated async waiter nodes.
Affected files:
v8/src/execution/futex-emulation.ccthird_party/blink/renderer/platform/scheduler/worker/worker_scheduler_impl.ccthird_party/blink/renderer/core/workers/worker_thread.ccthird_party/blink/renderer/modules/locks/lock_manager.cc
Estimated timestamp from git blame: 2026-04-01
Background
When a thread calls Atomics.wait, V8 uses FutexEmulation::WaitSyncImpl to block the thread. It retrieves a per-isolate singleton FutexWaitListNode and adds it to a global doubly-linked FutexWaitList. While blocked, the thread can be interrupted by V8 interrupts (e.g., via isolate->RequestInterrupt), which temporarily unlocks the futex mutex to process the interrupt.
Root Cause: Worker Freeze Re-entrancy
When Blink needs to freeze a worker (e.g., for BFCache), it issues a V8 interrupt that calls WorkerThread::PauseOrFreezeInsideV8InterruptOnWorkerThread. This changes the worker’s lifecycle state to kFrozen and enters a nested base::RunLoop.
While frozen, WorkerSchedulerImpl::PauseImpl leaves the unpausable_task_queue_ active. TaskType::kWebLocks is explicitly routed to this queue. If a pending WebLock request is granted while the worker is frozen in this nested loop, the task executes.
Before executing the JavaScript callback, V8 bindings check IsCallbackFunctionRunnable, which relies on ExecutionContext::IsContextPaused(). However, IsContextPaused() explicitly checks for mojom::blink::FrameLifecycleState::kPaused and returns false for kFrozen. Because of this gap, the safety check is bypassed, and attacker-controlled JavaScript executes on the worker thread while it is technically still inside the original Atomics.wait call.
Vulnerability: FutexWaitList Corruption
If the attacker’s WebLock callback calls Atomics.wait on a different SharedArrayBuffer location, the thread re-enters WaitSyncImpl. It retrieves the same per-isolate singleton FutexWaitListNode and attempts to add it to a new wait list bucket via FutexWaitList::AddNode.
AddNode has DCHECK_NULL(node->prev_) and DCHECK_NULL(node->next_), but these are compiled out in release builds. Because the node is still linked in the first wait location’s list, inserting it into a new empty list updates its wait_location_ but does not modify its prev_ or next_ pointers (because inserted is true). This leaves the original wait list with cross-bucket pointers, corrupting the doubly-linked list structure.
Exploitation (Use-After-Free)
By combining this synchronous wait corruption with Atomics.waitAsync, an attacker can allocate heap-backed AsyncWaiter nodes into the corrupted lists.
Because the list links (prev_ and next_) are corrupted and cross between different wait locations, allowing an AsyncWaiter to time out and trigger RemoveNode will unlink nodes incorrectly. This can leave a node’s next_ pointer permanently pointing to an AsyncWaiter that has been deleted and its memory freed.
The attacker can reclaim the freed AsyncWaiter memory with controlled data. When Atomics.notify (Wake) is called, it traverses the corrupted list, reading the attacker’s fake prev_ and next_ pointers. If NotifyAsyncWaiter calls RemoveNode on the controlled data, the unlinking logic (node->prev_->next_ = node->next_) provides an arbitrary memory write primitive.
Note: The prev_ and next_ pointers in FutexWaitListNode are raw pointers, so MiraclePtr (BRP) does not prevent this UAF.
Potential Reproduction Steps
Please note our tooling agent doesn’t yet have the ability to run code, so these are suggested steps based on static analysis.
- Create a Web Worker and share a
SharedArrayBufferwith the main thread. - On the main thread, request and hold a WebLock.
- In the worker, request the same WebLock (it will pend).
- In the worker, call
Atomics.wait(SAB, 0, 0). - On the main thread, add an async waiter:
Atomics.waitAsync(SAB, 0, 0). - Trigger a BFCache navigation to freeze the worker.
- Release the WebLock on the main thread. The grant callback task runs in the worker’s nested loop.
- In the worker’s callback, call
Atomics.wait(SAB, 1, 0). The singleton node is double-added, corrupting the list. - On the main thread, call
Atomics.waitAsync(SAB, 1, 0). The cross-bucket link is solidified. - Allow the first async waiter to time out, desyncing the list head/tail and leaving dangling pointers.
- Reclaim the freed async waiter memory with controlled data containing fake
prev_/next_pointers. - Call
Atomics.notify(SAB, 0)to trigger traversal into the freed node, executing the arbitrary write.
Suggested Fix
- Blink: Update
ExecutionContext::IsContextPaused()(orIsCallbackFunctionRunnable) to returntruewhen the lifecycle state iskFrozen, preventing script execution during BFCache freezes. - V8: Upgrade
DCHECK_NULL(node->prev_)to a hardCHECK_NULLinFutexWaitList::AddNodeto enforce the structural invariant of the global wait list in release builds and prevent any possibility of node double-insertion.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
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. And please feel free to reach out to me directly if you have concerns or feedback on the project.