← Back to blog
EngineeringAnti-detectionChromium2026-09-03 · 9 min read

How We Built an Undetectable Cloud Browser by Patching Chromium 147

Update: This post documents our original Chromium 147 build. Since then the patch set has been rebuilt on Chromium 151 — our patched engine is continuously updated with every Chromium release.

Every Chrome automation framework — Puppeteer, Playwright, Selenium — drives the browser over the Chrome DevTools Protocol. And every one of them leaves fingerprints. The navigator.webdriver flag, the --enable-automation switch, CDP input events flagged with a debugger source, injected scripts that show up in Error.stack. None of these are "caught by a clever detector" — they are leaks built into Chromium's own automation path.

Anti-bot vendors like DataDome, PerimeterX, Akamai and Cloudflare Turnstile check for exactly these signals. When we started, a vanilla headless Chrome got blocked on most of them. So we took a different approach: instead of masking fingerprints in JavaScript or shimming a stealth plugin on top, we patched Chromium itself — 31 patches in the Chromium source and 4 in V8 — so the browser is no longer distinguishable from a human-driven one at the engine level.

Why not a stealth library?

The popular approaches — puppeteer-extra-plugin-stealth, JS shims that override properties at page load — have a fundamental flaw: they patch symptoms, not roots. A detector doesn't have to catch your shim; it just has to check the underlying API in a way your shim didn't predict. The navigator.webdriver getter you overrode with JavaScript can be read again through a Proxy, a different frame, a Web Worker, or a V8-internal serialization path your override never touched.

The root cause of navigator.webdriver === true is not a JavaScript property. It is IsInAutomation(), a C++ flag deep in Blink that the CDP layer sets when a debugger connects. Patch the C++ flag and every detection path — property read, toString(), serialization, workers — agrees at once, because they all read the same source of truth.

Finding the leaks: differential enumeration + static analysis

To know what to patch, we built a two-stage discovery pipeline:

  • Runtime differential enumeration (fp_diff/v2): drive the same page once with CDP attached and once without, then enumerate every JavaScript-visible property and event difference between the two runs.
  • Static source analysis (fp_diff/v3_static): BFS-trace Chromium and V8 source code for every code path that branches on the CDP connection or automation flag. This produced 272 candidate findings, which we merged with 105 publicly-known detection points (75/105 = 71.4% recall on the first pass).

The golden rule we apply to every candidate fix: generality over specificity. A fix that makes one detector's test pass by special-casing its input is worthless — the detector changes its test parameters and you fail again. A fix to the rendering pipeline, the API layer, or the serialization layer holds for any input any detector can throw at it.

The patches, by layer

1. Static automation fingerprints (S1–S8)

  • S1: navigator.webdriver always returns false — patched at the IsInAutomation() source in Blink.
  • S2: cut the AutomationControlled feature flag activation entirely.
  • S3: neutralize the ApplyAutomationOverride probe.
  • S5: filter Browser.getBrowserCommandLine CDP responses (this is how a page can read the browser's real command-line flags, including --enable-automation).
  • S6: remove the HeadlessChrome identifier from the user agent.
  • S8: strip the kFromDebugger flag from CDP-dispatched input events.

2. Clean input path (CP1 + Phase 2)

When CDP dispatches a mouse or keyboard event, Chromium marks it internally as a "debugger" event. Page listeners can detect this via event.isTrusted semantics and other side channels. We built a clean injection path: synthetic input events are masked as non-debugger events (CP1), then extended with realistic detail — movementX/Y,wheelTicks, phase tracking, a unified path for mouse and drag events (Phase 2). A drag-and-drop synthesized over CDP now behaves like a real OS-level drag.

3. Tooling invisibility (T1–T3)

  • T1+T2: make Runtime.addBinding injections non-enumerable and spoof their toString() (V8 patches).
  • T3: strip sourceURL from injected scripts so they never appear in Error.stack traces.

4. Headless realism (H1–H12)

Headless mode changes dozens of observable defaults. We made the headless profile mirror a real device: screen dimensions and work-area insets (H2/H7), outerHeight/outerWidth (H1), standard PDF plugins in PluginData (H5), synthetic network connection defaults (H6), speech synthesis voices (H9), and the real keyboard layout map (H11). Screen size is even randomized at startup, per profile, so every session doesn't share one tell-tale resolution.

5. Fingerprint profile (L1–L3, F-series)

Hardware and font fingerprints are profile-driven rather than hard-coded: WebGL vendor/renderer,hardwareConcurrency, deviceMemory, platform,maxTouchPoints are all configurable via a JSON fingerprint profile (F1/F4/F5). User agent, navigator.language and timezone were added to the same profile (Profile-L10N, Profile-L2), and the HTTP layer is kept in sync so the header always matches the JS-visible UA.

Font rendering got special attention: measureText() differences come from font hinting. We overrode fontconfig rules to alias generic families (L1-fonts) and applied a targeted gasp-based hinting override for the specific sizes detectors probe (L1-fonts-gasp). After the fix, font metric differences against a real macOS browser went from 390 diffs to 0.

The V8 layer

Three patches landed in V8 itself, covering the deepest leak: CDP object serialization. When DevTools serializes a JavaScript object, it walks the prototype chain and can trigger getters,toString(), valueOf() — side effects a detector can use to distinguish an instrumented runtime. S10 protects getter access on the whole prototype chain for all CDP serialization paths; S11 adds command-line flags to disable the risky serialization entirely; S12 skips Symbol properties so they can't trigger toPrimitive during serialization.

A hard-won operational note: when Chromium bumps to a new stable version, the V8 patches must be re-applied separately (git am patches/v8/*.patch in the V8 sub-repo). Missing them silently breaks the addBinding enumeration test. Every new release is handled by a script that applies chromium-src and V8 patches together and locks the version file before rebuilding.

Does it hold up in the wild?

Lab detectors (Sannysoft, CreepJS, Rebrowser) are a start, but the real test is production anti-bot systems. In our field scans against 216 target sites — including DataDome-, PerimeterX-, Akamai-, and Cloudflare Turnstile-protected properties — Browser Forest's patched engine was able to extract content from 111 of them, where standard headless browsers were blocked outright.

In a direct head-to-head scrape test with a major competitor (same sites, same residential proxy, three runs each), our engine passed all three runs on a Cloudflare-protected site where the competitor was stopped by Cloudflare's Security Check on two of three. Engine-level patching is what separates the two: they route around fingerprints; we removed the fingerprints.

Undetectable is not a feature you bolt on — it's a property of the engine. Patching Chromium at the source is the only way we've found to make it real. If you're building agents or scrapers and getting blocked, the API is free to try — bring your hardest target and watch it load.