Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Bug ClassUAF
Tracker511277457
Fix commitfab48e05e108 (chromium/src) +112/-7
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/execution_context/execution_context.cc
modified
if
third_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.cc
  • third_party/blink/renderer/core/execution_context/execution_context.cc
  • third_party/blink/renderer/core/execution_context/execution_context.h
  • third_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/back-forward-cache/weblocks-worker.https.window.js
From fab48e05e108a252067742ccfb0ae5bd1349595c Mon Sep 17 00:00:00 2001
From: Yuzu Saijo <[email protected]>
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 <[email protected]>
Reviewed-by: Yuki Shiino <[email protected]>
Commit-Queue: Yuzu Saijo <[email protected]>
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');
Loading diff…

Regression Test / PoC

shipped with the fix
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');
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • third_party/blink/renderer/platform/scheduler/worker/worker_scheduler_impl.cc
  • third_party/blink/renderer/core/workers/worker_thread.cc
  • third_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.

  1. Create a Web Worker and share a SharedArrayBuffer with the main thread.
  2. On the main thread, request and hold a WebLock.
  3. In the worker, request the same WebLock (it will pend).
  4. In the worker, call Atomics.wait(SAB, 0, 0).
  5. On the main thread, add an async waiter: Atomics.waitAsync(SAB, 0, 0).
  6. Trigger a BFCache navigation to freeze the worker.
  7. Release the WebLock on the main thread. The grant callback task runs in the worker’s nested loop.
  8. In the worker’s callback, call Atomics.wait(SAB, 1, 0). The singleton node is double-added, corrupting the list.
  9. On the main thread, call Atomics.waitAsync(SAB, 1, 0). The cross-bucket link is solidified.
  10. Allow the first async waiter to time out, desyncing the list head/tail and leaving dangling pointers.
  11. Reclaim the freed async waiter memory with controlled data containing fake prev_/next_ pointers.
  12. Call Atomics.notify(SAB, 0) to trigger traversal into the freed node, executing the arbitrary write.

Suggested Fix

  1. Blink: Update ExecutionContext::IsContextPaused() (or IsCallbackFunctionRunnable) to return true when the lifecycle state is kFrozen, preventing script execution during BFCache freezes.
  2. V8: Upgrade DCHECK_NULL(node->prev_) to a hard CHECK_NULL in FutexWaitList::AddNode to 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.

View on issue tracker