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
src/execution/futex-emulation.ccsrc/execution/futex-emulation.htest/cctest/test-api.cc
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 <[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();