Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in V8
DescriptionInappropriate implementation in V8
ComponentV8
Sub-componentInterpreter
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker449760249
Fix commitb371b4f8ba07 (v8/v8) +166/-134
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle Big Sleep
Reported2025-10-07 (per vendor advisory)
Fix landed2025-10-10 (public git)
Disclosed2025-10-28
Patch gap18 days — fix public in source before the advisory

Background

Temporal Dead Zone (TDZ) hole check
A let/const binding holds the internal TheHole sentinel until its declaration runs, and V8’s bytecode generator emits a ThrowReferenceErrorIfHole check before any read so that early access throws a ReferenceError instead of exposing the sentinel.
Hole-check elision bitmap
BytecodeGenerator::hole_check_bitmap_ (a Variable::HoleCheckBitmap) records which numbered variables have already been hole-checked on the current path, so redundant ThrowReferenceErrorIfHole emissions can be skipped.
Merge scope
HoleCheckElisionMergeScope models branch-and-merge control flow (if/else, ternary, switch): each Branch accumulates its checks and the final Merge() keeps a variable marked “checked” only if it was checked on every branch (a bitwise AND of all branch bitmaps).
Labeled `break` escape path
A break/break label jumps out of a breakable construct from an arbitrary interior point, forming an additional exit path whose set of already-emitted hole checks can differ from the normal fall-through path.

Root Cause Analysis

The elision analysis treated the exit of a breakable block and the exit of a switch as if the only paths reaching the merge point were the normal fall-through / case-body paths; the break escape paths were never folded into the merge. In VisitBlockDeclarationsAndStatements, a breakable labeled block used a plain HoleCheckElisionScope (which only saves and restores the bitmap) rather than a merge scope, and the switch visitor built its own local HoleCheckElisionMergeScope that merged only the case clauses. Because TheHole check elision is sound only when a variable is provably hole-checked on all paths reaching a program point, omitting the break paths let the merge conclude a variable was checked even though a break could reach the post-construct code having skipped that check.

The fix gives ControlScopeForBreakable its own HoleCheckElisionMergeScope and calls the new MergeBranch at every CMD_BREAK, so each break’s current bitmap is AND-merged into the result; a hole check is now elided after the construct only if it was performed on the fall-through path and on every break path, restoring the all-paths invariant.

Key insight
The single mistake was omitting break/labeled-break exits from the hole-check merge, so an elision could be justified by paths the break actually bypasses; the fix routes every break through MergeBranch into the construct’s merge scope so an elided check must hold on all exits, break paths included.

Attack Path

  1. Construct a TDZ variable with an uneven break path Declare a let x in a block and arrange a switch (or breakable labeled block) where every case/fall-through path assigns or reads x (satisfying the merge), while a break label exits early before x is touched, as in the shipped test/message/fail/switch-labels.js.
  2. Trigger the faulty merge The old switch merge sees x checked on all case branches plus a default, so MergeIf(info.DefaultExists()) marks x as no-longer-needing a hole check after the switch, without accounting for the break target path.
  3. Reach the elided read with the sentinel live Taking the early break jumps past the assignment, so execution reaches the post-construct read (print(x)) while x still holds TheHole, and the hole check there has been elided.
  4. Observe `TheHole` in script The read returns the internal TheHole sentinel to JavaScript instead of throwing ReferenceError: Cannot access 'x' before initialization, which is the exact behavior the fix’s .out restores.
  5. Escalation (inferred) Surfacing the internal TheHole oddball into observable JS is a known precursor to type confusion; the patch itself only demonstrates the missing TDZ check, so any corruption or further primitive beyond observing the sentinel is not established by this diff.

Impact Assessment

An attacker who can run script in the V8/renderer context can cause a let/const read to return the internal TheHole sentinel instead of throwing the mandated ReferenceError, by shaping a break/labeled-break path around a switch or breakable block. The advisory classifies this as “Inappropriate implementation in V8,” severity high, with no in-the-wild exploitation stated; leaking TheHole to JavaScript is a recognized stepping stone toward type confusion, but the patch only demonstrates the elided TDZ check and the observable sentinel, so memory corruption or RCE is (inferred) and not proven by the diff. Preconditions are limited to executing attacker-controlled JavaScript; no special flags or privileges are required.

Changed Functions

FunctionChangeNotes
V8_NODISCARD
src/interpreter/bytecode-generator.cc
modified
merge_into_bitmap_
src/interpreter/bytecode-generator.cc
modified
BytecodeGenerator
src/interpreter/bytecode-generator.cc
modified
control_builder_
src/interpreter/bytecode-generator.cc
modified
merge_elider_
src/interpreter/bytecode-generator.cc
modified
switch
src/interpreter/bytecode-generator.cc
modified
switch
test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden
modified

Files Changed

  • src/interpreter/bytecode-generator.cc
  • test/message/fail/switch-labels.js
  • test/message/fail/switch-labels.out
  • test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden

Audit Directions

  • Unmerged early-exit paths
    Audit every ControlScope/ControlScopeForBreakable command (CMD_CONTINUE, CMD_RETURN, labeled continue, exception/try fallout) to confirm each non-fall-through exit that reaches a merge point also contributes to the hole-check merge, not just CMD_BREAK.
  • Elision soundness as an all-paths property
    Anywhere a bitmap or flag is used to skip a safety check (TDZ, map/shape checks, bounds), verify the “already done” state is the intersection over all paths reaching the point, especially across constructs that can be exited mid-body.
  • `TheHole`/internal-sentinel leakage
    Search for paths where an elided or reordered check can let TheHole (or other never-observable oddballs) reach user-visible reads; treat any observable sentinel as a type-confusion precursor and trace downstream use.
diff --git a/src/interpreter/bytecode-generator.cc b/src/interpreter/bytecode-generator.cc
index 688befd..37faf63 100644
--- a/src/interpreter/bytecode-generator.cc
+++ b/src/interpreter/bytecode-generator.cc
@@ -440,6 +440,106 @@
   }
 };
 
+// Scoped class to help elide hole checks within a conditionally executed basic
+// block. Each conditionally executed basic block must have a scope to emit
+// hole checks correctly.
+//
+// The duration of the scope must correspond to a basic block. Numbered
+// Variables (see Variable::HoleCheckBitmap) are remembered in the bitmap when
+// the first hole check is emitted. Subsequent hole checks are elided.
+//
+// On scope exit, the hole check state at construction time is restored.
+class V8_NODISCARD BytecodeGenerator::HoleCheckElisionScope {
+ public:
+  explicit HoleCheckElisionScope(BytecodeGenerator* bytecode_generator)
+      : HoleCheckElisionScope(&bytecode_generator->hole_check_bitmap_) {}
+
+  ~HoleCheckElisionScope() { *bitmap_ = prev_bitmap_value_; }
+
+ protected:
+  explicit HoleCheckElisionScope(Variable::HoleCheckBitmap* bitmap)
+      : bitmap_(bitmap), prev_bitmap_value_(*bitmap) {}
+
+  Variable::HoleCheckBitmap* bitmap_;
+  Variable::HoleCheckBitmap prev_bitmap_value_;
+};
+
+// Scoped class to help elide hole checks within control flow that branch and
+// merge.
+//
+// Each such control flow construct (e.g., if-else, ternary expressions) must
+// have a scope to emit hole checks correctly. Additionally, each branch must
+// have a Branch.
+//
+// The Merge or MergeIf method must be called to merge variables that have been
+// hole-checked along every branch are marked as no longer needing a hole check.
+//
+// Example:
+//
+//   HoleCheckElisionMergeScope merge_elider(this);
+//   {
+//      HoleCheckElisionMergeScope::Branch branch_elider(merge_elider);
+//      Visit(then_branch);
+//   }
+//   {
+//      HoleCheckElisionMergeScope::Branch branch_elider(merge_elider);
+//      Visit(else_branch);
+//   }
+//   merge_elider.Merge();
+//
+// Conversely, it is incorrect to use this class for control flow constructs
+// that do not merge (e.g., if without else). HoleCheckElisionScope should be
+// used for those cases.
+class V8_NODISCARD BytecodeGenerator::HoleCheckElisionMergeScope final {
+ public:
+  explicit HoleCheckElisionMergeScope(BytecodeGenerator* bytecode_generator)
+      : bitmap_(&bytecode_generator->hole_check_bitmap_) {}
+
+  ~HoleCheckElisionMergeScope() {
+    // Did you forget to call Merge or MergeIf?
+    DCHECK(merge_called_);
+  }
+
+  void MergeBranch(BytecodeGenerator* generator) {
+    merge_value_ &= generator->hole_check_bitmap_;
+  }
+
+  void Merge() {
+    DCHECK_NE(UINT64_MAX, merge_value_);
+    *bitmap_ = merge_value_;
+#ifdef DEBUG
+    merge_called_ = true;
+#endif
+  }
+
+  void MergeIf(bool cond) {
+    if (cond) Merge();
+#ifdef DEBUG
+    merge_called_ = true;
+#endif
+  }
+
+  class V8_NODISCARD Branch final : public HoleCheckElisionScope {
+   public:
+    explicit Branch(HoleCheckElisionMergeScope& merge_into)
+        : HoleCheckElisionScope(merge_into.bitmap_),
+          merge_into_bitmap_(&merge_into.merge_value_) {}
+
+    ~Branch() { *merge_into_bitmap_ &= *bitmap_; }
+
+   private:
+    Variable::HoleCheckBitmap* merge_into_bitmap_;
+  };
+
+ private:
+  Variable::HoleCheckBitmap* bitmap_;
+  Variable::HoleCheckBitmap merge_value_ = UINT64_MAX;
+
+#ifdef DEBUG
+  bool merge_called_ = false;
+#endif
+};
+
 // Scoped class for enabling break inside blocks and switch blocks.
 class BytecodeGenerator::ControlScopeForBreakable final
     : public BytecodeGenerator::ControlScope {
@@ -449,7 +549,10 @@
                            BreakableControlFlowBuilder* control_builder)
       : ControlScope(generator),
         statement_(statement),
-        control_builder_(control_builder) {}
+        control_builder_(control_builder),
+        merge_elider_(generator) {}
+
+  HoleCheckElisionMergeScope& merge_elider() { return merge_elider_; }
 
  protected:
   bool Execute(Command command, Statement* statement,
@@ -457,6 +560,7 @@
     if (statement != statement_) return false;
     switch (command) {
       case CMD_BREAK:
+        merge_elider_.MergeBranch(generator());
         PopContextToExpectedDepth();
         control_builder_->Break();
         return true;
@@ -472,6 +576,7 @@
  private:
   Statement* statement_;
   BreakableControlFlowBuilder* control_builder_;
+  HoleCheckElisionMergeScope merge_elider_;
 };
 
 // Scoped class for enabling 'break' and 'continue' in iteration
@@ -1110,102 +1215,6 @@
   ZoneMap<Key, int> map_;
 };
 
-// Scoped class to help elide hole checks within a conditionally executed basic
-// block. Each conditionally executed basic block must have a scope to emit
-// hole checks correctly.
-//
-// The duration of the scope must correspond to a basic block. Numbered
-// Variables (see Variable::HoleCheckBitmap) are remembered in the bitmap when
-// the first hole check is emitted. Subsequent hole checks are elided.
-//
-// On scope exit, the hole check state at construction time is restored.
-class V8_NODISCARD BytecodeGenerator::HoleCheckElisionScope {
- public:
-  explicit HoleCheckElisionScope(BytecodeGenerator* bytecode_generator)
-      : HoleCheckElisionScope(&bytecode_generator->hole_check_bitmap_) {}
-
-  ~HoleCheckElisionScope() { *bitmap_ = prev_bitmap_value_; }
-
- protected:
-  explicit HoleCheckElisionScope(Variable::HoleCheckBitmap* bitmap)
-      : bitmap_(bitmap), prev_bitmap_value_(*bitmap) {}
-
-  Variable::HoleCheckBitmap* bitmap_;
-  Variable::HoleCheckBitmap prev_bitmap_value_;
-};
-
-// Scoped class to help elide hole checks within control flow that branch and
-// merge.
-//
-// Each such control flow construct (e.g., if-else, ternary expressions) must
-// have a scope to emit hole checks correctly. Additionally, each branch must
-// have a Branch.
-//
-// The Merge or MergeIf method must be called to merge variables that have been
-// hole-checked along every branch are marked as no longer needing a hole check.
-//
-// Example:
-//
-//   HoleCheckElisionMergeScope merge_elider(this);
-//   {
-//      HoleCheckElisionMergeScope::Branch branch_elider(merge_elider);
-//      Visit(then_branch);
-//   }
-//   {
-//      HoleCheckElisionMergeScope::Branch branch_elider(merge_elider);
-//      Visit(else_branch);
-//   }
-//   merge_elider.Merge();
-//
-// Conversely, it is incorrect to use this class for control flow constructs
-// that do not merge (e.g., if without else). HoleCheckElisionScope should be
-// used for those cases.
-class V8_NODISCARD BytecodeGenerator::HoleCheckElisionMergeScope final {
- public:
-  explicit HoleCheckElisionMergeScope(BytecodeGenerator* bytecode_generator)
-      : bitmap_(&bytecode_generator->hole_check_bitmap_) {}
-
-  ~HoleCheckElisionMergeScope() {
-    // Did you forget to call Merge or MergeIf?
-    DCHECK(merge_called_);
-  }
-
-  void Merge() {
-    DCHECK_NE(UINT64_MAX, merge_value_);
-    *bitmap_ = merge_value_;
-#ifdef DEBUG
-    merge_called_ = true;
-#endif
-  }
-
-  void MergeIf(bool cond) {
-    if (cond) Merge();
-#ifdef DEBUG
-    merge_called_ = true;
-#endif
-  }
-
-  class V8_NODISCARD Branch final : public HoleCheckElisionScope {
-   public:
-    explicit Branch(HoleCheckElisionMergeScope& merge_into)
-        : HoleCheckElisionScope(merge_into.bitmap_),
-          merge_into_bitmap_(&merge_into.merge_value_) {}
-
-    ~Branch() { *merge_into_bitmap_ &= *bitmap_; }
-
-   private:
-    Variable::HoleCheckBitmap* merge_into_bitmap_;
-  };
-
- private:
-  Variable::HoleCheckBitmap* bitmap_;
-  Variable::HoleCheckBitmap merge_value_ = UINT64_MAX;
-
-#ifdef DEBUG
-  bool merge_called_ = false;
-#endif
-};
-
 class BytecodeGenerator::IteratorRecord final {
  public:
   IteratorRecord(Register object_register, Register next_register,
@@ -2092,22 +2101,18 @@
 
 void BytecodeGenerator::VisitBlockDeclarationsAndStatements(Block* stmt) {
   BlockBuilder block_builder(builder(), block_coverage_builder_, stmt);
-  ControlScopeForBreakable execution_control(this, stmt, &block_builder);
+
   if (stmt->scope() != nullptr) {
     VisitDeclarations(stmt->scope()->declarations());
   }
-  if (V8_UNLIKELY(stmt->is_breakable())) {
-    // Loathsome labeled blocks can be the target of break statements, which
-    // causes unconditional blocks to act conditionally, and therefore to
-    // require their own elision scope.
-    //
-    // lbl: {
-    //   if (cond) break lbl;
-    //   x;
-    // }
-    // x;  <-- Cannot elide TDZ check
-    HoleCheckElisionScope elider(this);
-    VisitStatements(stmt->statements());
+  if (stmt->is_breakable()) {
+    ControlScopeForBreakable execution_control(this, stmt, &block_builder);
+    {
+      HoleCheckElisionMergeScope::Branch branch_elider(
+          execution_control.merge_elider());
+      VisitStatements(stmt->statements());
+    }
+    execution_control.merge_elider().Merge();
   } else {
     VisitStatements(stmt->statements());
   }
@@ -2870,10 +2875,6 @@
     switch_builder.Break();
   }
 
-  // It is only correct to merge hole check states if there is a default clause,
-  // as otherwise it's unknown if the switch is exhaustive.
-  HoleCheckElisionMergeScope merge_elider(this);
-
   case_compare_ctr = 0;
   for (int i = 0; i < clauses->length(); ++i) {
     CaseClause* clause = clauses->at(i);
@@ -2897,11 +2898,11 @@
       switch_builder.BindDefault(clause);
     }
     // Regardless, generate code (in case of fall throughs).
-    HoleCheckElisionMergeScope::Branch branch_elider(merge_elider);
+    HoleCheckElisionMergeScope::Branch branch_elider(scope.merge_elider());
     VisitStatements(clause->statements());
   }
 
-  merge_elider.MergeIf(info.DefaultExists());
+  scope.merge_elider().MergeIf(info.DefaultExists());
 }
 
 template <typename TryBodyFunc, typename CatchBodyFunc>
diff --git a/test/message/fail/switch-labels.js b/test/message/fail/switch-labels.js
new file mode 100644
index 0000000..8f2841e
--- /dev/null
+++ b/test/message/fail/switch-labels.js
@@ -0,0 +1,21 @@
+// Copyright 2025 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+function trigger(cond) {
+  {
+    target: switch(1) {
+      case 1:
+        do {
+          if (cond) break target;
+        } while((x=1) && false);
+        break;
+      default:
+        x=1;
+    }
+    // %DebugPrint(x);
+    print(x);   // crash here due to seeing a hole
+    let x;
+  }
+}
+trigger(true);
diff --git a/test/message/fail/switch-labels.out b/test/message/fail/switch-labels.out
new file mode 100644
index 0000000..d9f0a9a
--- /dev/null
+++ b/test/message/fail/switch-labels.out
@@ -0,0 +1,7 @@
+*%(basename)s:17: ReferenceError: Cannot access 'x' before initialization
+    print(x);   // crash here due to seeing a hole
+          ^
+ReferenceError: Cannot access 'x' before initialization
+    at trigger (*%(basename)s:17:11)
+    at *%(basename)s:21:1
+
diff --git a/test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden b/test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden
index 26a3cc3..d2fed93 100644
--- a/test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden
+++ b/test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden
@@ -801,7 +801,7 @@
   {
     f = function f(a) {
   switch (a) {
-    case x: y; break;
+    case x: if (a) break; y; break;
     case 42: y; z;
     default: y; w;
   }
@@ -813,7 +813,7 @@
 "
 frame size: 1
 parameter count: 2
-bytecode array length: 57
+bytecode array length: 65
 bytecodes: [
   /*   24 S> */ B(LdaImmutableCurrentContextSlot), U8(3),
   /*   44 E> */ B(ThrowReferenceErrorIfHole), U8(0),
@@ -822,27 +822,31 @@
                 B(JumpIfTrue), U8(11),
                 B(LdaSmi), I8(42),
                 B(TestEqualStrict), R(0), U8(0),
-                B(JumpIfTrue), U8(10),
-                B(Jump), U8(16),
-  /*   47 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
+                B(JumpIfTrue), U8(16),
+                B(Jump), U8(22),
+  /*   47 S> */ B(Ldar), R(arg0),
+                B(JumpIfToBooleanFalse), U8(4),
+  /*   54 S> */ B(Jump), U8(24),
+  /*   61 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
                 B(ThrowReferenceErrorIfHole), U8(1),
-  /*   50 S> */ B(Jump), U8(18),
-  /*   68 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
+  /*   64 S> */ B(Jump), U8(18),
+  /*   82 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
                 B(ThrowReferenceErrorIfHole), U8(1),
-  /*   71 S> */ B(LdaImmutableCurrentContextSlot), U8(5),
+  /*   85 S> */ B(LdaImmutableCurrentContextSlot), U8(5),
                 B(ThrowReferenceErrorIfHole), U8(2),
-  /*   85 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
+  /*   99 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
                 B(ThrowReferenceErrorIfHole), U8(1),
-  /*   88 S> */ B(LdaImmutableCurrentContextSlot), U8(2),
-                B(ThrowReferenceErrorIfHole), U8(3),
-  /*   93 S> */ B(LdaImmutableCurrentContextSlot), U8(3),
-  /*   96 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
-  /*   99 S> */ B(LdaImmutableCurrentContextSlot), U8(5),
-                B(ThrowReferenceErrorIfHole), U8(2),
   /*  102 S> */ B(LdaImmutableCurrentContextSlot), U8(2),
                 B(ThrowReferenceErrorIfHole), U8(3),
+  /*  107 S> */ B(LdaImmutableCurrentContextSlot), U8(3),
+  /*  110 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
+                B(ThrowReferenceErrorIfHole), U8(1),
+  /*  113 S> */ B(LdaImmutableCurrentContextSlot), U8(5),
+                B(ThrowReferenceErrorIfHole), U8(2),
+  /*  116 S> */ B(LdaImmutableCurrentContextSlot), U8(2),
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden b/test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden
index 26a3cc3..d2fed93 100644
--- a/test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden
+++ b/test/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden
@@ -801,7 +801,7 @@
   {
     f = function f(a) {
   switch (a) {
-    case x: y; break;
+    case x: if (a) break; y; break;
     case 42: y; z;
     default: y; w;
   }
@@ -813,7 +813,7 @@
 "
 frame size: 1
 parameter count: 2
-bytecode array length: 57
+bytecode array length: 65
 bytecodes: [
   /*   24 S> */ B(LdaImmutableCurrentContextSlot), U8(3),
   /*   44 E> */ B(ThrowReferenceErrorIfHole), U8(0),
@@ -822,27 +822,31 @@
                 B(JumpIfTrue), U8(11),
                 B(LdaSmi), I8(42),
                 B(TestEqualStrict), R(0), U8(0),
-                B(JumpIfTrue), U8(10),
-                B(Jump), U8(16),
-  /*   47 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
+                B(JumpIfTrue), U8(16),
+                B(Jump), U8(22),
+  /*   47 S> */ B(Ldar), R(arg0),
+                B(JumpIfToBooleanFalse), U8(4),
+  /*   54 S> */ B(Jump), U8(24),
+  /*   61 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
                 B(ThrowReferenceErrorIfHole), U8(1),
-  /*   50 S> */ B(Jump), U8(18),
-  /*   68 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
+  /*   64 S> */ B(Jump), U8(18),
+  /*   82 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
                 B(ThrowReferenceErrorIfHole), U8(1),
-  /*   71 S> */ B(LdaImmutableCurrentContextSlot), U8(5),
+  /*   85 S> */ B(LdaImmutableCurrentContextSlot), U8(5),
                 B(ThrowReferenceErrorIfHole), U8(2),
-  /*   85 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
+  /*   99 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
                 B(ThrowReferenceErrorIfHole), U8(1),
-  /*   88 S> */ B(LdaImmutableCurrentContextSlot), U8(2),
-                B(ThrowReferenceErrorIfHole), U8(3),
-  /*   93 S> */ B(LdaImmutableCurrentContextSlot), U8(3),
-  /*   96 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
-  /*   99 S> */ B(LdaImmutableCurrentContextSlot), U8(5),
-                B(ThrowReferenceErrorIfHole), U8(2),
   /*  102 S> */ B(LdaImmutableCurrentContextSlot), U8(2),
                 B(ThrowReferenceErrorIfHole), U8(3),
+  /*  107 S> */ B(LdaImmutableCurrentContextSlot), U8(3),
+  /*  110 S> */ B(LdaImmutableCurrentContextSlot), U8(4),
+                B(ThrowReferenceErrorIfHole), U8(1),
+  /*  113 S> */ B(LdaImmutableCurrentContextSlot), U8(5),
+                B(ThrowReferenceErrorIfHole), U8(2),
+  /*  116 S> */ B(LdaImmutableCurrentContextSlot), U8(2),
+                B(ThrowReferenceErrorIfHole), U8(3),
                 B(LdaUndefined),
-  /*  107 S> */ B(Return),
+  /*  121 S> */ B(Return),
 ]
 constant pool: [
   INTERNALIZED_ONE_BYTE_STRING_TYPE ["x"],
@@ -870,7 +874,7 @@
 "
 frame size: 0
 parameter count: 2
-bytecode array length: 24
+bytecode array length: 22
 bytecodes: [
   /*   33 S> */ B(LdaImmutableCurrentContextSlot), U8(2),
                 B(ThrowReferenceErrorIfHole), U8(0),
@@ -880,7 +884,6 @@
   /*   58 S> */ B(LdaImmutableCurrentContextSlot), U8(3),
                 B(ThrowReferenceErrorIfHole), U8(1),
   /*   63 S> */ B(LdaImmutableCurrentContextSlot), U8(2),
-                B(ThrowReferenceErrorIfHole), U8(0),
   /*   66 S> */ B(LdaImmutableCurrentContextSlot), U8(3),
                 B(ThrowReferenceErrorIfHole), U8(1),
                 B(LdaUndefined),
Loading diff…

Original Bug Report

reported by bi...@google.com

V8: Hole leak in Ignition interpreter due to invalid hole-check removal

We are tracking this issue with the public ID BIGSLEEP-449910706. Please use this identifier for reference in any future communication.

Vulnerability Details

There is a hole leak due to an invalid hole-check removal in the Ignition interpreter.

JavaScript variables declared with let (or const) cannot be used before they are initialized (in contrast to var variables which will be undefined). In V8, this is implemented by initially setting the variable’s value to the special the_hole value and adding hole checks that raise an exception if a variable is used before it is initialized. As an optimization, V8’s bytecode compiler then attempts to remove redundant hole checks by analyzing the bytecode’s structure and looking for hole checks that are dominated by previous hole checks, in which case they are redundant and can be removed.

However, for the sample below, this optimization fails and incorrectly removes a hole check. In particular, it fails to realize that the hole check in the do-while loop’s footer does not dominate the hole check after the switch statement as it can be skipped due to the labelled break operation. As such, when x is used after the switch statement, its value is still the_hole but the hole check has been eliminated, leading to the_hole being leaked into JavaScript. This can be seen in the crash log or by uncommenting the %DebugPrint.

As hole leaks have been exploitable in the past, we’re reporting this issue as a high-severity vulnerability, although recent hardening around hole leaks [1] may affect the exploitability of this bug in the future.

[1] https://crbug.com/434179415

Affected Version(s)

The issue has been successfully reproduced:

  • at HEAD (commit e53002532ca9428582bf8726322d5fe191b8a094)
  • in stable release 14.1.146.11 (commit ad8af0fc661d278e87627fcaa3a7cf795ee80dd8)

Reproduction

Test Case

function trigger(cond) {
  {
    target: switch(1) {
      case 1:
        do {
          if (cond) break target;
        } while((x=1) && false);
        break;
      default:
        x=1;
    }
    // %DebugPrint(x);
    print(x);   // crash here due to seeing a hole
    let x;
  }
}
trigger(true);

Build Instructions

Follow the instructions at https://v8.dev/docs/build. The crash was verified on a debug build:

gm.py x64.debug

Command

./out/x64.debug/d8 crash.js

ASan Report

#
# Fatal error in ../../src/api/api.cc, line 831
# Debug check failed: !IsTheHole(heap_object).
#
#
#
#FailureMessage Object: 0x7b0426d6e860
==== C stack trace ===============================
    ./out/x64.debug/d8(___interceptor_backtrace+0x46) [0x559d6ecf7d96]
    v8/v8/out/x64.debug/libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x7f042a9d689e]
    v8/v8/out/x64.debug/libv8_libplatform.so(+0x7e07b) [0x7f04426f207b]
    v8/v8/out/x64.debug/libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0x2e2) [0x7f042a97c002]
    v8/v8/out/x64.debug/libv8_libbase.so(+0x87727) [0x7f042a97b727]
    v8/v8/out/x64.debug/libv8_libbase.so(V8_Dcheck(char const*, int, char const*)+0x4d) [0x7f042a97c14d]
    v8/v8/out/x64.debug/libv8.so(v8::Data::IsValue() const+0x25b) [0x7f0435bb151b]
    v8/v8/out/x64.debug/libv8.so(bool v8::internal::ValidateFunctionCallbackInfo<v8::Value>(v8::FunctionCallbackInfo<v8::Value> const&)+0x295) [0x7f0435cc0115]
    v8/v8/out/x64.debug/libv8.so(bool v8::internal::ValidateCallbackInfo<v8::Value>(v8::FunctionCallbackInfo<v8::Value> const&)+0x15) [0x7f0435c60805]
    ./out/x64.debug/d8(v8::WriteAndFlush(_IO_FILE*, v8::FunctionCallbackInfo<v8::Value> const&)+0x19) [0x559d6ee43379]
    ./out/x64.debug/d8(v8::Shell::Print(v8::FunctionCallbackInfo<v8::Value> const&)+0x3f) [0x559d6ee4340f]
    v8/v8/out/x64.debug/libv8.so(+0xa0a0450) [0x7f0434aa0450]

Reporter Credit

Google Big Sleep

Disclosure Policy

This bug is subject to a 90-day disclosure deadline. If a fix for this issue is made available to users before the end of the 90-day deadline, this bug report will become public 30 days after the fix was made available. Otherwise, this bug report will become public at the deadline. The scheduled deadline is 2026-01-05. For more information, visit https://goo.gle/bigsleep

View on issue tracker