CVE-2026-10935
Overview
Background
- Hole check / TDZ
- accessing a
let/constbefore initialization must throw aReferenceError; V8 enforces this with a “hole check” that tests whether the variable slot still holds the uninitializedthe_holesentinel. - Hole check elision
- an optimization that omits the hole check when the compiler can prove, via
access_positionvs. the variable’sinitializer_position, that the access provably follows initialization. - `Scope::Lookup`
- the AST variable-resolution routine that walks enclosing scopes to bind a
VariableProxy, updating*access_positionas it crosses scopes. - Sloppy `eval` / `kDynamicLocal`
- a sloppy-mode
evalscope resolves names dynamically;kDynamicLocaldenotes a dynamic proxy that shadows a known local variable, handled viaLookupSloppyEval.
Root Cause Analysis
Verify before building a PoC
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.
*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
- Nest a hoisted function in an arrow scope
Declare
const v1 = __f_9();and a hoistedfunction __f_9()in the same scope sov1is referenced from the hoisted function while still uninitialized (the shipped PoC layout). - Force dynamic/sloppy-eval resolution
Place an
eval("")inside__f_9so the namev1resolves through a dynamic (is_dynamic_scope()) sloppy-eval path and thekDynamicLocalproxy. - Elide the TDZ hole check
Pre-patch,
Scope::Lookupreaches the dynamic-scope handling with a staleaccess_position, letting hole-check elision wrongly conclude the access tov1is post-initialization. - Read the uninitialized hole (inferred)
The elided check lets the access observe the
the_holesentinel instead of throwingReferenceError; any type confusion or further primitive from leaking/usingthe_holeis(inferred)and not shown in the diff.
Impact Assessment
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.cctest/message/fail/regress-tdz-closure.jstest/message/fail/regress-tdz-closure.out
Audit Directions
- Ordering of position updates vs. scope-kind dispatchAudit
Scope::Lookupand 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 inputsReview every place
access_position/initializer_positionfeed hole-check elision, ensuring dynamic (sloppy-eval) and shadowed-local paths never inherit a stale or optimistic position. - Dynamic proxy requirement propagationCheck that requirements attached to a shadowed local (hole check, immutability) are consistently forwarded to
kDynamic/kDynamicLocalproxies created for sloppy-eval lookups.
Patch
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
Regression Test / PoC
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
Original Bug Report
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.