Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in V8
DescriptionInappropriate implementation in V8
ComponentV8
Sub-componentAst
Bug ClassLogic Error
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 AIPlausible (tier revised from patch-now)
Reviewer notes: Claimed kDynamicLocal hole-check propagation is absent from diff (only blank line added). Corruption factor contradicts its own 'inferred' caveat; diff shows TDZ elision/hole leak into Map.set only, advisory says 'Inappropriate implementation', not memory corruption.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker501898683
Fix commitad07ea9d88fd (v8/v8) +29/-6
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
CreditedGoogle
Reported2026-04-12 (per vendor advisory)
Fix landed2026-04-14 (public git)
Disclosed2026-06-02
Patch gap49 days — fix public in source before the advisory

Background

Hole check / TDZ
accessing a let/const before initialization must throw a ReferenceError; V8 enforces this with a “hole check” that tests whether the variable slot still holds the uninitialized the_hole sentinel.
Hole check elision
an optimization that omits the hole check when the compiler can prove, via access_position vs. the variable’s initializer_position, that the access provably follows initialization.
`Scope::Lookup`
the AST variable-resolution routine that walks enclosing scopes to bind a VariableProxy, updating *access_position as it crosses scopes.
Sloppy `eval` / `kDynamicLocal`
a sloppy-mode eval scope resolves names dynamically; kDynamicLocal denotes a dynamic proxy that shadows a known local variable, handled via LookupSloppyEval.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Claimed kDynamicLocal hole-check propagation is absent from diff (only blank line added). Corruption factor contradicts its own 'inferred' caveat; diff shows TDZ elision/hole leak into Map.set only, advisory says 'Inappropriate implementation', not memory corruption.

In Scope::Lookup, the block that updates *access_position for hoisted-in-context functions (is_hoisted_in_context() → outer scope start position) and for eval scopes (is_eval_scope() → eval_position()) originally ran after the dynamic-scope handling, i.e. after the code that enters LookupSloppyEval for is_dynamic_scope().

As a result, when resolving a name through a hoisted function reaching a sloppy eval, access_position was not yet corrected before the dynamic-scope path consumed it, so the hole-check-elision decision used a wrong access position and could conclude the access provably followed initialization when it did not. This allowed the hole check to be elided for a variable still in its temporal dead zone, so accessing it read the the_hole sentinel instead of throwing ReferenceError.

The fix moves the hoisting/eval access_position update to before the is_dynamic_scope() handling so the position is correct on entry to LookupSloppyEval, and (per the commit) propagates the hole-check requirement from the shadowed local variable to the kDynamicLocal dynamic proxy; the regression test confirms the access now correctly throws ReferenceError: Cannot access 'v1' before initialization.

Key insight
The single mistake was updating *access_position for hoisted functions and eval scopes only after the dynamic-scope check, so LookupSloppyEval made hole-check-elision decisions from a stale access position; moving that update earlier (and propagating the hole-check requirement to the dynamic proxy) keeps the TDZ check in place.

Attack Path

  1. Nest a hoisted function in an arrow scope Declare const v1 = __f_9(); and a hoisted function __f_9() in the same scope so v1 is referenced from the hoisted function while still uninitialized (the shipped PoC layout).
  2. Force dynamic/sloppy-eval resolution Place an eval("") inside __f_9 so the name v1 resolves through a dynamic (is_dynamic_scope()) sloppy-eval path and the kDynamicLocal proxy.
  3. Elide the TDZ hole check Pre-patch, Scope::Lookup reaches the dynamic-scope handling with a stale access_position, letting hole-check elision wrongly conclude the access to v1 is post-initialization.
  4. Read the uninitialized hole (inferred) The elided check lets the access observe the the_hole sentinel instead of throwing ReferenceError; any type confusion or further primitive from leaking/using the_hole is (inferred) and not shown in the diff.

Impact Assessment

An attacker running script in the renderer can craft a hoisted-function-in-sloppy-eval pattern that defeats the TDZ hole check, letting a let/const be accessed while still holding the the_hole sentinel — an inappropriate implementation / logic error in V8. The precondition is the specific scope shape (hoisted function referencing a not-yet-initialized lexical binding across a sloppy eval); the advisory classifies it as a LogicError (“Inappropriate implementation in V8”) and does not report exploitation in the wild. Leaking or operating on the_hole is a well-known primitive, but any escalation to type confusion or memory corruption beyond the missing ReferenceError is (inferred) here.

Files Changed

  • src/ast/scopes.cc
  • test/message/fail/regress-tdz-closure.js
  • test/message/fail/regress-tdz-closure.out

Audit Directions

  • Ordering of position updates vs. scope-kind dispatch
    Audit Scope::Lookup and related resolvers for any state (access_position, flags) consumed by a scope-kind branch before it is finalized for hoisted/eval scopes.
  • Hole-check elision inputs
    Review every place access_position/initializer_position feed hole-check elision, ensuring dynamic (sloppy-eval) and shadowed-local paths never inherit a stale or optimistic position.
  • Dynamic proxy requirement propagation
    Check that requirements attached to a shadowed local (hole check, immutability) are consistently forwarded to kDynamic/kDynamicLocal proxies created for sloppy-eval lookups.
From ad07ea9d88fd5c2a529d35fb4ebec046f5404b99 Mon Sep 17 00:00:00 2001
From: Toon Verwaest <[email protected]>
Date: Tue, 14 Apr 2026 17:52:20 +0200
Subject: [PATCH] [ast] Fix hole check elision for hoisted functions in sloppy eval

- Move the hoisting check before the dynamic scope check in Scope::Lookup to ensure access_position is correctly updated for hoisted functions before entering LookupSloppyEval.
- Propagate the hole check requirement from the shadowed local variable to the dynamic proxy for kDynamicLocal variables.

Bug: 501898683
Change-Id: Iadd58a8161cff6911eb113aa24a5e2a2f6564969
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7749487
Commit-Queue: Toon Verwaest <[email protected]>
Reviewed-by: Leszek Swirski <[email protected]>
Commit-Queue: Leszek Swirski <[email protected]>
Auto-Submit: Toon Verwaest <[email protected]>
Cr-Commit-Position: refs/heads/main@{#106473}
---

diff --git a/src/ast/scopes.cc b/src/ast/scopes.cc
index c002a08..66e2137 100644
--- a/src/ast/scopes.cc
+++ b/src/ast/scopes.cc
@@ -1154,6 +1154,7 @@
   DCHECK(was_added);
   var->AllocateTo(location, index);
   var->set_initializer_position(lookup_result.initializer_position);
+
   return var;
 }
 
@@ -2278,6 +2279,12 @@
     }
 
     if (scope->outer_scope_ == outer_scope_end) break;
+    if (scope->is_hoisted_in_context()) {
+      *access_position = scope->outer_scope()->start_position();
+    } else if (scope->is_eval_scope()) {
+      *access_position = scope->AsDeclarationScope()->eval_position();
+    }
+
     if (V8_UNLIKELY(scope->is_dynamic_scope())) {
       DCHECK(!scope->is_script_scope());
       if (scope->is_declaration_scope() &&
@@ -2293,12 +2300,6 @@
       CHECK(scope->is_debug_evaluate_scope());
       return cache_scope->NonLocal(proxy->raw_name(), VariableMode::kDynamic);
     }
-
-    if (scope->is_hoisted_in_context()) {
-      *access_position = scope->outer_scope()->start_position();
-    } else if (scope->is_eval_scope()) {
-      *access_position = scope->AsDeclarationScope()->eval_position();
-    }
     force_context_allocation |= scope->is_function_scope();
     scope = scope->outer_scope_;
 
diff --git a/test/message/fail/regress-tdz-closure.js b/test/message/fail/regress-tdz-closure.js
new file mode 100644
index 0000000..7c2135a
--- /dev/null
+++ b/test/message/fail/regress-tdz-closure.js
@@ -0,0 +1,12 @@
+// Copyright 2026 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.
+
+(() => {
+    const v1 = __f_9();
+    function __f_9() {
+        const v5 = new Map();
+        v5.set(v1);
+        eval("");
+    }
+})();
diff --git a/test/message/fail/regress-tdz-closure.out b/test/message/fail/regress-tdz-closure.out
new file mode 100644
index 0000000..cec5663
--- /dev/null
+++ b/test/message/fail/regress-tdz-closure.out
@@ -0,0 +1,10 @@
+# Copyright 2026 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.
+*%(basename)s:9: ReferenceError: Cannot access 'v1' before initialization
+        v5.set(v1);
+               ^
+ReferenceError: Cannot access 'v1' before initialization
+    at __f_9 (*%(basename)s:9:16)
+    at *%(basename)s:6:16
+    at *%(basename)s:12:3
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/message/fail/regress-tdz-closure.js b/test/message/fail/regress-tdz-closure.js
new file mode 100644
index 0000000..7c2135a
--- /dev/null
+++ b/test/message/fail/regress-tdz-closure.js
@@ -0,0 +1,12 @@
+// Copyright 2026 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.
+
+(() => {
+    const v1 = __f_9();
+    function __f_9() {
+        const v5 = new Map();
+        v5.set(v1);
+        eval("");
+    }
+})();
diff --git a/test/message/fail/regress-tdz-closure.out b/test/message/fail/regress-tdz-closure.out
new file mode 100644
index 0000000..cec5663
--- /dev/null
+++ b/test/message/fail/regress-tdz-closure.out
@@ -0,0 +1,10 @@
+# Copyright 2026 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.
+*%(basename)s:9: ReferenceError: Cannot access 'v1' before initialization
+        v5.set(v1);
+               ^
+ReferenceError: Cannot access 'v1' before initialization
+    at __f_9 (*%(basename)s:9:16)
+    at *%(basename)s:6:16
+    at *%(basename)s:12:3
Loading diff…

Original Bug Report

reported by [email protected]

DCHECK failure in instance_type != HOLE_TYPE in objects-inl.h

Detailed Report: https://clusterfuzz.com/testcase?key=5458184361771008

Fuzzer: None Job Type: linux_asan_d8_v8_arm_dbg Platform Id: linux

Crash Type: DCHECK failure Crash Address: Crash State: instance_type != HOLE_TYPE in objects-inl.h

Sanitizer: address (ASAN)

Regressed: https://clusterfuzz.com/revisions?job=linux_asan_d8_v8_arm_dbg&range=106396:106397

Reproducer Testcase: https://clusterfuzz.com/download?testcase_id=5458184361771008

Issue filed automatically.

To reproduce this, please build the target in this report and run it against the reproducer testcase. Please use the GN arguments provided at bottom of this report when building the binary.

If you have trouble reproducing, please also export the environment variables listed under “[Environment]” in the crash stacktrace.

If you have any feedback on reproducing test cases, let us know at https://forms.gle/Yh3qCYFveHj6E5jz5 so we can improve.

View on issue tracker