CVE-2026-95361
Overview
Background
- Custom formatters
- DevTools feature where a page registers
devtoolsFormatterswhoseheader/bodycallbacks return JSONML that the inspector renders as an object preview. - JSONML
- the array-based markup (
[tag, attributes, ...children]) that custom formatters emit and thatsubstituteObjectTagswalks to build acustomPreview. - `v8::Value::IsArray()`
- a V8 type predicate that returns
falsefor aJSProxy, so a proxy wrapping an array is not recognized as an array by this check. - `v8::Proxy`
- a JS
Proxyexotic object whose traps run arbitrary user code on property access and whose type predicates diverge from those of its target.
Root Cause Analysis
In substituteObjectTags (src/inspector/custom-preview.cc), the inspector validated JSONML nodes and their attribute dictionaries with plain type predicates such as IsObject() and IsArray(), then treated the value as a trusted object or array. A JS Proxy violates the invariant these checks assume: attributesValue->IsProxy() can be an object that passes the “attributes should be an Object” gate, and value->IsArray() returns false for a proxy wrapping an array, so a proxy child could slip past the recursive array-handling branch while still being consumed as JSONML. Because a proxy’s traps execute arbitrary page-controlled code during subsequent property reads, the formatter effectively became a confused deputy operating on attacker-controlled exotic objects.
The fix adds explicit value->IsProxy() and attributesValue->IsProxy() guards that call reportError and return false, rejecting proxies both as JSONML elements and as attribute dictionaries before any trap can be triggered. This restores the invariant that only genuine objects and arrays flow through the JSONML substitution path.
IsObject(), IsArray()) that a Proxy can either satisfy deceptively or evade entirely; the fix closes the gap by explicitly detecting IsProxy() and rejecting such values outright rather than trusting the shape checks.Attack Path
- Register a malicious formatter
A page sets
this.devtoolsFormattersto a formatter whoseheaderorbodyreturns JSONML embedding aProxy, e.g.['span', {}, new Proxy([], {})]. - Trigger preview generation
The page (or DevTools inspecting it) causes an object to be evaluated with
generatePreview, invoking the formatter and passing its JSONML intosubstituteObjectTags. - Bypass validation
A proxy-over-array child evades the
value->IsArray()branch, ornew Proxy({}, {})passes the attributes-object check, so the proxy is accepted as a valid JSONML element or attribute dictionary. - Trigger proxy traps Subsequent property reads on the accepted proxy run attacker-defined traps within the inspector’s formatting context, letting the page influence DevTools rendering it should not control.
Impact Assessment
Proxy into the DevTools custom-preview pipeline, causing the inspector to act on attacker-controlled exotic objects and run trap code during preview formatting (a confused deputy in DevTools). Exploitation requires the victim to have custom object formatters enabled and to inspect or generate a preview for the attacker’s object; the fix reduces the outcome to a clean Custom Formatter Failed error with no preview generated.Changed Functions
| Function | Change | Notes |
|---|---|---|
iftest/inspector/runtime/custom-preview-proxy.js |
modified |
Files Changed
test/inspector/runtime/custom-preview-proxy-expected.txttest/inspector/runtime/custom-preview-proxy.jssrc/inspector/custom-preview.cc
Audit Directions
- Type predicates vs. proxiesAudit other inspector/DevTools code that gates on
IsObject()/IsArray()and then dereferences; aJSProxymay pass or fail these deceptively and should be explicitly excluded where traps must not run. - Recursive JSONML/user-object walkersReview any code that recursively descends attacker-supplied object graphs (formatters, previews, serializers) for missing exotic-object rejection at each level, not just the top.
- Confused-deputy surfaces in DevToolsLook for validation gates that assume shape checks imply trusted, side-effect-free access, where a proxy could turn a trusted component into an executor of page-controlled code.
Patch
From b40ca4aa9abe852c75ebf0afaa348459b4161dd9 Mon Sep 17 00:00:00 2001
From: Simon Zünd <szuend@chromium.org>
Date: Fri, 28 Aug 2026 06:09:31 +0000
Subject: [PATCH] [inspector] Disallow proxy values in JSONML for custom formatters
In substituteObjectTags, JS proxies could masquerade as objects or
arrays and bypass validation, since v8::Value::IsArray() returns false
for JSProxy instances and attributes were not checked for proxies.
This CL adds explicit checks for v8::Value::IsProxy() in
substituteObjectTags and reports an error if encountered, ensuring
proxies are rejected both as JSONML elements and as attribute
dictionaries.
TAG=agy CONV=8316c18a-958e-4e9f-b927-e516a24655c4 R=dsv@chromium.org
Fixed: 533095855
Change-Id: Ic2afc2e0f82581169cd876634188e6099d482c98
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8312289
Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
Commit-Queue: Simon Zünd <szuend@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109553}
---
diff --git a/src/inspector/custom-preview.cc b/src/inspector/custom-preview.cc
index 5553068..469705c 100644
--- a/src/inspector/custom-preview.cc
+++ b/src/inspector/custom-preview.cc
@@ -84,6 +84,10 @@
reportError(context, tryCatch, "attributes should be an Object");
return false;
}
+ if (attributesValue->IsProxy()) {
+ reportError(context, tryCatch, "Proxy values are not allowed in JSONML");
+ return false;
+ }
v8::Local<v8::Object> attributes = attributesValue.As<v8::Object>();
v8::Local<v8::Value> originValue;
if (!attributes->Get(context, objectLiteral).ToLocal(&originValue)) {
@@ -146,6 +150,11 @@
reportError(context, tryCatch);
return false;
}
+ if (value->IsProxy()) {
+ reportError(context, tryCatch,
+ "Proxy values are not allowed in JSONML");
+ return false;
+ }
if (value->IsArray() && value.As<v8::Array>()->Length() > 0 &&
!substituteObjectTags(sessionId, groupName, context,
value.As<v8::Array>(), maxDepth - 1)) {
diff --git a/test/inspector/runtime/custom-preview-proxy-expected.txt b/test/inspector/runtime/custom-preview-proxy-expected.txt
new file mode 100644
index 0000000..60cdf68
--- /dev/null
+++ b/test/inspector/runtime/custom-preview-proxy-expected.txt
@@ -0,0 +1,91 @@
+Checks that proxies in JSONML are rejected by custom formatters
+Test proxy child in header JSONML..
+{
+ method : Runtime.consoleAPICalled
+ params : {
+ args : [
+ [0] : {
+ type : string
+ value : Custom Formatter Failed: Uncaught Proxy values are not allowed in JSONML
+ }
+ ]
+ executionContextId : <executionContextId>
+ timestamp : <timestamp>
+ type : error
+ }
+}
+No customPreview generated
+Test proxy attributes in header JSONML..
+{
+ method : Runtime.consoleAPICalled
+ params : {
+ args : [
+ [0] : {
+ type : string
+ value : Custom Formatter Failed: Uncaught Proxy values are not allowed in JSONML
+ }
+ ]
+ executionContextId : <executionContextId>
+ timestamp : <timestamp>
+ type : error
+ }
+}
+No customPreview generated
+Test proxy array child masquerading as tag..
+{
+ method : Runtime.consoleAPICalled
+ params : {
+ args : [
+ [0] : {
+ type : string
+ value : Custom Formatter Failed: Uncaught Proxy values are not allowed in JSONML
+ }
+ ]
+ executionContextId : <executionContextId>
+ timestamp : <timestamp>
+ type : error
+ }
+}
+No customPreview generated
+Test proxy attributes in object tag..
+{
+ method : Runtime.consoleAPICalled
+ params : {
+ args : [
+ [0] : {
+ type : string
+ value : Custom Formatter Failed: Uncaught Proxy values are not allowed in JSONML
+ }
+ ]
+ executionContextId : <executionContextId>
+ timestamp : <timestamp>
+ type : error
+ }
+}
+No customPreview generated
+Test proxy child in body JSONML..
+customPreview: ["span",{},"Header ok"]
+{
+ method : Runtime.consoleAPICalled
+ params : {
+ args : [
+ [0] : {
+ type : string
+ value : Custom Formatter Failed: Uncaught Proxy values are not allowed in JSONML
+ }
+ ]
+ executionContextId : <executionContextId>
+ timestamp : <timestamp>
+ type : error
+ }
+}
+{
+ id : <messageId>
+ result : {
+ result : {
+ type : undefined
+ }
+ }
+}
+Test formatting a proxy object itself (allowed)..
+customPreview: ["span",{},"Proxy formatted: ","proxyObject"]
diff --git a/test/inspector/runtime/custom-preview-proxy.js b/test/inspector/runtime/custom-preview-proxy.js
new file mode 100644
index 0000000..c525d84
--- /dev/null
+++ b/test/inspector/runtime/custom-preview-proxy.js
@@ -0,0 +1,115 @@
+// 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 proxies in JSONML are rejected by custom formatters');
+
+(async function test() {
+ Protocol.Runtime.enable();
+ Protocol.Runtime.setCustomObjectFormatterEnabled({ enabled: true });
+ Protocol.Runtime.onConsoleAPICalled(m => InspectorTest.logMessage(m));
+
+ InspectorTest.log('Test proxy child in header JSONML..');
+ await Protocol.Runtime.evaluate({
+ expression: `
+ this.devtoolsFormatters = [{
+ header: () => ['span', {}, 'Header with proxy child', new Proxy([], {})],
+ hasBody: () => false
+ }];
+ `
+ });
+ let result = await Protocol.Runtime.evaluate({ expression: '({})', generatePreview: true });
+ dumpCustomPreview(result);
+
+ InspectorTest.log('Test proxy attributes in header JSONML..');
+ await Protocol.Runtime.evaluate({
+ expression: `
+ this.devtoolsFormatters = [{
+ header: () => ['span', new Proxy({}, {}), 'Header with proxy attributes'],
+ hasBody: () => false
+ }];
+ `
+ });
+ result = await Protocol.Runtime.evaluate({ expression: '({})', generatePreview: true });
+ dumpCustomPreview(result);
+
+ InspectorTest.log('Test proxy array child masquerading as tag..');
+ await Protocol.Runtime.evaluate({
+ expression: `
+ this.devtoolsFormatters = [{
+ header: () => ['span', {}, new Proxy(['object', {object: {}}], {})],
+ hasBody: () => false
+ }];
+ `
+ });
+ result = await Protocol.Runtime.evaluate({ expression: '({})', generatePreview: true });
+ dumpCustomPreview(result);
+
+ InspectorTest.log('Test proxy attributes in object tag..');
+ await Protocol.Runtime.evaluate({
+ expression: `
+ this.devtoolsFormatters = [{
+ header: () => ['span', {}, ['object', new Proxy({object: {}}, {})]],
+ hasBody: () => false
+ }];
+ `
+ });
+ result = await Protocol.Runtime.evaluate({ expression: '({})', generatePreview: true });
+ dumpCustomPreview(result);
+
+ InspectorTest.log('Test proxy child in body JSONML..');
+ await Protocol.Runtime.evaluate({
+ expression: `
+ this.devtoolsFormatters = [{
+ header: () => ['span', {}, 'Header ok'],
+ hasBody: () => true,
+ body: () => ['span', {}, 'Body with proxy child', new Proxy([], {})]
+ }];
+ `
+ });
+ result = await Protocol.Runtime.evaluate({ expression: '({})', generatePreview: true });
+ await dumpCustomPreviewWithBody(result);
+
+ InspectorTest.log('Test formatting a proxy object itself (allowed)..');
+ await Protocol.Runtime.evaluate({
+ expression: `
+ var targetProxy = new Proxy({name: 'proxyObject'}, {});
+ this.devtoolsFormatters = [{
+ header: (x) => x === targetProxy ? ['span', {}, 'Proxy formatted: ', x.name] : null,
+ hasBody: () => false
+ }];
+ `
+ });
+ result = await Protocol.Runtime.evaluate({ expression: 'targetProxy', generatePreview: true });
+ dumpCustomPreview(result);
+
+ InspectorTest.completeTest();
+})();
+
+function dumpCustomPreview(result) {
+ const remoteObject = result.result.result;
+ if (remoteObject.customPreview) {
+ InspectorTest.log('customPreview: ' + remoteObject.customPreview.header);
+ } else {
+ InspectorTest.log('No customPreview generated');
+ }
+}
+
+async function dumpCustomPreviewWithBody(result) {
+ const remoteObject = result.result.result;
+ if (remoteObject.customPreview) {
+ InspectorTest.log('customPreview: ' + remoteObject.customPreview.header);
+ if (remoteObject.customPreview.bodyGetterId) {
+ const body = await Protocol.Runtime.callFunctionOn({
+ objectId: remoteObject.objectId,
+ functionDeclaration: 'function(bodyGetter) { return bodyGetter.call(this); }',
+ arguments: [{ objectId: remoteObject.customPreview.bodyGetterId }],
+ returnByValue: true
+ });
+ InspectorTest.logMessage(body);
+ }
+ } else {
+ InspectorTest.log('No customPreview generated');
+ }
+}