Detecting CDP in the wild: the Runtime.enable leak and the V8 patch war
Construct an Error, define a getter on its .stack property, log it to the console, and check whether the getter ran. On an ordinary Chrome tab nothing happens. On a Chrome that some automation framework is driving over the DevTools Protocol, the getter fires, and a single boolean tells you the browser is being puppeteered. No flag to disable, no property to spoof. The detection lives in the side effect of logging, and for years it was the cleanest tell in the business.
This post is the biography of that one signal. Not the whole catalog of CDP tells, not headless Chrome’s full list of giveaways, just this one: where it came from, why console.log on an error object reaches all the way down into the inspector, how DataDome turned a years-old private trick into public knowledge in 2024, and the two V8 commits in May 2025 that closed it almost without anyone noticing. The single most famous CDP signal had a beginning, a public peak, and a quiet death, and the shape of that arc says more about the anti-bot war than any list of signals would.
The sections below walk it in order. First, what Runtime.enable actually does and why every Chromium automation framework sends it. Then the mechanism: how a console call on an error becomes a getter invocation, and why that is observable from the page. Then the public history, from the trick circulating among detection vendors to DataDome’s June 2024 write-up. Then the patch war: the rebrowser and Patchright side that stopped sending the command, and the V8 side that made sending it harmless. A closing section on what is left in 2026, because the signal is mostly dead and the reasons it died are the interesting part.
What Runtime.enable is, and why every framework sends it
The Chrome DevTools Protocol is the wire format that DevTools itself speaks to the browser, and it is the same channel Puppeteer, Playwright, and Selenium’s Chromium driver use to control a page. The protocol is split into domains, and the Runtime domain is the one that deals with JavaScript execution. Its enable command does something that sounds innocuous in the official protocol reference: it “enables reporting of execution contexts creation by means of executionContextCreated event,” and once enabled, the event fires immediately for every context that already exists.
That single sentence is why automation libraries cannot easily live without it. To run JavaScript on a page over CDP you call Runtime.evaluate, and to target the right frame you need its executionContextId. A page is not one context. It is the main frame, every iframe, every worker, each with its own id, and those ids are assigned by the browser and change as frames come and go. The clean way to learn them is to subscribe to Runtime.executionContextCreated, which is exactly what Runtime.enable switches on. So the first thing a freshly attached Puppeteer or Playwright session does, for each frame, is send Runtime.enable and start listening. Without it the library is flying blind about where to run code.
The trouble is that Runtime.enable does not only turn on the context events. It turns on the whole Runtime domain’s reporting, and that includes Runtime.consoleAPICalled, the event that relays every console.* call back to the client with the arguments serialized as remote objects. DevTools needs that event to populate its console panel. An automation framework does not want it at all. But the command is coarse. Ask for execution-context reporting and you get console reporting bundled in, and console reporting is where the leak lives.
It helps to see why the framework cannot simply skip the command. CDP is asynchronous and stateless about your intentions. There is no “give me the id of the main frame’s context” request that returns a value synchronously; the protocol’s model is that you subscribe to a domain’s events and the browser pushes state at you. Runtime.evaluate does take an optional contextId, but to fill it in you first have to have seen the matching executionContextCreated event, and you only see those events after Runtime.enable. The alternative, evaluating without a context id, runs your code in whatever the browser picks as the default context for the frame, which is fine until the page has iframes or the main frame navigates and the default stops being the one you meant. For a library that promises to run your selector in the right frame every time, guessing is not acceptable. So the automatic enable is not laziness. It is the straightforward reading of how the protocol expects a client to learn the topology of a page.
A second subtlety is that Runtime.enable is sent per execution context, not once per browser. Every new frame, every worker, every navigation that creates a fresh context is another place the library wants an id, and historically the library re-enabled the domain for each of them. That multiplies the leak. A page that detects the tell does not need to win a race against a single enable at attach time; it has a fresh opportunity on every frame the automation touches, which for a modern site with embedded iframes is many opportunities across the life of the page. The signal was reliable partly because it kept being re-armed.
The mechanism: a console call that reads a getter
Here is the part that makes the signal elegant. When a client has Runtime.enable active and the page calls console.log (or console.debug, or any console method) with an argument, the browser does not just hand over the raw value. It builds a preview of the argument so DevTools can render something useful in its console panel before you expand it. Building that preview means walking the object’s properties and reading their values. For an Error object, the interesting property is .stack, the formatted stack trace, and the preview wants it.
If .stack is a plain string, reading it is free. But JavaScript lets you redefine any property as an accessor with a getter function. Define .stack as a getter, and the act of reading it runs your code. So the page sets a trap: create an error, replace .stack with a getter that flips a flag, log the error. If a CDP client is attached and previewing console output, the preview reads .stack, your getter runs, the flag flips. If no client is attached, nobody builds the preview, the getter never runs, the flag stays down. The mechanism in pseudocode looks like this:
detected = falsee = new Error()Object.defineProperty(e, "stack", { get() { detected = true; return "" }})console.debug(e) // only previewed when a CDP client listens// detected === true => something is driving this browser over CDPNothing here is an exploit. It is ordinary, specified JavaScript: defining an accessor property and calling a console method. The signal comes entirely from a browser-internal behavior that the page was never supposed to be able to observe. The preview was meant for a human looking at DevTools. The getter side effect leaked it to the document.
The choice of .stack is not incidental, and it is worth dwelling on why the error object specifically. When the inspector serializes an arbitrary object for the console panel it builds a preview that lists a few properties with short value strings, and for most objects those values are plain data slots that read without running anything. An Error is special: DevTools wants to show the stack trace inline, because a logged error with no visible stack is nearly useless to a developer, so the error preview path deliberately reaches for .stack. That makes the error the highest-value object to trap. You could in principle put a getter on any property the preview reads, but .stack on an Error is the one the inspector is most certain to touch, which is why every public version of the trick uses it. The later V8 fix would extend the same guard to .name and .message, the other two properties the error preview reads, which tells you those were the reachable surfaces.
There is also a reason the page uses console.debug rather than console.log in many versions of the trick. Functionally both route through consoleAPICalled and both build a preview, but console.debug output is hidden by default in the DevTools console panel under the verbose filter, so a detection script firing it repeatedly does not flood a real user’s console if that user happens to have DevTools open. It is a courtesy that also reduces the chance a human notices the probe. The mechanism does not depend on the choice; the choice is about staying quiet.
The strength of the tell is its specificity. navigator.webdriver is a documented flag that any stealth toolkit clears at launch. This was different. It did not ask “are you headless” or “did you set a flag.” It asked “is anything subscribed to console events right now,” and on a normal user’s browser the honest answer is almost always no. The same getter trap fires when a real human has DevTools open, which is the one meaningful source of false positives, and it is a small one. People browse with the console open a vanishing fraction of the time. For a detector willing to accept that sliver of noise, a flipped flag was close to a direct readout of “CDP is attached.”
Why this had teeth: CDP is the floor under every framework
The reason a CDP tell matters more than a per-property spoof is structural. Puppeteer is CDP. Playwright’s Chromium backend is CDP. Selenium 4’s Chromium driver talks CDP under the hood. Every mainstream way to drive a real Chromium engine for scraping reaches the browser through this one protocol, which means a reliable way to detect “CDP is attached” detects the whole category at once, regardless of which library sits on top or how many fingerprint properties it has patched. That is the case the related post on the Chrome DevTools Protocol as a detection vector lays out in full, and it is why vendors invested in CDP tells rather than chasing individual library quirks.
It also sits a level below the stealth toolkits. A plugin like puppeteer-extra-plugin-stealth works by patching JavaScript surfaces: faking navigator.plugins, clearing the webdriver flag, fixing the permissions contradiction. None of that touches the transport. The error-getter trap did not look at any of those surfaces. It looked at whether the transport itself was live, which is the gap the post on why stealth plugins lose keeps returning to. You can patch every property a detector reads and still answer “yes” to “is CDP attached,” because the attachment is what makes your framework work at all.
That structural position is also why the fix could not be a one-line patch in a stealth plugin. You cannot spoof your way out of a tell that reads the protocol you depend on. Either you stop sending the command that turns the reporting on, or the browser stops leaking when you send it. Both happened, on different timelines, and that split is the patch war.
The DevTools-open problem and how the signal was scored
A clean signal still has noise, and this one has a specific source of it: a real human with DevTools open trips the same trap. When a person opens the console panel, DevTools itself attaches over CDP, sends Runtime.enable, and starts building previews, so logging a trapped error fires the getter for exactly the same reason it fires for a bot. From the page’s point of view a developer inspecting the site and a Puppeteer session look identical at this layer. Both have a CDP client subscribed to console events.
That is why no serious detector treated the flipped flag as a verdict on its own. The honest way to read the signal is conditional. A flipped flag means “a CDP client is attached right now,” and the question of whether that client is a human’s DevTools or an automation framework is answered by everything else on the page. The base rate is what makes it usable: ordinary visitors browse with the console open a tiny fraction of the time, so across a population the flag is overwhelmingly a bot indicator, and the rare human developer who trips it is cheap to wave through on the strength of other signals. The rebrowser write-up frames the same trade-off from the automation side, noting that a normal user almost never has DevTools open, which is exactly what makes the presence of a CDP client suspicious in the first place.
In practice this signal entered a vendor’s pipeline as one weighted input, not a gate. The architecture is the same one the post on server-side versus client-side bot detection describes: a client-side script collects dozens of signals, the CDP-attached boolean among them, and ships them to a server that scores the bundle. A flipped error-getter flag pushes the score toward “automated,” but it sits alongside the network and TLS signals, the headless tells, the behavioral timing, and the proxy and ASN reputation. The way DataDome assembles those inputs is laid out in the companion piece on DataDome’s detection model; the CDP tell is one row in a much larger collection. Treating it as the whole decision would have meant blocking the occasional developer who opened the console, which no vendor wants, so the signal earned its weight precisely because it was strong enough to matter and noisy enough to need company.
There is a subtler reason the conditional reading matters. A signal that produces a hard verdict is a signal an adversary can probe and confirm they have defeated. A signal folded into a score is harder to reverse-engineer, because flipping it does not visibly change your outcome unless the rest of your fingerprint is also clean. That opacity is deliberate across the industry, and it is part of why the public death of the error-getter form mattered less to vendors than the headlines suggested. Losing one weighted input from a model with many is survivable. Losing a gate would not have been.
The public history: an old trick goes public in June 2024
The error-getter trick was not new when it became famous. Variants of using DevTools side effects to detect an open inspector had circulated for years, and several anti-bot vendors were already using the console-preview behavior quietly. Treating a privately-known detection as a competitive asset is the norm in this market, so the signal sat in production at multiple vendors without a public write-up that named the mechanism and gave page authors the exact recipe.
That changed on June 13, 2024, when DataDome published a threat-research piece on the CDP signal and headless Chrome, written by Antoine Vastel, who runs the company’s research. The post laid out the Runtime.enable mechanism plainly, including the console-side behavior that made the error-getter detection work, and connected it to the broader point that CDP attachment is detectable across frameworks. Publication did what publication always does. It moved the trick from a thing vendors knew to a thing everyone knew, and it put the same recipe in front of the automation authors whose tools it caught.
The automation side answered through tooling rather than blog posts. The rebrowser project shipped a public bot-detector demo that named the signal runtimeEnableLeak and let anyone test a browser against it, alongside a companion set of patches. Its summary of the tell was blunt: any website can detect the use of Runtime.enable with a few lines of code. Once a detection has a named test and a public reproduction, the clock starts on a fix, and in this case the fix came from two directions at once.
The patch war, part one: stop sending the command
The automation authors could not change what Runtime.enable did, so they changed whether they sent it. The default in Puppeteer and Playwright is to call Runtime.enable automatically for every frame, which is what made the leak fire without the user doing anything. The patches reorganize that so the command is never sent on the hot path, and the library learns execution-context ids another way.
The rebrowser patches expose three modes for getting a context id without the leaky automatic enable. The default, addBinding, creates a binding in the main world and calls it to recover the context id. A second mode, alwaysIsolated, uses Page.createIsolatedWorld to make a separate execution context with an id the library tracks itself, running all injected code in that isolated world so page scripts cannot see the changes through a MutationObserver either. A third, enableDisable, calls Runtime.enable and then Runtime.disable immediately, catching the executionContextCreated events in the narrow window between the two so the reporting is on for as short a time as possible. Each trades something. Isolated worlds lose direct access to main-world variables; the enable/disable dance leaves a sliver of exposure; the binding approach keeps main-world access at a theoretical minimum of risk.
Patchright, a patched Playwright distribution, takes the same idea to its conclusion and avoids the automatic Runtime.enable entirely, executing injected JavaScript in isolated execution contexts as a matter of course rather than as an opt-in mode. The newer generation of drivers built around this principle, including the ones the post on nodriver and undetected-chromedriver covers, follows the same logic: the cheapest way to beat a tell that watches for a command is to not send the command.
The isolated-world route deserves a closer look, because it is the most durable of the three and it carries the most baggage. An isolated world is a separate JavaScript execution context that shares the same DOM as the page but has its own global scope, the same mechanism Chrome extensions use for content scripts. Code injected there can read and modify the DOM, which is most of what a scraper needs, but it cannot see the page’s own JavaScript variables, because those live in the main world’s globals and the isolated world has its own. So a patch that runs all injected code in an isolated world dodges two tells at once: it never sends the leaky automatic enable on the main context, and its DOM modifications are invisible to a MutationObserver that the page registers in the main world. The cost is real, though. Anything that needs to touch a page-defined global, read a framework’s in-memory state, or call a function the page attached to window, breaks, because from the isolated world those things do not exist. The rebrowser team published a separate note on bridging back to main-world objects from an isolated context precisely because that gap is the most common thing the approach breaks.
This is the avoidance path, and it is the same instinct that drives the whole anti-instrumentation arms race that Kasada’s approach to detecting patched runtimes documents from the defensive side. It works, but it is fragile in the way all behavioral avoidance is fragile. You are matching the absence of a signal that a normal browser also lacks, which means any new thing the framework does differently from real Chrome becomes the next tell. The mainWorldExecution check in the rebrowser detector exists for exactly this reason: if your patched library ever does run something in the main world that alters a common function like document.querySelector, that alteration is itself observable, and you have traded one tell for another. Avoidance does not close the leak. It steps around it, and the leak is still there for anyone running an unpatched library, which is most automation traffic by volume.
The patch war, part two: V8 closes the leak
The other fix came from the engine, and it ended the signal more completely than any avoidance could. In May 2025, V8 landed two commits by Pavel Feldman that changed how the inspector previews error objects so that user-defined getters no longer run during a preview.
The first, commit 61a907540d, dated May 7, 2025, carries the title “[inspector] Prevent side effects during object preview.” The second, e08e973474, with an author date of May 7 and committed May 9, is titled “[inspector] Prevent side effects during error preview.” Both reference Chromium issue 415094795. The change introduces a guard so that when the inspector builds a preview and encounters a property backed by a user-supplied getter, identifiable because the getter has a real script id pointing at page JavaScript, it skips invoking it rather than running arbitrary page code during inspection. Reading .stack, .name, or .message on an error during preview no longer triggers a getter the page defined. The fix shipped on the M137 milestone train, which is when the public signal began going quiet in current Chrome.
That is a clean kill, and it is worth being precise about why. The avoidance patches were a property of the automation library. Anyone running stock Puppeteer was still detectable. The V8 change is a property of the browser engine, so once a user runs a current Chrome, the error-getter trap returns nothing whether or not the driver was patched, whether the driver is Puppeteer or Playwright or Selenium, whether stealth plugins are loaded or not. The leak is gone at the source. The security write-up that traced these commits and noticed the signal had stopped firing, Castle’s August 2025 post on the classic CDP signal, described the change matter-of-factly: V8 no longer triggers side effects when inspecting error objects or custom getters, and the trick depended entirely on that side effect.
*Commits 61a907540d and e08e973474 (May 2025, Chromium issue 415094795) skip user-defined getters during preview, so logging a trapped error tells the page nothing.*There is a small irony in who fixed it. The change was a correctness improvement, framed as preventing the inspector from running arbitrary page code while building a preview, which is a sound debugger-hygiene goal on its own terms. A debugger that triggers side effects in the program it is inspecting is a debugger with a bug. The fact that the same side effect was a load-bearing bot-detection signal made it a tell, but the engineering motivation reads as inspector correctness, and the related work on browser vendors quietly narrowing the automation-detection surface lands the same way. The second DataDome piece, on how browser vendors are making automation harder to detect, treats this as part of a pattern rather than a one-off.
It is worth being precise about what the V8 fix did and did not break, because the distinction is easy to blur. The fix removed the page’s ability to observe a side effect during console preview. It did not stop Runtime.enable from being sent, it did not stop the consoleAPICalled event from firing, and it did not hide the fact that a CDP client is attached from any other vantage point. A patched browser engine that no longer runs your getter is still a browser with a CDP client subscribed to its console. The leak was never that CDP existed; the leak was that one specific browser behavior reflected CDP’s presence back to the page. Close that reflection and the underlying attachment is exactly as present as before, just no longer visible through that particular window.
The other tells that never used this leak
The error-getter trick was the famous CDP signal, but it was never the only one, and the family it belongs to outlived it. Grouping the alternatives by what they observe is the clearest way to see why killing one did not retire the category.
The closest cousins are other variations on the same preview-side-effect idea. Before the V8 guard, a getter on any property the inspector reads during preview could be made to fire, not only .stack; the error path was simply the most reliable target. After the May 2025 fix the whole preview-side-effect family is gone in current Chrome, because the guard skips user getters wherever they appear in a preview, which is why this was a clean kill rather than a game of property whack-a-mole. That generality is the point of the fix. It did not patch one property. It removed the class.
A second group does not touch console previews at all. The sourceUrlLeak the rebrowser detector names comes from automation libraries appending a distinctive source-url marker to the scripts they inject, which shows up in stack traces the page can read even with no getter trickery, because a real stack string contains the injected frame’s url. A third group watches for the artifacts a specific driver leaves in the global scope, like Playwright’s internal init-script bookkeeping or the bindings a patched library installs to recover context ids; these are library tells rather than protocol tells, and they move every time the library changes. The post on Playwright versus Puppeteer versus Selenium detection surfaces walks those per-driver differences in detail.
A fourth group is the hardest to patch and the most interesting going forward: timing and behavioral differences in how the runtime acts while instrumented. Attaching a debugger changes when certain optimizations apply and how some operations are scheduled, and a page that measures the right micro-behavior can sometimes infer instrumentation without reading any property the engine could guard. These are noisier and more expensive to collect, which is why they were not the famous signal, but they do not have a single commit that turns them off. The detection that survives an engine patch is the detection that never depended on a single observable behavior in the first place, and that is the direction the surviving CDP tells point.
What is left in 2026
The error-getter form of the signal is effectively dead on current Chrome. Log a trapped error in a recent build and nothing fires, regardless of whether CDP is attached, which removes the single most quoted line of CDP-detection code from the working set. A detector that still ships only that check is reading a flag that no longer flips. That is the state to be honest about: the famous version of this tell is gone, and it went out not with a bypass but with an engine patch that made the bypass unnecessary.
What remains is narrower and more conditional. The leak still fires on older Chromium builds that predate the M137 fix, which matters because a meaningful slice of automated traffic runs pinned or stale Chromium, and a stale engine is itself a weak signal. There are other ways to notice CDP attachment that never depended on the error-getter path at all, and the broader CDP-as-a-vector and JavaScript runtime fingerprinting work catalogs them: timing differences in how the runtime behaves under instrumentation, artifacts left by specific drivers, the Runtime.enable reporting itself observed through other side channels. The death of one tell does not retire the category. It retires the cleanest member of it.
The arc is the lesson. A signal that read the protocol rather than a spoofable property looked, for a while, like the durable kind, the sort a stealth plugin could never patch around. It turned out to be just as mortal as the rest, killed not by the people it caught but by the engine team fixing an inspector that was running page code it had no business running. The next clean CDP tell will follow the same path: privately useful, publicly named, patched from both ends, and gone in a Chrome release or two. The cycle that the field-guide posts keep measuring at four-to-six years for fingerprint properties runs faster here, because protocol-level leaks have exactly one place to be fixed, and when Google fixes them they are fixed for everyone at once.
Sources & further reading
- Antoine Vastel / DataDome (2024), How New Headless Chrome & the CDP Signal Are Impacting Bot Detection — the June 13, 2024 write-up that made the Runtime.enable console-preview tell public.
- DataDome (2025), How Browser Vendors Are Quietly Making Automation Harder to Detect — vendor view of engine-level changes narrowing the CDP-detection surface.
- Antoine Vastel / Castle (2025), Why a classic CDP bot detection signal suddenly stopped working (and nobody noticed) — the August 28, 2025 post tracing the two V8 commits that broke the signal.
- Chrome DevTools team, Runtime domain — Chrome DevTools Protocol — reference for Runtime.enable, executionContextCreated, and consoleAPICalled.
- V8 project (2025), commit 61a907540d — [inspector] Prevent side effects during object preview — the May 7, 2025 change guarding object preview against user getters.
- V8 project (2025), commit e08e973474 — [inspector] Prevent side effects during error preview — the companion commit for error preview, referencing Chromium issue 415094795.
- rebrowser (2024), rebrowser-bot-detector — public test harness that names the runtimeEnableLeak signal and demonstrates it.
- rebrowser (2024), rebrowser-patches — patches for Puppeteer and Playwright with the addBinding, alwaysIsolated, and enableDisable modes for avoiding the leak.
- rebrowser (2024), How to fix Runtime.Enable CDP detection — explanation of why frameworks send Runtime.enable and the false-positive case of an open DevTools console.
- Kameleo (2025), Bypass Runtime.enable with Kameleo’s Undetectable Browser — anti-detect vendor’s account of the same signal and the Brotector test suite.
Further reading
Kasada's anti-instrumentation: how it detects CDP, Playwright, and patched runtimes
A reference on Kasada's automation-detection layer: how it spots Chrome DevTools Protocol instrumentation, Playwright and Puppeteer artifacts, and patched stealth runtimes that lie about themselves.
·17 min readHeadless Chrome detection: every tell from navigator.webdriver to missing codecs
A reference catalog of the signals that give headless Chrome away: the webdriver flag, empty plugin lists, the permissions contradiction, missing proprietary codecs, software WebGL renderers, and what --headless=new actually fixed.
·21 min readThe Chrome DevTools Protocol as a detection vector
Traces what attaching a CDP client changes inside a running Chrome: the websocket transport, the domains it switches on, the side effects that reach the page, and why a debugged browser is observably different from a hand-driven one.
·19 min read