CVE-2026-79097
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/heap/heap-write-barrier-inl.h |
modified | |
ifsrc/heap/heap-write-barrier.cc |
modified |
Files Changed
src/heap/factory.ccsrc/heap/heap-write-barrier-inl.hsrc/heap/heap-write-barrier.ccsrc/heap/heap-write-barrier.hsrc/heap/mark-compact.ccsrc/heap/read-only-promotion.cc
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
Original Bug Report
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:
- Incremental marking is active; the entry mark bit is initially
0. - The mutator enters
SetCodeAndEntrypointPointerviaResetIfCodeFlushedand loadsold_payload(withmarking_bit == 0). - The concurrent marking thread visits the
JSFunctionor itsFeedbackCell, invokesMark(), and successfully sets the entry’s mark bit to1in memory via CAS. - The mutator resumes and writes
new_payload(withmarking_bit == 0) via a blind store, clearing the mark bit back to0. - The write barrier
WriteBarrier::ForJSDispatchHandleearly-returns becauseSKIP_WRITE_BARRIERwas passed (v8/src/heap/heap-write-barrier-inl.h:293-305), so the mark bit is never restored. - During the atomic pause, the sweep phase (
JSDispatchTable::SweepviaGenericSweep) deallocates the unmarked entry and moves it to the freelist (v8/src/sandbox/external-entity-table-inl.h:387). - The
JSFunctionstill holds the now-freedJSDispatchHandle. 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 originalJSFunctionis 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.
- Define a JS function
fand invoke it to allocate a compiledJSFunctionand aJSDispatchHandlein theJSDispatchTable. - Stop calling
flong enough for its SFI bytecode to be aged and marked flushable, and trigger a GC cycle that flushes the bytecode toUncompiledData. - Initiate a subsequent GC cycle with concurrent marking active.
- Invoke
fto trigger compilation and enterResetIfCodeFlushedon the mutator thread. - Force/time a concurrent marking thread to visit
fand mark its dispatch entry after the mutator thread snapshots theold_payloadbut before it stores thenew_payloadinSetCodeAndEntrypointPointer. - Let GC run to completion, sweeping and recycling the
JSDispatchEntryinto the freelist. - Allocate new closures to reuse the freed dispatch index, then call the original function
fto 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.