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
TypeErrorfor 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.
