CVE-2026-17948
Overview
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 normalJSAnytype. - `.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.
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
- Pause in a class constructor
Define a class with a private member (giving it a
.brandslot), construct an instance, and hit adebuggerstatement inside the constructor beforesuper()completes so the debugger holds a paused frame. - Locate the synthetic slot
Walk the paused frame’s
scopeChainviaRuntime.getPropertiesto find the class block scope that owns the.brandcontext slot. - Overwrite `.brand`
Issue
Debugger.setVariableValuenaming.brandwith an attacker value such as the string"foo"; thekConst-mode slot passes the old private-mode-only check andContext::Setwrites the bogus value. - 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.
- 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
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
| Function | Change | Notes |
|---|---|---|
Btest/inspector/debugger/set-variable-value-class-synthetic.js |
modified | |
Ctest/inspector/debugger/set-variable-value-class-synthetic.js |
modified | |
footest/inspector/debugger/set-variable-value-class-synthetic.js |
modified | |
constructortest/inspector/debugger/set-variable-value-class-synthetic.js |
modified | |
runtest/inspector/debugger/set-variable-value-class-synthetic.js |
modified | |
fortest/inspector/debugger/set-variable-value-class-synthetic.js |
modified |
Files Changed
src/debug/debug-scopes.cctest/inspector/debugger/set-variable-value-class-synthetic-expected.txttest/inspector/debugger/set-variable-value-class-synthetic.js
Audit Directions
- Read/write filter asymmetryAudit 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 writesTrace all callers of
Context::SetandsetVariableValue-style entry points that can target compiler-introduced slots (.brand,.generator_object,#method), confirming none can receive an arbitraryJSAnyvalue. - Mode-based allowlistingReview checks that gate on
VariableMode(e.g.IsPrivateMethodOrAccessorVariableMode) as a proxy for “internal”, since new synthetic slots with other modes likekConstcan bypass them.
Patch
From bee4f84e3b1258e903e26236ca0260f7bfbc2699 Mon Sep 17 00:00:00 2001 From: Simon Zünd <[email protected]> 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. [email protected] Fixed: 516849257 Change-Id: Id1c7352b70a26550b0853806f863b35eebace900 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7984385 Reviewed-by: Philip Pfaffe <[email protected]> Commit-Queue: Simon Zünd <[email protected]> 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(); +})();