Medium patch-now ✔︎ verified CVSS 7.5 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in V8
DescriptionRace condition in V8
ComponentV8
Sub-componentSandbox
Bug ClassRace
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
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.
Independent review by AIConfirmed
Reviewer notes: Diff shows removal of kWriteBarrierSetsEntryMarkBit invariant and jdt.Mark, routing updates through SetCode with a real write barrier. The CAS loop itself is in a truncated hunk; mechanism partly rests on the commit message.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker525686865
Fix commit4a3d24cd677f (v8/v8) +102/-82
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-19 (per vendor advisory)
Fix landed2026-07-17 (public git)
Disclosed2026-08-25
Patch gap39 days — fix public in source before the advisory

Background

`JSDispatchTable`
A sandbox-hardened table whose entries (JSDispatchEntry) hold a Tagged<Code> pointer and entrypoint address that a JSFunction dispatches through.
`kMarkingBit`
A bit packed into a JSDispatchEntry’s encoded_word_ that the concurrent marker sets to keep the referenced Code object alive during a GC cycle.
Write barrier
A hook (WriteBarrier::ForJSDispatchHandle) run on pointer stores so the concurrent marker learns about newly-installed references before it finishes marking.
Black→white→black edge
A marking invariant violation where a fully-scanned (black) object is made to reference an unmarked (white) object that then gets reclaimed while still reachable.

Root Cause Analysis

Code installation into a dispatch entry went through JSDispatchEntry::SetCodeAndEntrypointPointer, which performed a plain relaxed/release store of the whole encoded_word_, unconditionally recomputing the payload and thereby clobbering any kMarkingBit the concurrent marker had just set. To paper over this, callers such as Code::SetMarkedForDeoptimization and factory.cc skipped the real write barrier and instead relied on an eager jdt.Mark(handle) plus the invariant JSDispatchTable::kWriteBarrierSetsEntryMarkBit, which is not atomic with respect to the marker. When a store from one JSFunction raced with the marker marking the same entry from another JSFunction, the freshly stored Code could be left white while its host was black, producing a black→white→black edge and letting a still-referenced Code object be freed.

The fix removes that fragile invariant, reintroduces a CAS loop in SetCodeAndEntrypointPointer that preserves the live kMarkingBit whenever isolate->isolate_data()->is_marking() is true (falling back to a cheap release store only when no marker runs), and routes all updates through JSDispatchTable::SetCode / SetCodeKeepTieringRequest, which invoke WriteBarrier::ForJSDispatchHandle properly instead of the ad-hoc Mark call.

Key insight
The core mistake was treating a dispatch-entry code update as a plain word store that could silently overwrite the marker’s kMarkingBit, guarded only by a non-atomic “write barrier sets the mark bit” invariant; the fix restores atomicity with a compare_exchange_weak CAS loop that keeps the marking bit intact and re-establishes a genuine write barrier on every update.

Attack Path

  1. Trigger concurrent marking Drive the V8 heap into a state where a concurrent major GC marking cycle is active while script continues to execute.
  2. Race a code update against the marker Cause a JSFunction code transition (e.g. lazy compile, tiering, or deopt via Code::SetMarkedForDeoptimization) so SetCodeAndEntrypointPointer stores a new payload at the same moment the marker sets kMarkingBit on that JSDispatchEntry.
  3. Clobber the mark bit The plain release store overwrites the marker’s bit, leaving the newly installed Code unmarked while its JSFunction host is already black, creating a black→white→black edge.
  4. Reclaim live Code The marker finishes without marking the referenced Code, and the sweeper frees it even though the dispatch entry still points at it.
  5. Use-after-free execution Subsequent dispatch through the stale entry jumps into freed/reallocated memory, giving an attacker a controllable use-after-free within the renderer.

Impact Assessment

An attacker with the ability to run JavaScript in a renderer gains a use-after-free of a Code object whose backing memory can be reclaimed and reallocated, a strong primitive toward arbitrary read/write and code execution inside the sandboxed renderer process. Exploitation requires winning a timing race against the concurrent marker during an active GC cycle, so it is probabilistic but repeatable across many allocation/GC iterations. The bug is confined to the V8 heap of the renderer and does not by itself cross the process sandbox boundary.

Changed Functions

FunctionChangeNotes
if
src/heap/heap-write-barrier-inl.h
modified
if
src/heap/heap-write-barrier.cc
modified
while
src/sandbox/js-dispatch-table-inl.h
modified
Isolate
src/sandbox/js-dispatch-table.h
modified
Counters
src/sandbox/js-dispatch-table.h
modified
Code
src/sandbox/js-dispatch-table.h
modified
HeapObject
src/sandbox/js-dispatch-table.h
modified

Files Changed

  • src/sandbox/js-dispatch-table.h
  • src/objects/code.cc
  • src/heap/heap-write-barrier-inl.h
  • src/heap/read-only-promotion.cc
  • src/sandbox/js-dispatch-table-inl.h
  • src/heap/mark-compact.cc
  • src/objects/js-function-inl.h
  • src/heap/factory.cc
  • src/heap/heap-write-barrier.h
  • src/heap/heap-write-barrier.cc

Audit Directions

  • Non-atomic bit-packed stores
    Audit any code that stores a whole packed word (payload plus GC/marking metadata) with a plain load-modify-store instead of a CAS, especially entries the concurrent marker mutates in parallel.
  • Skip-write-barrier plus eager mark
    Flag patterns that skip a write barrier and substitute a manual Mark/kWriteBarrierSetsEntryMarkBit-style invariant, since those are not atomic against the marker and can lose the mark bit.
  • Sandbox table entry updates
    Review other external/sandbox table update paths (dispatch, code pointer, trusted pointer tables) for the same missing is_marking() check before installing new pointers during GC.
From 4a3d24cd677fe4fbdc643c3ad3d100dcdf2d3fa8 Mon Sep 17 00:00:00 2001
From: Olivier Flückiger <olivf@chromium.org>
Date: Thu, 16 Jul 2026 12:00:07 +0000
Subject: [PATCH] [sandbox] Re-Introduc CAS loops in JSDispatchEntry

Remove the invariant that the JSDispatchEntry WB needs to be invoked
on every object, to fix the mark bit race.

Additionally, this CL:
- Checks !is_marking() in SetCodeAndEntrypointPointer to have a fast
  normal release store when no marker is running.
- Bottlenecks Code updates and WB in the JSDispatchTable.
- Updates the WB verification accordingly.

Bug: 525686865, 525689847

Change-Id: I40d00400e952bc0e0d9114e5942f3da4e0320e65
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8064734
Reviewed-by: Dominik Inführ <dinfuehr@chromium.org>
Reviewed-by: Samuel Groß <saelo@chromium.org>
Auto-Submit: Olivier Flückiger <olivf@chromium.org>
Commit-Queue: Samuel Groß <saelo@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108711}
---
diff --git a/src/heap/factory.cc b/src/heap/factory.cc
index 66368b0..c461222 100644
--- a/src/heap/factory.cc
+++ b/src/heap/factory.cc
@@ -5472,28 +5472,20 @@
       JSDispatchTable& jdt = isolate_->js_dispatch_table();
       Tagged<Code> old_code = jdt.GetCode(dispatch_handle);
 
-      // A write barrier is needed when settings code, because the update can
-      // race with marking which could leave the dispatch slot unmarked.
-      // TODO(olivf): This should be fixed by using a more traditional WB
-      // for dispatch handles (i.e. have a marking queue with dispatch handles
-      // instead of marking through the handle).
-      constexpr WriteBarrierMode mode_if_setting_code =
-          WriteBarrierMode::UPDATE_WRITE_BARRIER;
-
       // TODO(olivf): We should go through the cases where this is still
       // needed and maybe find some alternative to initialize it correctly
       // from the beginning.
       if (old_code->is_builtin()) {
-        jdt.SetCodeNoWriteBarrier(dispatch_handle, *code);
-        function->set_dispatch_handle(dispatch_handle, mode_if_setting_code);
+        jdt.SetCodeNoWriteBarrier(dispatch_handle, *code, isolate);
+        function->set_dispatch_handle(dispatch_handle, mode);
       } else {
         // On a transition of a feedback cell from one closure to many, make
         // sure that the code on the feedback cell isn't native context
         // specialized, and if it was, eagerly re-optimize.
         if (cell_transition == FeedbackCell::kOneToMany &&
             old_code->is_context_specialized()) {
-          jdt.SetCodeNoWriteBarrier(dispatch_handle, *code);
-          function->set_dispatch_handle(dispatch_handle, mode_if_setting_code);
+          jdt.SetCodeNoWriteBarrier(dispatch_handle, *code, isolate);
+          function->set_dispatch_handle(dispatch_handle, mode);
           DCHECK(old_code->kind() == CodeKind::MAGLEV ||
                  old_code->kind() == CodeKind::TURBOFAN_JS);
           if (!old_code->marked_for_deoptimization()) {
diff --git a/src/heap/heap-write-barrier-inl.h b/src/heap/heap-write-barrier-inl.h
index 32bce78..564a3b0 100644
--- a/src/heap/heap-write-barrier-inl.h
+++ b/src/heap/heap-write-barrier-inl.h
@@ -295,7 +295,7 @@
                                        WriteBarrierMode mode) {
 #if V8_VERIFY_WRITE_BARRIERS
   if (v8_flags.verify_write_barriers) {
-    CHECK(WriteBarrier::VerifyDispatchHandleMarkingState(host, handle, mode));
+    WriteBarrier::VerifyDispatchHandleWriteBarrier(host, handle, mode);
   }
 #endif  // V8_VERIFY_WRITE_BARRIERS
   if (mode == SKIP_WRITE_BARRIER) {
diff --git a/src/heap/heap-write-barrier.cc b/src/heap/heap-write-barrier.cc
index 479bf55..378b75b 100644
--- a/src/heap/heap-write-barrier.cc
+++ b/src/heap/heap-write-barrier.cc
@@ -191,7 +191,6 @@
   // Mark both the table entry and its content.
   Isolate* isolate = Isolate::Current();
   JSDispatchTable& jdt = isolate->js_dispatch_table();
-  static_assert(JSDispatchTable::kWriteBarrierSetsEntryMarkBit);
 #ifdef DEBUG
   Heap* heap = isolate->heap();
   jdt.VerifyEntry(handle, heap->js_dispatch_table_space(),
@@ -574,42 +573,36 @@
 #if V8_VERIFY_WRITE_BARRIERS
 
 // static
-bool WriteBarrier::VerifyDispatchHandleMarkingState(Tagged<HeapObject> host,
+void WriteBarrier::VerifyDispatchHandleWriteBarrier(Tagged<HeapObject> host,
                                                     JSDispatchHandle handle,
                                                     WriteBarrierMode mode) {
   JSDispatchTable& jdt = Isolate::Current()->js_dispatch_table();
   Tagged<Code> value = jdt.GetCode(handle);
 
   if (mode == SKIP_WRITE_BARRIER) {
+    // Builtins do not need write barriers, neither do their JSDispatch handles
+    // as the handles don't move and are immortal. This additional check is
+    // needed since in some configurations and cctests the builtins Code objects
+    // are not in R/O space.
     if (value->is_builtin()) {
-      // Builtins are immortal and immovable, so no write barrier needed.
-      BasePage* page = BasePage::FromHeapObject(value);
-      DCHECK(page->never_evacuate());
-      return true;
+      static_assert(!JSDispatchTable::kSupportsCompaction);
+      return;
     }
 
-    if (WriteBarrier::IsRequired(host, value)) {
-      return false;
+    // First check the barrier for the value
+    VerifySkipWriteBarrier(host, value, mode);
+
+    // Now check the barrier for the handle itself
+    if (jdt.InReadOnlySegment(handle)) {
+      return;
     }
+    if (IsMostRecentYoungAllocation(host->address())) {
+      return;
+    }
+    UNREACHABLE();
+  } else {
+    DCHECK_EQ(mode, UPDATE_WRITE_BARRIER);
   }
-
-  if (CurrentMarkingBarrier(host)->is_not_major()) return true;
-
-  // Ensure we don't have a black -> white -> black edge. This could happen when
-  // skipping a write barrier while concurrently the dispatch entry is marked
-  // from another JSFunction.
-  if (ReadOnlyHeap::Contains(host) ||
-      (IsMarking(host) && mode != SKIP_WRITE_BARRIER) ||
-      !CurrentMarkingBarrier(host)->IsMarked(host)) {
-    return true;
-  }
-  if (jdt.IsMarked(handle)) {
-    return true;
-  }
-  if (ReadOnlyHeap::Contains(value)) {
-    return true;
-  }
-  return !CurrentMarkingBarrier(host)->IsMarked(value);
 }
 
 #endif  // V8_VERIFY_WRITE_BARRIERS
diff --git a/src/heap/heap-write-barrier.h b/src/heap/heap-write-barrier.h
index 4439a05..bc075fc 100644
--- a/src/heap/heap-write-barrier.h
+++ b/src/heap/heap-write-barrier.h
@@ -154,7 +154,7 @@
   template <typename T>
   static inline bool IsRequired(const HeapObject* host, T value);
 
-  static bool VerifyDispatchHandleMarkingState(Tagged<HeapObject> host,
+  static void VerifyDispatchHandleWriteBarrier(Tagged<HeapObject> host,
                                                JSDispatchHandle value,
                                                WriteBarrierMode mode);
 #endif  // V8_VERIFY_WRITE_BARRIERS
diff --git a/src/heap/mark-compact.cc b/src/heap/mark-compact.cc
index 6881d42..5fa5816 100644
--- a/src/heap/mark-compact.cc
+++ b/src/heap/mark-compact.cc
@@ -3295,8 +3295,8 @@
                              code->kind() == CodeKind::TURBOFAN_JS ||
                              code->is_interpreter_trampoline_builtin());
                       entry.SetCodeAndEntrypointPointer(
-                          compile_lazy.ptr(),
-                          compile_lazy->instruction_start());
+                          compile_lazy.ptr(), compile_lazy->instruction_start(),
+                          isolate);
                     }
                   });
       })
@@ -5998,7 +5998,8 @@
 #undef CASE
             return code->instruction_start();
           })();
-          jdt.SetCodeAndEntrypointNoWriteBarrier(handle, code, new_entrypoint);
+          jdt.SetCodeAndEntrypointNoWriteBarrier(handle, code, new_entrypoint,
+                                                 heap_->isolate());
           CHECK_IMPLIES(jdt.IsTieringRequested(handle),
                         old_entrypoint == new_entrypoint);
         }
diff --git a/src/heap/read-only-promotion.cc b/src/heap/read-only-promotion.cc
index 254ed31..de6cad2 100644
--- a/src/heap/read-only-promotion.cc
+++ b/src/heap/read-only-promotion.cc
@@ -514,7 +514,8 @@
           Tagged<HeapObject> new_code = it->second;
           CHECK(IsCode(new_code));
           // TODO(saelo): is it worth logging something in this case?
-          jdt.SetCodeNoWriteBarrier(handle, TrustedCast<Code>(new_code));
+          jdt.SetCodeNoWriteBarrier(handle, TrustedCast<Code>(new_code),
+                                    isolate);
         });
     // Note the we should technically also update the entries in the
     // read_only_js_dispatch_table_space but it's currently not needed as we
diff --git a/src/objects/code.cc b/src/objects/code.cc
index 62e6371..20b4a94 100644
--- a/src/objects/code.cc
+++ b/src/objects/code.cc
@@ -180,7 +180,8 @@
       if (v8_flags.reopt_after_lazy_deopts &&
           isolate->concurrent_recompilation_enabled()) {
         jdt.SetCodeNoWriteBarrier(
-            handle, *BUILTIN_CODE(isolate, InterpreterEntryTrampoline));
+            handle, *BUILTIN_CODE(isolate, InterpreterEntryTrampoline),
+            isolate);
         // Somewhat arbitrary list of lazy deopt reasons which we expect to be
         // stable enough to warrant either immediate re-optimization, or
         // re-optimization after one invocation (to detect potential follow-up
@@ -215,12 +216,9 @@
             break;
         }
       } else {
-        jdt.SetCodeNoWriteBarrier(handle, *BUILTIN_CODE(isolate, CompileLazy));
+        jdt.SetCodeNoWriteBarrier(handle, *BUILTIN_CODE(isolate, CompileLazy),
+                                  isolate);
       }
-      // TODO(olivf, 525686865): Fix the race in dispatch handle marking by
-      // going back to a CAS loop.
-      static_assert(JSDispatchTable::kWriteBarrierSetsEntryMarkBit);
-      jdt.Mark(handle);
     }
     // Ensure we don't try to patch the entry multiple times.
     set_js_dispatch_handle(kNullJSDispatchHandle);
diff --git a/src/objects/js-function-inl.h b/src/objects/js-function-inl.h
index 253a444..d5fd5df 100644
--- a/src/objects/js-function-inl.h
+++ b/src/objects/js-function-inl.h
@@ -218,17 +218,14 @@
 }
 void JSFunction::UpdateDispatchEntry(Isolate* isolate, Tagged<Code> new_code,
                                      WriteBarrierMode mode) {
-  JSDispatchHandle handle = dispatch_handle();
-  isolate->js_dispatch_table().SetCodeNoWriteBarrier(handle, new_code);
-  WriteBarrier::ForJSDispatchHandle(this, handle, mode);
+  isolate->js_dispatch_table().SetCode(dispatch_handle(), new_code, this,
+                                       isolate, mode);
 }
 void JSFunction::UpdateDispatchEntryKeepTieringRequest(Isolate* isolate,
                                                        Tagged<Code> new_code,
                                                        WriteBarrierMode mode) {
-  JSDispatchHandle handle = dispatch_handle();
-  isolate->js_dispatch_table().SetCodeKeepTieringRequestNoWriteBarrier(
-      handle, new_code);
-  WriteBarrier::ForJSDispatchHandle(this, handle, mode);
+  isolate->js_dispatch_table().SetCodeKeepTieringRequest(
+      dispatch_handle(), new_code, this, isolate, mode);
 }
 JSDispatchHandle JSFunction::dispatch_handle() const {
   return dispatch_handle_.Relaxed_Load();
diff --git a/src/sandbox/js-dispatch-table-inl.h b/src/sandbox/js-dispatch-table-inl.h
index 3e5d3dd..e56cb58 100644
--- a/src/sandbox/js-dispatch-table-inl.h
+++ b/src/sandbox/js-dispatch-table-inl.h
@@ -12,6 +12,7 @@
 
 #include "src/builtins/builtins-inl.h"
 #include "src/common/code-memory-access-inl.h"
+#include "src/execution/isolate.h"
 #include "src/objects/objects-inl.h"
 #include "src/sandbox/external-entity-table-inl.h"
 #include "src/snapshot/embedded/embedded-data.h"
@@ -88,23 +89,42 @@
 }
 
 void JSDispatchTable::SetCodeNoWriteBarrier(JSDispatchHandle handle,
-                                            Tagged<Code> new_code) {
+                                            Tagged<Code> new_code,
+                                            Isolate* isolate) {
   SetCodeAndEntrypointNoWriteBarrier(handle, new_code,
-                                     new_code->instruction_start());
+                                     new_code->instruction_start(), isolate);
+}
+
+void JSDispatchTable::SetCode(JSDispatchHandle handle, Tagged<Code> new_code,
+                              Tagged<HeapObject> host, Isolate* isolate,
+                              WriteBarrierMode mode) {
+  SetCodeNoWriteBarrier(handle, new_code, isolate);
+  WriteBarrier::ForJSDispatchHandle(host, handle, mode);
 }
 
 void JSDispatchTable::SetCodeKeepTieringRequestNoWriteBarrier(
-    JSDispatchHandle handle, Tagged<Code> new_code) {
+    JSDispatchHandle handle, Tagged<Code> new_code, Isolate* isolate) {
   if (IsTieringRequested(handle)) {
-    SetCodeAndEntrypointNoWriteBarrier(handle, new_code, GetEntrypoint(handle));
+    SetCodeAndEntrypointNoWriteBarrier(handle, new_code, GetEntrypoint(handle),
+                                       isolate);
   } else {
     SetCodeAndEntrypointNoWriteBarrier(handle, new_code,
-                                       new_code->instruction_start());
+                                       new_code->instruction_start(), isolate);
   }
 }
 
+void JSDispatchTable::SetCodeKeepTieringRequest(JSDispatchHandle handle,
+                                                Tagged<Code> new_code,
+                                                Tagged<HeapObject> host,
+                                                Isolate* isolate,
+                                                WriteBarrierMode mode) {
+  SetCodeKeepTieringRequestNoWriteBarrier(handle, new_code, isolate);
+  WriteBarrier::ForJSDispatchHandle(host, handle, mode);
+}
+
 void JSDispatchTable::SetCodeAndEntrypointNoWriteBarrier(
-    JSDispatchHandle handle, Tagged<Code> new_code, Address new_entrypoint) {
+    JSDispatchHandle handle, Tagged<Code> new_code, Address new_entrypoint,
+    Isolate* isolate) {
   SBXCHECK(IsCompatibleCode(new_code, GetParameterCount(handle)));
 
   // The object should be in old space to avoid creating old-to-new references.
@@ -119,7 +139,8 @@
   uint32_t index = HandleToIndex(handle);
   DCHECK_GE(index, kEndOfReadOnlyIndex);
   CFIMetadataWriteScope write_scope("JSDispatchTable update");
-  at(index).SetCodeAndEntrypointPointer(new_code.ptr(), new_entrypoint);
+  at(index).SetCodeAndEntrypointPointer(new_code.ptr(), new_entrypoint,
+                                        isolate);
 }
 
 void JSDispatchTable::SetTieringRequest(JSDispatchHandle handle,
@@ -199,7 +220,8 @@
 }
 
 void JSDispatchEntry::SetCodeAndEntrypointPointer(Address new_object,
-                                                  Address new_entrypoint) {
+                                                  Address new_entrypoint,
+                                                  Isolate* isolate) {
   Address old_payload = encoded_word_.load(std::memory_order_relaxed);
   Address marking_bit = old_payload & kMarkingBit;
   Address parameter_count = old_payload & kParameterCountMask;
@@ -210,7 +232,20 @@
       ~kMarkingBit;
   Address new_payload = object | marking_bit | parameter_count;
   entrypoint_.store(new_entrypoint, std::memory_order_relaxed);
-  encoded_word_.store(new_payload, std::memory_order_release);
+  if (!isolate->isolate_data()->is_marking()) {
+    encoded_word_.store(new_payload, std::memory_order_release);
+  } else {
+    while (true) {
+      if (encoded_word_.compare_exchange_weak(old_payload, new_payload,
+                                              std::memory_order_release,
+                                              std::memory_order_relaxed)) {
+        break;
+      }
+      marking_bit = old_payload & kMarkingBit;
+      DCHECK_EQ(parameter_count, old_payload & kParameterCountMask);
+      new_payload = object | marking_bit | parameter_count;
+    }
+  }
   DCHECK(!IsFreelistEntry());
 }
 
@@ -268,17 +303,11 @@
 }
 
 void JSDispatchEntry::Mark() {
-  Address old_value = encoded_word_.load(std::memory_order_relaxed);
-  Address new_value = old_value | kMarkingBit;
-  // We don't need this cas to succeed. If marking races with
-  // `SetCodeAndEntrypointPointer`, then we are bound to re-set the mark bit in
-  // the write barrier.
-  static_assert(JSDispatchTable::kWriteBarrierSetsEntryMarkBit);
-  encoded_word_.compare_exchange_strong(old_value, new_value,
-                                        std::memory_order_relaxed);
+  encoded_word_.fetch_or(kMarkingBit, std::memory_order_relaxed);
 }
 
 void JSDispatchEntry::Unmark() {
+  DCHECK(Isolate::Current()->heap()->IsInGC());
   Address value = encoded_word_.load(std::memory_order_relaxed);
   value &= ~kMarkingBit;
   encoded_word_.store(value, std::memory_order_relaxed);
diff --git a/src/sandbox/js-dispatch-table.h b/src/sandbox/js-dispatch-table.h
index 7737f8b..0912c40 100644
--- a/src/sandbox/js-dispatch-table.h
+++ b/src/sandbox/js-dispatch-table.h
@@ -9,6 +9,7 @@
 #include "src/base/atomicops.h"
 #include "src/base/memory.h"
 #include "src/common/globals.h"
+#include "src/objects/objects.h"
 #include "src/runtime/runtime.h"
 #include "src/sandbox/external-entity-table.h"
 
@@ -18,6 +19,7 @@
 class Isolate;
 class Counters;
 class Code;
+class HeapObject;
 enum class TieringBuiltin;
 
 /**
@@ -42,7 +44,8 @@
   inline uint16_t GetParameterCount() const;
... (file diff truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

JSDispatchTable entry mark-bit erasure in Code::SetMarkedForDeoptimization

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 race condition exists in V8’s concurrent marking and deoptimization logic where Code::SetMarkedForDeoptimization updates a dispatch table entry without running a write barrier. During concurrent marking, a background thread’s CAS mark can be overwritten by the mutator’s blind store, leading to a live entry being swept. This could result in trusted-space type confusion and a potential V8 sandbox bypass.

Affected files:

  • v8/src/objects/code.cc
  • v8/src/sandbox/js-dispatch-table-inl.h

Estimated timestamp from git blame: 2024-12-05

Root Cause Analysis

In V8’s sandbox design, the JSDispatchTable acts as a protected control-flow integrity (CFI) table mapping JSDispatchHandles to (entrypoint, parameter_count) pairs. To ensure garbage collection correctness during concurrent marking, the table’s entries must be marked.

JSDispatchEntry::Mark() is implemented as a single-shot, non-looping CAS:

void JSDispatchEntry::Mark() {
  Address old_value = encoded_word_.load(std::memory_order_relaxed);
  Address new_value = old_value | kMarkingBit;
  static_assert(JSDispatchTable::kWriteBarrierSetsEntryMarkBit);
  encoded_word_.compare_exchange_strong(old_value, new_value,
                                        std::memory_order_relaxed);
}

Conversely, modifying an entry via SetCodeAndEntrypointPointer() performs a relaxed load, computes the new payload preserving the loaded marking bit, and writes it back via a blind store:

void JSDispatchEntry::SetCodeAndEntrypointPointer(Address new_object,
                                                  Address new_entrypoint) {
  Address old_payload = encoded_word_.load(std::memory_order_relaxed);
  Address marking_bit = old_payload & kMarkingBit;
  ...
  Address new_payload = object | marking_bit | parameter_count;
  entrypoint_.store(new_entrypoint, std::memory_order_relaxed);
  encoded_word_.store(new_payload, std::memory_order_release);
}

Because of this design, if concurrent marking sets the mark bit (using Mark()) after the modifier has loaded old_payload but before it executes its blind store, the mark bit will be silently overwritten and erased. To prevent this, V8 enforces an invariant that every modifier must invoke WriteBarrier::ForJSDispatchHandle after modifying an entry, which re-runs jdt.Mark(handle) and restores the mark bit if it was erased.

However, Code::SetMarkedForDeoptimization() in v8/src/objects/code.cc violates this invariant. It updates dispatch table entries using jdt.SetCodeNoWriteBarrier() but fails to execute a follow-up write barrier:

// v8/src/objects/code.cc
jdt.SetCodeNoWriteBarrier(
    handle, *BUILTIN_CODE(isolate, InterpreterEntryTrampoline));
...
jdt.SetCodeNoWriteBarrier(handle, *BUILTIN_CODE(isolate, CompileLazy));

Potential Trigger Path

Based on static analysis, the following sequence of events could potentially trigger this vulnerability:

  1. Optimized Code Creation: A function is compiled into optimized code, and its Code object has js_dispatch_handle set.
  2. Concurrent Marking: A major GC cycle begins. A background marking thread visits the JSFunction or its FeedbackCell, calling jdt.Mark(handle) and marking the entry.
  3. Deoptimization & Race: Concurrently, the main thread invalidates a code dependency (e.g., prototype change) and runs Code::SetMarkedForDeoptimization.
  4. Interleaved Execution:
    • The main thread executes SetCodeAndEntrypointPointer, loading the entry’s state with marking_bit = 0 (assuming it is called before the marker’s CAS succeeds or during a separate marking sweep where the marker re-evaluates the handle).
    • The background marking thread successfully executes Mark(), setting the marking bit to 1.
    • The main thread performs the blind store of new_payload (with marking_bit = 0), erasing the mark bit.
  5. Sweeping: Because the update was done via SetCodeNoWriteBarrier and no follow-up write barrier was executed, the entry remains unmarked. When the GC sweeps the JSDispatchTable via GenericSweep, it reclaims the entry and adds it to the free list.
  6. Use-After-Free: If the slot is subsequently reallocated for another function, calling the original JSFunction will jump to the new entry point with potentially mismatched signatures, leading to trusted-space type confusion.

Note: These steps are based on static code analysis; our tooling does not currently have the capability to run or dynamically verify this concurrent execution behavior.

Proposed Fix

To resolve this issue, a write barrier should be executed after updating the dispatch table entry in Code::SetMarkedForDeoptimization.

In v8/src/objects/code.cc:

jdt.SetCodeNoWriteBarrier(
    handle, *BUILTIN_CODE(isolate, InterpreterEntryTrampoline));
WriteBarrier::ForJSDispatchHandle(this, handle);

And similarly for the CompileLazy path.

Evaluated with Chrome root at commit: 75203b87cbf6681eb7c7dda8e1d0bf781538c76a


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