Skip to content
Cascading Labs QScrape VoidCrawl Yosoi

Stealth & Anti-Detection

VoidCrawl uses a minimal-footprint stealth strategy inspired by zendriver / nodriver. Stealth is enabled by default. The guiding rule: present a real browser consistently — don’t fake things.

For the hardest gates there is a second, opt-in layer that shrinks the CDP control channel itself — see Minimal CDP footprint. It is off by default because it trades away response capture.

TL;DR (at a glance)

What VoidCrawl does, and why each piece exists:

LayerWhat we doWhy
Launch flagsDrop chromiumoxide’s --enable-automation/--disable-extensions; add --disable-blink-features=AutomationControlled plus a curated zendriver/nodriver-derived setThe biggest automation signal lives in launch flags. AutomationControlled is what makes navigator.webdriver a native false — we do not patch it in JS.
CDP surfacecdp_mode="normal" by default; "minimal" is opt-in per sessionThe domains chromiumoxide enables eagerly (Runtime, Network, …) are themselves tells. Minimal drops them, at the cost of response capture.
No JS injectionaddScriptToEvaluateOnNewDocument is emptyEach injected script is itself a fingerprint. We patch nothing in page-world JS.
UA / Client HintsReal UA (Headless stripped), with navigator.platform + userAgentData (Sec-CH-UA) derived from that UA so they agreeA Linux UA with platform === "Win32" or empty brands is a bot tell.
GPU--headless=new + ANGLE + --disable-gpu-sandbox -> hardware WebGLLegacy headless renders WebGL with SwiftShader (software) — a strong bot signal.

Managed Cloudflare Turnstile (the hard case):

ModeResult
HeadfulBest option; still profile/IP/environment dependent. Docker headful, nodriver, and bare Docker Chrome currently all stall on the same Cloudflare canary from this host.
HeadlessUsually gated — use headful + warm profile + clean exit for Turnstile-walled targets.

All defaults are overridable by the caller (see Overriding the defaults).

Philosophy: less is more

Most automation tools try to spoof every fingerprint — fake plugins, fake WebGL, fake UA. This backfires against modern WAFs (Akamai, Cloudflare, PerimeterX) because:

  1. Spoofed values are inconsistent. A hardcoded Chrome/131 UA on a Chromium 148 build is an instant flag. A fake WebGL renderer that doesn’t match the real GPU is trivially caught.
  2. The spoofing itself is detectable. Every Page.addScriptToEvaluateOnNewDocument call is a fingerprint. Overriding navigator.plugins with a Proxy behaves differently from the real PluginArray prototype — and detectors check for exactly that.
  3. The automation signal is in the launch flags, not JS. chromiumoxide’s defaults include --enable-automation, which tells every WAF “I’m automated” before a page loads.

VoidCrawl’s approach: don’t fake anything. Launch with clean flags, let Chrome report its real values, and only ensure those values are internally consistent.

Lesson learned the hard way: VoidCrawl used to inject two JS patches — deleting navigator.webdriver and force-opening shadow DOMs. Both were removed. Deleting navigator.webdriver made it undefined (real Chrome reports falseundefined is the tell). Force-opening shadow DOMs broke Cloudflare Turnstile, which renders its challenge in a closed shadow root and tamper-checks it: forcing it open failed the challenge with ERROR 600010. We inject zero page-world JS today. To reach into a closed shadow root without tampering, use the AX-tree locators (ax_box_in_frame / click_ax_in_frame) — the browser-computed accessibility tree descends into closed roots, so a trusted compositor click can drive the widget with no shadow patch. See Challenge escalation.

The automation signal is in the launch flags

After disable_default_args() (which strips chromiumoxide’s toxic defaults) we re-add a curated set. Flags are stored without the leading -- (chromiumoxide prepends it; a literal -- would produce the inert ----flag — a bug we fixed, which had silently disabled the whole list).

Removed (toxic defaults)

FlagWhy it’s bad
--enable-automationLiterally opts in to automation detection
--disable-extensionsReal Chrome always has extension support

Anti-automation flags we add

FlagPurpose
--disable-blink-features=AutomationControlledRemoves the automation-controlled Blink feature, so navigator.webdriver is a native false — not a JS patch
--disable-infobarsSuppresses the “Chrome is being controlled by automated test software” bar
--disable-features=IsolateOrigins,site-per-process,TranslateUIKeeps cross-origin frames in-process so evaluate_js_in_frame can reach them, and drops the Translate UI
--no-pings, --disable-component-update, --disable-session-crashed-bubble, --disable-search-engine-choice-screen, --homepage=about:blankSuppress automation-ish background behaviour and first-run UI
--no-first-run, --no-service-autorun, --no-default-browser-check, --password-store=basic, --use-mock-keychainProfile / first-run hygiene

Plus the safe noise-reducers kept from chromiumoxide’s own list (--disable-background-networking, --disable-background-timer-throttling, --disable-backgrounding-occluded-windows, --disable-breakpad, --disable-client-side-phishing-detection, --disable-component-extensions-with-background-pages, --disable-default-apps, --disable-dev-shm-usage, --disable-hang-monitor, --disable-ipc-flooding-protection, --disable-popup-blocking, --disable-prompt-on-repost, --disable-renderer-backgrounding, --disable-sync, --force-color-profile=srgb, --metrics-recording-only) — 33 flags in total, defined as DEFAULT_CHROME_ARGS in crates/core/src/session.rs.

UA / platform / Client-Hints consistency

This is the only “override” we apply, via CDP Emulation.setUserAgentOverride (not page-world JS) in Page::apply_stealth. We probe the browser’s real UA, strip any Headless token, and from that one string derive a coherent identity so UA, navigator.platform, and navigator.userAgentData all agree:

SignalValue (for the real Linux/Chrome UA)
navigator.userAgentreal build, HeadlessChrome -> Chrome
navigator.platformLinux x86_64 (Win32 / MacIntel for those UAs)
Sec-CH-UA-PlatformLinux / Windows / macOS
userAgentData.brands / fullVersionListChromium/Google Chrome at the UA’s major/full version + a GREASE entry

A mismatch here (e.g. the old hardcoded platform: "Win32" on a Linux UA, or empty brands) is itself a bot signal — both flagged by the rebrowser bot-detector, now green.

GPU acceleration (hardware WebGL)

A headless browser that renders WebGL with SwiftShader (Chrome’s software fallback) advertises itself: WEBGL_debug_renderer_info returns "ANGLE (… SwiftShader …)", which Cloudflare and others weight as “no real GPU -> likely a bot/VM.”

VoidCrawl forces hardware rendering:

  • --headless=new — the legacy --headless forces SwiftShader; the new mode runs the full browser stack and can use a real GPU.
  • --use-angle=vulkan + --enable-gpu + --ignore-gpu-blocklist — route WebGL through ANGLE on the real GPU.
  • --disable-gpu-sandbox — lets the GPU process reach the DRM render node. This is the lever — on a host with a working driver it’s usually all you need (no VK_DRIVER_FILES juggling).

Verified on AMD (RADV): renderer becomes ANGLE (AMD, Vulkan … (AMD Radeon … RADV …)), radv) — hardware, not SwiftShader. The defaults are vendor-generic (ANGLE uses whatever Intel/AMD/NVIDIA driver the machine has); nothing is hardcoded per vendor.

In Docker, hardware GPU additionally needs Mesa drivers in the image + /dev/dri passthrough — see /voidcrawl/guides/docker/ and /voidcrawl/guides/docker/. Without a GPU passed through, the container falls back to SwiftShader.

To force software rendering (or pick a different backend), override --use-angle (see below).

What we don’t touch (and why)

We inject no page-world JS, and we leave these alone:

SignalWhy
navigator.webdriverThe launch flag already yields a native false. A JS patch (deleting it -> undefined, or a redefined getter) is itself detectable.
navigator.pluginsReal Chrome populates it; faking creates inconsistencies.
navigator.userAgentWe use the real UA (Headless stripped) — no version mismatch.
WebGL vendor/rendererThe real GPU string (once hardware-accelerated) beats any fake.
window.chrome.runtime, navigator.permissions, canvasDefault behavior is already correct; spoofing adds detectable noise.
Shadow DOM modeWe do not force-open it (it broke Turnstile). Interacting with a challenge widget works via real compositor clicks at pixel coordinates regardless of shadow mode — locate the target inside a closed root with ax_box_in_frame / click_ax_in_frame (see Challenge escalation).

Headful vs headless (and managed Turnstile)

For the toughest WAFs, headful is required — headless has detectable differences that survive every JS patch:

  • Different rendering/compositing pipeline.
  • Missing / non-default screen, media, and input-related properties.
  • The managed-challenge score is simply lower.

Concretely, against managed Cloudflare Turnstile / full-page challenge canaries, headful is necessary but not sufficient:

ModeOutcome
Host/container headfulBest baseline, but still depends on IP reputation, profile warmth, sandbox/container posture, and launch surface
Docker headful from this hostCurrently stalls at Just a moment… for VoidCrawl, nodriver, and bare Chrome alike
HeadlessUsually stalls; no token
import os
from voidcrawl import BrowserPool
# WAF / managed-Turnstile targets -- headful:
os.environ["CHROME_HEADLESS"] = "0"
async with BrowserPool.from_env() as pool:
async with pool.acquire() as tab:
await tab.navigate("https://waf-protected-site.com")
await tab.wait_for_network_idle(timeout=15.0)
html = await tab.content()
# Unprotected / bulk targets -- headless is fine and faster (default).

For a headless farm that still needs to clear Turnstile, run the headful GPU container (/voidcrawl/guides/docker/) rather than headless.

The full-page Managed Challenge / Challenge Page interstitial (“Just a moment…”, served by the edge in front of a route) is the hard gate. Headful + GPU is only the starting point; use a warm persisted profile and clean network exit before treating a library as the limiting factor.

Minimal CDP footprint (full-page Managed Challenge)

The full-page Cloudflare Managed Challenge interstitial is sensitive to the CDP control channel and to environment (IP/profile/container/sandbox). The current Docker canary from this host is challenged for VoidCrawl, nodriver, and bare Chrome alike, so use it as a parity benchmark rather than a claim that CDP minimization alone can pass every gate.

chromiumoxide eagerly enables a set of CDP domains on every target, and each one is a tell a clean browser never sends. cdp_mode="minimal" skips them:

CDP enableWhy it’s a tellIn minimal mode
Runtime.enableEmits Runtime.consoleAPICalled and serializes console arguments — the canonical CDP-automation signal, and observable from page JSskippedRuntime.evaluate still works in the main world
Network.enableA clean browser doesn’t subscribeskipped (lose response capture / network-idle)
Performance.enable, Log.enableEager instrumentationskipped (no loss)
Target.setAutoAttach(waitForDebuggerOnStart)Automation-shaped; observable via child-target startup timingskipped (lose OOPIF auto-attach)
Isolated-world addScriptToEvaluateOnNewDocumentA persistent injected scriptskipped (lose evaluate_function)
from voidcrawl import BrowserConfig, BrowserSession
async with BrowserSession(
BrowserConfig(headless=False, stealth=True, cdp_mode="minimal")
) as b:
page = await b.new_page("about:blank")
await page.navigate("https://site-behind-a-cloudflare-challenge.com")
# The interstitial clears on its own; poll the title rather than awaiting
# network idle, which needs the Network domain:
while "just a moment" in (await page.eval_js("document.title")).lower():
await asyncio.sleep(1)

The mode is per session, so one process can run a minimal session against a walled target and a normal session for capture work at the same time. In Rust: BrowserSessionBuilder::minimal_cdp(), or .cdp_mode(CdpMode::Minimal).

The older process-global VOIDCRAWL_STEALTH_NO_RUNTIME=1 environment variable still selects minimal mode when no explicit cdp_mode is given. Prefer cdp_mode: the variable applies to every session in the process, so it cannot express “minimal here, normal there”.

Escalating a minimal session

A minimal session is not a dead end. Network.enable and Runtime.enable are runtime CDP commands, not launch flags, so a tab can start quiet, clear a wall, and then be escalated deliberately. This is exactly what Chrome DevTools does when you open its Network panel — and why that panel says “reload to record”.

async with BrowserSession(BrowserConfig(cdp_mode="minimal")) as b:
page = await b.new_page("https://walled-site.com")
... # quiet CDP surface: no Network domain
await page.escalate_network() # explicit, greppable
await page.reload() # capture starts here
caps = await page.expect_responses({"api": "*/api/*"})

Escalation is never automatic. A session launched minimal was launched that way on purpose, so the network helpers fail closed with CdpDomainNotEnabled — naming the method that fixes it — rather than quietly making the session louder or handing back empty data.

MethodEnablesPage-observable?
await page.escalate_network()Network — response capture, request-header capture, network-idle waits, set_headersNo. It injects nothing and changes no JS-visible state, so it is the cheap one to turn on late
await page.escalate_runtime()Runtime — frame-scoped evaluate_js_in_frameYes. The most-cited CDP automation tell. Do not escalate Runtime on a tab that still has a challenge to pass

Both are idempotent and are no-ops under cdp_mode="normal", so mode-agnostic code can call them freely.

Tab routing

await tab.instrumentation_state() on any Page or PooledTab reports what that handle has enabled, which gives a simple routing rule: send gates to quiet tabs, capture to escalated ones, and reach for a fresh tab rather than trying to “un-taint” one.

state = await tab.instrumentation_state()
if state.low_cdp:
await tab.navigate("https://site-behind-a-managed-challenge.com")
else:
# Already instrumented -- use it for capture / debug work.
response = await tab.goto("https://example.com", capture_endpoints=True)
FieldMeaning
low_cdpTrue while neither high-signal domain is enabled — the state a cdp_mode="minimal" tab starts in
network_enabledTrue after Network.enable. Seeded True under cdp_mode="normal"
runtime_enabledTrue after Runtime.enable. eval_js uses one-shot Runtime.evaluate and does not set this

Escalation is per-tab and does not leak, so a fresh tab from the same session is still quiet.

Overriding the defaults

Every default flag is overridable by the caller — useful to force a GPU backend, disable acceleration, add a proxy bypass, etc. Caller args are merged by switch key, so a caller value replaces the matching default (we don’t emit duplicate switches — Chrome’s per-switch precedence is inconsistent):

from voidcrawl import BrowserConfig
# Force software rendering (e.g. to compare, or on a GPU-less box):
cfg = BrowserConfig(extra_args=["--use-angle=swiftshader"])
# Disable the GPU entirely:
cfg = BrowserConfig(extra_args=["--disable-gpu"])

The same applies through the MCP server / pool config (extra_args).

Waiting for readiness (event-driven)

JS-heavy sites and challenge pages aren’t ready at page load. Two event-driven waits — no polling, no sleeps:

async with pool.acquire() as tab:
await tab.navigate(url)
# Chrome's networkIdle lifecycle event (returns event name, or None on timeout):
await tab.wait_for_network_idle(timeout=15.0)
# …or an in-page MutationObserver for a specific selector:
await tab.wait_for_selector("#results", timeout=15.0)

Why networkIdle is unreliable for SPAs

networkIdle fires after zero in-flight requests for 500ms — but WebSockets, SSE/long-polling, analytics beacons, and lazy-loading keep the network active, so on many modern apps it never fires. Prefer wait_for_selector("<the element you actually care about>"): it resolves the moment that element is inserted, regardless of network.

Real-world results

TargetApproachResult
Akamai WAF (BusinessWire)chromiumoxide defaults (--enable-automation)403
Akamai WAF (BusinessWire)+ heavy JS spoofing + fake UA403
Akamai WAF (BusinessWire)disable_default_args + clean flags + real UASuccess
Managed Cloudflare / Turnstile gatesheadful, hardware GPU, consistent UA, no JS injection, plus opt-in cdp_mode="minimal"Best available posture; final result depends on profile/IP/environment
Docker Cloudflare canary from this hostVoidCrawl, nodriver, and bare ChromeAll challenged (Just a moment…)
Managed Cloudflare / Turnstile gatesheadlessUsually gated

The lesson, twice over: the flags + a consistent real browser matter more than JS patches — and a wrong JS patch is worse than none.