Skip to content
On this page

Calling toString() on a native browser function returns a fixed marker -- "function name() { [native code] }" -- while a JavaScript wrapper returns its actual source code. That single difference lets a page tell whether a built-in has been replaced by a hook. Fingerprinting and anti-bot scripts lean on it heavily, and patching it convincingly is a cascade of leaks, because every patch is itself a function that can be inspected the same way.

Quick facts

The tellNative fn stringifies to "[native code]"
A wrapperStringifies to its real JS source
Other leaksfn.name, fn.length, property descriptors
The cascadePatching toString leaks via Function.toString.call
Durable fixDo not create a JS wrapper at all

The native-code marker

Every built-in function in V8 stringifies to a canonical form ending in { [native code] } and starting with its real name. A monkey-patched wrapper cannot reproduce that -- calling toString() on it dumps your JavaScript. So the very first thing a careful collector does after touching a sensitive API is stringify it and look for the marker. No marker, or the wrong name, means a hook.

The patch cascade

The instinct is to patch toString too, so the wrapper lies about its own source. But that just moves the leak. The page can call Function.prototype.toString.call(yourWrapper) directly, bypassing any per-function override. So you patch Function.prototype.toString -- now that is a wrapper, and Function.prototype.toString.toString() exposes it. Meanwhile fn.name is empty or wrong, fn.length (arity) differs, and the property descriptor shows the wrong writable/configurable flags. Each fix adds a new function that must also be hidden. It is leaks all the way down.

Why this forces engine-level work

This cascade is the same shape as fingerprint lie detection: a single inconsistency exposes the whole disguise, and patching one signal creates another. The only way out is to never introduce a JavaScript-visible wrapper in the first place. If the observation happens inside the rendering engine, the JavaScript function the page sees is the original native function -- same source string, same name, same descriptor -- because nothing in JavaScript was replaced. There is no toString tell because there is no wrapper. This is why durable instrumentation, like durable automation, lives below the JS layer.

The recursion problem

The obvious response to toString exposing a replacement is to replace toString as well, so that it reports the expected native string for the hooked function. This works exactly once, and then reproduces the original problem one level up.

Function.prototype.toString is itself a native function, so it can be asked to describe itself: Function.prototype.toString.call(Function.prototype.toString). On an unmodified browser that returns the native-code form. If toString has been replaced in order to lie about something else, it must now also lie about itself - and the check is one line, so it is always present in practice.

The escape from that specific loop is a Proxy with an apply trap, which forwards to the genuine implementation for everything except the function being concealed. That does resolve the self-description case, but it introduces a new observable: the proxy is not the original object. Its behaviour differs on edge cases, its identity differs under strict comparison against a reference captured from another realm, and its interaction with receiver validation differs from the native it wraps.

The pattern generalises. Each fix converts one observable into another rather than eliminating it, because the layer doing the concealing is itself JavaScript, and distinguishing JavaScript from engine code is precisely what these checks do.

Where the check sits among its siblings

Serialisation is the most cited member of a family of integrity probes, but on its own it is also the weakest, because it is the one most consistently anticipated. A detection script that only calls toString is easy to satisfy; one that runs the full family is not. The siblings, covered in detail in native function integrity checking, attack from different angles:

  • Descriptor location - most built-in properties are accessors on a prototype, so they should not appear as own properties of the instance. A value assigned directly to the object is in the wrong place regardless of how it serialises.
  • Receiver brand checks - native getters validate their receiver in C++ and throw a TypeError for a foreign object. A JavaScript replacement typically returns a value instead.
  • Honeypot names - a property that never existed must read as undefined; a broadly written proxy answers for it.
  • Realm comparison - the same function read from a fresh iframe or a Worker can be compared against the page's copy, which sidesteps page-realm concealment entirely.

Realm comparison is the most awkward of the four to address, because concealment applied in the page realm simply is not present in a Worker. That is the structural reason engine-level modification and page-level hooking sit in different categories: when the value is changed inside the engine there is no wrapper to serialise, no descriptor in the wrong place, and no realm that missed the update.

Code example

javascript
// What an anti-bot collector actually checks:
function looksHooked(fn, expectedName) {
  const s = Function.prototype.toString.call(fn);   // bypasses fn.toString override
  if (!s.includes('[native code]')) return true;     // wrapper source leaked
  if (fn.name !== expectedName) return true;          // name wrong/empty
  const d = Object.getOwnPropertyDescriptor(
    HTMLCanvasElement.prototype, 'toDataURL');
  if (d && d.writable === true && d.configurable === true) return true; // re-patched
  return false;
}
looksHooked(HTMLCanvasElement.prototype.toDataURL, 'toDataURL');
// Any JS wrapper fails at least one of these. Engine-level hooks fail none.

Next in Instrumenting a browser · 6 of 6

Which leaves only one layer that is not observable.

What Is Engine-Level (Blink) Browser Instrumentation?

Related terms

Concept map

Concept map

How How Does toString() Reveal a Hooked Function connects

The terms most directly tied to this one. Hover a node to see its neighbours, click to preview, drag to rearrange.

0 terms · 0 connections
You are here · Reverse Engineering
Building map…

Frequently asked questions

Can I just patch toString to hide my wrapper?

Not reliably. The page can call Function.prototype.toString.call directly, bypassing a per-function override, and patching the prototype creates a new wrapper that leaks the same way. It is a cascade with no clean end in pure JavaScript.

What besides toString gives a hook away?

The function's name and length (arity) properties, and the property descriptor on the prototype (writable/configurable flags). Native built-ins have specific values for all of these that a naive wrapper does not reproduce.

Why is this called the native-code check?

Because native functions stringify to a form containing "[native code]" instead of their implementation. Scripts test for that exact substring to decide whether a function is the genuine built-in or a JavaScript replacement.

Last updated: 2026-07-30 · Facts last verified: 2026-07-30