Skip to content
Detection teardowns

Stack traces show what called the widget

new Error().stack records the engine's format and every function on the way in. Captured at the right moment, it shows whether a page or a driver did the calling.

Pim
Pim· Clearcote Research
8 min read

Key takeaways

  • V8 stacks start with Error and list at fn (url:line:col), keep at most 10 frames and point at the callee's start; JavaScriptCore and SpiderMonkey use fn@url:line:col, and JavaScriptCore points at the opening parenthesis.
  • A challenge loader samples new Error().stack when it renders the widget: a widget declared in markup shows only the loader's DOM-ready frames, a page script calling render adds that script's URL, line and column.
  • Code run through automation leaves distinctive frames: Puppeteer's pptr:evaluate;<encoded file:line:col> URL leaks the driver script's path, Playwright shows UtilityScript.evaluate, and raw protocol evaluation shows URL-less <anonymous> frames.

What a stack string contains

When a script creates an Error, the engine records the call stack at that moment and exposes it as a string in error.stack. The format is not standardised, and the engines differ:

  • V8 (Chrome, Edge, Node): a first line Error, then one line per frame, at functionName (url:line:column), or at url:line:column for anonymous code. Method frames are named by how they were called, such as Object.render or HTMLDocument.onReady. The default Error.stackTraceLimit is 10 frames. Columns point at the property name of a method call or the start of a plain call. URLs keep the query and drop the fragment.
  • JavaScriptCore (Safari): no header line, frames as functionName@url:line:column, named by definition rather than call form, positions at the opening argument parenthesis, query and fragment stripped. In strict code, proper tail calls can remove a caller's frame.
  • SpiderMonkey (Firefox): functionName@url:line:column, with eval and Function frames marked as line N > eval.

So a single stack string tells the engine, and the exact column numbers tell which engine's position rules produced it.

The render sample

A deployed challenge loader records a sample when it renders a widget: a method code, the elapsed milliseconds, and new Error().stack. The widget's iframe copies the sample into its first payload.

The sample shows who asked for the widget. When the widget is declared in the page's markup, the loader renders it from its own DOM-ready handler, and the stack contains only loader frames ending at the DOMContentLoaded handler. When a page script calls the render function, the stack contains that script's frames: its URL, line and column, and the names of its functions, up to the 10-frame limit in V8.

Because the sample is taken synchronously inside the render call, it shows whatever code was on the stack at that moment. Timers and event callbacks start with an empty stack: in our measurements a setTimeout callback's stack had exactly one frame, with no trace of who scheduled it.

What automation leaves in stacks

We measured new Error().stack in Chrome 156 for code run in different ways:

  • Puppeteer page.evaluate: frames at pptr:evaluate;%2Fpath%2Fto%2Fscript.js%3A20%3A33:1:8. Puppeteer appends a //# sourceURL= comment to evaluated code, and the URL encodes the driver script's absolute path, line and column on the controlling machine.
  • Playwright page.evaluate: at eval (eval at evaluate (:370:30), <anonymous>:1:7), at UtilityScript.evaluate (<anonymous>:372:16) and at UtilityScript.<anonymous>, because Playwright runs user code through an evaluator class.
  • Raw protocol evaluation, init scripts and evaluateOnNewDocument: frames like at <anonymous>:1:35, with no URL at all. Real page scripts always have a URL, and eval'd page code shows eval at with its parent's URL.

These frames only reach the widget if the injected code is on the stack when the sample is taken. When automation calls the render function itself from an evaluated script, the render sample contains the page frames followed by the driver's frames. A widget rendered from markup by the loader shows only loader frames, whatever drives the browser.

The click stack

The same widget creates another error inside its checkbox click handler. That stack runs from the event listener through the widget's own virtual machine to the instruction that created the error, and it has to look like the claimed engine's: V8 names the listener frame HTMLInputElement.<anonymous>, JavaScriptCore leaves many frames anonymous and includes native Reflect.construct frames. A Safari User-Agent with a V8-shaped stack, or a click handler whose stack contains frames that are not the widget's, contradicts the session. See what a checkbox click records.

What follows for automation

Two practical points follow. First, let the page render its own widget: code injected through the automation protocol should not call into the page's widget API, because whatever is on the stack during that call is recorded. Second, the stack format is the engine's, so it can only be right if the engine is the one claimed; a User-Agent override cannot change how V8 prints a frame. The engine-identification side of this is covered in identifying the engine behind the user agent, and the protocol side in what is CDP detection.

The through-line

A stack trace is the one value in the browser that records how the current code was reached. Most fingerprinting asks what the browser is; the stack asks how it got here, and an automation layer that sits on the call path shows up in the answer. The same idea, observing the observer, is behind the console getter described in timing native functions.

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

  • 228engine patches
  • 34,679lines added
  • 336Chromium files changed
  • 354checks 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.