Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentExecution
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker532921336
Fix commita777999ee486 (v8/v8) +91/-3
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-09 (per vendor advisory)
Fix landed2026-07-31 (public git)
Disclosed2026-09-08
Patch gap39 days — fix public in source before the advisory

Background

`Atomics.wait`
a JavaScript API that blocks the calling agent on a SharedArrayBuffer location until it is notified or times out, implemented in V8 via futex emulation.
`FutexWaitListNode`
a per-Isolate singleton node (reached through isolate->futex_wait_list_node()) that links an isolate into the global FutexWaitList while it is blocked in a wait.
`FutexEmulation::WaitSyncImpl`
the synchronous wait implementation that inserts the node into FutexWaitList and periodically drops the list mutex to run isolate->stack_guard()->HandleInterrupts().
Re-entrancy
nested execution of JavaScript on the same thread that occurs while an outer call is still on the stack, here triggered by an interrupt (e.g. DevTools Runtime.evaluate) fired during a wait.

Root Cause Analysis

The vulnerable path is FutexEmulation::WaitSyncImpl, which registers the isolate’s single FutexWaitListNode into the shared FutexWaitList and then, while still logically “waiting”, releases the FutexWaitList mutex to call isolate->stack_guard()->HandleInterrupts(). The implicit invariant is that a given FutexWaitListNode is linked into the wait list at most once at a time, but nothing enforced this against same-thread re-entrancy. During the interrupt window, re-entrant JavaScript (for example Atomics.wait invoked from a DevTools Runtime.evaluate interrupt on a frozen worker) reaches WaitSyncImpl again and re-uses the same singleton node, double-adding it to the list and corrupting the intrusive linked-list pointers, which produces a use-after-free.

The fix adds an in_sync_wait_ flag on FutexWaitListNode, sets it via a scoped InSyncWaitScope RAII guard on entry to a synchronous wait, and makes the second, re-entrant entry observe node->IsInSyncWait() and cleanly throw a TypeError (MessageTemplate::kAtomicsOperationNotAllowed) instead of re-inserting the node. This works because it detects that the thread is already inside a wait scope before any list mutation occurs, eliminating the double-add entirely.

Key insight
The single core mistake is treating the per-isolate FutexWaitListNode as if it could only ever be in one active synchronous wait, while WaitSyncImpl re-enters JavaScript via HandleInterrupts() and lets a nested Atomics.wait re-use the same node. The fix guards each synchronous wait with an in_sync_wait_ flag (InSyncWaitScope) so a re-entrant call is rejected with a TypeError before it can double-add the node.

Attack Path

  1. Block on a synchronous wait Attacker-controlled JS calls Atomics.wait on a SharedArrayBuffer, causing WaitSyncImpl to insert the isolate’s FutexWaitListNode and mark it waiting.
  2. Force an interrupt window While the node is waiting, WaitSyncImpl releases the FutexWaitList mutex and calls HandleInterrupts(), opening a re-entrancy window on the same thread.
  3. Re-enter via interrupt A same-thread interrupt (e.g. DevTools Runtime.evaluate on a frozen worker) runs nested JS that calls Atomics.wait again, reaching WaitSyncImpl with the same singleton node.
  4. Corrupt the wait list The re-entrant call double-adds the already-linked FutexWaitListNode to FutexWaitList, corrupting its intrusive next/prev pointers.
  5. Trigger use-after-free Subsequent list traversal or unlink operations dereference the corrupted pointers, yielding a use-after-free on the node’s memory.

Impact Assessment

An attacker gains a use-after-free in the V8 heap/wait-list bookkeeping within the renderer (or worker) process running the JavaScript, which can be leveraged toward memory corruption. Preconditions include the ability to run JavaScript that uses SharedArrayBuffer/Atomics.wait and a mechanism to re-enter JS during the wait’s interrupt window (such as a DevTools Runtime.evaluate interrupt on a frozen worker). The severity is rated medium, reflecting the specific re-entrancy conditions required to reach the corrupting path.

Changed Functions

FunctionChangeNotes
InSyncWaitScope
src/execution/futex-emulation.cc
modified
if
src/execution/futex-emulation.cc
modified
ReentrantWaitThread
test/cctest/test-api.cc
modified
TEST
test/cctest/test-api.cc
modified

Files Changed

  • test/cctest/test-api.cc
  • src/execution/futex-emulation.cc
  • src/execution/futex-emulation.h

Audit Directions

  • Interrupt-window re-entrancy
    Audit every code path that drops a mutex to run HandleInterrupts() or otherwise re-enters JS, and verify that singleton per-isolate state cannot be mutated again by a nested call.
  • Per-isolate singleton reuse
    Review other per-Isolate singleton nodes/objects linked into shared lists to confirm they cannot be double-inserted when the same thread re-enters the owning operation.
  • Lock-dropping invariants
    Check that invariants assumed to hold across a temporary mutex release (such as “this node is linked at most once”) are re-validated after HandleInterrupts() or defended by a scope flag rather than only by DCHECKs.
From a777999ee48633dc5587b01440180753a09eac31 Mon Sep 17 00:00:00 2001
From: Leszek Swirski <[email protected]>
Date: Fri, 17 Jul 2026 15:31:42 +0200
Subject: [PATCH] [execution] Throw TypeError on re-entrant Atomics.wait calls

Each Isolate owns a single per-isolate FutexWaitListNode instance
accessed via isolate->futex_wait_list_node().

When FutexEmulation::WaitSyncImpl unlocks the FutexWaitList mutex during
isolate->stack_guard()->HandleInterrupts(), re-entrant JS execution
(e.g., via DevTools Runtime.evaluate on frozen workers) can call
Atomics.wait again on the same thread.

This re-entrancy reuses the same singleton FutexWaitListNode,
double-adding it to FutexWaitList and corrupting list pointers, leading
to Use-After-Free.

This CL checks if node->waiting_ is already true on entry to
WaitSyncImpl, and throws a JS TypeError
(MessageTemplate::kAtomicsOperationNotAllowed) to reject re-entrant
synchronous wait calls cleanly.

Bug: 532921336
Change-Id: Id2d2eab9a7fb19f5ce15246692ac2c3693bdc2cb
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8086043
Commit-Queue: Olivier Flückiger <[email protected]>
Auto-Submit: Leszek Swirski <[email protected]>
Reviewed-by: Olivier Flückiger <[email protected]>
Cr-Commit-Position: refs/heads/main@{#108975}
---
diff --git a/src/execution/futex-emulation.cc b/src/execution/futex-emulation.cc
index 00e0cb4..3d0c691 100644
--- a/src/execution/futex-emulation.cc
+++ b/src/execution/futex-emulation.cc
@@ -324,6 +324,23 @@
                            rel_timeout_ns, CallType::kIsWasm);
 }
 
+namespace {
+class InSyncWaitScope {
+ public:
+  explicit InSyncWaitScope(FutexWaitListNode* node) : node_(node) {
+    DCHECK(!node_->IsInSyncWait());
+    node_->SetInSyncWait(true);
+  }
+  ~InSyncWaitScope() {
+    DCHECK(node_->IsInSyncWait());
+    node_->SetInSyncWait(false);
+  }
+
+ private:
+  FutexWaitListNode* node_;
+};
+}  // namespace
+
 #if V8_ENABLE_WEBASSEMBLY
 Tagged<Object> FutexEmulation::WaitWasmManagedObject(
     Isolate* isolate, Tagged<HeapObject> object, int32_t offset,
@@ -334,6 +351,12 @@
       base::TimeDelta::FromNanoseconds(rel_timeout_ns);
 
   FutexWaitListNode* node = isolate->futex_wait_list_node();
+  if (node->IsInSyncWait()) {
+    return isolate->Throw(*isolate->factory()->NewTypeError(
+        MessageTemplate::kAtomicsOperationNotAllowed,
+        isolate->factory()->NewStringFromAsciiChecked("Atomics.wait")));
+  }
+  InSyncWaitScope wait_scope(node);
 
   bool use_timeout = rel_timeout_ns >= 0;
 
@@ -401,6 +424,13 @@
   FutexWaitList* wait_list = GetWaitList();
   FutexWaitListNode* node = isolate->futex_wait_list_node();
 
+  if (node->IsInSyncWait()) {
+    return isolate->Throw(*isolate->factory()->NewTypeError(
+        MessageTemplate::kAtomicsOperationNotAllowed,
+        isolate->factory()->NewStringFromAsciiChecked("Atomics.wait")));
+  }
+  InSyncWaitScope wait_scope(node);
+
   base::TimeTicks timeout_time;
   if (use_timeout) {
     base::TimeTicks current_time = base::TimeTicks::Now();
@@ -430,9 +460,10 @@
     NoGarbageCollectionMutexGuard& lock_guard, bool use_timeout,
     base::TimeTicks timeout_time, T value, T loaded_value,
     std::optional<void*> wait_location) {
+  DCHECK(!node->IsWaiting());
+
   DirectHandle<Object> result;
   if (loaded_value != value) {
-    DCHECK(!node->waiting_);
     return direct_handle(Smi::FromInt(WaitReturnValue::kNotEqualValue),
                          isolate);
   }
diff --git a/src/execution/futex-emulation.h b/src/execution/futex-emulation.h
index 8c1bb3d..baad1a2 100644
--- a/src/execution/futex-emulation.h
+++ b/src/execution/futex-emulation.h
@@ -64,6 +64,9 @@
   void NotifyWake();
 
   bool IsAsync() const { return async_state_ != nullptr; }
+  bool IsWaiting() const { return waiting_.load(std::memory_order_relaxed); }
+  bool IsInSyncWait() const { return in_sync_wait_; }
+  void SetInSyncWait(bool v) { in_sync_wait_ = v; }
 
   // Returns false if the cancelling failed, true otherwise.
   bool CancelTimeoutTask();
@@ -139,9 +142,13 @@
   // this node is alive.
   void* wait_location_ = nullptr;
 
-  // waiting_ and interrupted_ are protected by `GetWaitList()::mutex()`.
-  bool waiting_ = false;
+  // waiting_ is std::atomic<bool> to allow safe relaxed reads outside mutex
+  // locks.
+  std::atomic<bool> waiting_{false};
   bool interrupted_ = false;
+  // in_sync_wait_ tracks whether the isolate thread is executing inside a
+  // WaitSync scope. Modified exclusively by the isolate thread itself.
+  bool in_sync_wait_ = false;
 
   // State used for an async wait; nullptr on sync waits.
   const std::unique_ptr<AsyncState> async_state_;
diff --git a/test/cctest/test-api.cc b/test/cctest/test-api.cc
index 854af64..35eb589 100644
--- a/test/cctest/test-api.cc
+++ b/test/cctest/test-api.cc
@@ -26207,6 +26207,56 @@
   timeout_thread.Join();
 }
 
+namespace {
+class ReentrantWaitThread : public v8::base::Thread {
+ public:
+  explicit ReentrantWaitThread(v8::Isolate* isolate)
+      : Thread(Options("ReentrantWaitThread")), isolate_(isolate) {}
+
+  static void InterruptCallback(v8::Isolate* isolate, void* data) {
+    v8::HandleScope scope(isolate);
+    v8::TryCatch try_catch(isolate);
+    CompileRun(
+        "var sab2 = new SharedArrayBuffer(4);"
+        "var i32a2 = new Int32Array(sab2);"
+        "Atomics.wait(i32a2, 0, 0, 10);");
+
+    CHECK(try_catch.HasCaught());
+    v8::String::Utf8Value exception_msg(isolate, try_catch.Exception());
+    CHECK_NOT_NULL(strstr(*exception_msg, "cannot be called in this context"));
+  }
+
+  void Run() override {
+    i::Isolate* i_isolate = reinterpret_cast<i::Isolate*>(isolate_);
+    // Wait until main thread enters WaitSyncImpl and marks node->waiting_ =
+    // true
+    while (!i_isolate->futex_wait_list_node()->IsWaiting()) {
+      v8::base::OS::Sleep(v8::base::TimeDelta::FromMilliseconds(1));
+    }
+    isolate_->RequestInterrupt(InterruptCallback, nullptr);
+  }
+
+ private:
+  v8::Isolate* isolate_;
+};
+}  // namespace
+
+TEST(FutexReentrantWait) {
+  v8::Isolate* isolate = CcTest::isolate();
+  v8::HandleScope scope(isolate);
+  LocalContext env;
+
+  ReentrantWaitThread thread(isolate);
+  CHECK(thread.Start());
+
+  CompileRun(
+      "var ab = new SharedArrayBuffer(4);"
+      "var i32a = new Int32Array(ab);"
+      "Atomics.wait(i32a, 0, 0, 500);");
+
+  thread.Join();
+}
+
 TEST(StackCheckTermination) {
   v8::Isolate* isolate = CcTest::isolate();
   i::Isolate* i_isolate = CcTest::i_isolate();
Loading diff…

Original Bug Report

reported by [email protected]

Use-After-Free in FutexEmulation via Re-entrant Atomics.wait during Worker Freeze

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. 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 FutexEmulation where the per-isolate singleton FutexWaitListNode can be double-added to the wait list. This condition is reachable when a worker thread is frozen/paused and a debugger session issues a Runtime.evaluate command, which executes on the unpausable kInternalInspector queue inside a nested event loop. Re-entering Atomics.wait during this execution corrupts the doubly-linked list of wait nodes, potentially leading to a use-after-free or heap out-of-bounds write in the renderer process.

Affected files:

  • v8/src/execution/futex-emulation.cc
  • third_party/blink/renderer/core/inspector/devtools_session.cc
  • third_party/blink/renderer/core/workers/worker_thread.cc
  • third_party/blink/renderer/platform/scheduler/worker/worker_scheduler_impl.cc

Estimated timestamp from git blame: 2019-05-14

Root Cause Analysis

In V8, FutexEmulation::WaitSyncImpl utilizes a per-isolate singleton FutexWaitListNode (isolate->futex_wait_list_node()) to manage synchronous Atomics.wait operations. A potential re-entrancy issue in this design allows the same singleton node to be added to the global wait list multiple times, corrupting the intrusive doubly-linked list of waiter nodes.

The execution flow to trigger this condition is as follows:

  1. Unpausable Task Queue: WorkerSchedulerImpl maps TaskType::kInternalInspector to unpausable_task_queue_ (third_party/blink/renderer/platform/scheduler/worker/worker_scheduler_impl.cc:215). This queue is initialized without a QueueEnabledVoter (worker_scheduler_impl.cc:64) and is therefore never disabled by PauseImpl() when the worker thread is paused or frozen.
  2. Direct IO Dispatch: When a Runtime.evaluate command is sent via chrome.debugger to a worker target, the browser routes it over the IO channel, and the renderer IO thread posts it directly to the kInternalInspector task runner as a nestable task (third_party/blink/renderer/core/inspector/devtools_session.cc:118-128).
  3. Interrupt during Wait: While a worker thread is blocked inside Atomics.wait, a lifecycle freeze of the parent page triggers a V8 API interrupt (worker_thread.cc:916-918). This wakes the worker thread from its condition variable. Inside WaitSyncImpl, the thread clears the interrupted_ flag, unlocks the wait list mutex, and calls isolate->stack_guard()->HandleInterrupts() (v8/src/execution/futex-emulation.cc:466-467) with the singleton node still linked on the active wait list.
  4. Nested Event Loop: The V8 interrupt handler invokes the Blink freeze callback, which pauses the scheduler and spins a nested event loop (worker_thread.cc:960-964) using Platform::Current()->CreateNestedMessageLoopRunner()->Run(). This runner executes nestable tasks.
  5. Re-entrant Execution: The nested event loop processes the pending nestable Runtime.evaluate command, allowing arbitrary JavaScript execution. If this evaluation calls Atomics.wait on a different address, it re-enters WaitSyncImpl with the same singleton node.
  6. List Corruption: In production (release) builds where DCHECK_NULL assertions are compiled out, the inner wait overwrites node->wait_location_ and calls AddNode on the already-linked singleton node. When the inner wait times out, it erases the new address entry, leaving the old address entry pointing to the singleton node. When the outer wait subsequently resumes and finishes, its call to RemoveNode is ineffective because node->wait_location_ was altered, leaving a stale pointer in the global wait registry.
  7. Heap Exploitation: Interleaving Atomics.waitAsync operations from a separate isolate can cause newly allocated and subsequently freed async waiter nodes to be linked to this stale pointer, resulting in a classic Use-After-Free (UAF) or wild memory write inside the sandboxed renderer process.

Potential Step-by-Step Reproduction Path

(Note: These are potential steps; our tooling has not executed this code to verify execution details.)

  1. Establish a Dedicated Worker and pass it a SharedArrayBuffer (SAB).
  2. Attach to the worker target using an extension with debugger permissions.
  3. Have the worker thread enter a sleep state by calling Atomics.wait(new Int32Array(sab), 0, 0) on the first index.
  4. Initiate a freeze transition of the worker’s parent context, prompting V8 to request an API interrupt via the StackGuard.
  5. The worker thread wakes from its condition variable and enters a nested event loop inside V8’s HandleInterrupts() with the singleton waiter node still linked.
  6. Via the attached debugger, execute the command Runtime.evaluate on the worker target, passing Atomics.wait(new Int32Array(sab), 1, 0, 100) to trigger a wait on the second index.
  7. The nested run loop processes this task, re-entering WaitSyncImpl and double-adding the singleton node.
  8. Allow the inner wait to time out (100ms) and the parent context to resume from freeze. The outer wait resumes and terminates, leaving a stale pointer to the singleton node on the first index’s wait list.
  9. Issue an Atomics.waitAsync from a separate isolate on the first index to allocate and link an async node. When that async node is subsequently freed, walking or manipulating the wait list results in a potential Use-After-Free.

Suggested Fix

To remediate this issue, consider implementing one of the following changes:

  1. Introduce a re-entrancy check within FutexEmulation::WaitSyncImpl that throws a RangeError or TypeError if a synchronous wait is initiated while the isolate’s singleton FutexWaitListNode is already in a waiting_ state.
  2. Ensure that any DevTools-initiated script evaluations on frozen worker threads (via Runtime.evaluate) are rejected or deferred if V8 is currently suspended inside an active HandleInterrupts() frame originating from a WaitSyncImpl state.

Evaluated with Chrome root at commit: 84065d9121f6e48f67755f0ae963cc09617e5c85


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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