
19 Partially new headless Chrome detection signals
Abstract. botstop.io tells you whether your browser looks like a bot, and it now shows every signal it looks at. This article documents the two newest groups of signals. The first group is 19 ways in which a web page ca…
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
Bot Detection Internet Bots JavaScript headless chrome puppeteer playwright Side Channels
Abstract. botstop.io tells you whether your browser looks like a bot, and it now shows every signal it looks at. This article documents the two newest groups of signals. The first group is 19 ways in which a web page can tell an automated or headless Chrome from a normal one. I found them by running 30,217 web-platform-tests in four differently started Chromes and keeping what was stable in each and different between two; almost every difference is traced to its cause, which is a DevTools protocol domain, a launch switch or a difference between Chrome and its headless builds. The second group does not ask the browser anything. It measures the machine: how long a fragment shader takes, whether 96 KB fit into the L1 data cache, and which sign the FPU gives a NaN. On eleven days of real sign-ins (1,535 page views, 153 of them from paying customers) the first version of these hardware probes flagged 29% of the page views of paying customers. The version that survived flags none of them and still fires for 51% of the refused sign-up attempts. I list everything that did not survive, because that list is the more useful half.
Contents: Two questions · The setting · Part I: automation signals · Part II: the hardware · Part III: real traffic · Versions · Limitations · Conclusion · References
In August I put botstop.io online and wrote that I would not spell out its checks, because that would only help the people who write the bots. Since the end of October the page does the opposite. It lists every signal, the value it saw in your browser and a paragraph on what that value means, and its header carries a version number, v12.0 as I write this.
I changed my mind for two reasons. A benchmark that hides what it measures is of little use: nobody can improve a bot, or a detector, against a score without a breakdown. And the signals I trust most by now are the ones that do not need to be secret. If a page measures that your "NVIDIA GeForce RTX 3060" needs 180 ms for something a graphics card does in 2 ms, knowing the limit does not help you. You need the card.
A page that wants to know what it is talking to can ask two different questions:
Part III holds both against real traffic. That is where I learned the most, mainly about my own first attempt.
Who is on the other side. What signs up to a service like ipapi.is today is rarely a script that speaks HTTP. It is a real Chrome on a rented server, started and steered by a program through the Chrome DevTools Protocol (CDP), usually with Puppeteer or Playwright or something built on them, and usually behind a proxy. It runs my JavaScript like any other browser. Its operator controls everything the browser says about itself: the User-Agent, navigator.webdriver, the WebGL renderer string, the number of cores. Anti-detect browsers patch those answers into the C++ of the browser, where no JavaScript can see the patch.
What a page has. JavaScript, the answers it gets, and a clock. The clock is coarse on purpose. After the cache attacks of the last decade [1] [2], performance.now() ticks in steps of 100 µs in Chrome [3], and in Firefox and Safari my data contains almost nothing but whole milliseconds. I wrote about these clocks five years ago. Everything in Part II has to live with them, and with a budget: the probes run once per page view on the main thread and take about 50 ms on my laptop.
The detector. botstop.io is a thin page around a library that also guards the sign-up and login forms of ipapi.is. A collector script gathers the signals and sends them along in a sealed envelope; a classifier on the server adds up weights. One strong signal, or 45 points, makes a visitor automated; 18 points make it suspicious. A signal with weight 0 is recorded and shown, but not scored. That last category matters a lot below: it is how a new signal gets measured before it is allowed to hurt anybody.
The data. Three sources, and each can show different things.
Everything below is counts and timings. No address, account or fingerprint appears in this article. The hardware probes sort machines into a handful of classes (ARM or x86, which cache, GPU or no GPU) and are far too noisy to recognize one machine among others of its class.
A page cannot see the DevTools protocol. It can only notice that the browser does something differently while a client is attached, or because of how it was started. The usual ways to find such a difference are to read Chromium's source, or to stumble over one. I wanted a method that finds differences without my having an idea first.
Browser vendors maintain the largest existing collection of "observe something from inside a page and compare it with what should happen": the web-platform-tests (WPT), tens of thousands of pages that each check one behavior and report PASS or FAIL per subtest [4]. If a test passes in a normal Chrome and fails in an automated one, it has observed, from inside a page, something that tells the two apart. It is a detection signal that somebody else wrote for me.
hHunter (headless hunter) is the harness I built around that thought. It runs the same tests in four Chromes:
Each configuration runs every test several times. A result that is identical in all repeats of each configuration and differs between two of them is a candidate.
A few details decide whether the result means anything:
The run that produced most of what follows took place on a rented Linux server without a GPU, with Chrome 156.0.8078.79 and the chrome-headless-shell of the same version: 30,217 tests in 4 configurations, 3 repeats each, 11.3 million subtest results, 147 browser-hours. The 448 tests behind any finding then ran 8 more times in every configuration.
A flip is a subtest that passes in one configuration and fails in the other, or a probe value that differs. A timeout is a test that reported in one and timed out in the other. The middle row is the first lesson of this article. 152 tests timed out only under the CDP client, in three out of three runs. Eight more runs left 13 such findings. On a busy server, a timeout is a statement about the server.
The first row is a result too, only a dull one: the new headless mode is the same binary as Chrome, and it behaves like it. Of its 14 flips, 13 are tests that need WebGL (below, H3), and on a Mac with a GPU nothing is left but what everybody knows: the 800×600 screen, the color depth, the HeadlessChrome token.
"Differs under a CDP client" is not useful yet, because a client does many things at once. So the confirmed tests ran again in configurations that each switch on one part of the client: attaching alone, the Page domain, the Runtime domain, the Network domain, Puppeteer's isolated world, and Playwright's way of attaching pages at browser level. Every finding showed up with exactly one part of the client, and with no other:
I repeated the first row for this article with nothing but a WebSocket and a page that runs C1 and C2 in a loop and writes the result into its title (Chrome 157, with a window). Attaching changes nothing. Page.enable, Network.enable, Debugger.enable and Log.enable change nothing. Runtime.enable switches both signals on, and Runtime.disable switches both off again.
That table is also the map for evasion. The tools that avoid Runtime.enable [5] take out the first row. The other rows stay.
"Recorded" means weight 0: shown on botstop.io as not scored, and counted in Part III. The causes in the launch-switch rows are not guesses: the switches are in the default arguments of Puppeteer 25.5 and Playwright 1.64. S6, S7 and F1 to F4 have been checked on macOS only so far.
C1 and C2: a listener on the Runtime domain. Runtime.enable is the command that makes Chrome report execution contexts and console calls to a client. Almost every automation library sends it. With it enabled, an error thrown by code that was compiled from a string (eval, new Function, a string timer) arrives at window.onerror without the URL of the page:
// Resolves true while a DevTools Runtime session is attached. function cdpAttached() { return new Promise((resolve) => { const prev = window.onerror; window.onerror = (msg, source) => { window.onerror = prev; resolve(source === ''); return true; // swallowed: nothing in the console }; setTimeout(() => eval('hh_probe'), 0); // ReferenceError from eval'd code }); }
Line number, column and stack are unchanged; only the file name is dropped. The same happens in a dedicated Worker, which page-level stealth patches do not reach. C1 has been in the collector since September. WPT found it again as runtime-error-in-setTimeout.html.
C2 is the survivor of a famous trick. For years the way to detect Runtime.enable was an Error with a getter on stack, logged with console.debug: the inspector read the property to build a preview, and the getter ran. V8 stopped calling user getters during previews in May 2025, and the trick went silent, as Antoine Vastel described [6]. But to describe an error the inspector still formats its stack, and for that V8 calls the hook that pages may install:
function cdpAttachedViaPrepareStackTrace() { let hit = false; const prev = Error.prepareStackTrace; Error.prepareStackTrace = () => { hit = true; return ''; }; console.debug(new Error()); // formatted only when someone listens Error.prepareStackTrace = prev; return hit; }
This one is not mine alone: rebrowser's own bot detector checks the same hook [5]. I have not seen C1 or C3 described elsewhere, which may only mean that I have not looked in the right places.
A warning that applies to all three C signals: an open DevTools window is a CDP client too. So is an AI agent that drives a real person's own browser. "A client is attached" is not the same statement as "this is abuse", and that is one reason why these three carry no weight of their own.
C3: a message that overtakes a load event. A frame whose inline script posts a message to its parent: Chrome fires the frame's load event first and delivers the message after it. With the Network domain enabled, which Puppeteer and Playwright both do for every frame, the order flips. WPT found it as a Content Security Policy test that only passes when load comes first.
// Needs a same-origin page that is loaded over the network: // <!doctype html><script>parent.postMessage('frame', '*')</script> function networkDomainEnabled(url = '/frame.html') { return new Promise((resolve) => { const frame = document.createElement('iframe'); let order = ''; const onMessage = (e) => { if (e.source === frame.contentWindow) order += 'm'; }; addEventListener('message', onMessage); frame.onload = () => { order += 'l'; }; frame.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden'; frame.src = url; document.body.append(frame); setTimeout(() => { removeEventListener('message', onMessage); frame.remove(); resolve(order === 'ml'); // message first, load second }, 1500); }); }
It held in 8 of 8 runs on Linux and 3 of 3 on macOS with my own client, and with real Puppeteer and real Playwright in both headless builds. Stealth tools that avoid Runtime.enable usually still enable Network. I was rather proud of this one. Remember it when you get to Part III.
F1: a rate limit that automation switches off. Blink lets a frame change its history state 200 times in 10 seconds and drops the rest. Puppeteer and Playwright start Chrome with --disable-ipc-flooding-protection, which lifts the limit. So a page can count:
// Resolves the number of history-state changes the browser let through, of 201. function stateChangesAccepted() { return new Promise((resolve) => { const frame = document.createElement('iframe'); frame.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden'; frame.srcdoc = ''; frame.onload = () => { const win = frame.contentWindow; let seen = 0; win
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。