Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in V8
DescriptionRace condition in V8
ComponentV8
Bug ClassRace
Tracker525686865
Fix commitc21a93ec80d5 (v8/v8) +4/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • src/objects/code.cc
From c21a93ec80d526fd270b119a2e8b7e26623d1b3b Mon Sep 17 00:00:00 2001
From: Olivier Flückiger <[email protected]>
Date: Fri, 10 Jul 2026 09:02:35 +0000
Subject: [PATCH] [sandbox] Fix JSDispatchTable entry mark-bit erasure

A 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.

Fixed: 525686865
Change-Id: I0cebd44b62ad5c16c19d73e5586058b1d2265ee4
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8074259
Commit-Queue: Olivier Flückiger <[email protected]>
Reviewed-by: Dominik Inführ <[email protected]>
Auto-Submit: Olivier Flückiger <[email protected]>
Cr-Commit-Position: refs/heads/main@{#108583}
---

diff --git a/src/objects/code.cc b/src/objects/code.cc
index 288ce77..62e6371 100644
--- a/src/objects/code.cc
+++ b/src/objects/code.cc
@@ -217,6 +217,10 @@
       } else {
         jdt.SetCodeNoWriteBarrier(handle, *BUILTIN_CODE(isolate, CompileLazy));
       }
+      // 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);
Loading diff…

Original Bug Report

reported by [email protected]

JSDispatchTable entry mark-bit erasure in Code::SetMarkedForDeoptimization

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

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

Affected files:

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

Estimated timestamp from git blame: 2024-12-05

Root Cause Analysis

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

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

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

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

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

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

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

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

Potential Trigger Path

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

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

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

Proposed Fix

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

In v8/src/objects/code.cc:

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

And similarly for the CompileLazy path.

Evaluated with Chrome root at commit: 75203b87cbf6681eb7c7dda8e1d0bf781538c76a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker