The Forensic Paper Trail#

Every modern browser exposes a debugging protocol. Chrome exposes CDP (Chrome DevTools Protocol); every automation library you use—Playwright, Puppeteer, Selenium—talks to the browser through it. A WAF cannot see your CDP traffic directly, but the consequences of that traffic leak into the JavaScript environment in ways that are measurable and damning.

The Runtime Leaks You Must Patch#

The following probes are the industry-standard leak checks that detection services like bot.sannysoft.com and creepjs.com run:

  • navigator.webdriver: The single most-shipped flag. Any value other than undefined (on Chromium) is treated as automation.
  • window.chrome: A real Chrome exposes several properties (runtime, loadTimes, csi). Headless Chromium often ships a truncated or missing object.
  • navigator.plugins & navigator.mimeTypes: Real browsers have an empty-but-present plugins array plus default PDF plugins; some sandboxes ship nothing at all.
  • Permissions API: the query() for camera/mic/notifications flips to a synthetic state in some automation modes.
  • WebGL renderer / vendor: headless profiles return "SwiftShader" software renderers instead of a real GPU string.
  • KeyboardEvent / MouseEvent coordinate tracing: Playwright-mimicked events carry a isTrusted: false flag, exposing synthetic input.
  • DevTools-induced window.outerWidth anomalies: browser window dimensions that never match a real monitor size.
  • Broken Media stacks, missing AudioContext, and createImageBitmap shape differences: cheap tells that the renderer was built without real media support.

Why Patching at JavaScript Level Is Not Enough#

If you only override JavaScript properties, you are faking the symptom, not the cause. Native C++ calls that bypass JavaScript entirely—like the audio fingerprint computed from the actual backend, or the WebGL string read from the GPU driver rather than the JS wrapper—still leak the real values. That is why simple Object.defineProperty hacks fail against modern WAFs that compute fingerprints from CDP-exposed native internals.

The Connection-Level Leaks#

Automation also changes transport behavior. A curl_cffi request over the network layer may look pristine; a browser automated through CDP may show:

  • HTTP/2 frames with unusual stream activity (Playwright prefetching, interrupting).
  • Extra round-trips from the DevTools protocol handshake on the same connection.
  • Websocket endpoints like /devtools/page/... in the network logs if your instrumentation is careless.
  • Timing that is fast and rigid rather than human and noisy (no cursor motion, instant clicks, instantaneous scroll).

These are harder to fix than JS flags, which is why the professional approach is to use a stealth-patched browser build or an anti-detect fork where the transport and renderer are both fixed, not just the DOM surface.

The Client-Side Test Batteries#

Rather than guessing which leaks matter, reproduce the detectors' own batteries locally. Well-known scanners—bot.sannysoft.com, arh.antoinevastel.com, creepjs.com, and the open-source fingerprintjs demo—run the same probe families that commercial WAFs run. Point a hardened profile at them after every configuration change and compare the pass/fail table.

A practical rule: if a public scanner flags something, a commercial WAF almost certainly flags it too, and usually with more sensitivity. Use the scanners as a regression suite, and capture screenshots of the results so a new browser build's regressions are visible in a diff rather than discovered in a production ban wave.

Stealth Is a System, Not a Property#

Patching navigator.webdriver alone is like closing one window in a house with a dozen open. A maintainable setup bundles a real browser binary, a patched automation driver, a warming routine, consistent proxy and timezone alignment, and a stable fingerprint profile. Verify with a detection scanner after every change—there is no substitute for actually measuring how many leaks remain.

A Leak Is a Difference, Not a Property#

The received wisdom is that headless Chrome leaks navigator.webdriver, and that deleting it fixes headless. Both halves are wrong. A leak is not a property; it is a difference between what the client reports and what the claimed browser would report. Deleting a value does not make the client a browser, it just removes one difference and, if the deletion is done from page script, introduces another.

So the useful artefact is a probe list with expected values for the browser being claimed, and a comparison. Everything below is that comparison run against an old headless build that claims Chrome 131 on Windows but is actually a Linux headless process.

# Headless leaks are not a list of one-line fixes. They are ordered by how
# much each one reveals, and by whether the fix introduces a tell of its own.
# An override that removes a value but not the code path is a new signal.

PROBES = [
    ("navigator.webdriver", "cdp", False),
    ("plugins.length", "js", 5),
    ("mimeTypes.length", "js", 2),
    ("navigator.languages", "js", ("en-US", "en")),
    ("chrome.runtime", "js", "present"),
    ("chrome.app", "js", "present"),
    ("chrome.csi", "js", "present"),
    ("chrome.loadTimes", "js", "present"),
    ("navigator.vendor", "js", "Google Inc."),
    ("navigator.hardwareConcurrency", "js", 8),
    ("outerWidth", "descriptor", 1920),
    ("outerHeight", "descriptor", 1080),
    ("navigator.platform", "descriptor", "Win32"),
    ("WebGL UNMASKED_RENDERER", "descriptor", "ANGLE (NVIDIA, RTX 3060)"),
    ("Permissions.query state", "descriptor", "prompt"),
    ("Notification.permission", "behavior", "default"),
    ("Function.prototype.toString", "cdp", "native"),
    ("CDP Runtime.enable side effects", "cdp", "absent"),
]

# old headless (pre --headless=new) values on a Linux build
HEADLESS = {
    "navigator.webdriver": True,
    "plugins.length": 0,
    "mimeTypes.length": 0,
    "navigator.languages": ("en-US",),
    "chrome.runtime": "absent",
    "chrome.app": "absent",
    "chrome.csi": "absent",
    "chrome.loadTimes": "absent",
    "navigator.vendor": "",
    "navigator.hardwareConcurrency": 2,
    "outerWidth": 800,
    "outerHeight": 600,
    "navigator.platform": "Linux x86_64",
    "WebGL UNMASKED_RENDERER": "Google SwiftShader",
    "Permissions.query state": "granted",
    "Notification.permission": "denied",
    "Function.prototype.toString": "native",
    "CDP Runtime.enable side effects": "present",
}

CHROME = dict((name, expected) for name, _layer, expected in PROBES)

# what an injected init script can reach
JS_FIXABLE = {
    "navigator.webdriver", "plugins.length", "mimeTypes.length",
    "navigator.languages", "chrome.runtime", "chrome.app", "chrome.csi",
    "chrome.loadTimes", "navigator.vendor", "navigator.hardwareConcurrency",
    "navigator.platform", "WebGL UNMASKED_RENDERER",
}

# an override that replaces a getter leaves Function.prototype.toString
# returning the replacement, which is itself a probe
TELL_SET = {"plugins.length", "mimeTypes.length", "chrome.runtime",
            "chrome.csi", "chrome.loadTimes", "navigator.vendor",
            "navigator.hardwareConcurrency"}

# descriptor-level fixes need the browser binary, not a script
DESCRIPTOR_FIXABLE = {"outerWidth", "outerHeight", "navigator.platform",
                      "WebGL UNMASKED_RENDERER", "Permissions.query state",
                      "Notification.permission"}

# engineering cost, 1 is a one-liner, 5 is a build
DESCRIPTOR_COST = {
    "navigator.webdriver": 1,
    "plugins.length": 1,
    "mimeTypes.length": 1,
    "navigator.languages": 2,
    "chrome.runtime": 2,
    "chrome.app": 2,
    "chrome.csi": 2,
    "chrome.loadTimes": 2,
    "navigator.vendor": 1,
    "navigator.hardwareConcurrency": 3,
    "outerWidth": 4,
    "outerHeight": 4,
    "navigator.platform": 4,
    "WebGL UNMASKED_RENDERER": 5,
    "Permissions.query state": 4,
    "Notification.permission": 3,
    "Function.prototype.toString": 5,
    "CDP Runtime.enable side effects": 5,
}

REPAIRS = [
    ("do nothing", set(), False),
    ("delete webdriver", {"navigator.webdriver"}, False),
    ("override every js-visible value", JS_FIXABLE, False),
    ("js overrides plus descriptors", JS_FIXABLE | DESCRIPTOR_FIXABLE, False),
    ("patch the browser stack", set(HEADLESS), True),
]


def leaking(name):
    return HEADLESS[name] != CHROME[name]


def audit(fixed, native):
    fails, by_layer, tells = [], {}, []
    for name, layer, _expected in PROBES:
        if leaking(name) and name not in fixed:
            fails.append(name)
            by_layer[layer] = by_layer.get(layer, 0) + 1
        if name in fixed and name in TELL_SET and not native:
            tells.append(name)
    return fails, by_layer, tells


def main():
    print("baseline: which of %d probes leak on old headless chrome" % len(PROBES))
    by_layer, leaks = {}, []
    for name, layer, expected in PROBES:
        if leaking(name):
            leaks.append((name, layer, expected))
            by_layer[layer] = by_layer.get(layer, 0) + 1
    for name, layer, expected in leaks:
        print("  %-34s %-10s chrome: %r" % (name, layer, expected))
    print("  %d of %d leak, across four layers: " % (len(leaks), len(PROBES))
          + ", ".join("%s=%d" % kv for kv in sorted(by_layer.items())))
    print("  the javascript layer is the cheap one; the cdp and descriptor")
    print("  layers are what a page script cannot reach at all.")
    print()

    print("repair ladder: each rung costs more and reveals something different")
    print("%-32s %6s %7s %6s %8s" % ("repair", "leaks", "layers", "tells",
                                      "signal"))
    rows = []
    for label, fixed, native in REPAIRS:
        fails, layers, tells = audit(fixed, native)
        signal = len(fails) + len(tells)
        rows.append((label, len(fails), len(tells), signal))
        print("%-32s %6d %7d %6d %8d"
              % (label, len(fails), sum(layers.values()), len(tells), signal))
    print()
    base = rows[0][3]
    prev_leaks, prev_tells, prev_signal = rows[0][1], rows[0][2], rows[0][3]
    for label, fails, tells, signal in rows[1:]:
        print("  %-32s leaks %+3d, tells %+2d, net signal %+d"
              % (label, fails - prev_leaks, tells - prev_tells,
                 signal - prev_signal))
        prev_leaks, prev_tells, prev_signal = fails, tells, signal
    print("  read the tells delta, not the leaks delta. a page can detect an")
    print("  override without any heuristics, so a value that is fixed but")
    print("  leaves a tell is not fixed. the third rung removes eleven leaks")
    print("  and adds seven tells, a net improvement of four rather than")
    print("  eleven. only the fourth rung removes the remaining descriptor")
    print("  leaks without adding tells, and only the fifth removes the")
    print("  tells as well.")
    print()

    print("the tells, named")
    _f, _l, tells = audit(JS_FIXABLE, False)
    for name in tells:
        print("  %-34s getter is no longer native code" % name)
    print("  a page can call Function.prototype.toString.call(")
    print("  Object.getOwnPropertyDescriptor(Navigator.prototype,")
    print("  'plugins').get) and see the injection. one line, no heuristics.")
    print()

    print("ordered repair plan: clean signal removed before neutral signal")
    print("%-34s %-10s %5s %6s %6s %6s" % ("probe", "layer", "cost",
                                           "leaks", "tells", "net"))
    order = []
    for name, layer, _expected in leaks:
        tell = name in TELL_SET
        order.append((0 if tell else 1, DESCRIPTOR_COST[name], name, layer, tell))
    for benefit, cost, name, layer, tell in sorted(order, reverse=True):
        print("%-34s %-10s %5d %6d %6d %6d"
              % (name, layer, cost, 1, 1 if tell else 0, benefit))
    print("  net is the change in signal from applying this one probe's fix in")
    print("  isolation: 1 when it removes a leak and leaves nothing behind, 0")
    print("  when the removal and the new tell cancel out. zero is not a win.")
    print()

    cheap = [n for _b, c, n, _l, _t in order if c <= 2]
    cheap_tells = [n for n in cheap if n in TELL_SET]
    print("what the ordering says about fast wins")
    print("  %d of %d probes can be reached at cost <= 2" % (len(cheap),
                                                             len(leaks)))
    print("  but %d of those leave a tell, so the honest floor for a"
          % len(cheap_tells))
    print("  script-only approach is the count of consequences, not the")
    print("  count of fixed values. a fleet that removes %d leaks and adds"
          % len(cheap))
    print("  %d tells is worse off than one that removes %d and adds none,"
          % (len(cheap_tells), len(cheap) - len(cheap_tells)))
    print("  and only a probe list that includes toString can see the")
    print("  difference. that is why the audit belongs in ci, per browser")
    print("  build, and not in a one-time setup script.")


main()
baseline: which of 18 probes leak on old headless chrome
  navigator.webdriver                cdp        chrome: False
  plugins.length                     js         chrome: 5
  mimeTypes.length                   js         chrome: 2
  navigator.languages                js         chrome: ('en-US', 'en')
  chrome.runtime                     js         chrome: 'present'
  chrome.app                         js         chrome: 'present'
  chrome.csi                         js         chrome: 'present'
  chrome.loadTimes                   js         chrome: 'present'
  navigator.vendor                   js         chrome: 'Google Inc.'
  navigator.hardwareConcurrency      js         chrome: 8
  outerWidth                         descriptor chrome: 1920
  outerHeight                        descriptor chrome: 1080
  navigator.platform                 descriptor chrome: 'Win32'
  WebGL UNMASKED_RENDERER            descriptor chrome: 'ANGLE (NVIDIA, RTX 3060)'
  Permissions.query state            descriptor chrome: 'prompt'
  Notification.permission            behavior   chrome: 'default'
  CDP Runtime.enable side effects    cdp        chrome: 'absent'
  17 of 18 leak, across four layers: behavior=1, cdp=2, descriptor=5, js=9
  the javascript layer is the cheap one; the cdp and descriptor
  layers are what a page script cannot reach at all.

repair ladder: each rung costs more and reveals something different
repair                            leaks  layers  tells   signal
do nothing                           17      17      0       17
delete webdriver                     16      16      0       16
override every js-visible value       5       5      7       12
js overrides plus descriptors         1       1      7        8
patch the browser stack               0       0      0        0

  delete webdriver                 leaks  -1, tells +0, net signal -1
  override every js-visible value  leaks -11, tells +7, net signal -4
  js overrides plus descriptors    leaks  -4, tells +0, net signal -4
  patch the browser stack          leaks  -1, tells -7, net signal -8
  read the tells delta, not the leaks delta. a page can detect an
  override without any heuristics, so a value that is fixed but
  leaves a tell is not fixed. the third rung removes eleven leaks
  and adds seven tells, a net improvement of four rather than
  eleven. only the fourth rung removes the remaining descriptor
  leaks without adding tells, and only the fifth removes the
  tells as well.

the tells, named
  plugins.length                     getter is no longer native code
  mimeTypes.length                   getter is no longer native code
  chrome.runtime                     getter is no longer native code
  chrome.csi                         getter is no longer native code
  chrome.loadTimes                   getter is no longer native code
  navigator.vendor                   getter is no longer native code
  navigator.hardwareConcurrency      getter is no longer native code
  a page can call Function.prototype.toString.call(
  Object.getOwnPropertyDescriptor(Navigator.prototype,
  'plugins').get) and see the injection. one line, no heuristics.

ordered repair plan: clean signal removed before neutral signal
probe                              layer       cost  leaks  tells    net
WebGL UNMASKED_RENDERER            descriptor     5      1      0      1
CDP Runtime.enable side effects    cdp            5      1      0      1
outerWidth                         descriptor     4      1      0      1
outerHeight                        descriptor     4      1      0      1
navigator.platform                 descriptor     4      1      0      1
Permissions.query state            descriptor     4      1      0      1
Notification.permission            behavior       3      1      0      1
navigator.languages                js             2      1      0      1
chrome.app                         js             2      1      0      1
navigator.webdriver                cdp            1      1      0      1
navigator.hardwareConcurrency      js             3      1      1      0
chrome.runtime                     js             2      1      1      0
chrome.loadTimes                   js             2      1      1      0
chrome.csi                         js             2      1      1      0
plugins.length                     js             1      1      1      0
navigator.vendor                   js             1      1      1      0
mimeTypes.length                   js             1      1      1      0
  net is the change in signal from applying this one probe's fix in
  isolation: 1 when it removes a leak and leaves nothing behind, 0
  when the removal and the new tell cancel out. zero is not a win.

what the ordering says about fast wins
  9 of 17 probes can be reached at cost <= 2
  but 6 of those leave a tell, so the honest floor for a
  script-only approach is the count of consequences, not the
  count of fixed values. a fleet that removes 9 leaks and adds
  6 tells is worse off than one that removes 3 and adds none,
  and only a probe list that includes toString can see the
  difference. that is why the audit belongs in ci, per browser
  build, and not in a one-time setup script.

Seventeen Differences Across Four Layers#

The baseline is worse than most people expect: seventeen of eighteen probes leak. The interesting structure is in the layer breakdown, because the layers are not equally reachable.

The javascript layer accounts for nine probes: the empty plugin and mime-type arrays, the single-element language list, the four missing chrome.* namespaces, the empty vendor string, and the core count. All of these are readable from page script and, in principle, all of them are writable from page script.

The descriptor layer accounts for five: outer window geometry, navigator.platform, the WebGL unmasked renderer, and the Permissions API state. These are not exposed as ordinary writable properties. They come from the browser's own view of its window and its graphics stack, and a page script reaches them only by replacing getters on the prototype, which is a different act with a different cost.

The cdp layer accounts for two: the webdriver flag, which is a consequence of automation being enabled, and whether Runtime.enable has left observable side effects. Neither is a value at all; they are facts about how the client is driven.

The one probe that does not leak is Function.prototype.toString itself. On stock headless it is native, exactly as on real Chrome. Hold that thought, because it is the probe that catches the most common repair.

The Ladder, and the Rung That Makes Things Worse#

Read the ladder by the tells column, not the leaks column. Deleting webdriver removes one leak and adds nothing. Overriding every javascript value removes eleven leaks and adds seven tells, so its net improvement is four, not eleven. Then descriptor fixes remove four more leaks cleanly. Only a native patch removes all seventeen leaks and the seven tells.

The tell is mechanical and needs no heuristics. Every chrome.* namespace, navigator.plugins, navigator.mimeTypes, navigator.vendor and navigator.hardwareConcurrency is exposed as a getter on a prototype. A page script that replaces the getter changes what Function.prototype.toString.call(descriptor.get) returns, and the replacement is not function () { [native code] }. A real Chrome returns native code for all of them. One line of script distinguishes an injected property from a built-in one, and it works without any knowledge of which properties you injected.

This is why CDP instrumentation leaks is the companion lesson. The webdriver flag and the Runtime.enable side effects are not values to overwrite; they are the mechanisms by which the client is being controlled, and they leak through channels that page script cannot reach. Overriding a value is visible; so is the act of overriding it.

The Ordered Plan, and Why Zero Is Not a Win#

The plan sorts by net change in signal rather than by leak count. Ten probes have a net of 1: fixing them removes a difference and leaves nothing behind. Seven have a net of 0: they remove a difference and add a tell of equal weight, so the signal is unchanged.

That zero column is the important one. Nine of the seventeen probes cost at most two to fix, and six of those nine leave a tell. So the natural reading, "nine cheap wins available", is wrong; only three of those nine are wins. A fleet that fixes nine and adds six tells is worse off than one that fixes three and adds none, and the probe list has to include toString for that to be visible at all.

The expensive cluster is the opposite. WebGL UNMASKED_RENDERER, the CDP Runtime.enable side effects, the outer geometry, platform and the Permissions state all have a net of 1 precisely because they cannot be reached from page script without a tell. They require either launch arguments, binary patches or a real browser build, which is a different category of work from writing an init script.

Confidence Probes, Not Just Value Probes#

Every probe above is a value probe: read a value, compare it to an expectation. The toString finding generalises into a second category that most audit lists omit entirely, and it is the category that catches overrides.

A confidence probe asks whether the shape of the environment is consistent with the value. Does Object.getOwnPropertyDescriptor(Navigator.prototype, "plugins").get stringify to native code. Does a property's getter, when deleted, fall back to the prototype's native implementation or disappear. Does assigning to a property that should have no setter throw in strict mode, or silently succeed because a replacement added a setter. Does the property descriptor list match the browser's, including the enumerability and configurability flags that injected properties usually get wrong. Does Object.keys(navigator) return the same order as the reference build.

None of these are value comparisons, and none of them can be satisfied by picking a better value. They are the reason a patching approach is more durable than injection even when injection is easier: a patch modifies the implementation, so the descriptor flags, the getter source and the prototype shape all remain consistent by construction. An override modifies the observation and leaves a lying descriptor behind.

The operational consequence is that an audit is only as good as its probe list. A list of seventeen values will pass a fleet with seventeen overrides and seventeen tells. The list has to include the confidence probes, and it has to be run against each browser build, in CI, because descriptors change when the browser does. That is the argument for treating detection debugging as a continuous process rather than a setup step, and it is the same argument made from the other side by automation framework comparison: a framework that patches at the source has fewer probes to fail, not because it hides better but because there is less to hide.