最知名的 CDP 检测技巧是在 Error.stack 上定义一个 getter:只要自动化客户端调用过 Runtime.enable,它就会被触发。2025 年 5 月的两次 V8 inspector 提交随 Chrome 138 发布后,这一招失效了。但这并没有让 CDP 驱动的浏览器隐身。研究者此后找到了其他观察 Runtime 域是否启用的方法,而且多数商业检测方案如今依赖的是框架痕迹、无头与运行环境破绽、输入属性,以及行为和网络信号,这些从来就不依赖那一项检查。
什么是 CDP 检测,为什么主流框架都会暴露
CDP 检测指的是识别出浏览器由 Chrome DevTools Protocol 驱动,而不是由人操作。它之所以可行,是因为这个协议本为调试而设计,部分调试功能会留下页面 JavaScript 可观察到的痕迹。
Chrome DevTools Protocol 就是 DevTools 窗口自身使用的 WebSocket API,由 Runtime、Page、Network、Input 等域组成,每个域都有命令和事件。大多数 Chromium 自动化都要经过它:
- Puppeteer 通过 CDP 控制 Chrome;对 Firefox 则改用 WebDriver BiDi。
- Playwright 对 Chromium 使用 CDP;对 Firefox 使用 Juggler,这是 Firefox 内部的另一套协议。
- Selenium 4 用 WebDriver 与 ChromeDriver 通信,ChromeDriver 在内部通过 CDP 驱动 Chrome;在迁移到 BiDi 的过程中,Selenium 也直接暴露 CDP 命令。
Firefox 已经告别 CDP。Mozilla 在 Firefox 129 中弃用了 CDP 实现,并在 Firefox 141 中彻底移除。因此 2026 年所说的“CDP 检测”,指的就是检测被自动化的 Chromium 系浏览器。
这类信号吸引防御方的原因,DataDome 在其 2024 年 6 月的研究(转载自其威胁研究博客)中说得很清楚:“能够检测浏览器被 CDP 插桩,是识别大多数现代机器人框架的关键。”新版 headless Chrome 与普通 Chrome 共用代码库之后,大部分指纹不一致都消失了。而协议层面的副作用可以同时抓住 Puppeteer、Playwright、Selenium,以及任何基于同一底层机制的未知框架。
经典的 Runtime.enable 检查如何运作
这项检查利用了一个事实:Runtime 域启用后,V8 的 inspector 会序列化每个 console 参数,以便向客户端发送预览。序列化对象要读取其属性,而读取带 getter 的属性就会执行页面代码。
Castle 记录的版本如下:
let wasAccessed = false;
const e = new window.Error();
Object.defineProperty(e, 'stack', {
configurable: false,
enumerable: false,
get: function () {
wasAccessed = true;
return '';
}
});
console.debug(e);
// wasAccessed === true => 有程序在监听 Runtime 域
逐步来看:
- Puppeteer、Playwright 和 ChromeDriver 会调用
Runtime.enable,因为它们需要Runtime.executionContextCreated事件来确定在哪里执行脚本。协议文档将其描述为启用“执行上下文创建的上报”。 - 域启用后,每次
console.*调用都会产生一个Runtime.consoleAPICalled事件,V8 会为其中每个参数构建预览。 - 对于
Error,构建预览需要读取.stack(以及.name和.message)。页面已把.stack换成了 getter,于是 getter 被执行,标志位随之翻转。 - 在没有任何调试器连接的普通浏览器中,不会构建预览,getter 也永远不会执行。
它只需几行 JavaScript,几乎没有开销,却能抓住所有调用了 Runtime.enable 的框架,无论它们怎样处理 navigator.webdriver 或 User-Agent。给 JavaScript 属性打补丁的 stealth 插件对此无能为力,因为泄漏发生在协议层,而不在页面里。后来出现的对策都在驱动层:rebrowser-patches 和 Patchright 避免调用 Runtime.enable;DataDome 在 2024 年也指出,nodriver 和 Selenium Driverless 使用的是“不依赖 Runtime.enable 的底层 CDP 命令”。
V8 在 2025 年改了什么
V8 在 2025 年 5 月通过两次提交堵住了这个副作用:inspector 在预览对象和错误时不再执行用户定义的 getter。修复进入 V8 13.8,随 Chrome 138 发布(2025 年 6 月 24 日进入稳定版)。
61a90754(2025 年 5 月 7 日),“[inspector] Prevent side effects during object preview”,避免“在启用 runtime 域时执行用户代码”。e08e9734(2025 年 5 月 9 日),“[inspector] Prevent side effects during error preview”,修改错误描述的生成方式,“以避免执行带副作用的 getter”。
两次提交都引用了 Chromium bug 415094795。第一次提交的作者是微软的 Playwright 维护者 Pavel Feldman,所以这项改动来自自动化工具一方,而不是某个 stealth 项目。Castle 的 Antoine Vastel 于 2025 年 8 月 28 日撰文分析,标题为 《一个经典的 CDP 机器人检测信号为何突然失效(而且没人注意到)》,文中指出新增的 getErrorProperty() 辅助函数会在预览时跳过用户 getter。在 Chrome 138 及更高版本上,即便使用原版 Puppeteer,上面的代码片段也会返回 false。
Runtime.enable 还能被检测到吗?
大概率还能,只是不如以前可靠。2026 年 3 月,独立研究者 Sveba 发表了一篇关于 V8 预览路径的分析,指出仍有两条途径。一条是 GetOwnPropertyDescriptor 失败时、2025 年 5 月的防护没有覆盖到的代码路径,作者称已“验证……在打补丁后的 Chrome 中可被利用”。另一条是在对象原型链上放置一个 Proxy:
let detected = false;
const trap = new Proxy({}, {
ownKeys() { detected = true; return []; }
});
const obj = Object.create(trap);
console.groupEnd(obj); // 任意 console 方法都可以
// detected === true => inspector 正在构建预览
按照这篇文章的说法,inspector 只检查值本身是否为 Proxy,而不检查其原型,并且会急切地收集所有键。因此遍历原型链时就会触发 ownKeys 陷阱。作者在 2026 年 3 月的 content_shell 构建上做了测试,报告称当时尚未修复。我们没有在每个 Chrome 稳定版上验证过,所以请把它当作“有报告”,而非普遍适用。
有两点限制了防御方如今对任何 Runtime.enable 信号的依赖程度。第一,打开 DevTools 的真实用户会走同一条代码路径,所以这个信号表达的是“有 inspector 连接”,而不是“这是机器人”。第二,最值得抓的那些框架早已避开 Runtime.enable,所以它主要标记的是未经修改的 Puppeteer、Playwright 和 Selenium。行业已转向叠加大量较弱的信号,Castle 的文章也是这么说的。
2026 年仍然有效的信号
如今站得住的信号来自框架在页面里做了什么、浏览器如何构建和启动、输入如何到达,以及会话在网络上的表现。隐藏某一个 CDP 副作用,消除不了其中任何一项。
主世界中的框架痕迹
原版框架会留下自己的指纹。rebrowser bot detector 列出了几项:
__pwInitScripts:Playwright 默认会把它添加到每个页面的全局作用域。sourceURL泄漏:Puppeteer 会给执行的脚本打上//# sourceURL=pptr:…标记,它可能出现在被钩住的函数内部捕获的错误堆栈里。- 主世界执行:如果你的脚本在页面自己的世界里调用
document.querySelector,而页面已经包装了这个函数,页面就能看到这次调用。 exposeFunction/ binding:page.exposeFunction()和 Playwright 的window.__playwright__binding__会安装页面可以枚举到的全局变量。它们底层使用Runtime.addBinding,该命令会“在所有被检查上下文的全局对象上”添加函数。- 工具世界名称:框架创建的隔离世界带有可识别的名称,rebrowser-patches 允许你重命名。
初始化脚本痕迹
Page.addScriptToEvaluateOnNewDocument 会“在每个 frame 创建时(在加载 frame 的脚本之前)”执行一段脚本。stealth 插件正是用它在网站代码运行前修改 navigator。协议本身不泄漏,但补丁会。正如 Camoufox README 所说,注入的 JavaScript 可以通过 Object.getOwnPropertyDescriptor 被发现,也可以通过函数的 toString() 不再返回 [native code],或者 window 与 Worker 中取值不同而被发现。
Worker 上下文
Worker 是补丁最常失手的地方。作用于页面的初始化脚本和属性覆盖,不会自动延伸到 dedicated、shared 或 service Worker。如果 Worker 报告真实的 navigator.userAgent、hardwareConcurrency 或 WebGL 渲染器,而 window 报告的是伪造值,这就是很强的破绽。CreepJS 专门检查 Worker 作用域,正是出于这个原因。
无头模式与构建特征
- Headless shell 与新版 headless。Chrome 在 Chrome 112 推出了
--headless=new,从 Chrome 132 起,旧模式只存在于独立的chrome-headless-shell二进制文件中。Playwright 在无头模式下默认使用 chromium headless shell,其行为与真实 Chrome 不同。 - 构建身份。“Chrome for Testing”的 User-Agent、默认视口(Puppeteer 为 800×600,Playwright 为 1280×720),以及保持开启的
navigator.webdriver,都是 rebrowser 检测器列出的低成本检查。 - 编解码器。据 Chromium 项目说明,开源 Chromium 构建不包含 Google Chrome 附带的专有编解码器,例如 H.264 和 AAC。自称 Chrome 却在 H.264 的
canPlayType检查上失败的浏览器,实际上是 Chromium。 - 权限与媒体设备。在无头和容器环境中,通知权限状态、空的媒体设备列表和缺失的语音合成声音,常常与 User-Agent 声称的不符。CreepJS 和 BrowserScan 会检查这些。
合成输入
通过 Input.dispatchMouseEvent 发送的输入会产生受信任事件(isTrusted 为 true),但它曾带有真实输入没有的属性。最有名的是在非全屏状态下 screenX/screenY 等于 pageX/pageY。Chromium 在 change 6917162(“Fix screen coordinates to avoid automation detection”)中修复了这个问题,于 2025 年 9 月 15 日合并,早于 Chrome 142 的分支点。为绕过这个问题而存在的 CDP-Patches 项目现在表示,CDP 输入也会产生合并事件(coalesced events),并已将自身归档。在当前的 Chrome 上,这两项具体检查基本失效,但轨迹、时序和点击位置分析依然适用。
行为与网络
下面这些信号都与 CDP 无关,却构成了厂商评分的大部分。Castle 列出了客户端行为分析、环境“红药丸”(red pills)、代理与网络检测,以及针对协同活动的时间序列分析。网络层面包括必须与所声称浏览器相符的 TLS 指纹和 HTTP/2 指纹,以及 IP 信誉。GPU 层面的指纹识别见我们的 Canvas 与 WebGL 深度解析。
Patchright、nodriver 和 Camoufox 如何处理 CDP 泄漏
三者都以不同方式避开了 Runtime.enable 类泄漏,但没有一个能解决行为或网络信誉问题。
Patchright 是仅支持 Chromium 的 Playwright 补丁版驱动。根据其 README,它的主要补丁“通过在(隔离的)ExecutionContext 中执行 JavaScript”来避免 Runtime.enable。它还完全禁用了 Console 域(因此 console 日志不可用),去掉 --enable-automation,加上 --disable-blink-features=AutomationControlled,并建议使用真实 Chrome(channel="chrome")且不注入指纹。它紧跟 Playwright 的版本发布(v1.63.0 驱动于 2026 年 9 月发布)。安装配置见我们的 Patchright 深度解析。
nodriver 是 undetected-chromedriver 的继任者,它去掉了 WebDriver,直接用 CDP 与你安装的 Chrome 通信。查看其当前 core/ 源码可以发现,它使用 Runtime.evaluate 和 Runtime.callFunctionOn 执行代码,从不调用 Runtime.enable,这与 DataDome 2024 年的描述一致。代价是代码在主世界中执行,包装了 DOM 函数的页面能看到它,而且没有指纹管理。PyPI 上最新版本是 2026 年 5 月发布的 0.50.3。参见我们的 nodriver 指南。
Camoufox 完全绕开了 CDP。它是 Firefox 的分支,Playwright 通过 Juggler 驱动 Firefox,而 Camoufox 对 Juggler 打了补丁,让 Playwright 在页面的隔离副本上工作:页面脚本既看不到注入的辅助代码,也无法监视 getter 读取。输入经由 Firefox 正常的输入处理流程,指纹值在 C++ 层伪造,而不是靠初始化脚本,这就消除了 toString 和 Worker 不一致这一类问题。代价是它终究是 Firefox。README 提到部分 WAF 会测试 SpiderMonkey 引擎行为,所以它无法冒充 Chrome。
信号速查表
| 信号 | 2026 年还有效吗? | 如何规避 | 规避成本 |
|---|---|---|---|
console 上的 Error.stack getter(经典版) | 在 Chrome 138+(V8 13.8)上无效 | 使用最新版 Chrome | 无 |
| 其他 inspector 预览副作用(如原型链 Proxy) | 截至 2026 年 3 月有报告称有效;打开 DevTools 的真实用户也会触发 | 不调用 Runtime.enable(Patchright、nodriver、rebrowser-patches)或不使用 CDP(Camoufox) | 低到中:驱动补丁,失去 console API |
__pwInitScripts、binding、pptr: sourceURL | 有效,针对原版框架 | 补丁版驱动;避免使用 exposeFunction | 低 |
| 主世界执行钩子 | 有效 | 在隔离世界中执行 | 中:无法直接访问页面全局变量 |
被 JS 打补丁的属性(toString、描述符) | 有效 | 不打补丁;使用真实 Chrome 或 C++ 层伪造 | 中到高 |
| window 与 Worker 不一致 | 有效 | 同上;补丁必须覆盖 Worker | 若用 JS 打补丁则高 |
| Headless shell / Chrome for Testing / 编解码器 | 有效 | 品牌版 Chrome,有头模式或新版 headless,虚拟显示 | 中:更多内存,服务器上需 Xvfb |
| CDP 输入:screenX == pageX、无合并事件 | 在当前 Chrome 中基本已修复 | 使用当前版 Chrome | 无 |
| 行为与时序 | 有效,且在评分中占比越来越大 | 真实的节奏,每个身份更少的请求 | 高:吞吐量 |
| TLS / HTTP/2 / IP 信誉 | 有效 | 真实浏览器网络栈,干净的住宅或 ISP IP | 持续的代理开销 |
如何测试你自己的环境
先用你自己的探测页面测试,再用公开检测页面测试,每次只改变一个变量。公开页面告诉你它们检测到了什么;你自己的页面告诉你哪种 Chrome 构建与驱动的组合仍会触发哪项检查。
1. 本地探测页面
把下面的内容保存为 probe.html。它会运行经典检查、原型链检查和几项低成本的痕迹检查,然后把结果写进 DOM,这样无论驱动的脚本运行在哪个世界,都能读取到。
<!doctype html>
<pre id="out">running</pre>
<script>
function classic() {
let hit = false;
const e = new Error();
Object.defineProperty(e, 'stack', { configurable: false, enumerable: false,
get() { hit = true; return ''; } });
console.debug(e);
return hit;
}
function protoProxy() {
let hit = false;
const trap = new Proxy({}, { ownKeys() { hit = true; return []; } });
console.groupEnd(Object.create(trap));
return hit;
}
const r = {
classicStackGetter: classic(),
protoProxyPreview: protoProxy(),
webdriver: navigator.webdriver,
pwInitScripts: '__pwInitScripts' in window,
h264: document.createElement('video').canPlayType('video/mp4; codecs="avc1.42E01E"'),
ua: navigator.userAgent,
};
setTimeout(() => { document.getElementById('out').textContent = JSON.stringify(r, null, 2); }, 300);
</script>
然后在你关心的每种环境下运行它:
import pathlib
from playwright.sync_api import sync_playwright # 可替换为:from patchright.sync_api import sync_playwright
url = pathlib.Path("probe.html").resolve().as_uri()
with sync_playwright() as p:
browser = p.chromium.launch(channel="chrome", headless=False)
page = browser.new_page()
page.goto(url)
page.wait_for_function("document.getElementById('out').textContent !== 'running'")
print(page.text_content("#out"))
browser.close()
依次运行原版 Playwright、Patchright,再在普通 Chrome 窗口中打开同一页面,分别测试打开和关闭 DevTools 的情况。DevTools 那一轮是你的对照组:凡是在那里触发的检查,对真实用户也会触发,因此单独来看只是弱信号。
2. 公开检测页面
- rebrowser bot detector:专注 Chromium,涵盖 Runtime.enable、sourceURL、主世界执行、
exposeFunction、__pwInitScripts、视口和 User-Agent。页面说明中提到,部分测试只有在你的脚本调用页面的dummyFn或暴露函数时才会触发。 - BrowserScan 机器人检测:报告 WebDriver、User-Agent、CDP 以及 navigator 一致性检查结果。
- CreepJS:开源指纹分析工具,会标记“谎言”(被打补丁的属性)、无头特征和 Worker 作用域不一致。要发现 JavaScript 层面的伪造,它是最好的选择。
- Device and Browser Info「Are you a bot?」:指纹与行为测试,包括 CDP、WebDriver、Playwright 和 headless Chrome 检查。
请通过生产环境中将使用的同一代理运行这些测试。其中几个页面会同时显示 IP 和时区以及浏览器信号,而在数据中心 IP 上拿到完美的浏览器评分,对真实流量几乎说明不了什么。使用粘性会话(USERNAME-sid-abc123-ttl-10m,配合住宅代理网关使用)可以在对比各轮测试时保持 IP 不变。
记住,通过测试页面并不等于通过真实网站。商业厂商使用服务端模型、跨会话历史,以及这些页面看不到的信号。
这些解决不了什么
堵上 CDP 泄漏只是去掉了一类证据。它不会让自动化看起来像人,也不会让数据采集变得合规。请求速率、访问顺序和每个身份的请求量仍会暴露自动化,IP 信誉和 TLS 仍必须与你所声称的浏览器相符。访问规则依然适用:robots.txt、服务条款,以及涉及个人信息时的数据保护法律。如果你做检测,2025 年的实际教训是:不要依赖浏览器厂商一次例行提交就能移除的单一副作用。如果你做自动化,请保持工具更新,优先选择不给 JavaScript 打补丁的驱动,并假定剩下的信号主要是行为层面的。






