You Cannot See the Protocol, Only Its Footprint

The Chrome DevTools Protocol is a JSON-over-WebSocket or pipe protocol that every automation library speaks. A WAF never sees your CDP frames: they do not cross the network, they travel over a file descriptor or a loopback socket between your process and the browser. What the WAF sees is the consequence of CDP being enabled, and those consequences are numerous, specific and cheap to check.

This lesson is the inventory. The rule it produces is simple: enable as few CDP domains as possible, inject with Page.addScriptToEvaluateOnNewDocument rather than after load, and never leave a debug port listening.

Enabling a Domain Has an Observable Side Effect

CDP is request/response plus events. Enabling a domain turns on its event stream, and some of those events are visible in the page because they change JavaScript semantics.

Runtime.enable is the important one. It tells the renderer to report execution-context lifecycle events, and while it is on, every value crossing the protocol boundary is wrapped in a remote object proxy rather than passed natively. That wrapper is a JavaScript object, and a page can see it:

// 1. Runtime.enable wraps values crossing the protocol boundary, and a
//    back-reference property appears on the object you passed to the console.
const probe = {};
console.debug(probe);
console.log('own props after console.debug:', Object.getOwnPropertyNames(probe));
console.log('console.debug is native:', /\{\s*\[native code\]\s*\}/.test(
    Function.prototype.toString.call(console.debug)));

// 2. Driver-injected frames are visible through the V8 stack hook.
const original = Error.prepareStackTrace;
let driverFrames = 0;
Error.prepareStackTrace = (err, frames) => {
  driverFrames = frames.filter(f => {
    const n = (f.getFunctionName && f.getFunctionName()) || '';
    const file = f.getFileName() || '';
    return n === '' || /VM\d+|extensions|nodemodules/.test(file);
  }).length;
  return original ? original(err, frames) : err.stack;
};
try { null.boom; } catch (e) { void e.stack; }
Error.prepareStackTrace = original;   // restore it or you break the next throw
console.log('suspicious stack frames:', driverFrames);

Page.enable turns on the frame lifecycle events, a weaker signal that still changes when the page observes navigation. Log.enable and Network.enable do not change JavaScript, but they add per-request and per-console-message event traffic, and the extra work in the renderer's main loop is measurable as a timing difference. Debugger.enable is the loudest: it can pause execution, it populates Error().stack with frames the page did not create, and a page that times a debugger statement can tell whether a debugger is attached. The rule that follows: enable Runtime, Page and DOM because you need them, leave Debugger, Log, Network, Performance and Tracing off unless the task requires them, and note that a driver which calls enable on everything has already leaked.

The console Wrapper and the prepareStackTrace Trap

Two detection tricks explain why JS-level spoofing is fragile, and both are worth implementing once so you know what you are up against.

The first is the console.debug object-identity check above. When Runtime.enable is active, arguments passed to console methods are converted to protocol objects and a back-reference property is set on the original object, so Object.getOwnPropertyNames() on a plain object you just logged returns something you never added. A "harmless" console.log(state) in your instrumentation code can therefore change the fingerprint of every page in the fleet.

The second is Error.prepareStackTrace, the V8 hook that replaces the stack accessor. A driver that calls into the page through Runtime.callFunctionOn, or that injects helper functions, puts its own frames on the stack; a page that throws and reads the structured stack sees CallSite.getFileName() values pointing at VM123, at an automation extension, or at a node_modules path that exists on nobody's disk. Once you have replaced the accessor you must restore it, and if you do not, the next error the page throws has a mangled stack, which is a signal and a functional bug at the same time.

Function.prototype.toString Is the Weakest Link in Every Spoof

Overriding navigator.webdriver in JavaScript is the most widely shipped evasion and the most widely detected, for one reason: the function you install is a JavaScript function, and Function.prototype.toString on it returns source code rather than function () { [native code] }. The standard check is:

const isNative = (f) => /\{\s*\[native code\]\s*\}/.test(Function.prototype.toString.call(f));
console.log('navigator.webdriver getter is native:',
            isNative(Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver').get));

And the obvious fix, patching Function.prototype.toString to return a fake native string for your own functions, is itself detectable, because the patched toString now behaves differently from a real one. The usual detection is to call it on a function whose source the attacker cannot predict, or to compare toString against a saved reference from before the patch, or to check that toString.length, toString.name and the function's behaviour on a bound and an arrow variant all match. Chromium also checks that the override is not itself an accessor with a getter that returns a different answer on a second call, which is why a get webdriver() { return false; } installed on the instance rather than the prototype is a different tell from one installed on the prototype.

The only version that survives is one implemented in the browser binary, where the property is defined by C++ and Function.prototype.toString reports it honestly. That is a build change, not a script, and Patching and Building Chromium for Stealth covers what it costs.

The cdc_ Family and Why Enumeration Finds It

ChromeDriver, in order to implement executeScript, injects a set of serialisation helpers into the page's global scope. They are named with a fixed prefix followed by the MD5 digest of a constant string, which is why they look like noise:

  • cdc_adoQpoasnfa76pfcZLmcfl_Array
  • cdc_adoQpoasnfa76pfcZLmcfl_JSON
  • cdc_adoQpoasnfa76pfcZLmcfl_Object
  • cdc_adoQpoasnfa76pfcZLmcfl_Promise
  • cdc_adoQpoasnfa76pfcZLmcfl_Proxy
  • cdc_adoQpoasnfa76pfcZLmcfl_Symbol
  • cdc_adoQpoasnfa76pfcZLmcfl_Serializer
  • cdc_adoQpoasnfa76pfcZLmcfl__lastEvaluationId

They are ordinary own properties of the global object, so Object.getOwnPropertyNames(window) returns them, and so does the older for (var key in window) enumeration. ChromeDriver also sets document.webdriver-evaluate and document.selenium-evaluate (and the matching -response properties) on each execute_script call. geckodriver and Selenium 3 leave __webdriver_evaluate, __selenium_evaluate, __driver_evaluate, __fxdriver_evaluate and the __*_unwrapped variants on the window. Under DevTools control, Chromium also exposes domAutomation and domAutomationController.

Deleting these from JavaScript does not work reliably. The cdc_ helpers are captured by reference in code the driver has already installed, and a page that enumerates before your init script runs has already seen them. The working fix is at the binary level: patch chromedriver on disk so the constants are replaced with the same length of filler, which is what the widely used undetected-chromedriver approach does, and which is a build-time concern rather than a script-time one.

Error Stacks, chrome.loadTimes and the DevTools Surface

new Error().stack under an active Debugger domain, or a Runtime with a paused call stack, includes frames from the driver, and their getFileName() values point at VM123, at an automation extension, or at a node_modules path. A page that throws and reads the structured stack can find a path that does not exist on any user's disk. chrome.loadTimes and chrome.csi exist in a real Chrome and not in headless_shell, and their shape differs between builds: chrome.loadTimes() returns requestTime, startLoadTime, commitLoadTime, finishDocumentLoadTime, finishLoadTime and firstPaintTime in a full browser and a reduced object in some automated builds.

The DevTools surface is the most reliable of the three and a trap in both directions. window.chrome.devtools exists while DevTools is open and is absent when it is closed, and a debugger statement is a no-op when DevTools is closed and a hard pause when it is open. That gives a page a one-line oracle for "is a human watching" and an operator a one-line oracle for "is my tab being inspected", because a debugger statement in your own code hangs the run for as long as the tab stays open. Never ship a debugger statement in code you run against a live target.

Page.addScriptToEvaluateOnNewDocument and Why Timing Is Everything

Page.addScriptToEvaluateOnNewDocument registers a script that runs in every new document before any of the page's own scripts, in the main world. That is the only correct injection point for a fingerprint patch:

Injection point Runs Page can detect
Page.addScriptToEvaluateOnNewDocument before the first inline script only the result
context.add_init_script (Playwright) before the first inline script only the result
page.evaluate after goto after the page's scripts have run a variable that existed a moment ago is now fake
page.add_script_tag with inline JS appended to the DOM a <script> node with no source URL
Runtime.evaluate in an isolated world in a separate world not directly, but Function.prototype.toString differences leak

An init script also runs in every frame, including iframes, which a post-load evaluate does not unless you enumerate frames yourself. The cost is that it runs before the DOM exists, so it may only use document-start APIs and anything touching document.body has to be deferred. Every init script must also be idempotent, because it runs once per document and a counter that increments across navigations is itself a state leak.

Timing Is the Leak Nobody Patches

A JS-level spoof costs time. Every property override is a function call where a native attribute read was a direct slot access, and a patched Function.prototype.toString that inspects a string and builds a result is orders of magnitude slower than the original C++ implementation. Multiply that by the number of toString calls a heavy page makes, and the difference is measurable from inside the page with performance.now() around a loop, and measurable from outside as a slower DOMContentLoaded or a longer time to first input.

The WAF does not need to know what you patched. It needs a distribution of "cost of 10,000 property reads" per client, and a client whose reads cost 40 microseconds where the population median is 3 is an outlier regardless of what the values were. This is why the same hardened profile that passes every value check still fails on a heavy single-page application: the overhead only shows up under load. Native implementations in the browser binary have no such cost, which is the same argument as the toString section, arriving from a different direction.

Scanning for the Markers

The audit is a pattern scan over the keys Object.getOwnPropertyNames(window) returns. Capture the list once, keep it in a file, and diff it against a known-clean baseline on every driver or browser upgrade.

import re

COMMON = [
    "window", "self", "document", "name", "location", "customElements", "history",
    "navigation", "locationbar", "menubar", "personalbar", "scrollbars", "statusbar",
    "toolbar", "status", "closed", "frames", "length", "top", "opener", "parent",
    "frameElement", "navigator", "origin", "external", "screen", "visualViewport",
    "innerWidth", "innerHeight", "outerWidth", "outerHeight", "devicePixelRatio",
    "clientInformation", "screenX", "screenY", "screenLeft", "screenTop",
    "styleMedia", "isSecureContext", "crossOriginIsolated", "performance", "crypto",
    "indexedDB", "sessionStorage", "localStorage", "caches", "cookieStore",
    "scheduler", "trustedTypes", "speechSynthesis", "chrome",
]

WINDOW_KEYS_CHROMEDRIVER = COMMON + [
    "cdc_adoQpoasnfa76pfcZLmcfl_Array", "cdc_adoQpoasnfa76pfcZLmcfl_JSON",
    "cdc_adoQpoasnfa76pfcZLmcfl_Object", "cdc_adoQpoasnfa76pfcZLmcfl_Promise",
    "cdc_adoQpoasnfa76pfcZLmcfl_Proxy", "cdc_adoQpoasnfa76pfcZLmcfl_Symbol",
    "cdc_adoQpoasnfa76pfcZLmcfl_Serializer",
    "cdc_adoQpoasnfa76pfcZLmcfl__lastEvaluationId",
    "webdriver-evaluate", "webdriver-evaluate-response",
    "selenium-evaluate", "selenium-evaluate-response",
    "__webdriver_evaluate", "__selenium_evaluate", "__driver_evaluate",
    "__fxdriver_evaluate", "__webdriver_unwrapped", "__driver_unwrapped",
    "__selenium_unwrapped", "__fxdriver_unwrapped",
    "_phantom", "callPhantom", "__nightmare", "domAutomation",
    "domAutomationController", "external_extension_hook",
]

WINDOW_KEYS_REAL_CHROME = COMMON + [
    "documentPictureInPicture", "launchQueue", "sharedStorage",
]

MARKERS = [
    (r"^cdc_", "critical", "ChromeDriver script serialiser, enumerable on window",
     "patch chromedriver on disk, never strip it in JS"),
    (r"^__(webdriver|selenium|driver|fxdriver)", "critical",
     "geckodriver or Selenium 3 wrapper global",
     "geckodriver via BiDi, or patch the driver binary"),
    (r"^(webdriver|selenium|driver)-evaluate", "critical",
     "chromedriver execute() bridge property on document",
     "use Page.addScriptToEvaluateOnNewDocument"),
    (r"^domAutomation", "critical", "Chromium automation controller, DevTools only",
     "attach over a CDP pipe, not a listening debug port"),
    (r"^(callPhantom|_phantom|__nightmare)", "high", "PhantomJS or Nightmare.js global",
     "obsolete PhantomJS/Nightmare stack"),
    (r"^external_extension_hook$", "medium", "older Selenium and CEF builds",
     "swap the driver; not safely patchable"),
    (r"^(window|chrome)$", "info", "sanity check, always present in real Chrome",
     "populate window.chrome.{runtime,loadTimes,csi} if chrome is empty"),
]

ORDER = {"critical": 0, "high": 1, "medium": 2, "info": 3}


def group(keys):
    hits = []
    for pattern, severity, why, fix in MARKERS:
        rx = re.compile(pattern)
        matched = [k for k in keys if rx.search(k)]
        if matched:
            hits.append((severity, len(matched), pattern, matched[0], why, fix))
    return sorted(hits, key=lambda h: (ORDER[h[0]], h[2]))


def report(label, keys):
    hits = group(keys)
    print("=" * 94)
    print("%s -- %d own properties on window" % (label, len(keys)))
    print("=" * 94)
    print("%-9s %-5s %-32s %s" % ("severity", "count", "pattern", "example key"))
    print("-" * 94)
    for severity, count, pattern, example, why, fix in hits:
        print("%-9s %-5d %-32s %s" % (severity, count, pattern, example))
    print("-" * 94)
    auto = sum(c for s, c, p, e, w, f in hits if s != "info")
    print("flagged %d of %d keys in %d families (%d need action)"
          % (sum(c for s, c, p, e, w, f in hits), len(keys), len(hits), auto))
    queue = {}
    for severity, count, pattern, example, why, fix in hits:
        if severity != "info":
            queue[fix] = queue.get(fix, 0) + count
    for fix, count in queue.items():
        print("  %-48s %d keys" % (fix, count))
    print()


report("selenium 4 with chromedriver 138", WINDOW_KEYS_CHROMEDRIVER)
report("stock Chrome 140, no automation", WINDOW_KEYS_REAL_CHROME)
==============================================================================================
selenium 4 with chromedriver 138 -- 77 own properties on window
==============================================================================================
severity  count pattern                          example key
----------------------------------------------------------------------------------------------
critical  4     ^(webdriver|selenium|driver)-evaluate webdriver-evaluate
critical  8     ^__(webdriver|selenium|driver|fxdriver) __webdriver_evaluate
critical  8     ^cdc_                            cdc_adoQpoasnfa76pfcZLmcfl_Array
critical  2     ^domAutomation                   domAutomation
high      3     ^(callPhantom|_phantom|__nightmare) _phantom
medium    1     ^external_extension_hook$        external_extension_hook
info      2     ^(window|chrome)$                window
----------------------------------------------------------------------------------------------
flagged 28 of 77 keys in 7 families (26 need action)
  use Page.addScriptToEvaluateOnNewDocument        4 keys
  geckodriver via BiDi, or patch the driver binary 8 keys
  patch chromedriver on disk, never strip it in JS 8 keys
  attach over a CDP pipe, not a listening debug port 2 keys
  obsolete PhantomJS/Nightmare stack               3 keys
  swap the driver; not safely patchable            1 keys

==============================================================================================
stock Chrome 140, no automation -- 54 own properties on window
==============================================================================================
severity  count pattern                          example key
----------------------------------------------------------------------------------------------
info      2     ^(window|chrome)$                window
----------------------------------------------------------------------------------------------
flagged 2 of 54 keys in 1 families (0 need action)

Twenty-six keys need attention and they collapse into six fixes, which is the useful shape of the result: one patched chromedriver binary removes eight keys, a move to BiDi removes eight more, and the transport choice removes two. The second report is the baseline you diff against after every driver or browser change, alongside CDP & Headless Leaks and Hardening Playwright with CDP.

Leak, Cause, Fix

Leak Cause Fix
cdc_adoQpoasnfa76pfcZLmcfl_* chromedriver's script serialiser patch the driver binary before launch
__webdriver_evaluate and siblings geckodriver or Selenium 3 use BiDi, or a patched geckodriver
document.webdriver-evaluate execute_script bridge inject with addScriptToEvaluateOnNewDocument
domAutomation, domAutomationController DevTools attached attach over a pipe, never a listening port
navigator.webdriver === true Blink's automation bit --disable-blink-features=AutomationControlled or a patched build
Getter with JavaScript source Object.defineProperty from a script patch the property in C++, or patch toString and accept the cost
Non-native Function.prototype.toString your own toString override implement the property in the binary instead
Back-reference property on a logged object Runtime.enable console wrapping avoid logging objects, or avoid Runtime.enable
Driver frames in Error().stack Runtime.callFunctionOn or Debugger keep Debugger off; avoid stack inspection helpers
Missing chrome.loadTimes, chrome.csi headless_shell or an old build use a full Chrome binary
debugger statement pauses DevTools is open on the tab never ship a debugger statement; check chrome.devtools yourself
Slower property reads and toString JavaScript-level overrides native implementation, or accept the timing outlier
Init script counter or mutable state the script runs per document make every init script idempotent

Failure Modes

  • Deleting cdc_ variables from JavaScript. Installed code already holds a reference, and any page that enumerated before your script ran has the list.
  • Patching Function.prototype.toString and paying for it. The override shows up in a property-read timing loop, on a heavy single-page application, which is exactly where a WAF has the budget to measure it.
  • Leaving Debugger.enable on in production. It changes stack contents, it can pause, and it makes the debugger-statement oracle irrelevant while introducing a worse one.
  • Instrumenting with console.log on page objects. Under Runtime.enable that mutates the objects you log.
  • Forgetting iframes. An init script covers every document; a post-load evaluate covers one, and a detector only needs one frame to disagree with the others.
  • Assuming the scan is complete. Object.getOwnPropertyNames catches globals, not stack shapes, not timing, not chrome.devtools. Treat it as the cheapest layer of a layered audit.
  • Diffing against a stale baseline. A browser upgrade adds and removes keys; re-capture the baseline or the diff is noise.

Instrumentation Checklist

  • Only Runtime, Page and DOM are enabled by default; everything else is opt-in per task.
  • The transport is a pipe, with no --remote-debugging-port in any configuration.
  • All injection uses Page.addScriptToEvaluateOnNewDocument or the framework equivalent, and every injected script is idempotent.
  • Every JavaScript override is paired with a Function.prototype.toString treatment, and the timing cost is measured on a heavy page.
  • The window key list is captured in CI and diffed against a clean baseline on every driver or browser upgrade.
  • The cdc_ family is absent because the driver binary was patched, not because a script deleted it.
  • No debugger statement and no chrome.devtools dependency anywhere in the code that runs against a live target.

The Legitimate Route

Everything in this lesson is about controlling your own instrumentation so that your authorised test traffic is not mistaken for an attack, and the same scan is a good defensive tool: run it against a page while DevTools is open and you can see for yourself which of these keys appear and vanish. The line is using the knowledge to make an unauthorised visit indistinguishable from a permitted one; where access is not permitted, the route is an API key, a partner feed or a crawler token, and re-adding traffic after a block is the point at which tooling stops being the issue. Red-Teaming Your Own Bot Defenses is how you use all of this against a system you own.