Headless-Browser-Erkennung: so finden Sie Ihre Lecks

headless browser detection - How to Find Headless Browser Detection Leaks in Your Stack

Headless-Browser-Erkennung ist kein einzelner Test, sondern ein Stapel kleiner Tests. Die meisten Automatisierungs-Setups fallen bei den gleichen drei durch, noch bevor die erste Seite fertig geladen ist. Ein Vendor-Dashboard brauchen Sie dafür nicht, denn alles Wichtige lässt sich aus dem Browser ablesen, den Sie schon offen haben. Dieser Beitrag zeigt vier Proben, sortiert die Befunde danach, wie schnell jeder einzelne zur Sperre führt, und behebt die, bei denen es sich lohnt. Er zieht außerdem eine Grenze, die viele Texte auslassen. Ein sauberer Fingerprint senkt, wie oft Sie eine Challenge bekommen, und bringt Sie nie auf null.

Was Sie brauchen

  • Chrome oder Chromium und eine Möglichkeit, JavaScript in der Seite auszuführen: die DevTools von Hand oder der evaluate-Aufruf Ihres Treibers.
  • Jeder Treiber, den Sie schon verwenden. Die Proben sind reines JavaScript, deshalb funktionieren Playwright, Puppeteer und Selenium unverändert.
  • Python 3.10 oder neuer, falls Sie den Patch und das Lösungsbeispiel am Ende ausführen wollen.
  • CapSkip installiert und laufend, und zwar nur für den letzten Abschnitt. Folgen Sie der Einrichtungsanleitung und achten Sie darauf, welchen Verbindungsmodus Sie gewählt haben, denn er entscheidet, auf welchen Host Ihr Code zeigt.

Eines sollten Sie vorher klären. Der Löser muss nicht auf derselben Maschine liegen wie der Browser. Der lokale Modus lauscht auf der Loopback-Adresse und bedient nur dieses Gerät, der Servermodus lauscht auf Ihrer Netzwerk- oder öffentlichen IP, sodass eine zweite Maschine, ein VPS oder ein gehosteter Runner dieselbe Instanz über die API erreichen kann. Beide sind unter Verbindungseinstellungenbeschrieben, und weiter unten folgt ein kurzer Abschnitt zum Serverfall.

Schritt 1: die Signale lesen, die früh entscheiden

Fangen Sie mit den billigen an. Jedes kommerzielle Anti-Bot-Skript liest sie in den ersten Millisekunden, weil es synchrone Property-Zugriffe ohne Netzwerkkosten sind. Fügen Sie das in die Konsole der Seite ein, die Sie tatsächlich blockiert, und nicht in einen leeren Tab, denn einige Werte hängen am Dokument.

// Paste into DevTools, or hand it to your driver's evaluate call
// so it runs in the real page context rather than a fresh tab.
const leaks = {
  webdriver: navigator.webdriver,
  plugins: navigator.plugins.length,
  languages: navigator.languages.join(","),
  cores: navigator.hardwareConcurrency,
  memory: navigator.deviceMemory,
  platform: navigator.platform,
  hasChrome: !!window.chrome,
  hasRuntime: !!(window.chrome && window.chrome.runtime),
};
console.table(leaks);   // read every row, not just the first

Eine echte Chrome-Sitzung meldet die webdriver-Property als undefined, drei bis acht Einträge in der Plugin-Liste, mindestens zwei akzeptierte Sprachen, eine Kernzahl passend zur Maschine, einen Plattform-String wie Win32 oder MacIntel und ein gefülltes window.chrome-Objekt mit einer runtime darin. Ein automatisierter Container meldet meist true, null, eine Sprache, zwei Kerne, Linux x86_64 und bei den letzten beiden Punkten gar nichts.

Die webdriver-Property kennen die meisten, und sie ist der einzige Eintrag der Liste, der absichtlich da ist. Sie ist standardisiert: die W3C-WebDriver-Spezifikation verlangt, dass ein konformer Treiber ein Flag setzt, das genau diese Property nach außen zeigt, und MDN dokumentiert dasselbe Verhalten. Sie ist also kein Bug, den jemand zu beheben vergessen hat, sondern der Browser, der genau tut, was die Spezifikation ihm vorschreibt.

Lesen Sie die Zeilen zusammen und nicht einzeln, denn Erkennungsskripte prüfen sie auf Übereinstimmung. Ein Windows-User-Agent neben einem Linux-Plattform-String ist ein deutlich stärkeres Signal als jeder der beiden Werte allein, und genau daran scheitert fast jeder selbstgebaute Patch im ersten Versuch.

Schritt 2: nach Treiber-Artefakten im window suchen

Chromedriver und die Injection-Schicht von Selenium hinterlassen benannte globale Variablen. Sie sind trivial aufzulisten, kein legitimer Nutzer hat sie, und ein Treffer ist ein Beweis und keine Wahrscheinlichkeit.

// Injected globals from Chromedriver and Selenium. A clean
// browser prints the word clean and nothing else.
const prefixes = ["__cdc", "__selenium", "__webdriver", "__driver"];
const found = Object.keys(window).filter(
  (k) => prefixes.some((p) => k.startsWith(p))
);
// Two more that Chromedriver adds under fixed names.
for (const name of ["domAutomation", "domAutomationController"]) {
  if (window[name] !== undefined) found.push(name);
}
console.log(found.length ? found : "clean");

Ein Treffer hier ist das Wertvollste, was dieses Audit liefern kann, weil es daran nichts zu deuten gibt. Ein zufälliger Name, der mit dem Präfix cdc beginnt, ist der klassische Chromedriver-Marker, und die übliche Antwort darauf lautet: nicht länger von Hand patchen, sondern auf einen Treiber-Build wechseln, der ihn für Sie entfernt. Diesen Weg haben wir separat im Leitfaden CAPTCHAs in undetected-chromedriver behandelnbeschrieben, und das größere Selenium-Bild steht auf der Seite Selenium-Captcha-Löser , die die Treiberoptionen ausführlicher behandelt.

Schritt 3: die GPU fragen, wofür sie sich hält

WebGL gibt Grafikhersteller und Renderer als einfache Strings heraus, und ein Container ohne GPU muss ehrlich den Namen seines Software-Fallbacks nennen. Das ist das Lauteste, was headless Chrome in Docker verrät, und kein navigator-Patch kommt daran heran.

// The renderer is exposed as a plain string, so read it directly.
const gl = document.createElement("canvas").getContext("webgl");
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
console.log(
  gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL),
  gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)
);
// SwiftShader, llvmpipe, Mesa or VMware means no real GPU here.

SwiftShader zu sehen ist nicht fatal, und ehrlich wegfälschen lässt es sich auch nicht, denn ein gefälschter Renderer-String muss immer noch zu den Pixeln passen, die Ihr Canvas tatsächlich erzeugt. In einem Container gegen ein aggressives Ziel bleiben zwei realistische Optionen: dem Container eine echte GPU geben oder die Aufgabe auf eine Maschine verschieben, die eine hat.

Schritt 4: den Worker-Kontext getrennt prüfen

Diese Probe erwischt halbfertige Stealth-Setups, und fast niemand führt sie aus. Patches am window der Seite wandern nicht in einen Web Worker, denn der bekommt sein eigenes frisches navigator-Objekt. Ein Setup, das in der Konsole sauber aussieht, antwortet eine Schicht tiefer also weiterhin wahrheitsgemäß.

// A Worker gets its own navigator, untouched by page patches.
const src = "postMessage(navigator.webdriver)";
const url = URL.createObjectURL(new Blob([src]));
new Worker(url).onmessage = (e) => console.log("worker says:", e.data);
// true here while the page says undefined is a mismatch,
// and a mismatch is a worse signal than either value alone.

Nehmen Sie den letzten Kommentar ernst, denn diese Skripte bewerten vor allem Konsistenz. Ein Browser, der in einem Kontext undefined und in einem anderen true antwortet, hat dem Detektor zwei Dinge verraten: dass er automatisiert ist und dass jemand das verbergen wollte. Das Zweite schiebt eine Sitzung von bewertet nach gesperrt.

Welche Signale der Headless-Browser-Erkennung am meisten zählen

Nicht jeder Befund verdient Ihren Nachmittag. Sortiert danach, wie schnell er wirkt:

SignalWie eine echte Sitzung aussiehtGewicht
Treiber-Globals im window-ObjektÜberhaupt keine vorhandenKritisch: beweisend beim Laden
Die webdriver-Property von navigatorUndefinedKritisch: wird in Millisekunden gelesen
Plattform, User-Agent und Sprache passen zusammenAlle drei beschreiben eine MaschineKritisch: ein Widerspruch wiegt mehr als jeder Wert
WebGL-Renderer-StringEine benannte GPU, kein Software-FallbackHoch: Challenge innerhalb von Sekunden
Synthetische Events mit isTrusted gleich falseTrue, weil die Eingabe vom Browser kamHoch: greift bei der ersten Interaktion
TLS- und HTTP/2-FingerprintPasst zum Chrome-Build, für den Sie sich ausgebenHoch, und für jede Probe oben unsichtbar
Leere Plugin-Liste, eine Sprache, zwei KerneGefüllt und plausibelMittel: fließt in eine Bewertung ein
Anzahl der Schriften und Audio-FingerprintEin Desktop-Schriftsatz, ein Audio-Hash ungleich nullMittel: allein selten entscheidend

Die Zeile zu isTrusted wird oft falsch verstanden, deshalb hier genauer. Ruft man die click-Methode eines Elements aus JavaScript auf, entsteht ein Event, dessen isTrusted false ist, und das fällt leicht auf. Derselbe Klick über Playwright, Puppeteer oder Selenium verhält sich anders, weil er durch die eigene Eingabe-Pipeline des Browsers läuft. Das ist also ein Leck in handgeschriebenem DOM-Scripting und nicht in Ihrem Treiber.

Schritt 5: die kritischen Punkte patchen

Zwei Regeln vor jedem Code. Patchen Sie früh, bevor Skripte der Seite laufen, sonst liest der Detektor den ursprünglichen Wert und Ihre Korrektur kommt zu spät. Und patchen Sie eng, denn eine plumpe Überschreibung ist selbst erkennbar: wer eine native Funktion überschreibt, macht deren Quelle für jeden sichtbar, der toString darauf aufruft, und verwandelt ein verstecktes Signal in ein offensichtliches.

# pip install playwright
from playwright.sync_api import sync_playwright

PATCH = """
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
window.chrome = window.chrome || { runtime: {} };
"""

with sync_playwright() as p:
    # Real Chrome leaks less than the bundled Chromium build.
    browser = p.chromium.launch(channel="chrome", headless=False)
    page = browser.new_page(locale="en-US")
    # add_init_script runs before any page script reads navigator.
    page.add_init_script(PATCH)
    page.goto("https://example.com/page-with-recaptcha")
    # Re-run the Step 1 probe here to confirm the patch landed.

Drei günstige Verbesserungen fehlen in diesem Snippet, weil sie Konfiguration sind und nicht Code. Nutzen Sie den echten Chrome-Kanal statt des mitgelieferten Chromium. Behalten Sie ein dauerhaftes Profilverzeichnis über mehrere Läufe, damit eine Sitzung mit Cookies und Verlauf ankommt und nicht wie neugeboren aussieht. Und passen Sie Locale und Zeitzone dorthin an, wo Ihr Traffic herzukommen scheint. Diese drei bewegen mehr als jede navigator-Überschreibung, und keine davon lässt sich beim Lügen erwischen. Wenn Sie die Framework-Details wollen: die Seite Playwright-Captcha-Löser behandelt die Verdrahtung auf dieser Seite, und Puppeteer-Nutzer bekommen die gleiche Anleitung auf der Seite Puppeteer-Captcha-Löser für ihr eigenes Treiber-Setup.

Was all das nicht behebt

Drei Kategorien liegen außerhalb des Browsers, deshalb ist jede Probe oben für sie blind. Ihr TLS-Handshake wird zum Fingerprint, bevor ein einziges Byte JavaScript läuft, und darum scheitert ein Python-HTTP-Client an Prüfungen, durch die dieselbe Anfrage aus Chrome glatt durchgeht. Ihre IP trägt die Reputation ihres Netzes. Und Ihr Verhalten wird über die Sitzung hinweg bewertet: Anfragerate, Reihenfolge der Navigation, Tempo beim Ausfüllen von Formularen. Headless-Browser-Erkennung ist also immer nur ein Teil des Grunds, warum Sie gestoppt wurden.

All das verändert die Wahrscheinlichkeit einer Challenge, und nichts davon beseitigt Challenges. Ein echter Browser an einem Privatanschluss trifft regelmäßig auf ein reCAPTCHA oder ein Turnstile-Widget, weil viele Seiten auf bestimmten Pfaden jeden Besucher prüfen, egal was die Bewertung sagt.

Die Challenge lösen, die trotzdem kommt

Sobald das Widget auftaucht, ist nicht mehr der Fingerprint das Problem, sondern das Token. CapSkip läuft auf Ihrer eigenen Hardware, antwortet über eine 2captcha-kompatible API und gibt ein Token zurück, das Sie selbst einsetzen und abschicken.

# pip install capskip
from capskip import CapSkip

# Local mode. In Server mode this is the solver box's address.
solver = CapSkip(host="127.0.0.1", port=8080)

# One call covers v2, Invisible, Enterprise and v3 as options.
result = solver.recaptcha(
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)

# Inject the token into the field the page submits with the form.
page.evaluate(
    "t => document.getElementById('g-recaptcha-response').value = t",
    result["code"],
)

print(result["code"][:24])   # token, ready to submit

Weil der Löser ein lokaler Daemon und kein abgerechneter Dienst ist, kostet ein erneuter Versuch nichts, und das zählt hier mehr als es zunächst wirkt. Fingerprint-Arbeit läuft in Iterationen, und dieselbe Challenge vierzig Mal zu lösen, während Sie einen Patch testen, wäre bei einer Abrechnung pro Lösung ein teures Debugging.

Den Löser stattdessen auf einem Server betreiben

Browser-Flotten laufen meist nicht auf einem Laptop, und der Löser muss das auch nicht. Die Verbindungseinstellungen bieten zwei Modi, und der einzige Unterschied ist, auf welchem Interface die API lauscht.

ModusLauscht aufSinnvoll, wenn
Lokal127.0.0.1, nur dieses GerätBrowser und Löser teilen eine Maschine
ServerIhre Netzwerkadresse oder öffentliche IPEine zweite Maschine, ein VPS, ein Container-Host oder ein gehosteter CI-Runner muss ihn erreichen

Im Servermodus richten Sie den SDK-Host auf diese Adresse statt auf die Loopback-Adresse und ändern sonst nichts, sodass eine Flotte aus zwanzig Containern einen einzigen Löser nutzen kann. Eine statische öffentliche IP lohnt sich, wenn die Aufrufer außerhalb Ihres Netzes sitzen, denn die Adresse landet in der Konfiguration. Alle Details stehen unter Verbindungseinstellungen in der Einrichtungsanleitung. Der Servermodus bleibt Ihre eigene Hardware und bleibt ohne Zähler, er verändert also, wo der Löser läuft, und nichts an den Kosten.

FAQ

Ist der neue Headless-Modus noch erkennbar?

Ja, nur weniger plump als der alte. Der neuere Headless-Modus von Chrome nutzt dasselbe Browser-Binary, deshalb sind der verräterische User-Agent-String und mehrere fehlende APIs verschwunden. Die webdriver-Property wird weiterhin gesetzt, Treiber-Globals werden weiterhin injiziert, und ein Container ohne GPU meldet weiterhin einen Software-Renderer. Headless ist ein Signal unter vielen und kein sofortiges Aus.

Reicht ein Stealth-Plugin für sich allein?

Es deckt die bekannten JavaScript-Properties gut ab und ist nützlich. An Ihren TLS-Fingerprint, Ihre IP-Reputation und Ihr Anfragemuster kommt es nicht heran, und seine Patches sind öffentlich, weshalb Erkennungsanbieter direkt dagegen testen. Führen Sie die vier Proben nach der Installation aus, statt die Sache für erledigt zu halten.

Meine Browser laufen auf gehosteten Runnern. Erreichen sie den Löser trotzdem?

Ja, mit CapSkip im Servermodus. Ein gehosteter Runner sieht Ihre Loopback-Adresse nicht, also stellen Sie den Löser auf Ihre Netzwerk- oder öffentliche IP um und richten den SDK-Host darauf. Eine Instanz bedient jeden Runner, ein Tunnel ist nicht beteiligt, und eine statische öffentliche IP hält die Konfiguration stabil. Beide Modi, und der Port, den jeder belegt, sind in der Einrichtungsanleitung unter Verbindungseinstellungen beschrieben, wo auch der Wechsel zwischen ihnen erklärt wird.

Beendet ein sauberer Fingerprint die CAPTCHAs?

Er reduziert sie und beendet sie nicht. Viele Seiten prüfen nach Pfad und nicht nach Bewertung, deshalb trifft auch ein perfekter Browser beim Login oder im Checkout auf ein Widget. Planen Sie beides ein: senken Sie die Rate, mit der Sie geprüft werden, und halten Sie in der Pipeline etwas bereit, das die trotzdem eintreffenden Prüfungen beantwortet.

Führen Sie die Proben aus, bevor Sie etwas ändern

Ein Audit der Headless-Browser-Erkennung dauert etwa zehn Minuten und findet meist zwei Probleme und nicht zwanzig. Räumen Sie die Treiber-Globals weg, bringen Sie Ihre navigator-Werte miteinander in Einklang, und prüfen Sie den Worker-Kontext, damit Ihre Patches sich nicht selbst widersprechen. Behandeln Sie die dann noch eintreffenden Challenges als eigene Aufgabe mit eigenem Werkzeug. Ein Captcha-Löser auf Hardware, die Ihnen gehört, erledigt sie ohne Abrechnung pro Lösung, und genau diese Form wollen die meisten Scraping-Flotten am Ende, wie die Hinweise zum Captcha-Löser für Web Scraping im Worker-Pool zeigen.