Patching and Building Chromium for Stealth
When the Fingerprint Is Not Computed in JavaScript
A JavaScript override can change what an API returns. It cannot change where that value came from, and a detector that reads the native side sees the disagreement. At that point no stealth plugin will help, because the fix is not in the page; it is in the browser source.
This lesson is about that escalation, and it is deliberately a last resort. The stealth browser route and the production Playwright pipeline clear most targets for a fraction of the cost.
Why an Override Cannot Reach the Source
The values a fingerprint script reads are computed in C++ and handed to JavaScript through Blink bindings. Canvas pixels come out of Skia in the renderer. UNMASKED_RENDERER_WEBGL is a string held by the GPU process, read from the real ANGLE backend. The audio fingerprint is a float buffer produced by the platform resampler in the audio service. An Object.defineProperty override changes the return value at the JS boundary; the native side still holds the truth.
That matters because the strongest probes do not use the boundary you patched. A worker-thread OffscreenCanvas never sees your main-world override, so its hash disagrees with the HTMLCanvasElement hash. An AudioWorklet runs on the audio thread with no DOM access at all. A second WebGLRenderingContext over a fresh canvas re-reads the GPU process. And Object.getOwnPropertyDescriptor shows the override sitting on the wrong prototype, at the wrong enumerability, with a toString that does not look native. Compare the treatment in spoofing canvas, WebGL and audio: JS-level spoofing is a presentation layer, and presentation layers get cross-examined.
What a Source Patch Looks Like
A patch is usually one line in one .cc file, and the whole difficulty is finding that line. The human-readable name rarely appears in the implementation: navigator.webdriver lives in the WebDriver module, not in a file called navigator.cc. Search for the machine-readable name instead.
# enum and extension names appear in the binding, not the prose
git grep -n 'UNMASKED_RENDERER_WEBGL' -- '*.cc' '*.h' '*.idl'
git grep -n 'webgl_renderer_info' -- '*.json' '*.idl'
git grep -n 'HeadlessChrome' -- '*.cc' '*.h'
git grep -n 'NavigatorWebdriver' -- '*.cc'
The result is a one-line diff, and in review it looks almost boring:
--- a/third_party/blink/renderer/modules/webdriver/navigator_webdriver.cc
+++ b/third_party/blink/renderer/modules/webdriver/navigator_webdriver.cc
@@ -14,7 +14,7 @@ bool NavigatorWebdriver::webdriver() const {
- return webdriver_enabled_;
+ return false;
That is the shape of the work. Not writing clever code, but locating the single place where a value is produced and changing what it produces, so that every consumer of that value -- including the worker, the audio thread and the GPU process -- agrees with the page.
The Patch Targets That Matter
| Signal | Where it is computed | What the patch does |
|---|---|---|
navigator.webdriver |
modules/webdriver/navigator_webdriver.cc |
return false regardless of --enable-automation / --headless |
HeadlessChrome in the UA |
content/common/user_agent.cc |
swap the headless product token for Chrome |
plugins / mimeTypes |
modules/navigator_plugins/navigator_plugins.cc |
return the five real PDF entries |
window.chrome surface |
chrome/browser/ extension and app bindings |
ship runtime, csi, loadTimes, app |
UNMASKED_VENDOR/RENDERER_WEBGL |
modules/webgl/webgl_rendering_context_base.cc |
substitute pinned vendor and renderer strings |
navigator.userAgentData |
modules/navigatorua/navigator_ua_data.cc |
pin brands, platform, bitness, model to match the UA |
| SwiftShader by default | headless/public/switches.cc, gpu/config/gpu_switches.cc |
stop injecting disable-gpu into headless defaults |
The UA-CH struct is the one people forget. navigator.userAgentData.getHighEntropyValues() returns platform, platformVersion, architecture, bitness, model and a full brand-version list, and the server compares it against the User-Agent header and the Sec-CH-UA request headers. A patched UA with an unpatched UA-CH struct is a contradiction the client did not need to be clever to spot. The GPU switches matter for the same reason: leave disable-gpu in place and UNMASKED_RENDERER_WEBGL reports SwiftShader no matter how many strings you patch on top of it.
Scripting and Verifying a Patch Set
Never hand-apply ten edits and hope. A patch set is a list of hunks, it is applied mechanically, and every hunk reports exactly one of three outcomes: applied, ambiguous, or missing. Ambiguous means your search string matched more than once and you would have patched the wrong site; missing means upstream moved the symbol. Both are CI failures.
import difflib
FIXTURES = {
"third_party/blink/renderer/modules/webdriver/navigator_webdriver.cc":
"bool NavigatorWebdriver::webdriver() const {\n"
" return webdriver_enabled_;\n"
"}\n",
"content/common/user_agent.cc":
"const char kHeadlessProduct[] = \"HeadlessChrome\";\n",
"third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc":
" case GL_debug_vendor_info::UNMASKED_VENDOR_WEBGL:\n"
" value = String::FromUTF8(context_->GetGLVendorString());\n"
" break;\n"
" case GL_debug_renderer_info::UNMASKED_RENDERER_WEBGL:\n"
" value = String::FromUTF8(context_->GetGLRendererString());\n"
" break;\n",
"third_party/blink/renderer/modules/navigator_plugins/navigator_plugins.cc":
"HeapVector<Member<Plugin>> NavigatorPlugins::plugins() const {\n"
" return HeapVector<Member<Plugin>>();\n"
"}\n",
"headless/public/switches.cc":
"const char kDisableGpu[] = \"disable-gpu\";\n"
"const char kEnableAutomation[] = \"enable-automation\";\n",
}
PATCHES = [
("P01", "navigator.webdriver -> false",
"third_party/blink/renderer/modules/webdriver/navigator_webdriver.cc",
" return webdriver_enabled_;\n", " return false; // P01\n"),
("P02", "HeadlessChrome UA token", "content/common/user_agent.cc",
"const char kHeadlessProduct[] = \"HeadlessChrome\";",
"const char kHeadlessProduct[] = \"Chrome\"; // P02"),
("P03", "UNMASKED_VENDOR_WEBGL", "third_party/blink/renderer/modules/webgl/"
"webgl_rendering_context_base.cc",
"value = String::FromUTF8(context_->GetGLVendorString());",
"value = \"Google Inc. (NVIDIA)\"; // P03"),
("P04", "UNMASKED_RENDERER_WEBGL", "third_party/blink/renderer/modules/webgl/"
"webgl_rendering_context_base.cc",
"case GL_debug_renderer_info::UNMASKED_RENDERER_WEBGL:\n"
" value = String::FromUTF8(context_->GetGLRendererString());",
"case GL_debug_renderer_info::UNMASKED_RENDERER_WEBGL:\n"
" value = spoofed_renderer_; // P04"),
("P05", "navigator.plugins array", "third_party/blink/renderer/modules/"
"navigator_plugins/navigator_plugins.cc",
" return HeapVector<Member<Plugin>>();", " return kSpoofedPluginList; // P05\n"),
("P06", "drop disable-gpu default", "headless/public/switches.cc",
"const char kDisableGpu[] = \"disable-gpu\";\n",
"// P06: disable-gpu default removed\n"),
("P07", "drop enable-automation default", "headless/public/switches.cc",
"const char kEnableAutomation[] = \"enable-automation\";\n",
"// P07: enable-automation default removed\n"),
("P08", "unanchored 'break;' edit", "third_party/blink/renderer/modules/webgl/"
"webgl_rendering_context_base.cc",
" break;\n", " break; // P08\n"),
("P09", "userAgentData brand list",
"third_party/blink/renderer/modules/navigatorua/navigator_ua_data.cc",
"brands_ = DefaultBrands();", "brands_ = PinnedBrands(); // P09\n"),
("P10", "WebGPU adapter string", "gpu/command_buffer/service/gpu_init.cc",
"software_rendering = true;", "software_rendering = false; // P10\n"),
]
REPORT = []
for pid, target, path, search, replace in PATCHES:
text = FIXTURES.get(path)
if text is None:
report = ("NOFILE", 0, "no such file in the checkout")
else:
hits = text.count(search)
if hits == 0:
report = ("MISSING", 0, "search string not found")
elif hits > 1:
report = ("AMBIGUOUS", hits, "add context to the search")
else:
FIXTURES[path] = text.replace(search, replace, 1)
report = ("APPLIED", hits, "")
REPORT.append((pid, target, path, report[0], report[1], report[2], search, replace))
print("patch-set dry run: {} hunks against {} files".format(len(PATCHES), len(FIXTURES)))
print()
print("{:<5} {:<29} {:<10} {:>4} {}".format("ID", "TARGET", "STATUS", "HITS", "FILE"))
print("-" * 100)
for pid, target, path, status, hits, note, _, _ in REPORT:
short = path.replace("third_party/blink/renderer/modules/", "tbb/") if "/" in path else path
print("{:<5} {:<29} {:<10} {:>4} {}".format(pid, target, status, hits, short))
if note:
print("{:<5} ^ {}".format("", note))
blocked = [r for r in REPORT if r[3] != "APPLIED"]
print()
print("{} of {} hunks applied; {} need re-anchoring".format(
len(REPORT) - len(blocked), len(REPORT), len(blocked)))
print()
for pid, target, path, status, hits, note, search, replace in REPORT:
if status == "APPLIED":
for line in difflib.unified_diff(search.splitlines(), replace.splitlines(),
fromfile="a/" + path, tofile="b/" + path,
lineterm="", n=0):
print(line)
print()
patch-set dry run: 10 hunks against 5 files
ID TARGET STATUS HITS FILE
----------------------------------------------------------------------------------------------------
P01 navigator.webdriver -> false APPLIED 1 tbb/webdriver/navigator_webdriver.cc
P02 HeadlessChrome UA token APPLIED 1 content/common/user_agent.cc
P03 UNMASKED_VENDOR_WEBGL APPLIED 1 tbb/webgl/webgl_rendering_context_base.cc
P04 UNMASKED_RENDERER_WEBGL APPLIED 1 tbb/webgl/webgl_rendering_context_base.cc
P05 navigator.plugins array APPLIED 1 tbb/navigator_plugins/navigator_plugins.cc
P06 drop disable-gpu default APPLIED 1 headless/public/switches.cc
P07 drop enable-automation default APPLIED 1 headless/public/switches.cc
P08 unanchored 'break;' edit AMBIGUOUS 2 tbb/webgl/webgl_rendering_context_base.cc
^ add context to the search
P09 userAgentData brand list NOFILE 0 tbb/navigatorua/navigator_ua_data.cc
^ no such file in the checkout
P10 WebGPU adapter string NOFILE 0 gpu/command_buffer/service/gpu_init.cc
^ no such file in the checkout
7 of 10 hunks applied; 3 need re-anchoring
--- a/third_party/blink/renderer/modules/webdriver/navigator_webdriver.cc
+++ b/third_party/blink/renderer/modules/webdriver/navigator_webdriver.cc
@@ -1 +1 @@
- return webdriver_enabled_;
+ return false; // P01
--- a/content/common/user_agent.cc
+++ b/content/common/user_agent.cc
@@ -1 +1 @@
-const char kHeadlessProduct[] = "HeadlessChrome";
+const char kHeadlessProduct[] = "Chrome"; // P02
--- a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
+++ b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
@@ -1 +1 @@
-value = String::FromUTF8(context_->GetGLVendorString());
+value = "Google Inc. (NVIDIA)"; // P03
--- a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
+++ b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
@@ -2 +2 @@
- value = String::FromUTF8(context_->GetGLRendererString());
+ value = spoofed_renderer_; // P04
--- a/third_party/blink/renderer/modules/navigator_plugins/navigator_plugins.cc
+++ b/third_party/blink/renderer/modules/navigator_plugins/navigator_plugins.cc
@@ -1 +1 @@
- return HeapVector<Member<Plugin>>();
+ return kSpoofedPluginList; // P05
--- a/headless/public/switches.cc
+++ b/headless/public/switches.cc
@@ -1 +1 @@
-const char kDisableGpu[] = "disable-gpu";
+// P06: disable-gpu default removed
--- a/headless/public/switches.cc
+++ b/headless/public/switches.cc
@@ -1 +1 @@
-const char kEnableAutomation[] = "enable-automation";
+// P07: enable-automation default removed
Two of the three blocked hunks here are the ones that would have wasted an afternoon. P08 searched for break;, which occurs twice in the WebGL switch; the patch would have landed on the vendor case and left the renderer case returning the real GPU string. P09 and P10 reference files a Chromium release no longer ships at those paths. Re-anchor them or drop them -- silently skipping a hunk is how a "patched" browser ships with a live leak.
The Build Itself
A Chromium build is a real engineering project with a real cost, and the numbers matter when you argue for it.
git clone --depth=1 https://chromium.googlesource.com/chromium/src.git chromium
cd chromium
git checkout 130.0.6723.92
git apply ../patches/0001-webdriver-always-false.patch
gclient config https://chromium.googlesource.com/chromium/src.git
gclient sync --with_branch_heads --shallow
gn gen out/stealth --args="is_debug=false symbol_level=1"
ninja -C out/stealth chrome
ls -lh out/stealth/chrome
Budget honestly: the shallow clone plus gclient sync pulls 30-40 GB of dependency repositories, out/stealth for the chrome target alone runs 20-40 GB, and the first build takes roughly 20-60 minutes on a 32-core machine and three to six hours on a laptop. Every upstream release you want to track means a new checkout and a new build, because patch hunks are written against one tree. Budget a machine with 16 cores, 64 GB of RAM and 200 GB of free disk, and cache the build artifacts in CI keyed on the commit.
What a Rebuild Does Not Fix
A patched binary is still your network stack, your driver, your clock and your IP address. It does not change your TLS fingerprint, your JA4, your HTTP/2 frame order, your header order, or the fact that a cloud provider owns your ASN. It does not make your timing look human, and it does not give you an aged account. These are the signals that decide most outcomes, and they are cheaper to fix: curl_cffi for the transport, the IP reputation and proxy work for the network, the behavioural model for the timing.
The practical consequence is that most teams run a hybrid: a stock browser from a vendor for 95 percent of traffic, and a self-built patched binary only for the handful of targets whose JavaScript challenge reads a native value your overrides cannot reach. That is why the browser pool treats the browser as a scarce leased resource rather than a per-request dependency.
Verifying the Result
Verification is the same regression suite you use for a stealth plugin, pointed at the new binary: the public scanners first (bot.sannysoft.com, creepjs.com, arh.antoinevastel.com), then a target's own challenge, then a real business flow with an assertion on the extracted data. The three checks worth automating on every rebuild:
- Property shape.
Object.getOwnPropertyDescriptorfor every value you patched returns the same descriptor the stock browser returns: same prototype, sameconfigurable, sameenumerable, same nativetoString. - Cross-context agreement. A worker
OffscreenCanvashash equals the main-world hash, and anAudioWorkletfingerprint equals theAudioContextfingerprint. Divergence here means you patched the boundary and not the source. - Batch diff. Store the scanner's JSON output per build. A new Chromium milestone and a new patch set should produce a diff you can read, not a surprise.
Reproducibility, and the Fork's Own Fingerprint
A self-built binary has an auditability a downloaded one does not: the patch set is in your repository, the build is reproducible from a pinned commit, and anyone on your team can read the complete list of what you changed. That is worth real money during an incident, and it is the strongest argument for building.
There is a counter-argument that experienced operators take seriously. A widely used vendor fork eventually becomes its own fingerprint. If three million sessions run a build with one particular quirk -- a slightly different extension list, a chrome.app shape that exists in no release build, a canvas noise function that never appears in the wild -- then that combination is a rare value, and rarity is the signal anomaly models key on. In practice this argues for keeping the patched surface as small as possible, matching the exact browser version your profile claims, and rotating builds on the same cadence as upstream. A patch that fixes one leak and introduces a rare signature has made things worse.
Checklist
Before a patched binary is worth shipping:
- The patch set lives in version control as
git apply-able hunks, one per signal, each carrying a comment naming the file it targets and the leak it closes, so a reviewer can read the whole surface in one diff. - A dry run reports applied, ambiguous or missing for every hunk, and any blocked hunk fails the build rather than shipping a partial patch.
- UA,
userAgentDataandSec-CH-UAare patched together, because a UA-CH struct that disagrees with the UA is a contradiction you created yourself. - The WebGL patch ships with the GPU switches that stop SwiftShader leaking through, and the probe checks the worker-thread, audio-thread and WebGPU paths rather than only main-world getters.
- Scanner output is captured per build and diffed against the previous milestone, so a regression is a line in a file instead of a week of mystery bans.
- A stock vendor browser remains the default path; the patched binary is scoped to named targets and named versions.
Staying Inside the Line
Building a patched browser for your own authorised testing, and for data you have a legitimate right to collect, is ordinary security engineering, and the version you can audit is the version you can reason about during an incident. What is out of scope for this course is shipping or selling a patched browser whose purpose is to defeat a specific site's access controls, or distributing a fork built to break one vendor's challenge. Keep the patch set about measuring and hardening your own clients, reach for the official API, a partner feed or a written permission when the target is not one you are authorised to test, and remember that a custom build still has to respect the access rules of whatever it visits.