CVE-2026-1862
Overview
Background
- Maglev
- V8’s mid-tier optimizing JIT compiler that lowers JavaScript operations into a graph of typed IR nodes.
- Module variable
- a binding backed by a
Cellobject, which Maglev lowers to aLoadTaggedField(cell, Cell::kValueOffset)read. - The hole
- a special internal sentinel (
RootIndex::kTheHoleValue) marking an uninitialized slot that must never surface as a normal JavaScript value. - `CanBeTheHoleValue`
- the Maglev predicate that decides whether a value or opcode may hold the hole, gating whether a hole check is emitted.
Root Cause Analysis
Maglev lowers reads of module variables to LoadTaggedField(cell, Cell::kValueOffset), and such a load can return the hole when the underlying module variable is still uninitialized. However, the hole-detection logic in ValueNode::CanBeTheHoleValue (in src/maglev/maglev-ir.cc) had no case for LoadTaggedField, and the opcode-level CanBeTheHoleValue(Opcode) predicate (in src/maglev/maglev-ir.h) omitted Opcode::kLoadTaggedField, so a load of Cell::kValueOffset was assumed never to produce the hole. This broke the invariant that any node capable of yielding the hole must be treated as such so hole checks are preserved, letting the hole flow past hole checking as if it were an ordinary value and causing type confusion downstream.
The fix adds a LoadTaggedField case returning Tribool::kMaybe when load->offset() == Cell::kValueOffset (and Tribool::kFalse otherwise), and registers Opcode::kLoadTaggedField in the opcode predicate, so these loads are conservatively treated as possibly-hole and correctly hole-checked.
CanBeTheHoleValue did not account for LoadTaggedField/kLoadTaggedField, so hole-bearing module-variable loads were classified as never-hole; the fix teaches both the value-node and opcode predicates that a LoadTaggedField at Cell::kValueOffset may be the hole.Attack Path
- Declare an uninitialized module variable
Author an ES module whose binding is accessed before initialization, so its backing
Cellstill holds the hole atCell::kValueOffset. - Trigger Maglev compilation
Run the accessing code hot enough that Maglev compiles it, lowering the module-variable read to
LoadTaggedField(cell, Cell::kValueOffset). - Bypass hole checking
Because
CanBeTheHoleValuedid not recognizeLoadTaggedField, Maglev omits the hole check and treats the loaded hole as a valid value. - Induce type confusion Feed the unchecked hole into an operation that assumes a well-typed value, producing type confusion in V8.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchsrc/maglev/maglev-ir.h |
modified |
Files Changed
src/maglev/maglev-ir.ccsrc/maglev/maglev-ir.h
Audit Directions
- Opcode allow-lists for hole/value classificationReview every predicate that enumerates opcodes (like
CanBeTheHoleValue) to ensure each lowering that can produce the hole is listed, since omissions silently disable safety checks. - Lowering of special bindings to generic loadsAudit places where module variables, context slots, or other cell-backed bindings are lowered to generic
LoadTaggedField/offset reads, checking that uninitialized (hole) states remain detectable after lowering. - Offset-sensitive load semanticsLook for
LoadTaggedFieldand similar loads whose safety depends on a specificoffset(e.g.Cell::kValueOffset), verifying that consumers account for the values such offsets can legitimately carry.
Patch
From 4508b5dfb26e86f975fc57cf04350d67071fe98e Mon Sep 17 00:00:00 2001
From: Victor Gomes <victorgomes@chromium.org>
Date: Fri, 30 Jan 2026 15:18:32 +0100
Subject: [PATCH] [maglev] Module variables can be the hole
Module variables are lowered in Maglev to
LoadTaggedField(cell, Cell:kValueOffset).
Drive-by: order opcodes alphabetically in CanBeTheHoleValue.
Fixed: 479726070
Change-Id: I2be5752906cf2ec8fdb4df497724a4d9ad55648d
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7534881
Auto-Submit: Victor Gomes <victorgomes@chromium.org>
Reviewed-by: Leszek Swirski <leszeks@chromium.org>
Commit-Queue: Leszek Swirski <leszeks@chromium.org>
Commit-Queue: Victor Gomes <victorgomes@chromium.org>
Cr-Commit-Position: refs/heads/main@{#105008}
---
diff --git a/src/maglev/maglev-ir.cc b/src/maglev/maglev-ir.cc
index 47c5e09..dd35ce7 100644
--- a/src/maglev/maglev-ir.cc
+++ b/src/maglev/maglev-ir.cc
@@ -669,6 +669,13 @@
if (const RootConstant* cst = TryCast<RootConstant>()) {
return ToTribool(cst->index() == RootIndex::kTheHoleValue);
}
+ if (const LoadTaggedField* load = TryCast<LoadTaggedField>()) {
+ // Modules variables can be the hole.
+ if (load->offset() == Cell::kValueOffset) {
+ return Tribool::kMaybe;
+ }
+ return Tribool::kFalse;
+ }
if (const LoadFixedArrayElement* load = TryCast<LoadFixedArrayElement>()) {
if (load->load_type() != LoadType::kUnknown) {
return Tribool::kFalse;
diff --git a/src/maglev/maglev-ir.h b/src/maglev/maglev-ir.h
index 11bc7d48..f4c0ff8 100644
--- a/src/maglev/maglev-ir.h
+++ b/src/maglev/maglev-ir.h
@@ -733,17 +733,18 @@
constexpr bool CanBeTheHoleValue(Opcode opcode) {
switch (opcode) {
- case Opcode::kInitialValue:
- case Opcode::kCallRuntime:
// TODO(victorgomes): Should we have a list of builtins that could
// return the hole?
case Opcode::kCallBuiltin:
+ case Opcode::kCallRuntime:
case Opcode::kGeneratorRestoreRegister:
- case Opcode::kRootConstant:
+ case Opcode::kInitialValue:
case Opcode::kLoadContextSlot:
case Opcode::kLoadContextSlotNoCells:
case Opcode::kLoadFixedArrayElement:
+ case Opcode::kLoadTaggedField:
case Opcode::kPhi:
+ case Opcode::kRootConstant:
return true;
default:
return false;
Original Bug Report
Hole leak in MaglevGraphBuilder
VULNERABILITY DETAILS
Summary
MaglevGraphBuilder::VisitThrowReferenceErrorIfHole incorrectly eliminates TDZ checks for module variables because CanBeTheHoleValue does not include LoadTaggedField in its whitelist. This causes the_hole_value (HOLE_TYPE) to leak into JS when accessing uninitialized module bindings, leading to type confusion. This issue is similar to crbug.com/450618029
Suggested Fix
Add Opcode::kLoadTaggedField to the CanBeTheHoleValue() whitelist, or special-case Cell::kValueOffset reads to return Tribool::kMaybe to prevent hole check elimination for module variable accesses.
Attached fix.diff which adds kLoadTaggedField to the CanBeTheHoleValue() whitelist:
diff --git a/src/maglev/maglev-ir.h b/src/maglev/maglev-ir.h
--- a/src/maglev/maglev-ir.h
+++ b/src/maglev/maglev-ir.h
@@ -734,6 +734,9 @@ constexpr bool CanBeTheHoleValue(Opcode opcode) {
switch (opcode) {
case Opcode::kInitialValue:
case Opcode::kCallRuntime:
+ case Opcode::kLoadTaggedField:
// TODO(victorgomes): Should we have a list of builtins that could
// return the hole?
case Opcode::kCallBuiltin:
Details
When accessing module variables, Ignition generates the following bytecode sequence:
LdaModuleVariableThrowReferenceErrorIfHole "<name>"Return
The ThrowReferenceErrorIfHole bytecode is critical for TDZ enforcement and must be preserved to prevent the_hole_value from leaking.
In MaglevGraphBuilder::VisitLdaModuleVariable, module variable accesses are compiled to read from a Cell via LoadTaggedField(Cell::kValueOffset). The issue is that ValueNode::IsTheHole returns kFalse for LoadTaggedField nodes because CanBeTheHoleValue does not include Opcode::kLoadTaggedField in its whitelist:
constexpr bool CanBeTheHoleValue(Opcode opcode) {
switch (opcode) {
case Opcode::kInitialValue:
case Opcode::kCallRuntime:
// TODO(victorgomes): Should we have a list of builtins that could
// return the hole?
case Opcode::kCallBuiltin:
case Opcode::kGeneratorRestoreRegister:
case Opcode::kRootConstant:
case Opcode::kLoadContextSlot:
case Opcode::kLoadContextSlotNoCells:
case Opcode::kLoadFixedArrayElement:
case Opcode::kPhi:
return true;
default:
return false;
}
}
As a result, when MaglevGraphBuilder::VisitThrowReferenceErrorIfHole checks IsTheHole() and gets kFalse, it skips inserting the hole check entirely. Additionally, MaglevGraphOptimizer::VisitThrowReferenceErrorIfHole removes any existing ThrowReferenceErrorIfHole nodes when IsTheHole() == kFalse.
ReduceResult MaglevGraphBuilder::VisitThrowReferenceErrorIfHole() {
// ThrowReferenceErrorIfHole <variable_name>
compiler::NameRef name = GetRefOperand<Name>(0);
ValueNode* value = GetAccumulator();
switch (value->IsTheHole()) {
case Tribool::kTrue:
return BuildThrow(Throw::kThrowAccessedUninitializedVariable,
GetConstant(name));
case Tribool::kFalse:
return ReduceResult::Done();
case Tribool::kMaybe:
DCHECK(value->is_tagged());
return AddNewNode<ThrowReferenceErrorIfHole>({value}, name);
}
}
This causes the_hole_value to leak into JavaScript, which then triggers crashes when the value is used (e.g., in Map.delete() or JSON.stringify()).
VERSION
V8 commit: 5853269075baaf304cd5be09d941a6c3e9671847
V8 Version: 14.6.101
REPRODUCTION CASE
Build args on commit 5853269075baaf304cd5be09d941a6c3e9671847:
is_component_build = false
is_debug = false
v8_enable_backtrace = true
dcheck_always_on = true
is_asan=true
v8_static_library=true
Run ./d8 --module --allow-natives-syntax poc.js, you would observe the DCHECK failure as the stack.txt shows.
- https://crbug.com/450618029
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/maglev/maglev-graph-builder.cc;l=16443
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/maglev/maglev-graph-builder.cc;l=7564
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/maglev/maglev-graph-optimizer.cc;l=939
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/maglev/maglev-ir.cc;l=667
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/maglev/maglev-ir.h;l=734