liarjs.dev
← Field notes
Architecture

[native code]: why JS-override stealth loses to engine-level spoofing

September 12, 2026 · 9 min read

A fault line runs through every anti-detect browser, and it is architectural rather than a matter of degree. On one side the spoof happens in JavaScript: a Proxy wraps a native function, or Object.defineProperty redefines a getter, or a value gets stapled onto the navigator instance. On the other side the spoof happens in the C++ implementation of the engine, so the value you read is what Blink produced. The first approach leaves residue that a lie-detector reads in a single toString() call. The second leaves none. What follows is why that gap cannot be closed from JavaScript, however careful the shim.

How Function.prototype.toString separates engine-level spoofing from JS overrides

The canonical probe: Function.prototype.toString

Ask any built-in function to describe itself and a real browser answers with a fixed grammar:

navigator.getGamepads.toString();
// "function getGamepads() { [native code] }"
Function.prototype.toString.call(HTMLCanvasElement.prototype.toDataURL);
// "function toDataURL() { [native code] }"

The [native code] marker is V8 reporting that the function has no JavaScript source to show, because it is a native binding. Replace that binding with a JS function (a wrapper, a Proxy, a redefined getter) and its source becomes visible, so toString returns the actual code:

const realUA = Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgent').get;
Object.defineProperty(Navigator.prototype, 'userAgent', {
  get() { return 'Mozilla/5.0 …'; }
});
Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgent').get.toString();
// "get() { return 'Mozilla/5.0 …'; }"  ← no [native code]. Caught.

The obvious counter is to spoof toString as well, so the wrapper reports [native code]. That cascades, and the cascade never terminates cleanly. If you patch Function.prototype.toString to lie about your wrapper, then toString itself is now a non-native function, so a detector calls toString on toString:

const ts = Function.prototype.toString;
ts.call(ts).includes('[native code]');   // must be true on a real engine
ts.toString();                            // "function toString() { [native code] }"

Our scanner’s integrity probe does exactly this before anything else, toStr.call(toStr), because a spoofer who patches toString to cover their tracks has to make toString lie about toString, and now every branch of that recursion has to stay internally consistent under adversarial re-reads from fresh realms. Getting it right in one realm on the main thread is achievable. Getting it right everywhere is where JS shims fall apart (more on realms below). The probe runs in plain V8, needs no privileged API, and is checked by CreepJS and every serious commercial detector.

The own-property vs prototype-getter tell

The second structural tell is cheaper still, and it catches the laziest and most common class of JS override. On a real browser, the interesting navigator properties are accessor getters defined on Navigator.prototype, not values on the instance:

Object.prototype.hasOwnProperty.call(navigator, 'userAgent');  // false, real
'userAgent' in navigator;                                       // true (inherited)
Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgent');
// { get: ƒ, set: undefined, enumerable: true, configurable: true }

navigator.userAgent resolves by walking the prototype chain to a getter on Navigator.prototype. The instance owns nothing. The path-of-least-resistance override writes the value directly onto the instance:

Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 …' });
Object.prototype.hasOwnProperty.call(navigator, 'userAgent');  // true, TELL
Object.getOwnPropertyNames(navigator);                         // ['userAgent', …], TELL

An own property where a prototype getter belongs gives the game away. Enumerating Object.getOwnPropertyNames(navigator) on a genuine browser returns essentially nothing interesting; on a naive spoof it returns the exact list of properties the operator decided to fake. Our scanner probes the full set (userAgent, platform, languages, hardwareConcurrency, deviceMemory, plugins, mimeTypes, webdriver, vendor, appVersion) and flags any own-instance hit. This is what separates a weekend page.evaluateOnNewDocument patch from anything considered production-grade.

Proxy tells and why they multiply

A Proxy is the sophisticated operator’s tool, because it can intercept get, has, and property-descriptor traps in one object. It is also the richest source of residue, because a Proxy has to behave like the thing it wraps across every reflective operation, and it never quite does:

// 1. toString still betrays the target through the trap
const p = new Proxy(function getGamepads() {}, {});
p.toString();                       // wrapper source, not "[native code]"

// 2. error stacks name the shim's realm and call frames
try { (new Proxy({}, {})).call(); } catch (e) { /* e.stack shows the trap */ }

// 3. descriptor invariants leak: a Proxy cannot report a non-configurable
//    descriptor that disagrees with its target without throwing
Object.getOwnPropertyDescriptor(navigator, 'userAgent');

// 4. timing: trap dispatch is measurably slower than a native property read
const t0 = performance.now();
for (let i = 0; i < 1e6; i++) void navigator.userAgent;
performance.now() - t0;             // elevated when a getter/trap sits in the path

Beyond these, any JavaScript shim, Proxy or plain wrapper, surfaces in Error.stack. A native getter contributes no JS frame. A shim inserts one, and a detector that throws inside a property access and walks the stack can see the injected frame and the script origin. Vast’s own headless-detection pages have long leaned on Error.stack shape as a signal.

The realm problem: the tell that JS cannot patch away

This is the deepest reason JS overrides lose. A JavaScript patch runs in one realm, typically the top document’s main-world context. But the page can mint fresh realms at will, and each one comes with a pristine set of built-ins that your patch never touched:

// a fresh iframe realm, untouched prototypes
const f = document.createElement('iframe');
document.body.appendChild(f);
const freshNav = f.contentWindow.navigator;
Object.prototype.hasOwnProperty.call(freshNav, 'userAgent');   // false, real behavior
f.contentWindow.Function.prototype.toString.call(
  f.contentWindow.navigator.getGamepads);                       // "[native code]" if untouched
// a Worker realm, re-read the same signals off the main thread
const w = new Worker(URL.createObjectURL(new Blob([`
  postMessage(navigator.hardwareConcurrency + '|' + navigator.userAgent);
`], { type: 'text/javascript' })));
w.onmessage = e => console.log(e.data);   // must match the main thread exactly

A shim that only patched the main-thread navigator will disagree with the Worker or with a freshly created iframe, and CreepJS explicitly cross-checks worker and iframe realms against the main thread for exactly this. To cover them all, a JS approach has to re-inject into every realm the moment it is created, racing the page’s own code. Any missed realm, any timing gap, is a lie.

Why engine-level spoofing has no fingerprint-layer tell

Here is the payoff. If the value is produced by the C++ implementation of the getter, a source-patched Chromium where Navigator::userAgent() in Blink returns the spoofed string, then at the JavaScript layer there is nothing to find:

Probe JS override (Proxy / defineProperty) Engine-level (patched Blink/V8)
fn.toString() wrapper source or patched toString genuine [native code]
toString of toString must recursively lie genuinely native
hasOwnProperty on instance often true (own prop) false, getter stays on prototype
Object.getOwnPropertyNames(navigator) leaks the faked list clean
Error.stack on access injected JS frame no JS frame
Property-read timing elevated (trap/getter dispatch) native-speed
Worker / iframe realm re-read may disagree unless re-injected inherits the same C++ behavior for free

The spoof is indistinguishable from truth at the JS layer because it is the implementation. There is no wrapper to unmask, no own property to enumerate, no extra stack frame, no trap to time, and every realm (main thread, Worker, nested iframe) inherits the patched behavior with zero extra work, because they all instantiate from the same compiled Blink. A source-patched Chromium, done right, has no “override” tells on the fingerprint layer at all. Our scanner’s “native function integrity” check is one of its highest-weighted single checks precisely because it cleanly partitions the two worlds. It deducts 35 points when core APIs are not genuine [native code] (spoofing is being done in JS) and warns at 15 when navigator carries own-instance properties where prototype getters belong. Nothing else separates the two architectures so sharply in a single reading.

The defender consensus points the same way

This is not a boutique concern invented by kernel authors. The people who build the detectors have said the quiet part out loud. As the ex-DataDome research VP Antoine Vastel and the niespodd notes put it:

Detection targets the underlying automation itself, including tools that try to hide behind anti-detection features. The AudioContext/WebGL overrides of commercial anti-detect browsers can be spotted with custom JS.

Read that carefully. The defender position is not “we detect bad browsers.” It is that the whole class of commercial anti-detect browsers, the ones that lag official Chromium by roughly ten versions and paper over the difference with JavaScript AudioContext/WebGL overrides, is one detectable population. From that vantage a JS-shimmed anti-detect browser is not hiding. It is announcing itself with a slightly different set of overrides than the last one. Engine-level spoofing is the direct answer: if there is no JS override to spot with custom JS, the premise of that detection collapses.

Necessary, not sufficient

One honest caveat, because the whole point of this blog is to hedge where hedging is due. Being [native code] everywhere removes the JS-layer tells, and only those. It does nothing for the orthogonal layers that live outside the fingerprint surface:

  • Network / TLS: JA3/JA4/JA4H, HTTP/2 SETTINGS frame order. A perfect [native code] browser behind a proxy that mangles the ClientHello is still caught at the transport layer, before a byte of JS runs.
  • CDP / automation: Runtime.enable leaks, execution-context residue, navigator.webdriver. These are properties of how you drive the browser, not of the engine’s getters, and a source-patched kernel driven carelessly over CDP will still trip a rebrowser-class probe.

Those are separate posts and separate accounts in the ledger. Passing the integrity check means you have won the fingerprint-layer argument. It does not mean you have won.

Takeaway

Function.prototype.toString is the cheapest lie-detector in the browser, and it draws the sharpest line in anti-detect. Any spoof implemented in JavaScript (Proxy, redefined getter, own-instance property) leaves a residue: a non-native toString, an enumerable own property, a stack frame, a timing delta, or a realm it forgot to patch. A spoof implemented in the C++ engine leaves none, because it is the implementation and every realm inherits it for free. If your stealth strategy is a pile of defineProperty calls, assume the fingerprint layer already knows. Being genuinely native everywhere is the price of entry. Necessary, not sufficient, but non-negotiable.

Sources & further reading
  1. CreepJS (Abraham Juliot)
  2. niespodd/browser-fingerprinting
  3. Antoine Vastel, bot detection research
More field notes
← Back to field notes