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.getOwnPropertyDescriptor for every value you patched returns the same descriptor the stock browser returns: same prototype, same configurable, same enumerable, same native toString.
  • Cross-context agreement. A worker OffscreenCanvas hash equals the main-world hash, and an AudioWorklet fingerprint equals the AudioContext fingerprint. 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, userAgentData and Sec-CH-UA are 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.