Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentSandbox
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — 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 AIPlausible
Reviewer notes: Shown diff truncates before SetCodeAndEntrypointPointer; the CAS loop and is_marking() fast path are evidenced only by the commit message and removed TODO. Reclamation of a live Code object is inferred, not shown. 'Non-atomic release store' is self-contradictory.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker525689847
Fix commit4a3d24cd677f (v8/v8) +102/-82
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-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 V8 sandbox-hardened table that maps a JSDispatchHandle to a Code object and its entrypoint, indirecting all JSFunction calls through a tamper-resistant slot.
`JSDispatchEntry`
A single table row holding the encoded Code pointer, entrypoint, parameter count, and a kMarkingBit, updated via SetCodeAndEntrypointPointer.
Write barrier
The mechanism (WriteBarrier::ForJSDispatchHandle) that keeps the GC’s marking state consistent when a reference is stored, ensuring live objects are not reclaimed.
Concurrent marking
A GC phase where the marker traverses the heap on a background thread while the mutator keeps running, requiring atomic coordination on shared mark bits.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Shown diff truncates before SetCodeAndEntrypointPointer; the CAS loop and is_marking() fast path are evidenced only by the commit message and removed TODO. Reclamation of a live Code object is inferred, not shown. 'Non-atomic release store' is self-contradictory.

The vulnerable path updated a JSDispatchEntry by first writing code with a no-barrier store (SetCodeNoWriteBarrier) and then invoking WriteBarrier::ForJSDispatchHandle separately, under the invariant that the barrier “sets the entry mark bit” on every object (kWriteBarrierSetsEntryMarkBit). Because SetCodeAndEntrypointPointer performed an unconditional plain release store of the encoded word, a concurrent marker running on another thread could have its update to the entry’s kMarkingBit clobbered by the mutator’s store, producing a black→white→black edge where a reachable Code object is left unmarked. That unmarked dispatch entry allowed the still-referenced Code to be reclaimed, yielding a use-after-free when the dispatch handle was later invoked.

The fix removes the fragile invariant and re-introduces a compare-and-swap (CAS) loop in SetCodeAndEntrypointPointer: when is_marking() is true it CAS-updates the payload while preserving whatever marking_bit the marker concurrently set, and otherwise takes the fast release-store path. Code updates and barriers are bottlenecked through JSDispatchTable::SetCode / SetCodeKeepTieringRequest, and the verification (VerifyDispatchHandleWriteBarrier) is rewritten to match the new model instead of checking the old marking-state invariant.

Key insight
The single mistake was assuming a non-atomic release store of the dispatch entry could coexist with a concurrent marker that also mutates the entry’s mark bit; the fix guards the store with a CAS loop while marking is active so the marker’s kMarkingBit update is never lost.

Attack Path

  1. Trigger dispatch entry update during GC An attacker runs JavaScript that forces a Code update on a JSFunction (e.g., tiering, deopt, or feedback-cell transition) precisely while V8 is in a concurrent marking cycle.
  2. Race the mark bit The mutator’s plain release store in SetCodeAndEntrypointPointer overwrites the kMarkingBit that the concurrent marker set on the same JSDispatchEntry, so the entry is treated as unmarked.
  3. Leave live Code unmarked Marking finishes believing the dispatch entry (and its Code) is dead even though a JSFunction still references it, creating the black→white→black edge.
  4. Reclaim the Code object The subsequent sweep frees the Code object whose memory is still live from the perspective of the dispatch handle.
  5. Invoke freed code Calling the JSFunction dereferences the dangling dispatch entry, executing or corrupting reclaimed memory and giving the attacker a use-after-free primitive.

Impact Assessment

An attacker who runs script in the renderer gains a use-after-free on a Code object referenced through the JSDispatchTable, a strong primitive typically escalated to controlled reads/writes and code execution inside the sandboxed renderer process. Exploitation requires the ability to run JavaScript and to reliably win the timing race against V8’s concurrent marker, so it depends on triggering dispatch-entry code updates during an active GC marking phase.

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.cc
  • src/heap/heap-write-barrier.h

Audit Directions

  • Non-atomic stores to concurrently-marked metadata
    Audit every plain relaxed/release store to table entries or objects whose mark bit can be mutated by the concurrent marker; these need CAS or explicit synchronization.
  • Split store-then-barrier sequences
    Look for code that updates a reference with a NoWriteBarrier call and issues the barrier separately, since the gap invites lost-mark and reordering races; prefer unified bottleneck setters.
  • Invariant-dependent write barriers
    Scrutinize any barrier logic guarded by assumptions like “the barrier always sets the entry mark bit”; verify the invariant holds under concurrent GC rather than trusting a static_assert.
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

Potential JSDispatchEntry mark-bit drop in JSFunction::ResetIfCodeFlushed leading to UAF

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 race condition in V8’s JSDispatchTable allows the mutator thread to overwrite and erase a newly-set mark bit during concurrent marking when JSFunction::ResetIfCodeFlushed updates a dispatch entry using SKIP_WRITE_BARRIER. This causes the live entry to be swept and freed during the garbage collection cycle, leading to a use-after-free (UAF) and potential V8 sandbox bypass.

Affected files:

  • v8/src/objects/js-function-inl.h

Estimated timestamp from git blame: 2025-12-09

Root Cause Analysis

When incremental marking is active, the garbage collector concurrently marks objects and JSDispatchTable entries. JSDispatchEntry::Mark() is a single-shot, non-looping compare-and-swap (CAS) operation whose correctness relies on the invariant that any concurrent updates on the mutator thread will be followed by a write barrier to restore the mark bit if a race occurs (v8/src/sandbox/js-dispatch-table-inl.h:237-246):

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);
}

However, JSFunction::ResetIfCodeFlushed hard-codes SKIP_WRITE_BARRIER when calling UpdateCode (v8/src/objects/js-function-inl.h:608-637):

void JSFunction::ResetIfCodeFlushed(...) {
  ...
  if (kBytecodeCanFlush && NeedsResetDueToFlushedBytecode(isolate)) {
    ResetTieringRequests(isolate);
    UpdateCode(isolate, *BUILTIN_CODE(isolate, CompileLazy),
               SKIP_WRITE_BARRIER);
    raw_feedback_cell()->reset_feedback_vector(gc_notify_updated_slot);
    return;
  }
  ...
  if (kBaselineCodeCanFlush && NeedsResetDueToFlushedBaselineCode(isolate)) {
    ResetTieringRequests(isolate);
    UpdateCode(isolate, *BUILTIN_CODE(isolate, InterpreterEntryTrampoline),
               SKIP_WRITE_BARRIER);
  }
}

When UpdateCode is called, it triggers SetCodeAndEntrypointPointer which loads the current payload, modifies it (preserving the snapshotted mark bit), and writes it back (v8/src/sandbox/js-dispatch-table-inl.h:186-200):

void JSDispatchEntry::SetCodeAndEntrypointPointer(Address new_object,
                                                  Address new_entrypoint) {
  Address old_payload = encoded_word_.load(std::memory_order_relaxed);   // [A] snapshot mark bit
  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);            // [B] blind store
}

Race Interleaving Sequence

If the mutator is context-switched out between loading the old_payload [A] (which has marking_bit == 0) and writing the new_payload [B], the following race can occur:

  1. Incremental marking is active; the entry mark bit is initially 0.
  2. The mutator enters SetCodeAndEntrypointPointer via ResetIfCodeFlushed and loads old_payload (with marking_bit == 0).
  3. The concurrent marking thread visits the JSFunction or its FeedbackCell, invokes Mark(), and successfully sets the entry’s mark bit to 1 in memory via CAS.
  4. The mutator resumes and writes new_payload (with marking_bit == 0) via a blind store, clearing the mark bit back to 0.
  5. The write barrier WriteBarrier::ForJSDispatchHandle early-returns because SKIP_WRITE_BARRIER was passed (v8/src/heap/heap-write-barrier-inl.h:293-305), so the mark bit is never restored.
  6. During the atomic pause, the sweep phase (JSDispatchTable::Sweep via GenericSweep) deallocates the unmarked entry and moves it to the freelist (v8/src/sandbox/external-entity-table-inl.h:387).
  7. The JSFunction still holds the now-freed JSDispatchHandle. A subsequent allocation of a new entrypoint can reuse this index, resulting in a Use-After-Free (UAF) and potential control flow hijacking when the original JSFunction is invoked.

Potential Steps to Trigger

Note: These are potential steps and have not been executed, as our tooling does not currently have the capability to run code.

  1. Define a JS function f and invoke it to allocate a compiled JSFunction and a JSDispatchHandle in the JSDispatchTable.
  2. Stop calling f long enough for its SFI bytecode to be aged and marked flushable, and trigger a GC cycle that flushes the bytecode to UncompiledData.
  3. Initiate a subsequent GC cycle with concurrent marking active.
  4. Invoke f to trigger compilation and enter ResetIfCodeFlushed on the mutator thread.
  5. Force/time a concurrent marking thread to visit f and mark its dispatch entry after the mutator thread snapshots the old_payload but before it stores the new_payload in SetCodeAndEntrypointPointer.
  6. Let GC run to completion, sweeping and recycling the JSDispatchEntry into the freelist.
  7. Allocate new closures to reuse the freed dispatch index, then call the original function f to trigger a type confusion or jump to an unexpected entrypoint.

Suggested Fix

To resolve this race, avoid using SKIP_WRITE_BARRIER in JSFunction::ResetIfCodeFlushed. Use UPDATE_WRITE_BARRIER instead, ensuring that the write barrier is always executed to restore/re-set the mark bit if a race with concurrent marking occurs.

In v8/src/objects/js-function-inl.h, update the UpdateCode invocations to use UPDATE_WRITE_BARRIER:

// v8/src/objects/js-function-inl.h
  if (kBytecodeCanFlush && NeedsResetDueToFlushedBytecode(isolate)) {
    ResetTieringRequests(isolate);
    UpdateCode(isolate, *BUILTIN_CODE(isolate, CompileLazy),
               UPDATE_WRITE_BARRIER);
    raw_feedback_cell()->reset_feedback_vector(gc_notify_updated_slot);
    return;
  }
  ...
  if (kBaselineCodeCanFlush && NeedsResetDueToFlushedBaselineCode(isolate)) {
    ResetTieringRequests(isolate);
    UpdateCode(isolate, *BUILTIN_CODE(isolate, InterpreterEntryTrampoline),
               UPDATE_WRITE_BARRIER);
  }

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