CDP war nie darauf angelegt, leise zu sein
Puppeteer, Playwright, Selenium 4 und jeder darauf aufbauende „Stealth“-Wrapper teilen dieselbe Grundlage: das Chrome DevTools Protocol. Es ist derselbe Kanal, den dein Browser öffnet, wenn du F12 drückst. Chrome stellt es bereit, damit ein Debugger die Seite inspizieren und steuern kann — und es wurde mit genau einem Nicht-Ziel entworfen: zu verbergen, dass ein Debugger angehängt ist.
Das ist die Wurzel des Problems. Die folgenden Signale sind keine Fehler in Puppeteer. Die meisten sind CDP, das sich korrekt verhält — auf eine Weise, wie es ein von Menschen bedienter Browser nie tut. Ein Erkennungsanbieter muss nichts kaputtmachen, um sie zu finden; er muss nur Werte lesen, die die Plattform kostenlos herausgibt.
Es folgt die Liste mit Stand 2026, grob danach sortiert, wie billig jeder Punkt zu prüfen ist.
navigator.webdriver, und warum ein JS-Patch schlimmer ist
Das älteste Signal. navigator.webdriver liefert true, sobald Chrome mit aktivierter Automatisierung gestartet wurde. Jedes Tutorial rät, es zu überschreiben:
Object.defineProperty(navigator, 'webdriver', { get: () => false })
Das ist keine Lösung. Es ist ein zweites, lauteres Signal. In einem unveränderten Browser ist webdriver ein Accessor auf Navigator.prototype, keine eigene Eigenschaft der Instanz. Nach dem obigen Schnipsel sind drei Dinge wahr, die sonst nirgends wahr sind:
Object.getOwnPropertyDescriptor(navigator, 'webdriver')liefert einen Deskriptor. In einem echten Browser liefert esundefined, weil die Eigenschaft am Prototyp hängt.- Der
toString()des Getters liest() => falsestattfunction get webdriver() { [native code] }. - Die Eigenschaft überdeckt nun den Prototyp, sodass
'webdriver' in navigatorund der Prototyp-Deskriptor sich widersprechen.
Erkennungsskripte laufen die toString-Kette seit Jahren ab. Ein Browser, der das ehrliche true liefert, ist ein Browser mit Automatisierung. Ein Browser, der ein gefälschtes false liefert, ist ein Browser mit Automatisierung, der das zu verbergen versucht — und das ist für die meisten Risiko-Engines das Schlimmere von beidem.
Die einzige Variante, die einer Prüfung standhält, ist die, bei der der C++-Getter selbst false zurückgibt: keine eigene Eigenschaft, keine Arrow Function und nichts, was in der Prototypenkette fehl am Platz wäre.
Runtime.enable: das Leck, das allen den Stack umgeschrieben hat
Um JavaScript in einer Seite auszuführen, rufen die meisten Bibliotheken Runtime.enable auf. Dieser eine Befehl hat Nebenwirkungen, die bis in die Seite reichen: Er beginnt, Runtime.executionContextCreated-Benachrichtigungen zu senden, und ändert, wie Konsolenargumente serialisiert werden.
Der klassische Exploit passt in einen Tweet. Lege ein Objekt mit einem Getter an, übergib es an console.debug und schau, ob der Getter feuert. Mit angehängtem Debugger und aufgerufenem Runtime.enable serialisiert das Protokoll das Objekt für den Client, und der Getter läuft. Ohne Debugger rührt es niemand an.
let leaked = false;
const probe = { get id() { leaked = true; return 1; } };
console.debug(probe);
// leaked === true → something is listening on CDP
Das hat das Ökosystem zu gepatchten Forks getrieben — rebrowser-puppeteer und Verwandte —, die Runtime.enable meiden und stattdessen isolierte Welten oder addBinding nutzen. Diese Patches funktionieren und sind ihr Geld wert. Sie schließen allerdings genau ein Loch von zehn.
Drei Fehler bei synthetischer Eingabe
Wenn Automatisierung eine Maus bewegt, bewegt sie keine Maus. Sie ruft Input.dispatchMouseEvent auf, und Chrome baut ein Ereignisobjekt, das dem, was die echte Eingabe-Pipeline erzeugt, sehr nahekommt — aber eben nicht ganz. Drei Unterschiede sind von einer Seite aus trivial beobachtbar.
screenX und screenY stimmen nicht
Das ist Chromium-Bug 40280325. CDP setzt screenX = clientX und screenY = clientY — Viewport-Koordinaten dort, wo Bildschirmkoordinaten hingehören. Bei einem echten Klick unterscheiden sich beide um die Fensterposition plus die Browser-Chrome: Tableiste, Adressleiste, Lesezeichen. Bei einem maximierten Fenster sind das rund 90 bis 130 Pixel vertikaler Versatz.
Ein echter Klick nahe dem Seitenanfang hat also ein screenY um die 150. Ein CDP-Klick an derselben Stelle meldet screenY = 20. Cloudflare Turnstile wertet ein niedriges screenY als Bot-Klick, und das zu Recht: Kein Mensch kann oberhalb der eigenen Browser-Chrome klicken.
UIEvent.detail ist null
Bei einem echten Klick trägt event.detail die Klickzahl — 1 für einen Einfachklick, 2 für einen Doppelklick. Über CDP ausgelöste Klickereignisse kommen mit click_count = 0 an, also ist detail gleich 0. Keine Nutzergeste erzeugt einen Klick mit detail null. Turnstile prüft das innerhalb seines Challenge-iframes.
getCoalescedEvents() ist leer
Moderne Browser bündeln hochfrequente Zeigerbewegungen und geben die Rohwerte über PointerEvent.getCoalescedEvents() heraus. Ein echtes pointermove trägt immer mindestens ein gebündeltes Ereignis: sich selbst. Ein über CDP ausgelöstes pointermove trägt ein leeres Array, weil es nie einen Hardware-Messwert zum Bündeln gab.
Nichts davon lässt sich aus JavaScript reparieren. Das Ereignisobjekt wird in C++ konstruiert, bevor irgendein Seitenskript es sieht, und die Eigenschaften sind schreibgeschützt.
Die Strings, die Automatisierung herumliegen lässt
Zwei Familien von Zeichenketten landen dort, wo eine Seite sie lesen kann.
ChromeDriver-Marker. ChromeDriver verfolgt die Ergebnisse eigener Aufrufe, indem es Dokument-Eigenschaften mit den Präfixen cdc_, $cdc_ oder $chrome_ anlegt. Versteckt sind sie nicht. Eine dreizeilige Schleife über Object.getOwnPropertyNames(document) findet sie, und Erkennungsskripte suchen seit 2018 genau danach.
Puppeteer-Quell-URLs. Puppeteer hängt //# sourceURL=pptr:... an die Skripte an, die es auswertet, und legt eine isolierte Welt namens __puppeteer_utility_world__ an. Beides taucht in Error.stack auf, wenn eine Ausnahme die Grenze überschreitet:
try { null.f() } catch (e) { /* e.stack may contain "pptr:" */ }
Das sind die am leichtesten zu entfernenden Signale und zugleich die, die am häufigsten stehen bleiben — weil sie nur unter Bedingungen auftauchen, die Entwickler selten testen: ein nicht abgefangener Fehler in einer ausgewerteten Funktion.
Worker: der Kontext, den niemand abdeckt
Das ist die Lücke, die die ansonsten sorgfältigsten Setups erwischt.
Web Worker, Service Worker und Shared Worker bekommen jeweils einen eigenen globalen Scope mit eigenem navigator. Dieses Objekt erzeugt die Engine im Worker-Thread aus den echten Werten des Browsers. CDP-Overrides auf Seitenebene — Emulation.setUserAgentOverride, eingeschleuste Skripte, Page.addScriptToEvaluateOnNewDocument — erreichen es nicht.
Das Ergebnis ist ein Browser, der im Haupt-Thread das eine behauptet und im Worker das andere. Ein Erkennungsskript muss nur zweimal fragen:
// main thread says 8; worker says 24
new Worker(URL.createObjectURL(new Blob([
'postMessage([navigator.hardwareConcurrency, navigator.deviceMemory, navigator.platform])'
], { type: 'text/javascript' })));
Jede Abweichung zwischen beiden ist eindeutig. Echte Browser können keine erzeugen, weil beide Scopes dieselben zugrunde liegenden Werte lesen. Über diesen Weg lecken unter anderem hardwareConcurrency, deviceMemory, platform, die vollständige Markenliste aus userAgentData und — über OffscreenCanvas — Vendor und Renderer von WebGL.
Von außen lässt sich das nicht vollständig beheben. Die Werte müssen aus der Engine kommen, das heißt: Der navigator des Workers muss aus demselben Profil gebaut werden wie der des Fensters.
Die Kleinigkeiten, die sich trotzdem summieren
Noch drei, jede billig zu prüfen und jede für sich imstande, eine Sitzung zu beenden.
Außenmaße bei angedockten DevTools
Die Differenz zwischen outerWidth und innerWidth ist die Browser-Chrome — rund 16 Pixel horizontal und 85 bis 90 vertikal in Desktop-Chrome. Dockt man die DevTools an, springt diese Lücke um Hunderte Pixel. Ein Fenster, das outerHeight - innerHeight = 400 meldet, ist ein Fenster mit geöffnetem Debugger.
Speicherkontingent
navigator.storage.estimate() liefert ein Kontingent, das sich aus dem freien Speicherplatz ableitet. Schon für sich ein starkes Hardwaresignal — und es verrät zusätzlich Container und Inkognito-Kontexte, die charakteristische runde Zahlen weit unterhalb jeder echten Festplatte melden.
Fehlende APIs für die behauptete Plattform
Sagt dein User Agent Windows Chrome 147, kann die Seite prüfen, welche APIs Windows Chrome 147 tatsächlich mitbringt. Headless-Builds hatten historisch weder chrome.runtime noch den vollständigen Berechtigungsablauf von Notification noch die Codec-Unterstützung der Headful-Builds. Eine Plattform zu behaupten, die man nicht belegen kann, ist schlimmer, als gar nichts zu behaupten.
Warum die Lösung im Quellcode liegen muss
Betrachtet man die Liste, zeigt sich ein Muster. Fast jedes Signal entsteht unterhalb von JavaScript:
- Ereigniseigenschaften werden in C++ gesetzt, bevor ein Skript sie anfassen kann.
- Worker-navigators werden auf einem Thread konstruiert, den die Seite nicht erreicht.
- Der webdriver-Getter hängt an einem Prototyp, dessen Form selbst ein Signal ist.
- Die Nebenwirkungen von Runtime.enable passieren im V8-Inspector, nicht in der Seite.
Ein Content-Skript läuft nach all dem. Es kann nur überschreiben, was längst entschieden ist, und jedes Überschreiben hinterlässt die oben beschriebenen Spuren: eigene Eigenschaften, wo Prototypen hingehören, Arrow Functions, wo nativer Code hingehört, Widersprüche zwischen Kontexten.
Das ist das ehrliche Argument für einen veränderten Chromium-Build — und es lohnt, auch seine Grenzen zu nennen. Ein Patch der Engine löst die Schicht unterhalb von JavaScript. Er löst nicht die TLS- und HTTP/2-Fingerprints, die vollständig unterhalb des Browsers liegen, und er tut nichts fürs Verhalten: Eine Sitzung, die mit unmenschlicher Regelmäßigkeit klickt, wird markiert, so sauber ihre Eigenschaften auch sein mögen.
Das eigene Setup testen
Glaub keinem Anbieter auf sein Wort, uns eingeschlossen. Die Prüfungen sind öffentlich und an einem Nachmittag durchführbar:
- rebrowser bot detector — die Referenzsuite speziell für CDP-Leaks, inklusive der Konsolensonde für
Runtime.enableund der Utility-World-Prüfungen. - CreepJS — der aggressivste öffentliche Fingerprint-Prüfer. Er führt den oben beschriebenen Vergleich zwischen Worker und Fenster durch und meldet die Abweichung ausdrücklich.
- Eine eigene Seite — der schnellste Test passt in zwanzig Zeilen:
screenYunddetailaus einem Klick-Handler protokollieren,getCoalescedEvents().lengthaus einem pointermove protokollieren und den navigator eines Workers an den Haupt-Thread zurückschicken. Weicht auch nur eines der drei von einem per Hand bedienten Browser ab, hast du dein Problem gefunden.
Lass sie gegen das laufen, was du heute benutzt, bevor du irgendetwas änderst. Ein Werkzeug, das neun dieser Prüfungen besteht und an der zehnten scheitert, ist nicht zu neun Zehnteln am Ziel — eine einzige eindeutige Abweichung reicht.