Back to Knowledge Hub
Fingerprint

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

Documentation

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 -> clean and the same for Array.prototype.map and Function.prototype.toString.
  • Replacing Math.random with 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 toString to that function does not fix it: the probe prints failed toString | failed toString toString | extra own keys: toString, so the patch now leaks three ways instead of one.
  • A function that returns 0.42 still shows its body when the detector calls the real toString through a bound reference, so clone:fnToString-call prints the page's own source and the clone case still flags.
  • A Proxy that intercepts apply passes the whole battery, so proxy:Math.random -> clean while 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] } and bind:checks -> clean.
  • A clean native and a patched one differ by exactly one flag on the cheap path, so separation -> cleanFlags=0 patchedFlags=1 is 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=true for a built-in getter whose source is an implementation body.

Contents

A first panel shows an untouched native printing the native-code shape and passing. A second shows a plain replacement flagged once by its source text. A third shows a fake toString adding two more flags, including an extra own key. A fourth shows a Proxy and a bound function both printing the native shape and passing while the value they return comes from the page.

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:

  1. The check names that fired, not only a lie count, because failed toString and extra own keys describe different edits and only the name says which.
  2. 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.
  3. Whether the value was compared against a reference, because a static check passes a Proxy and a bound function while a behavioural one may not.
  4. 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

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.

#Lie Detection#Native Functions#Proxy#Integrity#Fingerprinting

This article is part of the guide: What Is a Browser Fingerprint?

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.