CVE-2026-79097
Overview
Background
- `JSDispatchTable`
- A V8 sandbox-hardened table that maps a
JSDispatchHandleto aCodeobject and its entrypoint, indirecting allJSFunctioncalls through a tamper-resistant slot. - `JSDispatchEntry`
- A single table row holding the encoded
Codepointer, entrypoint, parameter count, and akMarkingBit, updated viaSetCodeAndEntrypointPointer. - 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
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.
kMarkingBit update is never lost.Attack Path
- Trigger dispatch entry update during GC
An attacker runs JavaScript that forces a
Codeupdate on aJSFunction(e.g., tiering, deopt, or feedback-cell transition) precisely while V8 is in a concurrent marking cycle. - Race the mark bit
The mutator’s plain release store in
SetCodeAndEntrypointPointeroverwrites thekMarkingBitthat the concurrent marker set on the sameJSDispatchEntry, so the entry is treated as unmarked. - Leave live Code unmarked
Marking finishes believing the dispatch entry (and its
Code) is dead even though aJSFunctionstill references it, creating the black→white→black edge. - Reclaim the Code object
The subsequent sweep frees the
Codeobject whose memory is still live from the perspective of the dispatch handle. - Invoke freed code
Calling the
JSFunctiondereferences the dangling dispatch entry, executing or corrupting reclaimed memory and giving the attacker a use-after-free primitive.
Impact Assessment
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
| 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.ccsrc/heap/heap-write-barrier.h
Audit Directions
- Non-atomic stores to concurrently-marked metadataAudit 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 sequencesLook for code that updates a reference with a
NoWriteBarriercall and issues the barrier separately, since the gap invites lost-mark and reordering races; prefer unified bottleneck setters. - Invariant-dependent write barriersScrutinize 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.
Patch
From 4a3d24cd677fe4fbdc643c3ad3d100dcdf2d3fa8 Mon Sep 17 00:00:00 2001 From: Olivier Flückiger <[email protected]> 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 <[email protected]> Reviewed-by: Samuel Groß <[email protected]> Auto-Submit: Olivier Flückiger <[email protected]> Commit-Queue: Samuel Groß <[email protected]> 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
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.