深入解析网站如何通过 CDP 痕迹、WebGL 渲染器和特性缺口检测 Selenium、Puppeteer、Playwright 等无头浏览器。
无头浏览器承载着互联网自动化的大量需求:CI 测试套件、可用性监控、价格抓取,以及另一面——凭证填充、黄牛抢购和点击欺诈机器人。检测技术早已超越了过去那个 User-Agent 字符串里直接写着 HeadlessChrome 的年代。如今,网站通过一套层层叠加的信号体系来识别无头自动化——由代码控制的浏览器会持续泄露这些痕迹。本文逐一解析这些信号,以及为何它们难以被抹除。
核心要点
navigator.webdriver是最直接的自动化标志,任何由 WebDriver 驱动的浏览器都会将其设为true;错误的修补方式本身也是一种指纹。- CDP 痕迹难以彻底清除:Chrome DevTools Protocol 会在全局留下特征性对象和时序模式,许多隐身补丁也无法消除它们。
- 特性缺口会暴露无头运行时环境:纯软件 WebGL 渲染器、缺失的音频编解码器、矛盾的权限状态,以及空白的插件列表,都与真实桌面浏览器明显不同。
- Selenium、Puppeteer 和 Playwright 各有独特特征,即便替换了 User-Agent,每个框架在全局作用域中仍留有已知痕迹,网络行为也各有差异。
- 隐身插件提高了门槛,但无法彻底封堵:它们只能修补 JavaScript 层的泄露,无法触及其下的信号——TLS 指纹、HTTP/2 帧顺序或操作系统进程树。
什么是无头浏览器?
无头浏览器运行完整的渲染引擎——HTML 解析、CSS 布局、JavaScript 执行——却不向任何可见屏幕绘制内容。程序通过控制协议发送指令,浏览器响应,整个过程无需人类介入。Puppeteer、Playwright 和 Selenium WebDriver 都采用这种模式,通常对接以 --headless 参数启动的 Chrome Headless,或 Firefox 的无头模式。
无头浏览器对测试如此有用的特性,恰恰也是其可被检测的根源:它们由代码控制,而代码会留下痕迹。
navigator.webdriver 标志
最直接的无头信号是 navigator.webdriver。WebDriver 规范要求任何受 WebDriver 控制的浏览器将该属性暴露为 true。在真实桌面浏览器中,它返回 false 或为 undefined。
// A detector reads this in a single line.
if (navigator.webdriver === true) {
// Browser is almost certainly under WebDriver control.
}
单独来看,这是个薄弱的信号——隐身补丁多年前就能压制它。但错误的修补方式本身也是破绽。如果属性描述符看起来不符合规范、get 函数可被序列化,或 Object.getOwnPropertyDescriptor(navigator, 'webdriver') 返回的对象与真实浏览器的不一致,细心的检测器都会发现。粗糙地将 navigator.webdriver 修改为 false,往往比原始的 true 更可疑。
CDP 与 DevTools 协议痕迹
Puppeteer 和 Playwright 都通过 Chrome DevTools Protocol 驱动 Chrome——这与 Chrome 开发者工具背后的协议相同。该控制通道会在页面中留下痕迹。
全局变量泄露
部分 CDP 使用模式会向页面全局作用域注入对象。历史版本的 Puppeteer 会暴露 __puppeteer_evaluation_script__;其他框架则留有 __nightmare、_phantom 或 window.callPhantom。检测脚本会逐一枚举这些变量:
const knownArtifacts = [
'__nightmare', '_phantom', 'callPhantom',
'__puppeteer_evaluation_script__',
'__selenium_evaluate', '__webdriver_evaluate',
];
const found = knownArtifacts.filter(k => k in window);
// Any hit is a strong automation signal.
事件监听器异常
拦截 fetch 或 XMLHttpRequest 以修改响应的自动化框架,会在事件监听器映射中留下特征模式。通过对 window 对象调用 getEventListeners,检测器可以看到真实用户会话中不存在的监听器堆栈。
时序旁路信道
CDP 通信具有可测量的延迟。将一系列任务用 performance.now() 计时,可以发现特征性的抖动——这种抖动出现在每个步骤都需要经过 CDP 连接往返,而非在浏览器事件循环上同步执行时。
Selenium、Puppeteer 与 Playwright 的各自特征
三个框架最终都是在驱动浏览器,但各自留下的痕迹不同。
| 框架 | 控制接口 | 主要特征 |
|---|---|---|
| Selenium | 基于 HTTP 的 W3C WebDriver | navigator.webdriver = true(未修补)、客户端库产生的 WebDriver 特定 HTTP 头顺序 |
| Puppeteer | Chrome DevTools Protocol | CDP 全局注入、旧版 window.__puppeteer_* 变量;仅支持 Chrome,TLS 与真实 Chrome 一致 |
| Playwright | CDP(Chrome/Edge)、内部协议(Firefox/WebKit) | 默认值比 Puppeteer 更干净;Firefox/WebKit 变体在特性集上与对应真实浏览器存在差异 |
Playwright 在降低底层泄露方面投入更多,这也是为什么当怀疑目标使用 Playwright 时,检测研究更专注于上述那些更隐蔽的信号。
缺失或不一致的浏览器特性
无头环境在多个特性领域持续与真实桌面浏览器存在差异。检测脚本会综合探测这些缺口。
插件列表
真实桌面 Chrome 内置一组插件(如 PDF 查看器等),可通过 navigator.plugins 访问。历史上,Chrome Headless 返回空数组。现代版本有所改进,但插件对象本身的属性描述符仍有差异,可与真实用户浏览器的暴露内容进行交叉验证。
媒体编解码器
真实浏览器会与操作系统和底层 GPU 协商编解码器支持。在没有显示硬件的无头环境中,HTMLMediaElement.canPlayType() 往往报告有限的编解码器支持。一个声称自己是 Windows 上 Chrome 却表示无法播放 H.264 视频的浏览器,是明显的矛盾信号。
WebGL 与 GPU 渲染
WebGL 是最有力的无头特征之一。没有物理 GPU 时,Chrome Headless 会回退到 SwiftShader——其纯软件光栅化器。检测器读取 gl.getParameter(gl.RENDERER) 并检查是否出现 SwiftShader 或 Google SwiftShader 字样,而非真实 GPU 名称:
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
const renderer = gl.getParameter(gl.RENDERER);
// "ANGLE (Intel, ...)" or "Apple M2" → real hardware
// "Google SwiftShader" → headless software render
一个声称是高性能设备却在 WebGL 渲染器中报告纯 CPU 软件渲染的 User-Agent,是直接的自我矛盾。
Permissions API 矛盾
Permissions API 允许脚本查询用户是否已授予特定权限。在无头环境中,内部权限存储可能处于真实浏览器会话中不可能出现的状态——例如通知权限同时处于 denied(已拒绝)和 prompt(待询问)状态。检测脚本通过探测多个权限并检查逻辑一致性,可以捕捉到这些无效组合。
隐身插件的尝试与局限
puppeteer-extra-plugin-stealth 等库会在检测脚本读取上述信号之前对其进行修补:覆盖 navigator.webdriver、注入逼真的插件列表、清除 CDP 特定全局变量、规范化 Permissions API 响应。对于简单的检测机制,这些方法效果不错。
但问题在于结构性缺陷。首先,每一处补丁都可能成为新的破绽:Object.defineProperty 包装器的 toString() 输出与原生属性不同,通过探测 Object.getOwnPropertyDescriptor(navigator, 'webdriver') 就能发现人工描述符。检测系统现在会将补丁的存在本身视为信号。其次,隐身插件完全在 JavaScript 层运行,无法触及:
- TLS ClientHello 指纹 —— 浏览器在握手中提供的密码套件和扩展,反映的是底层网络库(Chromium 中的 BoringSSL),而非 JavaScript 层。无头 Chrome 与真实 Chrome 共享相同的 TLS 指纹,但伪装成 Chrome 的 Python 脚本则不同。
- HTTP/2 帧顺序 —— HTTP/2 帧的顺序和优先级,在真实浏览器与构建于其上的自动化客户端之间存在差异。
- 操作系统进程树 —— 服务器端检测如果发现 ChromeDriver 或 geckodriver 进程与浏览器进程同时存在,就获得了 JavaScript 补丁无法消除的带外自动化证据。
测试你自己的自动化配置
如果你出于合法目的运行无头自动化——CI 流水线、无障碍审计、可用性监控——可以直接查看检测系统对你的会话"看到"了什么。BrowserInsight 的机器人检测工具会实时报告你的 navigator.webdriver 状态、插件数量、WebGL 渲染器及其他自动化信号。内核检测工具会揭示 User-Agent 声明背后真正运行的渲染引擎。在上线生产之前,用这些工具审计你的配置。
对于 JavaScript 无法感知的网络层信号,请参阅关于机器人检测技术的补充文章,其中涵盖 TLS 和 HTTP/2 指纹识别内容。
常见问题
修补 navigator.webdriver 能骗过检测系统吗?
只能应对简单检查。真实浏览器中该属性自然为 false,但如何为 false 至关重要。原生的 false 与修补后的 false 在属性描述符层面一目了然。将修补与 WebGL 和插件检查结合后,剩余信号已足以对大多数自动化行为做出判定。
Playwright 比 Selenium 更难被检测到吗?
Playwright 的默认配置比经典 Selenium 更干净,其跨浏览器支持也意味着 Firefox 和 WebKit 驱动能规避部分 Chrome 特有的 CDP 痕迹。但检测系统同样在追踪 Playwright 特有的信号。目前没有任何框架能对测试完整信号集的检测栈完全隐身。
为什么 Chrome Headless 会报告 SwiftShader?
SwiftShader 是 Chrome 的纯 CPU 软件渲染器——在没有真实 GPU 时启用。运行无头 Chrome 的服务器或容器通常没有物理显示适配器,因此 GPU 会回退到 SwiftShader。真实桌面 Chrome 则报告实际的 GPU 驱动名称:Intel、NVIDIA、AMD 或 Apple Silicon。
有没有能通过所有检测的无头配置?
理论上存在,实践中极难实现。你需要:修复 JavaScript 层泄露(隐身插件可处理大部分)、提供真实 GPU 访问(修复 WebGL 问题)、使用具备正确 TLS 的真实 Chrome 网络栈(对基于 Chrome 的自动化基本满足),以及在规模上产生逼真的行为噪声。商业反检测浏览器正在尝试这一方向;我们关于反检测浏览器检测的文章详细介绍了网站如何应对这类工具。


