Fixing CDP & Headless Leaks
On this page
- The Forensic Paper Trail
- The Runtime Leaks You Must Patch
- Why Patching at JavaScript Level Is Not Enough
- The Connection-Level Leaks
- The Client-Side Test Batteries
- Stealth Is a System, Not a Property
- A Leak Is a Difference, Not a Property
- Seventeen Differences Across Four Layers
- The Ladder, and the Rung That Makes Things Worse
- The Ordered Plan, and Why Zero Is Not a Win
- Confidence Probes, Not Just Value Probes
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 thanundefined(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-presentpluginsarray plus default PDF plugins; some sandboxes ship nothing at all.PermissionsAPI: thequery()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/MouseEventcoordinate tracing: Playwright-mimicked events carry aisTrusted: falseflag, exposing synthetic input.- DevTools-induced
window.outerWidthanomalies: browser window dimensions that never match a real monitor size. - Broken
Mediastacks, missingAudioContext, andcreateImageBitmapshape 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.