Skip to content

The Chrome DevTools Protocol as a detection vector

· 19 min read
Copyright: MIT
Wordmark reading CDP with an orange websocket arrow and the label devtools-protocol

Almost every Chromium automation stack in production rides on one protocol. Puppeteer speaks it, Playwright speaks it for Chromium, Selenium’s Chromium driver speaks it under the hood, and the dozens of “undetected” wrappers built on top of those speak it too. The Chrome DevTools Protocol is the wire format DevTools itself uses to drive the browser, and when a script controls a tab, that control flows through CDP. So a natural question for anyone defending a site: if the controller has to attach a debugger to the browser, does attaching leave a mark? Can the page tell that something is listening on the other end of the protocol?

The short answer is that it can, more often than the automation side would like, and the reasons are baked into how CDP works rather than into any single bug. Attaching a CDP client is not a passive read. It flips the browser into a state where certain events get generated, certain object previews get built, certain execution contexts get created and named, and several of those state changes are observable from inside the page the protocol is supposed to be inspecting from the outside. This post takes the protocol-level view. Not the catalog of headless tells, not the property-spoofing arms race, just CDP itself as an attack surface: the transport, the domains, and the gap between a browser that has a debugger attached and one that does not.

The sections below go in order from the outside in. First the shape of the protocol and its websocket transport, because the discovery endpoints are themselves a signal. Then what attaching actually changes in the running browser, domain by domain, and why “enable” is a verb with side effects. Then the specific reachable leaks: the console preview path, the execution-context and isolated-world story, and the stack-trace markers that automation libraries stamp into their own injected scripts. Then a section on the new-headless shift and what it did and did not fix. A closing read on why this surface is structural and not going away.

The protocol and its transport

CDP is a JSON message protocol carried over a WebSocket. A client opens a socket to a debuggable target, sends commands, and receives two kinds of frames back: responses to commands it issued, and events the browser pushes on its own. The official protocol reference describes the instrumentation as split into domains, each a namespace of related commands and events. Page, Network, Runtime, DOM, Debugger, Target, and roughly forty others. A command is a JSON object with an id, a method like "Page.navigate", and a params object. The response carries the same id and a result. Events have no id; they carry a method and params and arrive whenever the browser has something to say.

There are two protocol surfaces. The tip-of-tree version tracks Chromium head and “changes frequently and can break at any time,” while the stable 1.3 version was tagged at Chrome 64 and covers a smaller, frozen subset. The canonical definitions live in the Chromium tree as .pdl files and are mirrored to the devtools-protocol GitHub repo, where the JSON, TypeScript, and closure typedefs are generated from them. That is worth holding onto, because it means the set of capabilities a CDP client has is large, versioned, and fully documented. There is no hidden API; the attack surface is published.

To talk to a browser, a client first has to find the socket. Chrome exposes the endpoint over plain HTTP when it is launched with --remote-debugging-port. A GET to /json/version returns metadata including a webSocketDebuggerUrl that looks like ws://localhost:9222/devtools/browser/<id>, and /json (or /json/list) enumerates every page-level target with its own ws://localhost:9222/devtools/page/<targetId>. Connect to one of those and you are driving that tab. Chrome 63 added support for multiple clients on the same target, so DevTools and an automation library can both be attached; when one drops, the browser sends an Inspector.detached event with a reason such as "replaced_with_devtools".

discovery over HTTP, control over WebSocket CDP client Puppeteer / Playwright GET /json/version webSocketDebuggerUrl {id, method, params} {id, result} | {method, params} events arrive unsolicited once a domain is enabled Chrome --remote-debugging-port browser target + N page targets The transport is published and discoverable; the capability set is the documented domain list. *The discovery handshake and the command/event split. Finding the socket is an HTTP GET; driving the tab is a stream of JSON frames over one WebSocket.*

The transport detail that matters most for detection is where the socket binds. From Chromium M113 onward, --remote-debugging-address=0.0.0.0 is internally forced back to 127.0.0.1 for security, so the debugging endpoint listens on loopback by default and a remote attacker cannot reach it across the network. That hardening is good for the user. It also means the debugging surface is, by design, a localhost-only thing, which is one reason port-scanning the loopback interface from page JavaScript has limited reach: a browser cannot open a raw TCP socket to 127.0.0.1:9222 and read the response. What page code can sometimes do is attempt to load resources from a guessed local origin and time the result, but the same-origin policy and the lack of a readable response make this a weak and noisy signal, not the clean tell people imagine. The strong CDP signals live elsewhere, inside the browser, not on the wire.

What “enable” does to a running browser

The protocol’s most important verb is enable. Most domains start dormant and report nothing. You call Network.enable to start receiving Network.requestWillBeSent events, Page.enable for navigation lifecycle events, Debugger.enable to start receiving Debugger.scriptParsed, and Runtime.enable to start receiving execution-context and console events. Enabling a domain is not a read. It changes the browser’s behavior for the lifetime of the session, switching on machinery that would otherwise be off, and some of that machinery has effects that reach into the page.

The cleanest example, and the one that defined CDP detection for years, is the console preview path that Runtime.enable switches on. The mechanics of that signal, its public history through DataDome’s 2024 write-up, and the two V8 commits in May 2025 that broke it, are covered in depth in the companion post on the Runtime.enable leak and the V8 patch war. The short version, because it is the archetype for this whole class of leak: once a client has the Runtime domain enabled and the page calls any console.* method, the browser serializes the arguments into a remote-object preview so DevTools can render them. Building that preview walks the object and reads its properties. If the page has defined an accessor on one of those properties, reading it runs page code. So the page can construct an object whose .stack getter, or whose prototype’s ownKeys trap, flips a flag, log it, and learn whether anyone on the other end is building previews. Nothing is built unless a client is attached. The presence of the debugger is what arms the trap.

What makes this a protocol-level problem and not a one-off bug is the structural shape. The page is calling a perfectly ordinary API, console.log. The side effect comes entirely from the fact that a CDP client enabled a domain. That coupling, where a benign page operation acquires an observable side effect because of out-of-band debugger state, recurs across domains. It is why enable is the word to watch. Castle’s analysis of the V8 patches traces how the fix landed in the inspector’s value-mirror.cc, with a getErrorProperty guard that checks whether a getter is user code by looking at its ScriptId and skips it if so. That fix closes one path. The general property, that enabling a domain changes what the page can observe, is not something a single commit can close.

same page code, two browser states no client attached console.log(trap) no preview is built getter never runs flag = false Runtime domain enabled console.log(trap) preview serializes args accessor on prototype runs flag = true *The archetype CDP leak. The page calls an ordinary API; the observable difference comes entirely from a domain the client enabled out of band.*

The same enable-has-side-effects logic shows up on other domains in quieter ways. Anti-fraud write-ups note that a session with Network.setUserAgentOverride in play can produce a user-agent string in the navigator that no longer matches the Client Hints headers the same browser sends, because the override changed the header path without changing every navigator surface in lockstep. That is not strictly a leak of CDP itself; it is a leak of the way a CDP client reconfigured the browser. The distinction matters. Some signals say “a debugger is attached,” and some say “someone used the debugger to lie to me,” and the second class is often easier to catch because the lie is internally inconsistent.

Execution contexts, isolated worlds, and where injected code lives

To run JavaScript on a page over CDP, a client calls Runtime.evaluate, and to aim that call at the right frame it needs the frame’s executionContextId. A page is not a single context. It is the main frame plus every iframe plus every worker, each with its own id assigned by the browser, and those ids appear and disappear as frames come and go. The protocol’s model for learning them is to subscribe to Runtime.executionContextCreated, which is precisely what Runtime.enable turns on. This is why automation libraries historically sent Runtime.enable per frame the moment they attached: it was the documented path to knowing where to run code. Rebrowser’s write-up on the Runtime.enable problem walks the tradeoffs the libraries faced once that command became a liability, including deferring the enable and reconstructing context ids by other means.

There are two places injected code can run, and the choice leaks information either way. The main world is the page’s own JavaScript context. Code evaluated there shares globals with the page, which is exactly what you want when you are spoofing a navigator property or patching a function, because the page has to see your patch. It is also exactly the problem, because the page can see everything else you left behind: a stray global, a patched function whose toString no longer reads [native code], a property descriptor that does not match a real Chrome’s. Code injected via Page.addScriptToEvaluateOnNewDocument runs in the main world by default and shares the environment with whatever detection script the site loaded.

The other place is an isolated world, created with Page.createIsolatedWorld. It runs against the same DOM but with a separate JavaScript global, so the page cannot reach your variables and cannot catch your mutations through a MutationObserver the way it could in the main world. That isolation is why automation frameworks reach for it. The cost is that an isolated world is itself a thing the browser knows about, and the act of creating one and naming it can surface. Puppeteer’s utility world has historically carried the literal name __puppeteer_utility_world__, and scripts evaluated through it carry a sourceURL of __puppeteer_evaluation_script__. Those strings are not properties on window; they live in the machinery around evaluation. But they are the kind of internal label that can surface through a stack trace, which brings us to the most self-inflicted CDP tell of all.

The stack-trace markers automation stamps into its own scripts

When a CDP client evaluates code, V8 has to give that code a source identity for its stack frames. Puppeteer’s convention has been to label evaluations with a pptr: scheme and an internal source name, and Octo Browser’s breakdown of CDP leaks describes the consequence plainly: a page that triggers an error inside or near automation-injected code and reads Error.stack can find a frame whose source is pptr:evaluate or a similar marker, or worse, an absolute filesystem path like C:\Users\...\script.js that a normal browser visiting a public site would never expose. A real user’s page has no reason to have a stack frame pointing at a local Node script. The presence of one is close to a confession.

This is a different class of signal from the console preview leak. The console leak is a property of the protocol’s serialization behavior, fixable in the browser. The stack-trace marker is a property of how a specific automation library names its own injected code, fixable only in that library, by stripping or renaming the marker before the command goes out. It is also why “the browser patched the leak” never means “automation is undetectable now.” The browser can stop leaking its own debugger state, but it cannot stop a library from stamping pptr: into a stack trace; only the library can do that, and only for itself.

two layers, two owners, two fixes browser-side console preview path value-mirror.cc fix lands in V8 / Chromium library-side pptr: source markers __puppeteer_evaluation_script__ fix lands in the automation lib *The browser can patch its own serialization side effects. It cannot un-stamp the labels a library writes into its own injected code; that is the library's job.*

There is a subtler timing variant in the same family. A script injected at document_start through Page.addScriptToEvaluateOnNewDocument runs before the page’s own scripts, in a window a normal page does not have, and the synchronous work it does can introduce a measurable gap between two browser lifecycle events that a site can time. Whether that gap is reliable enough to act on alone is doubtful, but it is the kind of second-order signal that survives even when the obvious markers are scrubbed, because it comes from the fact that something is executing in a slot reserved for the automation client.

What new headless changed, and what it did not

For years the loudest signal was not CDP at all but the old headless implementation. Old headless Chrome was a separate code path that diverged from real Chrome in dozens of small ways, and detectors keyed on those divergences without ever touching the protocol. That changed. Google made the old behavior available as a standalone chrome-headless-shell binary in 2023, announced the deprecation of --headless=old in October 2024, and removed the old mode entirely in Chrome 132 so that --headless and --headless=new now run the same browser code as a headed Chrome, just without painting to a screen. The practical effect is that “is this headless” got much harder to answer from rendering quirks, because new headless is the real browser.

It is worth being precise about what that did and did not do to the CDP surface. New headless removed a pile of headless-specific tells. It did not remove the CDP attack surface, because that surface is about a debugger being attached, not about whether a window is painted. A new-headless Chrome driven by Puppeteer still enables domains, still builds console previews, still creates named isolated worlds, still stamps source markers into its evaluations. The protocol-level signals are orthogonal to the headless question. A fully headed, visible Chrome driven over CDP carries the same protocol tells as a headless one. This is the point that gets lost in “new headless is undetectable now” claims: it closed the rendering gap, not the debugger gap.

The other thing the new-headless shift did was raise the relative value of CDP signals for defenders. When headless was easy to spot, the protocol-level work was almost unnecessary. With headless now indistinguishable from headed at the rendering layer, the question “is a debugger attached” carries more weight, because it is one of the few remaining signals that separates a CDP-driven browser of any kind from a human-driven one. That is the lane where this whole topic now sits. The companion posts on why stealth plugins lose the gap to real Chrome and on the detection surface of each driver cover how that pressure plays out across specific tooling.

The Target domain and the cost of attaching at all

So far the leaks have been about what happens once a client is driving a tab. There is a layer below that, the Target domain, which is how a CDP client discovers and attaches to the browser’s individual targets in the first place: the page, its out-of-process iframes, its workers, its service workers. A client calls Target.setDiscoverTargets to start receiving Target.targetCreated events, then Target.attachToTarget to open a session against a specific one, and increasingly libraries set autoAttach so that every child target a page spawns is attached automatically as it appears. This is the plumbing that lets Puppeteer “just work” across iframes and workers without the caller wiring up each one.

The detection consequence is indirect but real. A page that spins up a worker, or an out-of-process iframe, exists in a browser where the automation client wants a session against that new target too. Auto-attach means the client reaches into each new context as it is born, enabling domains there, creating utility worlds there, and doing it on the browser’s schedule rather than the page’s. Each of those attachments is another instance of every leak discussed above, re-armed in a fresh context the page just created. A site that seeds a few short-lived iframes is not just testing the main frame; it is multiplying the number of contexts in which a console-preview trap or a stack-marker probe gets a fresh chance to fire. The companion post on the detection surface of each driver gets into how Playwright, Puppeteer, and Selenium differ in exactly this attach-and-enable choreography.

autoAttach re-arms every leak in every child context Target domain setAutoAttach main frame — domains enabled child iframe — domains enabled worker — domains enabled *Auto-attach turns one attached session into many. Each child target the page creates is another context where the same enable-side-effects apply.*

There is a quieter cost to attaching that has nothing to do with any specific event. A browser with a CDP client connected is, at minimum, a browser that responded to a session attach, that holds an open inspector session, and whose Target graph includes a client. None of that is directly readable from page JavaScript, which is the saving grace, but it is the reason the strong signals come from side effects rather than from a single navigator.cdp property that could be spoofed away. There is no such property because the protocol was never meant to be hidden. The browser does not advertise that it is being debugged, and it does not have to, because the act of debugging changes enough observable behavior that advertising would be redundant.

Why the surface is structural

Step back from the individual leaks and a pattern holds across all of them. CDP was built to let a developer inspect a browser from the outside, and a good inspector has to reach deeply into the runtime: serialize arbitrary objects for display, enumerate keys, read properties, create execution contexts, name and isolate them, attach source identities to evaluated code. Every one of those capabilities is a place where the act of inspecting changes what the inspected program can observe, because the inspector and the inspected share one runtime. You cannot build a faithful debugger that is also perfectly invisible to the thing it debugs. The two goals pull against each other, and detection lives in the gap.

That is why patching individual leaks works without ever ending the war. The May 2025 V8 commits genuinely killed the console-error-getter trick; a page that relied on it gets flag = false now even against an attached client. But the prototype-chain Proxy variant was still reachable in early 2026 builds, the isolated-world names still exist, the source markers are still the library’s to scrub, and the next inspector capability someone leans on will have its own observable edge. The fixes are real and the surface is permanent. Defenders get a steady supply of new signals because the protocol’s whole purpose is to make the runtime observable, and a runtime you can observe from the privileged side is, in narrow and shifting ways, a runtime that can observe you back.

The concrete state in 2026: the single most famous CDP tell, the error-getter console leak, is dead in current Chrome. The class it belonged to is not. As long as automation rides on a protocol designed for inspection rather than for stealth, the question “is something attached to this browser” will keep having answers, and the answers will keep moving from the browser’s side of the fix to the library’s side and back. The protocol is published, the capabilities are documented, and the side effects of using them are the detection vector. None of that changes by spoofing a property.


Sources & further reading

Further reading