Skip to content
Detection teardowns

Timing native functions: how a page finds a hook that hides its source

Making a hooked function print native code is easy; making it take the time a native call takes, in every realm, is not.

Pim
Pim· Clearcote Research
7 min read

Key takeaways

  • A toString check alone is weak: a shim can make any hooked function print function x() { [native code] }. Detection pairs it with checks the shim cannot see, such as a second realm's toString and the property's descriptor.
  • A deployed challenge widget converts about 150 natives to strings and records how long each call takes; a Proxy or wrapper adds work on every call, and the timings are reported next to the result.
  • A getter on an error's stack passed to console.debug runs only when an inspector serialises console arguments, which is what an automation client listening to the console runtime does.

The toString check and why it is not enough

Every built-in function in a browser prints the same shape when converted to a string: function getParameter() { [native code] }. A function written in JavaScript prints its source instead. That is the oldest test for a spoofed API: replace WebGLRenderingContext.prototype.getParameter with your own function and its source gives you away.

The countermeasure is equally old. A shim wraps Function.prototype.toString itself, or uses a Proxy whose target is the original native, so the hooked function prints a native-looking string. Against a page that only reads the string, that is enough, which is why the string alone has not been a serious check for years. The toString Q&A covers the basic mechanism.

What replaced it is a set of questions asked around the string: questions the shim has to answer too, and which most shims were never written to handle.

Asking around the string

A second realm. A fresh about:blank iframe has its own Function.prototype.toString, created by the engine moments ago. Calling it on the parent's functions sidesteps a shim installed on the parent's toString. The mechanics are covered in cross-realm coherence.

The descriptor. Native accessors live on prototypes. navigator.plugins is a getter on Navigator.prototype, not a property of the navigator object. A spoof that defines plugins directly on the instance leaves an own property where the engine never puts one, and Object.getOwnPropertyDescriptor(Navigator.prototype, 'plugins').get still points at the original. Deployed widgets read exactly that descriptor.

The error path. Natives throw specific errors with specific stacks when called with the wrong receiver. A widget's native-inspection probe reads stack from inside a toString call, and a wrapper adds a frame that a native call does not have.

Timing each call

The newest layer is time. One deployed widget converts about 150 natives and getters to strings, covering canvas, WebGL, WebGPU, audio, Intl, permissions, media, WebRTC, client hints, the keyboard API and every navigator getter it cares about, and records how long each call took, next to the result.

A native toString on a built-in is a fast path inside the engine. A shimmed one runs JavaScript: a Proxy trap, a lookup in a WeakMap of originals, a string build. Each step is small, but it happens on every call, and across 150 calls the overhead adds up to a pattern rather than a single noisy number. The clock limits the precision: Chrome exposes performance.now() in 100 µs steps, and some frames on other engines only in whole milliseconds, which is why the measurements are reported per call and compared as a profile rather than one by one.

Timing also explains why the same widget measures the clock resolution itself in a worker, with 5,000 pairs of readings. A timing profile only means something when the reader knows how coarse the clock was.

The console getter

One more check targets the automation channel rather than the spoof. The page creates an error, defines a getter on its stack property and passes the error to console.debug. In a normal browser nothing reads that property: the console message is never displayed, so the getter never runs.

When an inspector is attached and listening to console events, the browser serialises the message's arguments to send them over the protocol, and serialisation reads stack. The getter fires. DevTools does this; so does an automation client that enables the runtime domain to receive console messages, which is the side effect described in what is CDP detection. The same probe also runs debugger inside a worker with a timeout, which only pauses when a debugger is attached.

Neither check looks at a value the page could spoof. They observe whether something outside the page is watching.

The through-line

Each layer here exists because the previous one was patched. The string was faked, so detection read the descriptor; the descriptor was fixed, so it read a second realm; the realm was reached, so it timed the call. Every patch made in JavaScript adds code, and added code has a source, a location, a stack frame or a cost that native code does not have. The durable answer is to change the value where the engine produces it, so the function being inspected is the native one. That is the approach behind engine-level browsers such as Clearcote, described in how a stealth browser is built and verified.

For the wider set of checks that need no reference data at all, see spec invariants as lie detectors, and run the native-function rows yourself in the fingerprint test.

#bot-detection#cdp#reference

Clearcote puts this into practice

An open-source Chromium with fingerprint control compiled into the engine. A drop-in for Playwright & Puppeteer.

  • 222engine patches
  • 34,478lines added
  • 330Chromium files changed
  • 352checks per build

Engine numbers are for the Pro build; the free open build is a smaller, public patch set.

Free for one browser with GitHub. No card.