Low watch CVSS 7.5 chrome Type Confusion 🔧 Commit mapped

Overview

Low
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Sub-componentDebug
Bug ClassType Confusion
Action tierwatch — corruption primitive but gated (local) — 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.
Tracker516849257
Fix commitbee4f84e3b12 (v8/v8) +94/-6
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-26 (per vendor advisory)
Fix landed2026-06-23 (public git)
Disclosed2026-07-29
Patch gap36 days — fix public in source before the advisory

Background

`ScopeIterator`
The V8 debug-side iterator that walks a paused frame’s scope chain and exposes each scope’s variables to the DevTools frontend for reading and writing.
Synthetic variable
A compiler-introduced context slot (e.g. .brand, .generator_object, #method) that has no user-visible name and may hold values outside the normal JSAny type.
`.brand`
The synthetic slot that stores a private-class brand symbol, used as a property key when a constructor stamps the private brand onto a newly created instance.
Type confusion
A memory-safety error where a value is interpreted as a type it is not, here because a slot expected to hold a specific internal type is overwritten with an arbitrary JS value.

Root Cause Analysis

The debugger’s ScopeIterator filtered synthetic, compiler-introduced variables out of the locals it exposed on the read side, so the frontend never saw slots like .brand, but the write path in ScopeIterator::SetVariableValue (and its helper SetContextVariableValue) only rejected variables whose mode was IsPrivateMethodOrAccessorVariableMode. Because the class .brand slot carries VariableMode::kConst rather than a private-method/accessor mode, a Debugger.setVariableValue request naming .brand slipped past the mode check and overwrote the brand slot via Context::Set. The invariant violated is that a synthetic slot must only ever hold the internally-typed value the compiler placed there; once overwritten with an attacker-chosen JS value (e.g. the string "foo"), the next constructor invocation used that bogus value as the property key when stamping the private brand, causing a type confusion.

The fix restores agreement between the read and write sides by rejecting any name for which ScopeInfo::VariableIsSynthetic returns true at the SetVariableValue entry point, and removes the now-redundant per-mode check in SetContextVariableValue.

Key insight
The core mistake was an asymmetry between the read and write filters: the read side hid all synthetic variables while the write side only blocked private-method/accessor modes, leaving kConst synthetic slots like .brand writable. The fix aligns both sides by filtering on ScopeInfo::VariableIsSynthetic before any write, so no synthetic slot can be overwritten.

Attack Path

  1. Pause in a class constructor Define a class with a private member (giving it a .brand slot), construct an instance, and hit a debugger statement inside the constructor before super() completes so the debugger holds a paused frame.
  2. Locate the synthetic slot Walk the paused frame’s scopeChain via Runtime.getProperties to find the class block scope that owns the .brand context slot.
  3. Overwrite `.brand` Issue Debugger.setVariableValue naming .brand with an attacker value such as the string "foo"; the kConst-mode slot passes the old private-mode-only check and Context::Set writes the bogus value.
  4. Trigger the confusion Resume execution so the constructor stamps the private brand using the corrupted slot value as the property key, treating an arbitrary JS value as the internal brand symbol type.
  5. Exploit the type confusion Leverage the mismatched type interpretation to corrupt object state and build a primitive toward memory disclosure or control-flow hijack inside the renderer.

Impact Assessment

An attacker gains a type confusion in V8 within the renderer process, potentially escalating to arbitrary read/write and code execution in that sandboxed context. Exploitation requires the DevTools debugger protocol (Debugger.setVariableValue) to be reachable against the target and the ability to pause inside a class constructor that owns a synthetic .brand slot. In practice this means a scenario where an attacker can drive the inspector protocol, such as a malicious automation client or a compromised debugging channel.

Changed Functions

FunctionChangeNotes
B
test/inspector/debugger/set-variable-value-class-synthetic.js
modified
C
test/inspector/debugger/set-variable-value-class-synthetic.js
modified
foo
test/inspector/debugger/set-variable-value-class-synthetic.js
modified
constructor
test/inspector/debugger/set-variable-value-class-synthetic.js
modified
run
test/inspector/debugger/set-variable-value-class-synthetic.js
modified
for
test/inspector/debugger/set-variable-value-class-synthetic.js
modified

Files Changed

  • src/debug/debug-scopes.cc
  • test/inspector/debugger/set-variable-value-class-synthetic-expected.txt
  • test/inspector/debugger/set-variable-value-class-synthetic.js

Audit Directions

  • Read/write filter asymmetry
    Audit every place where the debugger or inspector exposes a filtered view for reads but performs a narrower or different check on the corresponding write, ensuring both sides use the same predicate such as VariableIsSynthetic.
  • Synthetic slot writes
    Trace all callers of Context::Set and setVariableValue-style entry points that can target compiler-introduced slots (.brand, .generator_object, #method), confirming none can receive an arbitrary JSAny value.
  • Mode-based allowlisting
    Review checks that gate on VariableMode (e.g. IsPrivateMethodOrAccessorVariableMode) as a proxy for “internal”, since new synthetic slots with other modes like kConst can bypass them.
From bee4f84e3b1258e903e26236ca0260f7bfbc2699 Mon Sep 17 00:00:00 2001
From: Simon Zünd <szuend@chromium.org>
Date: Tue, 23 Jun 2026 11:12:29 +0000
Subject: [PATCH] [debug] Disallow setVariableValue on synthetic context slots

Synthetic, compiler-introduced variables (e.g. ".brand",
".generator_object", "#method") are filtered from the locals that
ScopeIterator exposes to the debugger frontend, but the write side only
filtered private method/accessor modes. The class ".brand" slot has
VariableMode::kConst, so a Debugger.setVariableValue request naming
".brand" would overwrite the slot and the next constructor invocation
would use the bogus value as a property key when stamping the private
brand on the new instance.

Reject all synthetic variable names at the
ScopeIterator::SetVariableValue entry point so the read and write sides
agree, and drop the now-redundant per-mode check in
SetContextVariableValue.

R=pfaffe@chromium.org

Fixed: 516849257
Change-Id: Id1c7352b70a26550b0853806f863b35eebace900
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7984385
Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
Commit-Queue: Simon Zünd <szuend@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108170}
---

diff --git a/src/debug/debug-scopes.cc b/src/debug/debug-scopes.cc
index 5c2fe8b5..4624daf 100644
--- a/src/debug/debug-scopes.cc
+++ b/src/debug/debug-scopes.cc
@@ -696,6 +696,9 @@
                                      DirectHandle<Object> value) {
   DCHECK(!Done());
   name = isolate_->factory()->InternalizeString(name);
+  // Synthetic variables are compiler-introduced and not exposed to the user, so
+  // they may carry values outside the JSAny type and must not be overwritten.
+  if (ScopeInfo::VariableIsSynthetic(*name)) return false;
   switch (Type()) {
     case ScopeTypeGlobal:
     case ScopeTypeWith:
@@ -1216,13 +1219,8 @@
 
 bool ScopeIterator::SetContextVariableValue(DirectHandle<String> variable_name,
                                             DirectHandle<Object> new_value) {
-  VariableLookupResult lookup_result;
-  int slot_index =
-      context_->scope_info()->ContextSlotIndex(*variable_name, &lookup_result);
+  int slot_index = context_->scope_info()->ContextSlotIndex(*variable_name);
   if (slot_index < 0) return false;
-  if (IsPrivateMethodOrAccessorVariableMode(lookup_result.mode)) {
-    return false;
-  }
   Context::Set(context_, slot_index, new_value, isolate_);
   return true;
 }
diff --git a/test/inspector/debugger/set-variable-value-class-synthetic-expected.txt b/test/inspector/debugger/set-variable-value-class-synthetic-expected.txt
new file mode 100644
index 0000000..0b7135d
--- /dev/null
+++ b/test/inspector/debugger/set-variable-value-class-synthetic-expected.txt
@@ -0,0 +1,31 @@
+Checks that Debugger.setVariableValue cannot overwrite synthetic class context slots
+Class block scope found: true
+Setting .brand to "foo"
+{
+    error : {
+        code : -32603
+        message : Internal error
+    }
+    id : <messageId>
+}
+Own property names of inst:
+{
+    id : <messageId>
+    result : {
+        result : {
+            type : string
+            value : []
+        }
+    }
+}
+inst.run():
+{
+    id : <messageId>
+    result : {
+        result : {
+            description : 42
+            type : number
+            value : 42
+        }
+    }
+}
diff --git a/test/inspector/debugger/set-variable-value-class-synthetic.js b/test/inspector/debugger/set-variable-value-class-synthetic.js
new file mode 100644
index 0000000..ddf8c9f
--- /dev/null
+++ b/test/inspector/debugger/set-variable-value-class-synthetic.js
@@ -0,0 +1,59 @@
+// 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 {session, contextGroup, Protocol} = InspectorTest.start(
+    'Checks that Debugger.setVariableValue cannot overwrite synthetic class context slots');
+
+const source = `
+class B {}
+class C extends B {
+  #m() { return 42; }
+  foo() { return C; }
+  constructor() { debugger; super(); }
+  run() { return this.#m(); }
+}
+globalThis.inst = new C();
+globalThis.inst.run();
+`;
+
+(async () => {
+  await Protocol.Debugger.enable();
+
+  const evalPromise = Protocol.Runtime.evaluate({expression: source});
+  const {params: {callFrames}} = await Protocol.Debugger.oncePaused();
+  const {callFrameId, scopeChain} = callFrames[0];
+
+  let scopeNumber = -1;
+  for (let i = 0; i < scopeChain.length; ++i) {
+    if (scopeChain[i].type !== 'block') continue;
+    const {result: {result}} = await Protocol.Runtime.getProperties(
+        {objectId: scopeChain[i].object.objectId});
+    if (result.some(p => p.name === 'C')) {
+      scopeNumber = i;
+      break;
+    }
+  }
+  InspectorTest.log('Class block scope found: ' + (scopeNumber >= 0));
+
+  InspectorTest.log('Setting .brand to "foo"');
+  InspectorTest.logMessage(await Protocol.Debugger.setVariableValue({
+    scopeNumber,
+    variableName: '.brand',
+    newValue: {value: 'foo'},
+    callFrameId,
+  }));
+
+  await Protocol.Debugger.resume();
+  await evalPromise;
+
+  InspectorTest.log('Own property names of inst:');
+  InspectorTest.logMessage(await Protocol.Runtime.evaluate(
+      {expression: 'JSON.stringify(Object.getOwnPropertyNames(globalThis.inst))'}));
+
+  InspectorTest.log('inst.run():');
+  InspectorTest.logMessage(await Protocol.Runtime.evaluate(
+      {expression: 'globalThis.inst.run()'}));
+
+  InspectorTest.completeTest();
+})();
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.