Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in DevTools
DescriptionInsufficient validation of untrusted input in DevTools
ComponentDevTools
Bug ClassLogic Error
Tracker513713927
Fix commitd025eb48e8fe (deps/inspector_protocol) +205/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
crdtp/cbor.cc
modified
TEST
crdtp/cbor_test.cc
modified

Files Changed

  • crdtp/cbor.cc
  • crdtp/cbor.h
  • crdtp/cbor_test.cc
From d025eb48e8febbe8b846b2675e031295c8ef605e Mon Sep 17 00:00:00 2001
From: Alex Rudenko <alexrudenko@chromium.org>
Date: Mon, 18 May 2026 10:33:51 +0000
Subject: [PATCH] Add HasKeyInMap

Similar to GetString8ValueFromMap it will be used for validating CDP
messages. Specifically, it will be used to ensure that events have do
not top-level ID attribute. The method does not check types because
clients might treat ID the same way irrespective of the type.

Bug: 513713927
Change-Id: I342961856151195f75ea465e3317cf8324297b3f
---

diff --git a/crdtp/cbor.cc b/crdtp/cbor.cc
index 04088ab..7e62d76 100644
--- a/crdtp/cbor.cc
+++ b/crdtp/cbor.cc
@@ -1119,5 +1119,52 @@
   }
   return result;
 }
+
+bool HasKeyInMap(span<uint8_t> message, span<uint8_t> key) {
+  CBORTokenizer tokenizer(message);
+  if (tokenizer.TokenTag() != CBORTokenTag::ENVELOPE) {
+    return false;
+  }
+  tokenizer.EnterEnvelope();
+  if (tokenizer.TokenTag() != CBORTokenTag::MAP_START) {
+    return false;
+  }
+  tokenizer.Next();
+  bool is_key = true;
+  size_t nested_depth = 0;
+  while (tokenizer.TokenTag() != CBORTokenTag::DONE &&
+         tokenizer.TokenTag() != CBORTokenTag::ERROR_VALUE) {
+    if (tokenizer.TokenTag() == CBORTokenTag::STOP) {
+      if (nested_depth == 0) {
+        break;
+      }
+      nested_depth--;
+      if (nested_depth == 0) {
+        is_key = !is_key;
+      }
+      tokenizer.Next();
+      continue;
+    }
+    if (tokenizer.TokenTag() == CBORTokenTag::MAP_START ||
+        tokenizer.TokenTag() == CBORTokenTag::ARRAY_START) {
+      nested_depth++;
+      tokenizer.Next();
+      continue;
+    }
+    if (nested_depth > 0) {
+      tokenizer.Next();
+      continue;
+    }
+    if (is_key) {
+      if (tokenizer.TokenTag() == CBORTokenTag::STRING8 &&
+          SpanEquals(tokenizer.GetString8(), key)) {
+        return true;
+      }
+    }
+    tokenizer.Next();
+    is_key = !is_key;
+  }
+  return false;
+}
 }  // namespace cbor
 }  // namespace crdtp
diff --git a/crdtp/cbor.h b/crdtp/cbor.h
index 95e916e..d7bcfa2 100644
--- a/crdtp/cbor.h
+++ b/crdtp/cbor.h
@@ -322,6 +322,10 @@
 // Explicitly rejects duplicate keys and non-STRING8 values.
 CRDTP_EXPORT span<uint8_t> GetString8ValueFromMap(span<uint8_t> message,
                                                   span<uint8_t> string8_key);
+// Safely checks if |key| exists in the top-level of a CBOR encoded map wrapped
+// in an envelope. Shallow parser that skips nested structures.
+// Returns true as soon as the key is found at the top level.
+CRDTP_EXPORT bool HasKeyInMap(span<uint8_t> message, span<uint8_t> key);
 
 namespace internals {  // Exposed only for writing tests.
 CRDTP_EXPORT size_t ReadTokenStart(span<uint8_t> bytes,
diff --git a/crdtp/cbor_test.cc b/crdtp/cbor_test.cc
index 07e764d..4b23d36 100644
--- a/crdtp/cbor_test.cc
+++ b/crdtp/cbor_test.cc
@@ -1648,5 +1648,159 @@
   EXPECT_TRUE(result.empty());
 }
 
+TEST(HasKeyInMapTest, FindsKeySuccessfully) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("key1"), &encoded);
+  EncodeString8(SpanFrom("value1"), &encoded);
+
+  EncodeString8(SpanFrom("key2"), &encoded);
+  EncodeInt32(42, &encoded);
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key1")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key2")));
+}
+
+TEST(HasKeyInMapTest, HandlesMissingKey) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("key1"), &encoded);
+  EncodeString8(SpanFrom("value1"), &encoded);
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  EXPECT_FALSE(HasKeyInMap(SpanFrom(encoded), SpanFrom("missing_key")));
+}
+
+TEST(HasKeyInMapTest, WorksWithVariousValueTypes) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("string_key"), &encoded);
+  EncodeString8(SpanFrom("value"), &encoded);
+
+  EncodeString8(SpanFrom("int_key"), &encoded);
+  EncodeInt32(42, &encoded);
+
+  EncodeString8(SpanFrom("double_key"), &encoded);
+  EncodeDouble(3.14, &encoded);
+
+  EncodeString8(SpanFrom("bool_true_key"), &encoded);
+  encoded.push_back(EncodeTrue());
+
+  EncodeString8(SpanFrom("bool_false_key"), &encoded);
+  encoded.push_back(EncodeFalse());
+
+  EncodeString8(SpanFrom("null_key"), &encoded);
+  encoded.push_back(EncodeNull());
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("string_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("int_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("double_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("bool_true_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("bool_false_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("null_key")));
+}
+
+TEST(HasKeyInMapTest, HandlesErrorValue) {
+  // Envelope with 2 bytes of content:
+  // [Map Start (0xbf), String Start length 1 (0x61)].
+  // The string key is truncated (missing its data byte), which triggers
+  // CBORTokenTag::ERROR_VALUE.
+  std::vector<uint8_t> encoded = {
+      0xd8, 0x5a, 0, 0, 0, 2, EncodeIndefiniteLengthMapStart(),
+      0x61 /* major type 3 (string), additional info 1 (length 1) */
+  };
+
+  EXPECT_FALSE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key")));
+}
+
+TEST(HasKeyInMapTest, AllowsDuplicateKeys) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("key"), &encoded);
+  EncodeInt32(42, &encoded);
+
+  EncodeString8(SpanFrom("key"), &encoded);
+  EncodeInt32(24, &encoded);
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key")));
+}
+
+TEST(HasKeyInMapTest, AllowsNestedStructures) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/crdtp/cbor_test.cc b/crdtp/cbor_test.cc
index 07e764d..4b23d36 100644
--- a/crdtp/cbor_test.cc
+++ b/crdtp/cbor_test.cc
@@ -1648,5 +1648,159 @@
   EXPECT_TRUE(result.empty());
 }
 
+TEST(HasKeyInMapTest, FindsKeySuccessfully) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("key1"), &encoded);
+  EncodeString8(SpanFrom("value1"), &encoded);
+
+  EncodeString8(SpanFrom("key2"), &encoded);
+  EncodeInt32(42, &encoded);
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key1")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key2")));
+}
+
+TEST(HasKeyInMapTest, HandlesMissingKey) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("key1"), &encoded);
+  EncodeString8(SpanFrom("value1"), &encoded);
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  EXPECT_FALSE(HasKeyInMap(SpanFrom(encoded), SpanFrom("missing_key")));
+}
+
+TEST(HasKeyInMapTest, WorksWithVariousValueTypes) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("string_key"), &encoded);
+  EncodeString8(SpanFrom("value"), &encoded);
+
+  EncodeString8(SpanFrom("int_key"), &encoded);
+  EncodeInt32(42, &encoded);
+
+  EncodeString8(SpanFrom("double_key"), &encoded);
+  EncodeDouble(3.14, &encoded);
+
+  EncodeString8(SpanFrom("bool_true_key"), &encoded);
+  encoded.push_back(EncodeTrue());
+
+  EncodeString8(SpanFrom("bool_false_key"), &encoded);
+  encoded.push_back(EncodeFalse());
+
+  EncodeString8(SpanFrom("null_key"), &encoded);
+  encoded.push_back(EncodeNull());
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("string_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("int_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("double_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("bool_true_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("bool_false_key")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("null_key")));
+}
+
+TEST(HasKeyInMapTest, HandlesErrorValue) {
+  // Envelope with 2 bytes of content:
+  // [Map Start (0xbf), String Start length 1 (0x61)].
+  // The string key is truncated (missing its data byte), which triggers
+  // CBORTokenTag::ERROR_VALUE.
+  std::vector<uint8_t> encoded = {
+      0xd8, 0x5a, 0, 0, 0, 2, EncodeIndefiniteLengthMapStart(),
+      0x61 /* major type 3 (string), additional info 1 (length 1) */
+  };
+
+  EXPECT_FALSE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key")));
+}
+
+TEST(HasKeyInMapTest, AllowsDuplicateKeys) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("key"), &encoded);
+  EncodeInt32(42, &encoded);
+
+  EncodeString8(SpanFrom("key"), &encoded);
+  EncodeInt32(24, &encoded);
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key")));
+}
+
+TEST(HasKeyInMapTest, AllowsNestedStructures) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("key1"), &encoded);
+  // Start a nested map as value.
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+  EncodeString8(SpanFrom("inner_key"), &encoded);
+  EncodeInt32(42, &encoded);
+  encoded.push_back(EncodeStop());
+
+  EncodeString8(SpanFrom("key2"), &encoded);
+  EncodeInt32(24, &encoded);
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  // We should find top-level keys even with nested structures.
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key1")));
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key2")));
+
+  // We should NOT find keys that are inside nested structures (they are not
+  // top-level).
+  EXPECT_FALSE(HasKeyInMap(SpanFrom(encoded), SpanFrom("inner_key")));
+}
+
+TEST(HasKeyInMapTest, HandlesTruncation) {
+  std::vector<uint8_t> encoded;
+  EnvelopeEncoder envelope;
+  envelope.EncodeStart(&encoded);
+  encoded.push_back(EncodeIndefiniteLengthMapStart());
+
+  EncodeString8(SpanFrom("key1"), &encoded);
+  // Missing value here.
+  // Truncated before we can write key2.
+
+  encoded.push_back(EncodeStop());
+  envelope.EncodeStop(&encoded);
+
+  // "key1" is found because we return early on seeing the key.
+  EXPECT_TRUE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key1")));
+
+  // "key2" is not found because it is not in the map (and map is truncated).
+  EXPECT_FALSE(HasKeyInMap(SpanFrom(encoded), SpanFrom("key2")));
+}
+
+TEST(HasKeyInMapTest, InvalidMessage) {
+  std::vector<uint8_t> msg = {
+      0xd8, 0x5a, 0, 0, 0, 2, EncodeIndefiniteLengthMapStart(), 42};
+  EXPECT_FALSE(HasKeyInMap(SpanFrom(msg), SpanFrom("key")));
+}
+
 }  // namespace cbor
 }  // namespace crdtp
Loading diff…

Original Bug Report

reported by vm...@google.com

CDP response spoofing across DevTools sessions via compromised renderer

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic flaw in the DevTools frontend’s message handling combined with insufficient validation in the browser allows a compromised renderer to spoof Chrome DevTools Protocol (CDP) responses for other sessions. By predicting shared message IDs, an attacker can inject malicious data into the DevTools UI for victim origins.

Affected files:

  • third_party/devtools-frontend/src/front_end/core/protocol_client/DevToolsCDPConnection.ts
  • content/browser/devtools/devtools_session.cc
  • third_party/devtools-frontend/src/front_end/core/protocol_client/InspectorBackend.ts
  • third_party/devtools-frontend/src/front_end/core/sdk/Connections.ts
  • chrome/browser/devtools/devtools_ui_bindings.cc

Estimated timestamp from git blame: 2025-10-31

Summary

A potential vulnerability exists in the Chrome DevTools protocol handling that allows a compromised renderer process (Session B) to spoof protocol responses for a different inspected session (Session A) managed by the same DevTools window. This is caused by a lack of session ID verification in the frontend’s command-response matching logic and a failure in the browser process to filter restricted fields in renderer-originated notifications.

Technical Details

1. Frontend: Lack of Session Validation in DevToolsCDPConnection

In the DevTools frontend, the DevToolsCDPConnection class manages CDP communication. It uses a single internal map #callbacks and a shared counter #lastMessageId for all targets multiplexed over the connection (e.g., the main page and cross-origin iframes).

When a command is sent, the callback metadata includes the sessionId, but when a message is received in onMessage, the frontend only uses the id field to retrieve and resolve the callback:

// third_party/devtools-frontend/src/front_end/core/protocol_client/DevToolsCDPConnection.ts
private onMessage(message: string|Object): void {
  // ... parsing ...
  if ('id' in messageObject && messageObject.id !== undefined) {
    const callback = this.#callbacks.get(messageObject.id);
    this.#callbacks.delete(messageObject.id);
    if (!callback) { return; }

    // VULNERABILITY: sessionId in messageObject is not compared against callback.sessionId
    callback.resolve(messageObject);
  }
}

2. Browser: Insufficient Notification Filtering

A compromised renderer can send protocol messages to the frontend via the DispatchProtocolNotification method of the blink.mojom.DevToolsSessionHost interface. The browser’s implementation in DevToolsSession::ValidateSessionId ensures the sessionId in the message matches the renderer’s session, but it does not prohibit the presence of an id field in the message body.

Typically, only responses (which are generated by the browser or a trusted backend) should contain an id. By allowing a renderer to send a notification containing an id field, the browser allows the renderer to mimic a command response.

Potential Attack Scenario

  1. A user opens DevTools on a page containing a compromised renderer (Session B) and a victim cross-origin iframe (Session A).
  2. The compromised renderer predicts the next sequential messageId (e.g., 100) used by the shared DevToolsCDPConnection.
  3. When the developer performs an action targeting Session A (e.g., viewing source code), the frontend sends a command with id: 100 and sessionId: "SessionA".
  4. The compromised renderer B immediately sends a spoofed notification via Mojo: {"id": 100, "sessionId": "SessionB", "result": {"scriptSource": "/* MALICIOUS CODE */"}}.
  5. The browser validates that sessionId is “SessionB” and forwards the message.
  6. The frontend receives the message, matches id: 100, and resolves the victim Session A’s request with the attacker’s data, as it fails to verify that the session IDs match.

Impact

  • Cross-Origin Content Spoofing: An attacker can replace the source code, network responses, or console data displayed for a victim origin in the DevTools UI.
  • Feature Redirection: Malicious responses for commands like Target.getTargetInfo could potentially redirect privileged DevTools features (e.g., Lighthouse, Recorder) to attacker-controlled targets.

Suggested Remediation

  1. Frontend: Update DevToolsCDPConnection.onMessage to verify that the sessionId in the incoming message matches the sessionId stored in the callback metadata before resolving.
  2. Browser: In DevToolsSession::DispatchProtocolNotification, reject or strip any message body that contains an id field, as notifications should not possess a call ID.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker