High patch-now ✔︎ verified CVSS 8.8 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped 🧬 TDZ hole-check elided across a branch

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Sub-componentMaglev
Bug ClassType Confusion
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Root cause matches diff. Downstream type confusion/memory corruption is asserted from advisory, not shown (no regression test). Requires Maglev-optimized module code reading a TDZ module binding; 'conservatively' ignores that non-Cell LoadTaggedField now explicitly returns kFalse.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker479726070
Fix commit4508b5dfb26e (v8/v8) +11/-3
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedChaoyuan Peng (@ret2happy)
Reported2026-01-29 (per vendor advisory)
Fix landed2026-01-30 (public git)
Disclosed2026-02-03
Patch gap4 days — fix public in source before the advisory

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 Cell object, which Maglev lowers to a LoadTaggedField(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.

Key insight
The single mistake was that 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

  1. Declare an uninitialized module variable Author an ES module whose binding is accessed before initialization, so its backing Cell still holds the hole at Cell::kValueOffset.
  2. Trigger Maglev compilation Run the accessing code hot enough that Maglev compiles it, lowering the module-variable read to LoadTaggedField(cell, Cell::kValueOffset).
  3. Bypass hole checking Because CanBeTheHoleValue did not recognize LoadTaggedField, Maglev omits the hole check and treats the loaded hole as a valid value.
  4. Induce type confusion Feed the unchecked hole into an operation that assumes a well-typed value, producing type confusion in V8.

Impact Assessment

An attacker who can run scripted JavaScript in the renderer gains a type-confusion primitive inside the V8 engine, a common stepping stone to memory corruption and arbitrary code execution in the sandboxed renderer process. The precondition is that the victim loads attacker-controlled JavaScript (an ES module with an uninitialized module variable) that gets optimized by Maglev.

Changed Functions

FunctionChangeNotes
switch
src/maglev/maglev-ir.h
modified

Files Changed

  • src/maglev/maglev-ir.cc
  • src/maglev/maglev-ir.h

Audit Directions

  • Opcode allow-lists for hole/value classification
    Review 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 loads
    Audit 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 semantics
    Look for LoadTaggedField and similar loads whose safety depends on a specific offset (e.g. Cell::kValueOffset), verifying that consumers account for the values such offsets can legitimately carry.
From 4508b5dfb26e86f975fc57cf04350d67071fe98e Mon Sep 17 00:00:00 2001
From: Victor Gomes <[email protected]>
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 <[email protected]>
Reviewed-by: Leszek Swirski <[email protected]>
Commit-Queue: Leszek Swirski <[email protected]>
Commit-Queue: Victor Gomes <[email protected]>
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;
Loading diff…

Original Bug Report

reported by [email protected]

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:

  • LdaModuleVariable
  • ThrowReferenceErrorIfHole "<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.

View on issue tracker