Research · · 4 min read

Why a classic CDP bot detection signal suddenly stopped working (and nobody noticed)

Why a classic CDP bot detection signal suddenly stopped working (and nobody noticed)

Over the past few years, I’ve written a lot about detecting automated browsers by exploiting side effects from the Chrome DevTools Protocol (CDP).

I first covered it in this article on how the new headless Chrome changed the bot landscape, where I explained how modern anti-detect frameworks started removing CDP artifacts that leak automation.

I also shared detection tricks in multiple posts, including how to spot bots using Puppeteer and Playwright.

But here’s the thing: That CDP signal doesn’t work anymore.

And nobody seems to be talking about it.

The CDP signal: what it was and how it worked

The detection technique was simple but effective:

let wasAccessed = false;
const e = new window.Error();
Object.defineProperty(e, 'stack', {
  configurable: false,
  enumerable: false,
  get: function () {
    wasAccessed = true;
    return '';
  }
});
console.debug(e);

As I explained in previous posts, this kind of check relied on a subtle CDP side effect.

CDP gives automation frameworks full control over Chrome, but it also introduces artifacts. One of them shows up when the browser serializes objects across the WebSocket used by CDP. That serialization can trigger JavaScript getters, which creates an opportunity for detection.

The trick works like this:

  1. Trigger object serialization, which only happens when CDP is active and listening.
  2. Detect that serialization using a custom getter—like get stack()—that flips a flag when accessed.

A common way to trigger this was by logging an error object using console.debug().

This depends on CDP’s Runtime.consoleAPICalled event. It only fires if the automation client has enabled the Runtime domain via Runtime.enable. If not, Chrome just drops the log silently. But when the domain is active, Chrome serializes the object, including any error properties, and that’s when it reads the getter and flips wasAccessed.

This became a reliable and widely used signal. It worked even when bots removed navigator.webdriver and spoofed their user agent.

Eventually, many attackers started avoiding CDP-heavy tools like ChromeDriver and Selenium because of detection tricks like this one.

The v8 commits that made CDP-based detection fail silently

This detection technique didn’t break silently. It was discussed publicly, even outside the bot detection space. For example, it showed up in this Chromium issue. At a high level, Chromium devs don’t want DevTools to be detectable. But that’s not why this signal stopped working.

The real reason is a change in how V8 previews objects.

V8 no longer triggers side effects when inspecting error objects or custom getters.

That change broke the CDP detection signal, because the whole trick depended on triggering a side effect when DevTools accessed the .stack property.

Two V8 commits caused the break:

  1. 7 May 2025Avoid error side effects in DevTools
  2. 9 May 2025Apply getter guard throughout error preview

Here’s what those commits changed.

1) Error previews now refuse to run user-defined getters

A new helper was added in src/inspector/value-mirror.cc: getErrorProperty(...). It guards all reads of .stack, .name, and .message:

v8::MaybeLocal<v8::Value> getErrorProperty(
    v8::Local<v8::Context> context,
    v8::Local<v8::Object> object,
    v8::Local<v8::String> name) {

  // Resolve the property descriptor
  if (getDescriptor->IsFunction()) {
    v8::Local<v8::Function> function = getDescriptor.As<v8::Function>();

    // Skip user-defined getters (ScriptId != kNoScriptId)
    if (deepBoundFunction(function)->ScriptId() != v8::UnboundScript::kNoScriptId) {
      return v8::MaybeLocal<v8::Value>();
    }
  }

  // Safe to read
  return object->Get(context, name);
}

If the getter is user code (has a valid ScriptId), it’s skipped. If it’s native, the read goes through.

This avoids any side effects during object preview and completely breaks the CDP detection trick.

2) All error property reads now use the guard

Before, the inspector called object->Get(...) directly to access .name, .message, and .stack.

Now, all reads go through getErrorProperty(...):

if (getErrorProperty(context, object, toV8String(isolate, "stack"))
      .ToLocal(&stackValue) && stackValue->IsString()) {
  // safe to use stack
}

If the getter is user-defined, it’s not called, so any detection flag based on wasAccessed = true never flips.

Why the CDP detection signal broke

Here’s the detection pattern again:

let wasAccessed = false;
const e = new Error();
Object.defineProperty(e, 'stack', {
  configurable: false,
  enumerable: false,
  get() {
    wasAccessed = true;
    return '';
  }
});
console.debug(e); // used to trigger getter when CDP was active

Before this change, CDP’s Runtime.consoleAPICalled would serialize the error when Runtime.enable was active. That caused DevTools to preview the error, which read .stack, ran the getter, and flipped the flag.

After the V8 patch:

The signal stays silent. The V8 team made this explicit in the commit message:

“Prevent side effects during error preview.”

That’s exactly what this does: logging or inspecting an error no longer runs user code, including any detection hook based on getters.

How this affects real-world bot detection

The CDP signal used to be a strong detection tool. It worked even when bots changed their user agent or removed navigator.webdriver = true.

It was especially useful against basic or misconfigured bots that still relied on ChromeDriver or frameworks like Puppeteer and Playwright with default settings. It gave defenders a low-noise way to catch automation, without needing full behavioral analysis or advanced heuristics.

But as we explained in this article on how anti-detect frameworks evolved, most serious frameworks had already adapted. Tools like Nodriver and Rebrowser patches were already avoiding this signal.

So why does this change still matter?

Even the simplest bots, the ones that used to get caught, don’t trigger the CDP signal anymore.

At Castle, we’ve already moved past it. We still detect CDP-based automation using other JavaScript signals that leak in certain setups. But more importantly, our detection is based on a multi-layered approach:

Bots today are more realistic out of the box, even the cheap ones. If you’re still relying on navigator.webdriver or this broken CDP signal, you’re flying blind. You need a detection stack that combines multiple signals, not just easy ones.

Read next