CVE-2026-6301
Overview
Background
- `ContextAccess`
- a TurboFan operator parameter (
src/compiler/js-operator.h) describing a context-slot access by itsdepth,index, andimmutableflag. - Context depth
- the number of enclosing lexical-scope contexts the optimizer must walk up to reach the target context holding a variable slot.
- `base::BitField`
- a V8 helper that packs several small fields into one integer word, exposing per-field
encode/decodeand aNext<T, bits>chaining alias. - Type confusion
- treating a value or slot as a type it is not, which in TurboFan follows from reading a wrong context slot and misinterpreting its contents.
Root Cause Analysis
ContextAccess stored depth_ as a uint16_t, and its constructor set it via depth_(static_cast<uint16_t>(depth)) while only asserting the bound under DCHECK. Because DCHECK is compiled out in release builds, a context access with depth > 65535 silently truncated modulo 65536, so depth() returned a value different from the one the front end requested. TurboFan then emitted context-walk code for the truncated depth, reaching the wrong context object and reading the wrong slot, whose contents were assumed to have the type of the intended slot — a type confusion.
The fix packs immutable and depth into a single uint32_t immutable_and_depth_ bitfield, giving depth 31 bits (DepthField::Next<uint32_t, 31>), and replaces the compiled-out DCHECK with a CHECK_EQ(depth, DepthField::decode(immutable_and_depth_)) round-trip that aborts in every build if the value cannot be stored faithfully.
size_t depth in a uint16_t field guarded only by a release-stripped DCHECK, letting a large depth truncate unnoticed; the fix widens the field to 31 bits and enforces round-trip fidelity with an always-on CHECK_EQ.Attack Path
- Construct deep nesting Craft JavaScript whose lexical scoping produces a variable access at a context depth exceeding 65535 (e.g. via extreme nesting or generated code).
- Trigger optimization
Get the containing function tiered up so TurboFan builds a
ContextAccessoperator for that access, truncating the storeddepthto 16 bits. - Emit wrong context walk The optimizer computes the context chain for the truncated depth, so generated code walks to the wrong context object.
- Read wrong slot The access reads a slot in the wrong context whose stored value has a type differing from what the optimizer assumed for the intended slot.
- Exploit the confusion Downstream optimized operations act on the misinterpreted value, yielding a type confusion the attacker can shape into memory corruption.
Impact Assessment
Files Changed
src/compiler/js-operator.ccsrc/compiler/js-operator.h
Audit Directions
- Narrowing casts on sizesSearch for
static_cast<uint16_t>/static_cast<uint32_t>applied tosize_toperator parameters, where a large value can truncate. - `DCHECK`-only boundsFlag range or fit invariants enforced solely by
DCHECK, which vanish in release builds; preferCHECKor round-trip verification for security-relevant fields. - Bitfield capacityReview
base::BitField/Next<>field widths against the full range of values they may store, ensuring encode/decode round-trips cannot lose bits.
Patch
From 036e5e8f69be9fddc80bdbac10406186be2fa5b5 Mon Sep 17 00:00:00 2001 From: Nico Hartmann <[email protected]> Date: Wed, 25 Mar 2026 12:11:21 +0100 Subject: [PATCH] [turbofan] Grow ContextAccess' depth field to 31 bits Fixed: 495273999 Change-Id: I1ce294051aad3f413744386223976cd9c8b24bca Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7698216 Commit-Queue: Nico Hartmann <[email protected]> Reviewed-by: Darius Mercadier <[email protected]> Auto-Submit: Nico Hartmann <[email protected]> Cr-Commit-Position: refs/heads/main@{#106031} --- diff --git a/src/compiler/js-operator.cc b/src/compiler/js-operator.cc index a598cbc..46a4346 100644 --- a/src/compiler/js-operator.cc +++ b/src/compiler/js-operator.cc @@ -152,16 +152,14 @@ return OpParameter<CallRuntimeParameters>(op); } - ContextAccess::ContextAccess(size_t depth, size_t index, bool immutable) - : immutable_(immutable), - depth_(static_cast<uint16_t>(depth)), + : immutable_and_depth_(ImmutableField::encode(immutable) | + DepthField::encode(static_cast<uint32_t>(depth))), index_(static_cast<uint32_t>(index)) { - DCHECK(depth <= std::numeric_limits<uint16_t>::max()); + CHECK_EQ(depth, DepthField::decode(immutable_and_depth_)); DCHECK(index <= std::numeric_limits<uint32_t>::max()); } - bool operator==(ContextAccess const& lhs, ContextAccess const& rhs) { return lhs.depth() == rhs.depth() && lhs.index() == rhs.index() && lhs.immutable() == rhs.immutable(); diff --git a/src/compiler/js-operator.h b/src/compiler/js-operator.h index ac3b4c0..a2c4fa4 100644 --- a/src/compiler/js-operator.h +++ b/src/compiler/js-operator.h @@ -354,15 +354,19 @@ public: ContextAccess(size_t depth, size_t index, bool immutable); - size_t depth() const { return depth_; } + size_t depth() const { return DepthField::decode(immutable_and_depth_); } size_t index() const { return index_; } - bool immutable() const { return immutable_; } + bool immutable() const { + return ImmutableField::decode(immutable_and_depth_); + } private: + using ImmutableField = base::BitField<bool, 0, 1>; + using DepthField = ImmutableField::Next<uint32_t, 31>; + // For space reasons, we keep this tightly packed, otherwise we could just use // a simple int/int/bool POD. - const bool immutable_; - const uint16_t depth_; + uint32_t immutable_and_depth_; const uint32_t index_; };
Original Bug Report
V8 TurboFan contextAccess depth truncation cause type confusion in Module Variable Access
Steps to reproduce the problem
Use CF’s default run command:
ASAN_OPTIONS="alloc_dealloc_mismatch=0:allocator_may_return_null=1:allow_user_segv_handler=1:check_malloc_usable_size=0:detect_leaks=1:detect_odr_violation=0:detect_stack_use_after_return=1:external_symbolizer_path=/mnt/scratch0/clusterfuzz/bot/builds/v8-asan_linux-release_dd2f90e18dce5d8550461e387b6dcf5a476ceb72/symbolized/debug/llvm-symbolizer:fast_unwind_on_fatal=1:handle_abort=1:handle_segv=1:handle_sigbus=1:handle_sigfpe=1:handle_sigill=1:handle_sigtrap=1:malloc_context_size=128:print_scariness=1:print_summary=1:print_suppressions=0:redzone=128:strict_memcmp=0:symbolize=1:symbolize_inline_frames=true:use_sigaltstack=1" out/x64.asan/d8 --fuzzing --disable-abortjs --disable-in-process-stack-traces --verify-heap poc.js
Problem Description
A uint16_t integer truncation in TurboFan’s ContextAccess class causes a type confusion when accessing module variables from deeply nested closures. When the context chain depth from a closure to its enclosing module scope exceeds 65535, the depth is silently truncated modulo 65536 in the TurboFan optimizing compiler. This causes the context chain walk to terminate prematurely—landing on an intermediate context instead of the module context. When the walk lands on a catch context, the EXTENSION_INDEX slot aliases THROWN_OBJECT_INDEX, giving the attacker direct control over the value that TurboFan unconditionally treats as a SourceTextModule pointer. The subsequent unguarded field loads from this attacker-controlled object cause heap memory corruption.
2. Root Cause: uint16_t Truncation in ContextAccess::depth_
The Vuln (src/compiler/js-operator.h:361-366)
class ContextAccess final {
public:
ContextAccess(size_t depth, size_t index, bool immutable);
size_t depth() const { return depth_; } //returns size_t (64-bit)
size_t index() const { return index_; }
bool immutable() const { return immutable_; }
private:
// For space reasons, we keep this tightly packed, otherwise we could just use
// a simple int/int/bool POD.
const bool immutable_;
const uint16_t depth_; //truncated to 16 bits
const uint32_t index_;
};
The Constructor (src/compiler/js-operator.cc:156-162)
ContextAccess::ContextAccess(size_t depth, size_t index, bool immutable)
: immutable_(immutable),
depth_(static_cast<uint16_t>(depth)),
index_(static_cast<uint32_t>(index)) {
DCHECK(depth <= std::numeric_limits<uint16_t>::max());
DCHECK(index <= std::numeric_limits<uint32_t>::max());
}
Key observations:
- The
depthparameter arrives assize_t(64-bit). - It is stored into a
uint16_tfield viastatic_cast<uint16_t>(depth). Any value > 65535 is silently truncated todepth % 65536. - The getter
depth()returnssize_t, masking the internal truncation from callers.
The bytecode format uses kUImm (scalable unsigned byte) which correctly scales to 2-byte or 4-byte encoding for values > 255. The interpreter walks the full context chain correctly.
The truncation happens only when TurboFan compiles the bytecode into its IR. The BytecodeGraphBuilder reads the correct uint32_t depth from bytecode, but then passes it to ContextAccess where it gets truncated:
// src/compiler/bytecode-graph-builder.cc:2386-2393
void BytecodeGraphBuilder::VisitStaModuleVariable() {
int32_t cell_index = bytecode_iterator().GetImmediateOperand(0);
uint32_t depth = bytecode_iterator().GetUnsignedImmediateOperand(1); // correct value
Node* module = NewNode(
javascript()->LoadContextNoCell(depth, Context::EXTENSION_INDEX, true));
//passes to ContextAccess,truncated
Node* value = environment()->LookupAccumulator();
NewNode(javascript()->StoreModule(cell_index), module, value);
}
This means:
- During interpreter execution (before JIT): the function runs correctly,
exportedValueis accessed at the right module context. - After TurboFan compilation (triggered by warmup loop): the optimized code uses the truncated depth, context walk stops short, and the type confusion occurs.
Additional Comments
Introduced by commit
commit a9f593ef6ba936f07c034ce74e93a1bd9507df5f
[compiler,modules] Introduce JS operators for module loads and stores.
With this CL, the bytecode graph builder no longer translates module
loads/stores as runtime calls but in terms of two new JS operators. These are
lowered in typed-lowering to a sequence of LoadField's.
[email protected]
[email protected]
BUG=v8:1569
Review-Url: https://codereview.chromium.org/2489863003
Cr-Commit-Position: refs/heads/master@{#40881}
Suggested Fix
Widen depth_ to uint32_t
// src/compiler/js-operator.h
private:
const bool immutable_;
- const uint16_t depth_;
+ const uint32_t depth_;
const uint32_t index_;
// src/compiler/js-operator.cc
ContextAccess::ContextAccess(size_t depth, size_t index, bool immutable)
: immutable_(immutable),
- depth_(static_cast<uint16_t>(depth)),
+ depth_(static_cast<uint32_t>(depth)),
index_(static_cast<uint32_t>(index)) {
- DCHECK(depth <= std::numeric_limits<uint16_t>::max());
+ DCHECK(depth <= std::numeric_limits<uint32_t>::max());
DCHECK(index <= std::numeric_limits<uint32_t>::max());
}
Summary
V8 TurboFan contextAccess depth truncation cause type confusion in Module Variable Access
Custom Questions
Type of crash:
tab
Crash state:
AddressSanitizer:DEADLYSIGNAL
=================================================================
==2487637==ERROR: AddressSanitizer: SEGV on unknown address 0x000041414141 (pc 0x62e9e0000a9a bp 0x7ffed7927c88 sp 0x7ffed7927c68 T0)
==2487637==The signal is caused by a READ memory access.
#0 0x62e9e0000a9a (<unknown module>)
#1 0x62e9e00011d5 (<unknown module>)
#2 0x62e9b8a70f29 in Builtins_PromiseFulfillReactionJob setup-isolate-deserialize.cc
#3 0x62e9b8966552 in Builtins_RunMicrotasks setup-isolate-deserialize.cc
#4 0x62e9b89315aa in Builtins_JSRunMicrotasksEntry setup-isolate-deserialize.cc
#5 0x62e9b3ea2a57 in v8::internal::(anonymous namespace)::Invoke(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) src/execution/simulator.h:216:12
#6 0x62e9b3ea4b69 in v8::internal::(anonymous namespace)::InvokeWithTryCatch(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) src/execution/execution.cc:534:18
#7 0x62e9b3ea4f4f in v8::internal::Execution::TryRunMicrotasks(v8::internal::Isolate*, v8::internal::MicrotaskQueue*) src/execution/execution.cc:638:10
#8 0x62e9b3f867aa in v8::internal::MicrotaskQueue::RunMicrotasks(v8::internal::Isolate*) src/execution/microtask-queue.cc:185:22
#9 0x62e9b3f86184 in v8::internal::MicrotaskQueue::PerformCheckpointInternal(v8::Isolate*) src/execution/microtask-queue.cc:129:3
#10 0x62e9b3f06922 in v8::internal::Isolate::FireCallCompletedCallbackInternal(v8::internal::MicrotaskQueue*) src/execution/microtask-queue.h:48:5
#11 0x62e9b3b5e123 in v8::CallDepthScope<true>::~CallDepthScope() src/execution/isolate.h:1798:5
#12 0x62e9b3b1403c in v8::Script::Run(v8::Local<v8::Context>, v8::Local<v8::Data>) src/api/api-inl.h:259:20
#13 0x62e9b3767137 in v8::Shell::ExecuteString(v8::Isolate*, v8::Local<v8::String>, v8::Local<v8::String>, v8::Shell::ReportExceptions, v8::Global<v8::Value>*) src/d8/d8.cc:1041:44
#14 0x62e9b379f8e9 in v8::SourceGroup::Execute(v8::Isolate*) src/d8/d8.cc:5670:10
#15 0x62e9b37abded in v8::Shell::RunMainIsolate(v8::Isolate*, bool) src/d8/d8.cc:6689:37
#16 0x62e9b37ab225 in v8::Shell::RunMain(v8::Isolate*, bool) src/d8/d8.cc:6597:18
#17 0x62e9b37ae99b in v8::Shell::Main(int, char**) src/d8/d8.cc:7514:18
#18 0x7a56e002a1c9 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
#19 0x7a56e002a28a in __libc_start_main csu/../csu/libc-start.c:360:3
#20 0x62e9b365e029 in _start (/home/qy/0321/asanre/v8/out/x64.asan/d8+0x1346029) (BuildId: 7c522f96ec63a807)
==2487637==Register values:
rax = 0x0000000000000002 rbx = 0x0000000000000000 rcx = 0x000062e9e0000a80 rdx = 0x000000004141411e
rdi = 0x0000750e014f8be5 rsi = 0x0000750e014f8c05 rbp = 0x00007ffed7927c88 rsp = 0x00007ffed7927c68
r8 = 0x0000750e0101edc5 r9 = 0xffffff0000000000 r10 = 0x000076563f2c8000 r11 = 0x4000000000000000
r12 = 0x000078a6df1ea860 r13 = 0x00007966df1e1080 r14 = 0x0000750e00000000 r15 = 0x0000000000165200
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV (<unknown module>)
==2487637==ABORTING
Reporter credit:
qymag1c
Additional Data
Category: Security
Chrome Channel: Not sure
Regression: N/A \