CVE-2026-87625
Overview
Background
- `Atomics.wait`
- a JavaScript API that blocks the calling agent on a
SharedArrayBufferlocation until it is notified or times out, implemented in V8 via futex emulation. - `FutexWaitListNode`
- a per-
Isolatesingleton node (reached throughisolate->futex_wait_list_node()) that links an isolate into the globalFutexWaitListwhile it is blocked in a wait. - `FutexEmulation::WaitSyncImpl`
- the synchronous wait implementation that inserts the node into
FutexWaitListand periodically drops the list mutex to runisolate->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.
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
- Block on a synchronous wait
Attacker-controlled JS calls
Atomics.waiton aSharedArrayBuffer, causingWaitSyncImplto insert the isolate’sFutexWaitListNodeand mark it waiting. - Force an interrupt window
While the node is waiting,
WaitSyncImplreleases theFutexWaitListmutex and callsHandleInterrupts(), opening a re-entrancy window on the same thread. - Re-enter via interrupt
A same-thread interrupt (e.g. DevTools
Runtime.evaluateon a frozen worker) runs nested JS that callsAtomics.waitagain, reachingWaitSyncImplwith the same singleton node. - Corrupt the wait list
The re-entrant call double-adds the already-linked
FutexWaitListNodetoFutexWaitList, corrupting its intrusive next/prev pointers. - 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
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
| Function | Change | Notes |
|---|---|---|
InSyncWaitScopesrc/execution/futex-emulation.cc |
modified | |
ifsrc/execution/futex-emulation.cc |
modified | |
ReentrantWaitThreadtest/cctest/test-api.cc |
modified | |
TESTtest/cctest/test-api.cc |
modified |
Files Changed
test/cctest/test-api.ccsrc/execution/futex-emulation.ccsrc/execution/futex-emulation.h
Audit Directions
- Interrupt-window re-entrancyAudit 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 reuseReview other per-
Isolatesingleton nodes/objects linked into shared lists to confirm they cannot be double-inserted when the same thread re-enters the owning operation. - Lock-dropping invariantsCheck 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 byDCHECKs.
Patch
From a777999ee48633dc5587b01440180753a09eac31 Mon Sep 17 00:00:00 2001
From: Leszek Swirski <leszeks@chromium.org>
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 <olivf@chromium.org>
Auto-Submit: Leszek Swirski <leszeks@chromium.org>
Reviewed-by: Olivier Flückiger <olivf@chromium.org>
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();
Original Bug Report
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.ccthird_party/blink/renderer/core/inspector/devtools_session.ccthird_party/blink/renderer/core/workers/worker_thread.ccthird_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:
- Unpausable Task Queue:
WorkerSchedulerImplmapsTaskType::kInternalInspectortounpausable_task_queue_(third_party/blink/renderer/platform/scheduler/worker/worker_scheduler_impl.cc:215). This queue is initialized without aQueueEnabledVoter(worker_scheduler_impl.cc:64) and is therefore never disabled byPauseImpl()when the worker thread is paused or frozen. - Direct IO Dispatch: When a
Runtime.evaluatecommand is sent viachrome.debuggerto a worker target, the browser routes it over the IO channel, and the renderer IO thread posts it directly to thekInternalInspectortask runner as a nestable task (third_party/blink/renderer/core/inspector/devtools_session.cc:118-128). - 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. InsideWaitSyncImpl, the thread clears theinterrupted_flag, unlocks the wait list mutex, and callsisolate->stack_guard()->HandleInterrupts()(v8/src/execution/futex-emulation.cc:466-467) with the singleton node still linked on the active wait list. - 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) usingPlatform::Current()->CreateNestedMessageLoopRunner()->Run(). This runner executes nestable tasks. - Re-entrant Execution: The nested event loop processes the pending nestable
Runtime.evaluatecommand, allowing arbitrary JavaScript execution. If this evaluation callsAtomics.waiton a different address, it re-entersWaitSyncImplwith the same singleton node. - List Corruption: In production (release) builds where
DCHECK_NULLassertions are compiled out, the inner wait overwritesnode->wait_location_and callsAddNodeon 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 toRemoveNodeis ineffective becausenode->wait_location_was altered, leaving a stale pointer in the global wait registry. - Heap Exploitation: Interleaving
Atomics.waitAsyncoperations 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.)
- Establish a Dedicated Worker and pass it a
SharedArrayBuffer(SAB). - Attach to the worker target using an extension with
debuggerpermissions. - Have the worker thread enter a sleep state by calling
Atomics.wait(new Int32Array(sab), 0, 0)on the first index. - Initiate a freeze transition of the worker’s parent context, prompting V8 to request an API interrupt via the StackGuard.
- 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. - Via the attached debugger, execute the command
Runtime.evaluateon the worker target, passingAtomics.wait(new Int32Array(sab), 1, 0, 100)to trigger a wait on the second index. - The nested run loop processes this task, re-entering
WaitSyncImpland double-adding the singleton node. - 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.
- Issue an
Atomics.waitAsyncfrom 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:
- Introduce a re-entrancy check within
FutexEmulation::WaitSyncImplthat throws aRangeErrororTypeErrorif a synchronous wait is initiated while the isolate’s singletonFutexWaitListNodeis already in awaiting_state. - 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 activeHandleInterrupts()frame originating from aWaitSyncImplstate.
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.