CVE-2026-78901
Overview
Background
- `JSDispatchTable`
- A sandbox-hardened table whose entries (
JSDispatchEntry) hold aTagged<Code>pointer and entrypoint address that aJSFunctiondispatches through. - `kMarkingBit`
- A bit packed into a
JSDispatchEntry’sencoded_word_that the concurrent marker sets to keep the referencedCodeobject 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.
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
- Trigger concurrent marking Drive the V8 heap into a state where a concurrent major GC marking cycle is active while script continues to execute.
- Race a code update against the marker
Cause a
JSFunctioncode transition (e.g. lazy compile, tiering, or deopt viaCode::SetMarkedForDeoptimization) soSetCodeAndEntrypointPointerstores a new payload at the same moment the marker setskMarkingBiton thatJSDispatchEntry. - Clobber the mark bit
The plain release store overwrites the marker’s bit, leaving the newly installed
Codeunmarked while itsJSFunctionhost is already black, creating a black→white→black edge. - 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. - 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
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
| Function | Change | Notes |
|---|---|---|
ifsrc/heap/heap-write-barrier-inl.h |
modified | |
ifsrc/heap/heap-write-barrier.cc |
modified | |
whilesrc/sandbox/js-dispatch-table-inl.h |
modified | |
Isolatesrc/sandbox/js-dispatch-table.h |
modified | |
Counterssrc/sandbox/js-dispatch-table.h |
modified | |
Codesrc/sandbox/js-dispatch-table.h |
modified | |
HeapObjectsrc/sandbox/js-dispatch-table.h |
modified |
Files Changed
src/sandbox/js-dispatch-table.hsrc/objects/code.ccsrc/heap/heap-write-barrier-inl.hsrc/heap/read-only-promotion.ccsrc/sandbox/js-dispatch-table-inl.hsrc/heap/mark-compact.ccsrc/objects/js-function-inl.hsrc/heap/factory.ccsrc/heap/heap-write-barrier.hsrc/heap/heap-write-barrier.cc
Audit Directions
- Non-atomic bit-packed storesAudit 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 markFlag 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 updatesReview 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.
Patch
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)
Original Bug Report
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.ccv8/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:
- Optimized Code Creation: A function is compiled into optimized code, and its
Codeobject hasjs_dispatch_handleset. - Concurrent Marking: A major GC cycle begins. A background marking thread visits the
JSFunctionor itsFeedbackCell, callingjdt.Mark(handle)and marking the entry. - Deoptimization & Race: Concurrently, the main thread invalidates a code dependency (e.g., prototype change) and runs
Code::SetMarkedForDeoptimization. - Interleaved Execution:
- The main thread executes
SetCodeAndEntrypointPointer, loading the entry’s state withmarking_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 to1. - The main thread performs the blind store of
new_payload(withmarking_bit = 0), erasing the mark bit.
- The main thread executes
- Sweeping: Because the update was done via
SetCodeNoWriteBarrierand no follow-up write barrier was executed, the entry remains unmarked. When the GC sweeps theJSDispatchTableviaGenericSweep, it reclaims the entry and adds it to the free list. - Use-After-Free: If the slot is subsequently reallocated for another function, calling the original
JSFunctionwill 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.