Skip to content
Detection teardowns

Inside a challenge widget: what one bot check collects in two seconds

A modern challenge widget runs close to forty probes before the checkbox appears, and most of them ask whether the browser's answers agree, not whether they are rare.

Pim
Pim· Clearcote Research
9 min read

Key takeaways

  • A widely deployed challenge widget runs 38 probes per session inside an obfuscated, per-session virtual machine; field names are randomised and records are shuffled, but the browser APIs each probe calls stay the same.
  • 23 of the 38 probes read device facts that stay constant for a given machine (property surface, rendering, fonts, Intl output); the rest read per-session values such as requests, timing, WebRTC state and a proof of work.
  • Identical device answers across many sessions are normal, because millions of phones share the same hardware; what a widget can rely on is whether the answers agree with each other and with the network.

How a challenge widget runs

A challenge widget is the small frame a site embeds to decide whether a visitor gets a checkbox, an automatic pass or a harder challenge. It loads in a cross-origin iframe, so the embedding page cannot read or patch what happens inside it. The script it loads is obfuscated, and the code that collects data is not plain JavaScript at all: it is bytecode for a small register machine that the script defines, with opcodes, masks and handler order that change from session to session.

Public reverse-engineering work on one widely deployed widget shows the structure that survives the obfuscation. Each session runs 38 probes. Every probe writes a record into an encrypted payload; the record fields get random names per build and the records are shuffled per session. What stays stable is the set of browser APIs each probe touches, because the obfuscation can rename fields but cannot rename getParameter or getHighEntropyValues.

That makes the API strings a reliable map of what is collected. The sections below group the probes by what they ask, not by the order they run in.

The inventory, grouped by question

  • Property surface. One probe opens a fresh hidden iframe and walks about 1,400 property names on window, navigator, document, screen and screen.orientation, grouping each name by the type of its value: native function, null, true, false, a specific number or string. A property that exists where it should not, or holds the wrong type, moves into a different group.
  • Native inspection. About 150 built-in methods and getters, from CanvasRenderingContext2D.prototype.getImageData to Navigator.prototype.webdriver, are converted to strings and matched against the native-code pattern, and each call is timed. See timing native functions.
  • Automation presence. A getter on an error's stack passed to console.debug, a debugger statement in a worker with a timeout, checks for module, global, process and pyimport, and a scan of comment nodes for strings that browser automation extensions leave behind.
  • Rendering. WebGL 1 and 2 parameters with both the supported and the unsupported extension lists, shader precision, pixels read back from two shaders, the WebGPU adapter's info, limits, features and WGSL language features, a render pass read back from WebGPU, a 2D canvas scene, an OfflineAudioContext graph, SVG text geometry and the boxes of ten elements under extreme CSS transforms.
  • Host and locale. A 52-name font list spanning Windows, macOS, Linux and Office fonts, a flag emoji drawn to a canvas, Intl output, the keyboard layout map, User-Agent Client Hints and historical timezone offsets. See locale, time and glyph tells.
  • Network and requests. Codec support for 18 media types, RTP capabilities, WebRTC ICE gathering against public STUN servers, an image preload, a CORS fetch from a worker, a Private Access Token request and the page's resource timing entries.
  • Compute. A worker that times four WebAssembly kernels, a worker that measures the smallest performance.now() step, and a timed SHA-256 proof of work that starts ceil(hardwareConcurrency × 0.75) workers.

Outside the probes, the widget keeps a tally of input events and builds a click record (whether the event is trusted, whether it was a touch, which element sits under the point, the offset inside the checkbox), and the transport adds a TLS ClientHello and a header order that differs per request kind.

Device facts and session values

The probes split cleanly into two kinds. Device facts are answers a given machine gives every time: the property surface, Math output, fonts, rendering results, Intl strings, storage quota, the emoji pixels. Twenty-three of the 38 probes are of this kind. Session values change every time: request timings, the ICE candidates of a fresh peer connection, the iframe's own URL, the proof-of-work nonce, the benchmark's clock readings.

The split matters for how the data can be used. A device fact cannot identify a person on a platform where everyone owns the same device: two iPhones of the same model and iOS version return the same Math output, the same Intl strings and the same WebGL parameters. So the widget cannot expect device facts to be unique. What it can check is that they are possible: that this set of WebGL limits exists on a GPU that matches the claimed platform, that the fonts belong to the claimed operating system, that the clock resolution fits the claimed browser.

Session values serve a different purpose. They prove the answers were produced now, by a browser that really made the requests: the image the widget preloaded was fetched, from this IP and over this TLS session; the proof of work took as long as the claimed core count allows.

Noise is normal

A common assumption is that a canvas or audio hash must be stable for a device. Captures of the same phone in two sessions say otherwise for one major browser: its canvas readback differed by one to four values per colour channel, and its audio hash differed completely, because the browser adds its own noise to these readbacks. A widget that treated an unstable canvas hash as suspicious would flag that browser's whole user base.

The same captures show the other side. On stock desktop Chrome, two sessions on one machine returned identical canvas, WebGL, audio, font, rect, Math and Intl answers; only URLs, timestamps and timings changed. Stability is therefore a property of the browser, and the expected amount of noise is part of what the claimed browser predicts. Random noise on an engine that never adds any is as inconsistent as a stable hash on one that always does.

The through-line

Read as a whole, the inventory is a long list of questions that only make sense together. The property surface has to fit the User-Agent; the WebGPU limits have to fit the WebGL renderer; the fonts and the flag emoji have to fit the platform; the Intl strings, keyboard layout and timezone history have to fit the language and the IP; the proof of work has to fit the core count. None of these needs a population database. Each needs only the browser's own answers, which is the argument made in coherence over camouflage and, realm by realm, in cross-realm coherence.

Most of the same questions run in the open fingerprint test, which shows the measurement behind every row. For the layers around the widget, from the driver to the network, see how automation gets caught, layer by layer.

#bot-detection#fingerprinting#coherence#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
  • 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.