解析网站如何通过 CDP 副作用(如 Runtime.enable 序列化泄露)检测 Puppeteer、Playwright 和 Selenium。
Puppeteer、Playwright,以及现代版本的 Selenium,几乎都是通过同一条底层通道驱动 Chrome 的:Chrome DevTools Protocol(CDP),也就是驱动 Chrome DevTools 本身的那套接口。这种共享基础对自动化工具的开发者来说很方便,但也意味着这些工具都可能留下同一类破绽——与 User-Agent 字符串或 navigator.webdriver 是否伪装无关。本文将解释什么是 CDP、它具体带来了哪些副作用,以及检测系统如何把这些副作用变成一种信号。
核心要点
- CDP 是共享的控制通道,Puppeteer、Playwright 的 Chromium 驱动,以及基于 CDP 的 Selenium 会话都建立在它之上——一个专门识别 CDP 本身的检测器,可以一次性捕获这三者。
- 启用 Runtime 域会带来可观测的副作用:一旦客户端发送
Runtime.enable,每一次console.*调用都会被拦截并对其参数进行序列化以生成预览,这会主动触发一些页面本不会触碰的属性 getter。 - 这种序列化副作用本身就是一种检测手段:页面可以在传给
console.log的值上定义一个 getter,检查它是否立即被触发——从而在不显示任何可见日志的情况下,揭示是否连接了 CDP 客户端。 - 各框架暴露程度不同:Puppeteer 和 Playwright 的 Chromium 模式默认会启用
Runtime.enable以追踪执行上下文;经过修补的版本会用别的方式管理上下文 ID,从而避免这一点。 - 这只是分层检测体系中的一个信号,并非万能解法——它与我们无头浏览器检测指南中介绍的
navigator.webdriver及渲染异常检测互为补充。
什么是 CDP,自动化工具为何依赖它
Chrome DevTools Protocol 是一套 JSON-RPC 接口,让外部客户端可以检查并控制一个正在运行的 Chromium 实例:执行 JavaScript、拦截网络请求、截图,以及接收控制台和异常事件。这正是你手动打开 DevTools 时,DevTools 本身与浏览器通信所用的协议。
Puppeteer 和 Playwright 的 Chromium 后端直接通过 CDP 连接。Selenium 传统上使用 W3C WebDriver 线协议,但 Chromium 的 WebDriver 实现(chromedriver)本身底层就是一个 CDP 客户端,而 Selenium 4 针对 Chromium 的 DevTools API 更是直接暴露了 CDP。既然三者最终都共享这同一个控制层,一个针对 CDP 机制本身、而非某个框架特有痕迹的检测器,就有机会一次性捕获所有三者。
页面能观测到的 CDP 信号
页面没有直接的 API 可以询问"是否连接了 CDP 客户端"。CDP 的设计初衷就是对页面 JavaScript 不可见。但连接客户端会以某些方式改变渲染进程内部发生的事情:某些协议域只有在客户端请求时才会发出事件,而处理这些事件会在页面正在执行的同一 JavaScript 线程上运行额外代码。其中最有用的一种,正是启用 Runtime 域时发生的情况。
Runtime.enable 与控制台序列化泄露
自动化客户端发送 Runtime.enable 以接收执行上下文和控制台 API 调用事件——Puppeteer 和 Playwright 需要它来知道该在哪个上下文 ID 中执行脚本。一旦这个域被激活,V8 检查器就会在每一次 console.* 调用到达可见控制台之前将其拦截,并为对应的 CDP 事件构建对象预览——无论是否有真人打开 DevTools 去查看。
构建这个预览意味着要遍历参数的属性,而遍历属性就意味着触发它们的 getter。在未被检测工具插桩的页面上,传给 console.log 的对象上的 getter,只有在真人打开 DevTools 并正在查看时才会触发。而当 Runtime.enable 处于激活状态时,它会作为 CDP 事件管道自身的副作用,无条件、立即触发:
// 只有当 CDP 客户端启用了 Runtime 域时,这段代码才会
// 立即执行(在任何地方出现任何输出之前)。
let cdpDetected = false;
const probe = {};
Object.defineProperty(probe, 'flag', {
get() {
cdpDetected = true;
return 'x';
},
});
console.log(probe);
// 在下一个 tick 检查 cdpDetected——不需要任何可见的控制台。
这是一个时序/序列化侧信道,而不是一个有文档记录的 API:它依赖于 V8 检查器如何构建控制台预览,这属于实现细节,而非稳定契约。这也是为什么它从未成为一个永久破绽——Chromium 改变检查器处理控制台参数的方式时,这一确切的引擎级行为也随之发生过变化。
Puppeteer 与 Playwright 的痕迹
两个框架默认配置下都会启用 Runtime 域,这正是这项技术能广泛适用于开箱即用脚本、而非罕见边缘情形的原因。
| 工具 | 默认 CDP 行为 | 常见缓解方式 |
|---|---|---|
| Puppeteer | 发送 Runtime.enable 以追踪执行上下文 | 社区分支避免保持该域持续启用,改用其他方式恢复上下文 ID |
| Playwright(Chromium) | 为其默认页面 world 的每个执行上下文启用 Runtime | 社区补丁在无需 Runtime.enable 获取的隔离 world 中运行自动化脚本 |
Selenium(chromedriver / W3C) | 主要使用 W3C WebDriver 线协议;CDP 用法更窄且需主动选用 | 避免使用 DevTools API 即可完全绕开这项具体检测 |
实际结果是:这种基于单一机制的检测器,曾一度能把默认配置的 Puppeteer/Playwright 会话,与基于纯 WebDriver 的 Selenium 会话区分开来,直到自动化工具的作者调整了各自管理 CDP 的方式。
检测手法与规避手段的较量
这与我们机器人检测技术指南中讨论的猫鼠游戏如出一辙,只是发生在技术栈更底一层的地方。这个 Runtime.enable 侧信道一经公开,就迅速在各家反机器人厂商中传播开来,因为它能用一次检测捕获多个框架,而且不依赖隐身插件早已覆盖的 JavaScript 层补丁。自动化工具的维护者随后推出了跳过 Runtime.enable、或改用其他方式追踪执行上下文的版本,堵上了这个特定漏洞,同时保持框架其余行为不变——这与一个跟踪该库自动化检测报告多年的长期 Puppeteer GitHub issue 中反映的模式如出一辙。
双方谁都无法长期领先。一个依赖未公开检查器内部机制的信号,恰恰是引擎变更可能悄悄打破或恢复的那种东西,这也是为什么真正严肃的检测体系,会把它当作更大打分系统中的一项输入——与 navigator.webdriver、WebGL 渲染异常,以及本站其他文章描述的网络层指纹并列——而非当作一个可以独立下定论的信号。
测试你自己的自动化环境
如果你维护 CI 浏览器测试、无障碍审计或监控脚本,了解检测系统能看到你的会话中的什么,是值得的。BrowserInsight 的机器人检测工具会展示你的浏览器当前暴露的 navigator.webdriver、无头痕迹及其他自动化信号,内核检测则会确认底层实际运行的渲染引擎。这两个工具都不会直接检查 CDP 协议流量本身——那种检查发生在服务端——但它们展示了真实检测体系会与之结合使用的相邻信号。
常见问题
网站能否直接检测到 CDP 已连接?
无法通过有文档记录的 API 直接检测——CDP 的设计初衷就是对页面 JavaScript 不可见。页面能检测到的,是特定 CDP 域被激活时产生的副作用,例如 Runtime 域启用后出现的控制台序列化行为。
这对 Selenium 的影响和 Puppeteer、Playwright 一样吗?
不完全一样。Selenium 的经典路径运行在 W3C WebDriver 线协议之上,而非直接使用 CDP,因此它不会像 Puppeteer 和 Playwright 默认的 Chromium 配置那样,自动触发 CDP 域的副作用。但如果 Selenium 会话显式使用了 Chromium 的 DevTools API,仍然会触发同样的检测。
启用 Runtime.enable 是自动化工具作者的失误吗?
不是——这是通过 CDP 获取执行上下文和控制台事件最直接的方式,也正是人类打开 DevTools 时它自身所做的事。这个副作用之所以成为机器人信号,仅仅是因为脚本不像人类,在生产环境中没有其他理由连接 CDP 客户端。
修复这一个信号能让自动化变得不可检测吗?
不能。它只是从打分系统中移除了一项输入,而该系统还包括渲染不一致性、行为分析和网络层指纹——详见我们关于无头浏览器检测和机器人检测技术的文章,了解这套体系的其余部分。


