Native Override Lies and What a toString Whitelist Sees
One probe on patched APIs: which overrides a toString whitelist catches, which descriptors still leak, and why a Proxy or a bound function passes as native.
BotBrowser Team
Want the structured docs for Fingerprint?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping a bounded record of which APIs a page read, but it cannot hide a JavaScript-level override from a lie detector, cannot make a Proxy wrapper print the wrapped function's source, and cannot change what Function.prototype.toString reports for a function the engine still considers native. This review reproduces the check set an open-source fingerprint library runs, applies it to ten fixed cases, and records which overrides the whitelist catches, which descriptors still expose a patch, and the two wrappers that come back clean even though the page did change the value a reader receives.
TL;DR
- An untouched native passes every check, so the probe prints
control:Math.random -> cleanand the same forArray.prototype.mapandFunction.prototype.toString. - Replacing
Math.randomwith a plain function leaves one flag:naive:replaced Math.random -> failed toString, because the source text is no longer the[native code]shape. - Adding a fake
toStringto that function does not fix it: the probe printsfailed toString | failed toString toString | extra own keys: toString, so the patch now leaks three ways instead of one. - A function that returns
0.42still shows its body when the detector calls the realtoStringthrough a bound reference, soclone:fnToString-callprints the page's own source and the clone case still flags. - A
Proxythat interceptsapplypasses the whole battery, soproxy:Math.random -> cleanwhile the value returned to the page is the trap's value. - A bound function also passes, because the probe prints
bind:fnToString -> function () { [native code] }andbind:checks -> clean. - A clean native and a patched one differ by exactly one flag on the cheap path, so
separation -> cleanFlags=0 patchedFlags=1is all the margin the detection has. - The host itself can produce a flag that is not a page override: the probe prints
getter:checks -> failed toString hostShape=truefor a built-in getter whose source is an implementation body.
Contents
- What the whitelist accepts
- What a naive override leaves behind
- What a fake toString still leaks
- The two wrappers the whitelist cannot see
- Record a lie-detection reading
What the whitelist accepts
The cheapest integrity check in the fingerprinting world reads each API function's source text and compares it against a fixed list of shapes the engine can produce. An open-source fingerprint suite publishes exactly this list in its lie-detection module, and the shapes are small enough to write out exactly:
// the three shapes the list accepts, with NAME standing for the API's own name
`function ${name}() { [native code] }``function get ${name}() { [native code] }`;
('function () { [native code] }');
A check that reads the source text cannot see a value a wrapper computes, and crypto-js random source and library entropy records the same limit for a library function whose output is read instead of its code. Each of those three also appears in a line-broken form with newlines and four spaces before [native code], which is what some engines emit. The own-key half of the check depends on the property order the engine reports, which object key order describes for a plain object. Anything outside that set is recorded as a lie against the API's name, and the report groups the lies by the API they belong to.
The check runs the real Function.prototype.toString on the function, and then runs it again on the value's own toString. That second call is the part that matters, because a page that replaces toString on a function has to replace it on the replacement toString as well, and the whole chain has to agree. A page that only patches the outer level is caught by the inner check.
The probe's control cases confirm that the whitelist does not fire on untouched natives. control:Math.random -> clean, control:Array.prototype.map -> clean and control:Function.prototype.toString -> clean all print with no flags. That matters for the reading: a detector that fired on the control would be useless, so the three control lines are the evidence that the rest of the flags mean something.
The control also fixes what the whitelist is measuring. It is not measuring whether the function behaves correctly, and it is not comparing the behaviour against a reference. It reads one string per function and compares that string against a fixed set. A page whose patched function returns exactly what the native would have returned still fails the check, because the source text is different; a page whose wrapper returns something completely different can pass, as the last section shows, as long as the wrapper is a shape the engine itself prints.
What a naive override leaves behind
The first patch a page writes is the plainest one: assign a new function over the property. The probe does exactly that to Math.random and runs the battery:
naive:replaced Math.random -> failed toString
separation -> cleanFlags=0 patchedFlags=1
One flag, and it is the source check. The replacement's source is function random() { return 0.42; }, which matches none of the six accepted shapes, so it is recorded. Every other check passes, because the replacement is an ordinary function with the usual length, name and no extra keys. The whole detection rests on that single string comparison, and the last line of the probe says so directly: a clean native produces zero flags and a patched one produces one.
That one-flag margin has a practical consequence for anyone reading a report. A lie list that says random: failed toString is telling you one specific thing, which is that the source text was not the engine's shape. It is not telling you what the function now does, when it was patched, or whether the patch changed any value. A report that only prints the count of lies hides which check fired, and the check is where the evidence lives.
The margin also makes the ordering of checks matter for a reader. If the source check were the only one, a page could clear it by patching toString on the function, which is the next case. Because the detector also reads the descriptor set and the own keys, that second patch costs the page more flags than it saves. The battery is designed so that the cheapest fix makes the report larger, not smaller.
There is a legitimate case that the plain source check misreads, and the probe hits it in the host itself rather than in a page. One line reads a built-in getter and prints getter:checks -> failed toString hostShape=true, because the accessor's source is an implementation body with private fields rather than the browser's get native() { [native code] } shape. That flag describes the host the probe ran on, not a page override. A detector that treats every flag as evidence of tampering would convict a host that never patched anything, which is why a report has to say which environment produced it.
What a fake toString still leaks
The obvious response to the source check is to give the replacement a toString that returns the accepted shape. The probe does that and gets three flags instead of one:
fake-tostring:Math.random -> failed toString | failed toString toString | extra own keys: toString
fake-tostring:inner -> failed toString
The first flag is still failed toString, and the reason is worth following. The detector calls Function.prototype.toString on the function, not the function's own toString, so it reads the real source text regardless of what the property says. A page that defines toString on the function changes what fn.toString() returns but not what Function.prototype.toString.call(fn) returns, and the detector uses the second form.
The second flag is the inner check. The detector also reads the source of the function's own toString, and the page's fake for that is a page function whose source is again not the accepted shape. So the patch that was meant to silence the first flag creates a second flag on the value it installed.
The third flag is the own-key check. A real native function exposes exactly length and name as own keys, and sometimes prototype. Adding a toString property makes a third own key appear, and the probe prints extra own keys: toString for it. So the patch is visible from the property list even to a reader who never looks at source text at all.
The three flags together are why the cheap fix is a bad trade. A page starts with one flag, patches toString to remove it, and ends with three. A detector that published its check names would let the page see that the fix makes the report worse, and the page's next move would be to stop patching and change the property some other way, which is where the next section starts.
The probe's clone case tries a more careful version of the same idea, binding the real Function.prototype.toString to the real function and installing the bound reference as the clone's toString. The line clone:fnToString-call prints the clone's own body, function random() { return 0.42; }, so the bind does not carry the target's source text into the call. The clone still flags on failed toString and again on extra own keys: toString, which is the same pair a plain patch produced, with the extra work buying nothing.
The two wrappers the whitelist cannot see
Two shapes pass the entire battery while still changing the value a page receives, and the probe records both.
The first is a Proxy that intercepts apply. The probe wraps the real Math.random in a proxy whose trap returns 0.42, assigns the proxy, and runs the checks:
proxy:Math.random -> clean
No flag. The proxy's Function.prototype.toString call forwards to the target, which is the untouched native, so the source text is the accepted shape. The proxy has no own toString of its own, so the inner check reads the native shape too. And the own keys of the proxy's target are the native's, so the own-key check passes. Every signal the battery reads describes the target, while the value the page gets comes from the trap.
Note that this is a proxy over a callable native, and the earlier article on proxy traps and invariants covers what the engine enforces for that shape. The invariants constrain what a proxy can report about a target's properties, and none of them constrain the value an apply trap returns. That is why the proxy is invisible to a source-and-descriptor check: there is nothing inconsistent to catch, because the proxy is a legal, coherent wrapper around the native.
The second shape is a bound function. The probe binds Math.random with no arguments and runs the checks:
bind:fnToString -> function () { [native code] }
bind:checks -> clean
A bound function is created by the engine itself, so its source text is the accepted function () { [native code] } shape, and it carries the native's own-key set. A page can therefore take any native, bind it, and get a value that passes an integrity check that reads only source and descriptors. Whether binding changes behaviour depends on the target: binding Math.random to a this does nothing, but binding a method that reads this to a proxy or to a crafted receiver does change what the caller sees.
The two blind spots share a cause, which is that the battery reads static facts about a function object and the page controls what the caller actually gets. A check that wants to close both has to compare a returned value against a reference the page cannot reach, not just read the function's text. That is the difference between a static integrity check and a behavioural one, and a report that only runs the static half says so only if it names the check that ran.
A reading of this kind is only as good as the list of APIs it covers, and that list is where the practical weakness sits. The suite this probe models walks a fixed inventory: the math functions, the navigator getters, the worker constructor, and a handful of element APIs. Every one of those checks reads the same two things, the source text and the own-key set, so a page that patches an API outside the inventory, or patches it in one of the two invisible shapes, produces a clean report from a patched browser. The margin is not a bug in the detector; it is the definition of a static check, and the report is honest exactly when it names the inventory it ran instead of printing a total.
The cheap way to compare two reports is to diff the flag names rather than the counts. A run that returns failed toString for one API and nothing for the rest says one thing was overridden and nothing else, while a run that returns extra own keys for several APIs says a single patch installed a property across a family of functions. The two patterns point at different code, and a count of three could be either one. That is why the record keeps the names, and why a reader who only sees a number is one step short of the evidence.
A concrete example makes the difference between the two families visible. Suppose a page wants to make navigator.hardwareConcurrency report a smaller number. The direct approach redefines the getter on Navigator.prototype, and the descriptor check reads the new getter's source, which is a page function and fails the whitelist. The indirect approach leaves the native getter in place and instead changes what the getter sees, for example by patching a lower-level function the getter calls. The native getter's own source stays the engine's shape, and the descriptor check passes, while the returned number is the page's. Both edits change what the caller reads; only one of them is visible to a check that reads function text, and that is the boundary the last section describes.
Record a lie-detection reading
A lie-detection reading supports a narrow claim: this detector, these checks, this environment, produced these flags. Four fields keep it checkable:
- The check names that fired, not only a lie count, because
failed toStringandextra own keysdescribe different edits and only the name says which. - The accepted source shapes, because the whitelist is a fixed set and a report without it cannot be re-run on another engine that prints a shape the set omits.
- Whether the value was compared against a reference, because a static check passes a
Proxyand a bound function while a behavioural one may not. - The host that produced the flags, because a built-in accessor can print an implementation body that looks like a page patch, and a reading without the host cannot tell the two apart.
Public sources
- MDN: Function.prototype.toString()
- ECMAScript: Function.prototype.toString
- ECMAScript: OwnPropertyKeys
- MDN: Proxy
- MDN: Function.prototype.bind()
- BotBrowser: automation consistency reference
Practical conclusion
Read a lie report by check name, because the count alone cannot tell a plain override from a fake toString from a third own key. Expect a Proxy and a bound function to pass a source-and-descriptor check, since both are engine-created shapes around an untouched native, and treat a clean result from that check as "the source text looks native" rather than "the value is the native's". The two earlier articles on proxy traps and invariants and on object key order cover the mechanics the own-key check depends on, and the record should always name the checks that ran and the host that produced the flags, because a built-in accessor can print a body that looks exactly like a patch.
This article is part of the guide: What Is a Browser Fingerprint?
Related Articles
Crypto-JS Random Source: Which Branch Your Build Uses
FingerprintCrypto Randomness Observability and Browser Fingerprints
FingerprintBotD Detector Registry: Which Checks Actually Run
FingerprintNetwork Information API Fingerprinting: A Policy Review
FingerprintWeakMap and WeakSet: What They Hold and What They Drop
FingerprintClient Hints Fingerprinting: HTTP Headers as Identity
Take BotBrowser from research to production
The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.