如何找出让人识破无头浏览器的那些泄露点

headless browser detection - How to Find Headless Browser Detection Leaks in Your Stack

无头浏览器检测不是一项测试,而是一堆小测试。多数自动化环境栽在同样的三项上,而且是在第一个页面加载完之前就栽了。你不需要哪家厂商的后台才能看见它们,因为真正要紧的东西都能从你已经打开的浏览器里读出来。这篇文章给出四个探测脚本,按照哪一项让你被拦得最快来排序,并修掉值得修的那几个。文章还划了一条大多数教程会跳过的界线:干净的指纹能降低你被挑战的频率,但永远不会把它降到零。

你需要什么

  • Chrome 或 Chromium,以及在页面里执行 JavaScript 的办法:手动开 DevTools,或者用驱动的 evaluate 调用。
  • 你已经在用的任何驱动。探测脚本都是普通 JavaScript,所以 Playwright、Puppeteer 和 Selenium 都不用改就能跑。
  • 如果你想跑最后那段补丁和求解示例,需要 Python 3.10 或更新的版本。
  • 装好并运行 CapSkip,这一项只有最后一节才用得上。请按照 设置指南 操作,并留意你选的是哪种连接模式,因为它决定代码里该填哪个 host。

开始之前有一件事值得先定下来。求解器不必和浏览器待在同一台机器上。本地模式监听 loopback 地址,只服务这一台设备;服务器模式监听你的内网地址或公网 IP,这样另一台机器、一台 VPS 或者托管的 runner 就能通过 API 访问同一个实例。两者都记录在 连接设置这一节里,下面还有一小节专门讲服务器的情况。

第 1 步:先读那些一开始就定生死的信号

从便宜的开始。任何商业反机器人脚本都会在最初几毫秒里读这些值,因为它们都是同步的属性读取,没有网络开销。把这段粘进真正拦你的那个页面的控制台,而不是一个空白标签页,因为有些值取决于当前文档。

// Paste into DevTools, or hand it to your driver's evaluate call
// so it runs in the real page context rather than a fresh tab.
const leaks = {
  webdriver: navigator.webdriver,
  plugins: navigator.plugins.length,
  languages: navigator.languages.join(","),
  cores: navigator.hardwareConcurrency,
  memory: navigator.deviceMemory,
  platform: navigator.platform,
  hasChrome: !!window.chrome,
  hasRuntime: !!(window.chrome && window.chrome.runtime),
};
console.table(leaks);   // read every row, not just the first

真实的 Chrome 会话会把 webdriver 属性报成 undefined,插件列表里有三到八项,至少两种可接受语言,核心数与机器相符,平台字符串是 Win32 或 MacIntel 这类值,还有一个内含 runtime 的完整 window.chrome 对象。自动化容器往往报成 true、零、一种语言、两个核心、Linux x86_64,最后两项则什么都没有。

webdriver 属性大家早就知道,而它是这份清单里唯一一项刻意存在的东西。这是标准的一部分: W3C WebDriver 规范 要求符合规范的驱动设置一个标志位,而这个属性正是它对外的表现,同时 MDN 也记录了同样的行为。所以它不是谁忘了修的 bug,而是浏览器在照规范办事。

把这些行放在一起读,而不是一行一行看,因为检测脚本比对的是它们彼此是否吻合。Windows 的 user agent 挨着 Linux 的平台字符串,这个信号比其中任何一个单独的值都强得多,而几乎每个手写补丁第一次都在这里翻车。

第 2 步:找出留在 window 里的驱动痕迹

Chromedriver 和 Selenium 的注入层会留下带名字的全局变量。它们很容易被枚举出来,正常用户身上不会有,而且找到一个就是定论,不是概率。

// Injected globals from Chromedriver and Selenium. A clean
// browser prints the word clean and nothing else.
const prefixes = ["__cdc", "__selenium", "__webdriver", "__driver"];
const found = Object.keys(window).filter(
  (k) => prefixes.some((p) => k.startsWith(p))
);
// Two more that Chromedriver adds under fixed names.
for (const name of ["domAutomation", "domAutomationController"]) {
  if (window[name] !== undefined) found.push(name);
}
console.log(found.length ? found : "clean");

这里查出东西,是整个审计能给出的最有价值的结果,因为它没有什么可争辩的余地。以 cdc 前缀开头的随机名字就是经典的 Chromedriver 标记,通常的应对办法是:别再手工打补丁,换一个会替你去掉它的驱动构建。这条路线我们单独写过,见 如何在 undetected-chromedriver 里处理验证码,更完整的 Selenium 情况写在 Selenium 验证码识别工具 这个页面上,驱动选项在那里讲得更细。

第 3 步:问问 GPU 它以为自己是谁

WebGL 会把显卡厂商和 renderer 以普通字符串的形式报出来,而没有 GPU 的容器只能老实说出自己软件回退方案的名字。这是 Docker 里的无头 Chrome 暴露得最响的一点,任何针对 navigator 的补丁都碰不到它。

// The renderer is exposed as a plain string, so read it directly.
const gl = document.createElement("canvas").getContext("webgl");
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
console.log(
  gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL),
  gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)
);
// SwiftShader, llvmpipe, Mesa or VMware means no real GPU here.

看到 SwiftShader 不算致命,但也没法诚实地把它伪装掉,因为伪造的 renderer 字符串仍然要和 canvas 真正画出来的像素对得上。如果你在容器里对付一个激进的目标,现实的选择只有两个:给这个容器一块真显卡,或者把任务挪到有显卡的机器上。

第 4 步:单独检查 Worker 上下文

这个探测能抓出做了一半的 stealth 环境,而几乎没人会跑它。打在页面 window 上的补丁不会传进 Web Worker,因为 Worker 拿到的是一个全新的 navigator 对象,所以在控制台里看起来干净的环境,往下一层仍然在说真话。

// A Worker gets its own navigator, untouched by page patches.
const src = "postMessage(navigator.webdriver)";
const url = URL.createObjectURL(new Blob([src]));
new Worker(url).onmessage = (e) => console.log("worker says:", e.data);
// true here while the page says undefined is a mismatch,
// and a mismatch is a worse signal than either value alone.

最后那句注释要当真,因为这些脚本打分看的就是一致性。一个在某个上下文里答 undefined、在另一个上下文里答 true 的浏览器,等于同时告诉检测方两件事:它是自动化的,而且有人试图掩盖。第二件事才是把会话从被评分推向被封的原因。

无头浏览器检测里哪些信号最要紧

不是每个发现都值得占掉你一个下午。按生效速度排序:

信号真实会话是什么样权重
留在 window 对象里的驱动全局变量一个都没有关键:加载时就能定论
navigator 上的 webdriver 属性Undefined关键:毫秒级就被读走
平台、user agent 与语言彼此吻合三者描述的是同一台机器关键:互相矛盾比任何单个值都致命
WebGL 的 renderer 字符串有名有姓的 GPU,而不是软件回退高:几秒内就会给你挑战
isTrusted 为 false 的合成事件True,因为输入是浏览器发出的高:第一次交互就会触发
TLS 与 HTTP/2 指纹与你自称的那个 Chrome 构建相符高,而且上面每个探测都看不见它
插件列表为空、只有一种语言、两个核心内容完整且合理中:计入总体评分
字体数量与音频指纹桌面级字体集合,非零的音频哈希中:单独很少起决定作用

isTrusted 这一行常被误解,所以有必要说准。用 JavaScript 调用元素的 click 方法,产生的事件 isTrusted 为 false,这很容易被看出来。同样的点击如果经由 Playwright、Puppeteer 或 Selenium 发出就不是这样,因为它们走的是浏览器自己的输入管线。所以这是手写 DOM 脚本的泄露,而不是你的驱动的泄露。

第 5 步:把关键的几项补上

写代码之前有两条规矩。要早补,也就是在页面脚本运行之前补,否则检测方已经读到了原始值,你的修改来得太晚。还要补得窄,因为粗糙的覆盖本身就能被检测出来:改写一个原生函数,会让它的源码暴露给任何对它调用 toString 的人,把一个隐蔽的信号变成一个明显的信号。

# pip install playwright
from playwright.sync_api import sync_playwright

PATCH = """
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
window.chrome = window.chrome || { runtime: {} };
"""

with sync_playwright() as p:
    # Real Chrome leaks less than the bundled Chromium build.
    browser = p.chromium.launch(channel="chrome", headless=False)
    page = browser.new_page(locale="en-US")
    # add_init_script runs before any page script reads navigator.
    page.add_init_script(PATCH)
    page.goto("https://example.com/page-with-recaptcha")
    # Re-run the Step 1 probe here to confirm the patch landed.

那段代码里少了三个便宜的改进,因为它们属于配置而不是代码。用真正的 Chrome 通道,而不是自带的 Chromium。在多次运行之间保留一个持久的配置目录,让会话带着 cookie 和历史记录出现,而不是像刚出生一样。再把 locale 和时区调成与你流量看起来的来源一致。这三项加起来比任何 navigator 覆盖都更能挪动评分,而且都不会被抓到在说谎。如果你想看具体框架的做法, Playwright 验证码识别 这个页面讲了这一侧怎么接,用 Puppeteer 的人可以在 Puppeteer 验证码识别 页面找到同样的说明,按自己的驱动来配。

这些都修不了的部分

有三类东西在浏览器之外,所以上面每个探测对它们都是瞎的。你的 TLS 握手在第一个字节的 JavaScript 运行之前就已经被取了指纹,这就是为什么同一个请求从 Chrome 发出能顺利通过,用 Python 的 HTTP 客户端却过不了。你的 IP 背着它所在网络的信誉。而你的行为会在整个会话里被打分:请求速率、访问顺序、表单填得多快。所以无头浏览器检测永远只是你被拦下的部分原因。

这些做法都只是改变你被挑战的概率,没有一项能取消挑战。家庭宽带上的真实浏览器照样会经常碰到 reCAPTCHA 或 Turnstile 组件,因为很多站点在某些路径上会挑战每一个访客,评分说什么都不管用。

解决那些照样会来的挑战

组件一出现,问题就不再是指纹,而是 token。CapSkip 跑在你自己的硬件上,通过与 2captcha 兼容的 API 作答,返回一个由你自己注入并提交的 token。

# pip install capskip
from capskip import CapSkip

# Local mode. In Server mode this is the solver box's address.
solver = CapSkip(host="127.0.0.1", port=8080)

# One call covers v2, Invisible, Enterprise and v3 as options.
result = solver.recaptcha(
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)

# Inject the token into the field the page submits with the form.
page.evaluate(
    "t => document.getElementById('g-recaptcha-response').value = t",
    result["code"],
)

print(result["code"][:24])   # token, ready to submit

求解器是本地守护进程,不是按次计费的服务,所以重试一次失败的求解不花钱,而这一点在这里比看上去更重要。指纹这类活是反复迭代的,为了验证一个补丁而把同一个挑战解四十遍,在按次计费的 API 上会是一种昂贵的调试方式。

改成把求解器跑在服务器上

浏览器集群通常不跑在笔记本上,求解器也不必。连接设置提供两种模式,两者唯一的区别是 API 监听在哪个网络接口上。

模式监听地址适用场景
本地127.0.0.1,仅限该设备浏览器和求解器共用一台机器
服务器你的内网地址或公网 IP需要让另一台机器、一台 VPS、容器主机或托管的 CI runner 访问

在服务器模式下,你把 SDK 的 host 指向那个地址而不是 loopback,其他什么都不用改,于是二十个容器的集群可以共用一个求解器。如果调用方在你的网络之外,值得准备一个静态公网 IP,因为这个地址会写进配置。完整细节见 连接设置 这一节,就在设置指南里。服务器模式仍然是你自己的硬件,也仍然不计量,所以它改变的是求解器在哪里运行,而不是它要花多少钱。

常见问题

新的无头模式还会被检测出来吗?

会,只是不像旧的那么粗糙。Chrome 较新的无头模式用的是普通浏览器的同一个二进制文件,所以那个一眼露馅的 user agent 字符串和几个原本缺失的 API 都没了。webdriver 属性照样会被设置,驱动的全局变量照样会被注入,没有 GPU 的容器照样报软件 renderer。无头只是众多信号中的一个,不是当场判死。

只装一个 stealth 插件够不够?

它把那些众所周知的 JavaScript 属性处理得不错,值得用。但它碰不到你的 TLS 指纹、IP 信誉和请求节奏,而且它的补丁是公开的,检测厂商会直接针对它们做测试。装完之后请把四个探测都跑一遍,别当成事情已经办完了。

我的浏览器跑在托管 runner 上,它们还能访问到求解器吗?

能,只要 CapSkip 处于服务器模式。托管 runner 看不到你的 loopback 地址,所以把求解器改成监听你的内网地址或公网 IP,并让 SDK 的 host 指向它。一个实例就能服务所有 runner,中间不需要任何隧道,静态公网 IP 能让配置保持稳定。两种模式以及各自绑定的端口,都写在设置指南的 连接设置 一节里,两者之间怎么切换也在那里说明。

干净的指纹能让验证码彻底消失吗?

能减少,不会终结。很多站点是按路径而不是按评分来挑战的,所以再完美的浏览器在登录或结账路径上照样会碰到组件。两头都要准备:把被挑战的频率降下来,同时在流水线里留一个能应付照样会来的那些的东西。

先跑探测,再改任何东西

一次无头浏览器检测审计大约十分钟,通常查出两个问题,而不是二十个。清掉驱动的全局变量,让你的 navigator 各项值彼此吻合,再检查 Worker 上下文,别让你的补丁自己打自己的脸。然后把仍然出现的挑战当成另一件事,用另一个工具去做。在你自己的硬件上运行 验证码识别工具 就能处理这些挑战,而且不必为每次求解付费,多数爬虫集群最后想要的正是这种形态,就像 网络爬虫验证码识别工具 里针对 worker 池讲的那样。