指纹浏览器:隐匿 Puppeteer/Playwright 的自动化特征(`navigator.webdriver` 等)

在指纹浏览器与风控系统的无声战役中,无数开发者曾陷入一个致命的认知陷阱:认为只要加上了 --disable-blink-features=AutomationControlled,或者在页面加载时通过 Object.definePropertynavigator.webdriver 改成 false,就能成功骗过风控系统。

然而,现代风控系统的探针早已不局限于此。当你用 Puppeteer 或 Playwright 启动一个浏览器,并通过 CDP 建立连接的那一刻起,无数隐秘的"幽灵指纹"就已经在 V8 引擎的内存态、Blink 渲染引擎的原型链、以及 C++ 底层的事件循环中生根发芽。

风控系统只需在页面中执行几行极其隐蔽的 JS 探针,读取 navigator.webdrivergetter 描述符,检查 Error().stack 的调用栈痕迹,或者探测 window.chrome 对象内部的残缺结构,就能瞬间将你的自动化脚本剥得底裤不剩。

Puppeteer 和 Playwright 的自动化特征,绝不是几个简单的 JS 属性修改就能掩盖的。这是一场从应用层 JS Hook 到 V8 引擎底层,再到 Chromium C++ 源码级的立体防御战。

本文将拆解:如何从物理层面彻底斩断 Puppeteer/Playwright 的自动化基因,从源码级重构 navigator.webdriver,抹除 CDP 执行侧信道,并构建防篡改的深层拟态伪装架构。

第一章:表层伪装的失效与原生特征溯源

在深入底层改造之前,必须彻底弄清,为什么你在网上搜到的那些"防检测"代码,在高级风控面前如同纸糊的。

在 Chromium 源码中,navigator.webdriver 的定义位于 third_party/blink/renderer/core/frame/navigator.cc

当 Chrome 以自动化模式启动时,会附带 --enable-automation 参数。这个参数会触发底层将 is_webdriver 标志位设为 true

cpp 复制代码
// Navigator.cc 源码逻辑
bool Navigator::webdriver() const {
  return DomWindow()->GetFrame()->GetSettings()->GetNavigatorWebDriver();
}

很多开发者尝试用 JS 覆盖它:

javascript 复制代码
Object.defineProperty(navigator, 'webdriver', {
    get: () => false
});

致命痛点:这种 JS 层的覆盖是极其脆弱的。风控系统只要执行以下代码,你的伪装就会瞬间破功:

javascript 复制代码
const descriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver');
// 真正的浏览器中,descriptor.get 是原生的 Blink C++ 绑定函数
// 其 toString() 应该是 "function get webdriver() { [native code] }"
console.log(descriptor.get.toString());

当你用 Object.defineProperty 覆盖后,descriptor.get 变成了普通的 JS 匿名函数,toString() 的结果变成了 "function () { return false; }"。这种原型链上的非原生痕迹,是风控判定 Bot 的铁证。

2. window.chrome 对象的残缺

原生 Chrome 浏览器中,window.chrome 是一个包含 runtime, loadTimes, csi 等复杂子对象的结构。

致命痛点 :当使用 Headless Chrome 或通过 CDP 接管时,window.chrome 对象经常是残缺的,或者 window.chrome.runtime 根本不存在。风控只需检查 window.chrome.runtime 是否为 undefined,就能判定当前为无头或自动化环境。

3. --enable-automation 的信息栏副作用

虽然 Puppeteer 会通过 --disable-automation 尝试关闭顶部的"Chrome 正受到自动测试软件的控制"信息栏,但这个参数在 Chromium 底层会触发一系列连锁反应,修改 Navigation Timing 的某些指标,甚至影响 Performance API 的某些时序特征。

第二章:C++ 层的降维打击:从源头切断自动化标记

要实现真正的隐匿,不能在 JS 层打补丁,必须在 Chromium 编译阶段,从 C++ 源码层进行"基因编辑"。

精准坐标third_party/blink/renderer/core/frame/navigator_id.cc

我们不能让浏览器返回 false,而是要让浏览器根本不知道有 webdriver 这个属性存在 ,或者让其 getter 保持绝对的原生性。

cpp 复制代码
// 修改前
bool Navigator::webdriver() const {
  return DomWindow()->GetFrame()->GetSettings()->GetNavigatorWebDriver();
}
// 修改后(指纹浏览器拦截点)
bool Navigator::webdriver() const {
  // 直接硬编码返回 false,无视 --enable-automation 参数
  return false;
}

但这还不够,风控可能会探测属性是否可枚举。我们需要在 V8 绑定层面确保其 getter 依然是 [native code]。通过直接修改 V8Navigator::webdriverAttributeGet,确保其行为与原生完全一致,无任何 JS 注入痕迹。

2. 物理阻断 --enable-automation 的全局副作用

精准坐标chrome/browser/chrome_browser_main.cc

在 Chrome 主进程初始化阶段,拦截命令行参数解析。即使外部控制脚本传入了 --enable-automation,我们在 C++ 层将其静默丢弃,但为了保证 Puppeteer/Playwright 的 API 不报错,我们伪造一个内部的 automation_enabled 状态返回给 CDP,而在真正的 Blink 渲染引擎和底层网络栈中,彻底关闭 automation 模式。

3. 完善 window.chrome 的物理重建

精准坐标third_party/blink/renderer/modules/chromeos/chromeos.cc 或相关的 Chrome 对象绑定层。

在 C++ 层,当页面初始化 window 对象时,强制注入完整的 chrome 对象结构。不仅仅是注入空壳,而是要实现 chrome.runtimechrome.loadTimes 等方法的原生 C++ 绑定,使其 toString() 返回 [native code]。这需要深入 V8 的 ObjectTemplate 机制,在 isolate 级别为每个上下文注入不可枚举的原生方法。

第三章:JS 沙箱与 Object.defineProperty 的防御战

虽然 C++ 层的修改是终极解法,但在某些无法重编译 Chromium 的场景下,我们需要通过 CDP 的 Page.addScriptToEvaluateOnNewDocument 注入极其高阶的 JS 拦截器。这绝不是简单的赋值,而是深度的原型链防御。

1. 伪造不可察觉的原生 getter

我们要用 Object.defineProperty,但必须让拦截函数看起来像原生的。

javascript 复制代码
// 防检测注入脚本:必须在页面任何 JS 执行前运行
(() => {
    // 1. 保存原生的 toString 方法
    const nativeToStringFn = Function.prototype.toString;
    
    // 2. 我们自定义的拦截函数
    const fakeGetter = function() { return false; };
    
    // 3. 伪造 fakeGetter 的 toString,让它看起来像原生的
    const fakeGetterToString = function() {
        return "function get webdriver() { [native code] }";
    };
    
    // 4. 将伪造的 toString 绑定到 fakeGetter 上
    // 并利用 Proxy 保证 toString 本身的 toString 也是原生的
    const proxyHandler = {
        apply: function(target, thisArg, args) {
            return nativeToStringFn.call(fakeGetter);
        }
    };
    fakeGetter.toString = new Proxy(nativeToStringFn, proxyHandler);
    
    // 5. 最终覆写 webdriver 属性
    Object.defineProperty(Navigator.prototype, 'webdriver', {
        get: fakeGetter,
        configurable: true,
        enumerable: true
    });
})();

2. Error().stack 的调用栈清洗

致命痛点 :当 Puppeteer 通过 Runtime.evaluate 在页面执行 JS 时,V8 引擎会为这次执行生成调用栈。风控系统可以通过 new Error().stack 发现调用栈中包含 puppeteer__puppeteer_evaluation_script、或 cdp 等敏感字眼。

破局策略:V8 层 StackTrace 拦截

如果无法修改 C++ 源码,在 JS 层我们可以通过 Proxy 劫持 Error 构造函数,但这极易被风控用 if (Error.toString.toString().includes('native')) 戳穿。

工业级方案必须回到 C++ 层。在 v8/src/inspector/v8-stack-trace-impl.cc 中,拦截 StackTrace 的构建逻辑。当检测到调用栈中包含 CDP 通信通道的帧(如 v8_inspectorRuntime.evaluate 触发的帧)时,直接将这些帧从栈数组中抹除,让 Error().stack 返回的结果看起来就像是 JS 在原生事件循环中自发执行的。

风控经常通过 navigator.permissions.query({name:'notifications'}) 来交叉验证。

在自动化环境中,即使你修改了 navigator.webdriver,如果 permissions 返回的状态与 Notification.permission 不一致,依然会被判定为异常。

必须在注入脚本中,完整重建 Permissions.prototype.query 的逻辑,确保其返回的 state 与当前伪装的浏览器特征(如无插件、无通知权限)完全自洽。

第四章:CDP 指令链路的拟态与侧信道对抗

Puppeteer 和 Playwright 的核心是通过 CDP 发送指令。CDP 通道本身就是一个巨大的侧信道泄漏源。

1. 斩断 Runtime.enable 的执行上下文污染

Puppeteer 在连接的第一步必然调用 Runtime.enable。这会触发 V8 Inspector 开启执行上下文追踪,产生极大的内存和时序特征。

破局策略 :在上一篇文章《自定义 CDP 服务端》中提到的思路,必须在 CDP 中台直接拦截 Runtime.enable。向 Puppeteer 返回伪造的成功响应,但绝不将其转发给 Chrome 内核的 V8 Inspector。

Puppeteer 后续的 Runtime.evaluate 指令,我们通过自定义的 C++ 模块,利用 v8::Isolate::RequestInterrupt 在底层静默执行,彻底绕开 Inspector 的追踪机制。

2. document.hasFocus 的自动化异常

当浏览器处于 Headless 模式,或者后台标签页通过 CDP 执行操作时,document.hasFocus() 经常返回 false。但真实用户在进行点击、输入等交互时,页面必然处于聚焦状态。

风控会在点击事件的回调中检查 document.hasFocus(),如果是 false,秒封。

破局策略 :在 C++ 层的 Document::hasFocus 实现中,或者通过 JS Hook,强制让该函数在特定任务执行期间返回 true

3. 鼠标事件缺失的物理悖论

致命痛点 :Puppeteer 的 page.click('#btn') 底层是通过 CDP 的 Input.dispatchMouseEvent 直接向浏览器注入合成事件。这种合成事件虽然包含了 click,但经常缺失 mousemove 或真实的 pointerover 事件。风控监听这些前置事件,发现点击凭空产生,直接拦截。

破局策略 :在自定义 CDP 服务端拦截 Input.dispatchMouseEvent。当收到 click 指令时,服务端自动补发一系列符合贝塞尔曲线的 mouseMoved 事件,并在 click 前补发 mousePressedmouseReleased,确保事件序列符合人类物理交互的完整链路。

第五章:Playwright/Puppeteer 独有特征的物理抹除

除了 Chrome 本身的自动化痕迹,两个主流框架自身也会在浏览器中留下独特的 DNA。

1. Puppeteer 的 cdc_ 变量

早期 Puppeteer 和 Selenium 使用的 ChromeDriver 会在 document 对象上注入形如 cdc_adoQpoasnfa76pfcZLmcfl_ 的变量,用于存放驱动与浏览器通信的数组。

虽然新版 Puppeteer 连接模式有所改变,但在某些底层绑定中,依然会暴露 __puppeteer_evaluation_script 或类似的全局变量。

破局策略 :在 C++ 层的 V8Context::Global 初始化阶段,设置一个白名单机制。任何尝试在 windowdocument 上注入非标准 Web API 属性的 CDP 指令,都会被底层静默丢弃或重命名到内部闭包中。

2. Playwright 的 __playwright__ 与内部绑定

Playwright 为了实现比 Puppeteer 更强大的事件监听(如网络请求的详细监听),会在每个上下文中通过 Runtime.addBinding 注入一个名为 __playwright__ 或类似前缀的隐藏函数。

致命痛点 :风控系统只需遍历 window 对象的所有属性(包括不可枚举属性),就能发现这个非原生的绑定函数。

javascript 复制代码
// 风控探针示例
const props = Object.getOwnPropertyNames(window);
if (props.some(p => p.includes('playwright') || p.includes('__pw_'))) {
    // Bot detected!
}

破局策略

  1. 在 CDP 中台拦截 :当 Playwright 调用 Runtime.addBinding 时,我们的自定义 CDP 服务端将其拦截,不转发给 Chrome。
  2. 内部通信重构 :Playwright 需要这个 binding 来接收页面内发出的事件(如 console.log)。我们在自定义 CDP 服务端内部,通过 C++ 模块直接监听 V8 的 console 事件,并将其转化为标准的 CDP Runtime.consoleAPICalled 事件发回给 Playwright。这样,Playwright 的业务逻辑完全正常,但浏览器底层没有任何额外变量被注入。

3. 自动化框架的 User-Agent 留痕

某些自动化框架在启动时,会在 UA 后面追加特定标识(虽然现代 Puppeteer 已经修复,但第三方库或老版本依然存在)。

必须在网络栈最底层 URLRequest::BeforeNetworkStart 阶段,强制重写 UA,抹除任何非标准字符串。

第六章:避坑实录:自动化隐匿的三大致命暗礁

在落地这套从 C++ 到 JS 的全链路隐匿架构时,有三个极度隐蔽的陷阱,稍有不慎就会导致前功尽弃。

1. iframe 跨域的上下文重入陷阱

现象 :主框架的 navigator.webdriver 伪装得完美无缺,但页面中嵌入了一个跨域的 iframe。风控在 iframe 内部执行检测,瞬间返回 true

原因 :JS 注入脚本只运行在主框架的上下文中。跨域 iframe 拥有独立的 V8 Context,你的 Object.defineProperty 根本无法触及。

破局

  • 如果是 C++ 层修改,则天然免疫此问题,因为 C++ 层的 Navigator::webdriver 对所有上下文统一生效。
  • 如果是 JS 注入,必须使用 Page.addScriptToEvaluateOnNewDocument 并设置 worldNamerunImmediately,确保在所有新创建的 Frame(包括跨域 iframe)加载任何脚本前,先执行你的伪装代码。但即便如此,依然存在极小的时序窗口风险。终极解法依然是 C++ 层拦截。

2. Webpack/Parcel 打包环境下的原型链污染失效

现象 :在某些现代前端网站(如使用 React/Vue 编写的复杂单页应用),你的 Object.defineProperty 报错或被覆盖。

原因 :现代打包工具为了保证代码隔离,经常会在最外层包裹一层 IIFE(立即执行函数),甚至使用了类似 core-js 的 polyfill,这些 polyfill 会极早地缓存 Navigator.prototype 的引用。如果你的注入脚本执行时机晚于这些 polyfill,它们使用的依然是旧的 webdriver 描述符。

破局 :必须在 V8 引擎初始化阶段,即在任何 JS 代码(包括网站框架的 polyfill)执行前,通过 C++ 直接修改 Navigator 的 V8 ObjectTemplate。只有在 C++ 层面修改原型链的默认 descriptor,才能保证绝对的全局优先级。

3. 时钟漂移与 performance.now() 的物理悖论

现象 :所有 JS 层的伪装都做到了原生级,但依然被判定为自动化环境。

原因 :Puppeteer 通过 CDP 发送指令,V8 引擎在处理 CDP 消息时,会导致主线程微任务队列的短暂挂起。风控在页面中高频执行 performance.now() 记录时间戳,会发现时间线中出现不规律的微小停顿。这种停顿的频率与 CDP 指令下发的频率高度吻合,是极其强烈的侧信道特征。

破局:这不是 JS 层能解决的。

  1. 降低 CDP 频率:合并批量指令,减少 CDP 通信次数。
  2. V8 层时序模糊 :在 C++ 层 Hook V8 的 performance.now() 实现,引入极微小的随机噪声,打乱挂起导致的规律性停顿。
  3. 彻底隔离执行 :如前文所述,将 Runtime.evaluate 从 V8 Inspector 通道转移到 RequestInterrupt 通道,避免阻塞主微任务队列。

第七章:架构巅峰:从特征抹除走向拟态欺骗

当我们实现了 C++ 源码级的 webdriver 抹除、CDP 指令链路的重构、以及 Playwright/Puppeteer 独有特征的物理剥离后,我们是否就高枕无忧了?

最高级的风控对抗,不仅是"隐藏自己",更是"主动欺骗"。

1. 拟态事件流的生成

仅仅抹除自动化特征,让浏览器看起来像一个"没有被自动化控制的正常浏览器"是不够的。因为一个完全静止、没有任何人类交互事件的浏览器,在风控图模型中依然是孤立的异常节点。

终极策略 :在 CDP 中台层,建立一个后台拟态引擎。即使爬虫脚本没有下发任何指令,中台也会定期通过底层的 C++ 注入点,向页面发送符合人类行为模式的微小事件:鼠标的随机微移、页面的轻微滚动、焦点的随机切换。

这些事件不经过 Puppeteer/Playwright,直接由 C++ 层派发,具有绝对的原生性。风控系统看到的是一个充满真实呼吸感的活跃用户。

2. 指纹与环境的强一致性自洽

我们抹除 navigator.webdriver 的同时,必须确保整个环境自洽。如果浏览器声明是 Mac OS,但 navigator.platform 返回 Win32;如果声明是移动端,但缺少 Touch events 支持,这种逻辑撕裂比单纯的 webdriver=true 更快招致封禁。

在自定义 CDP 服务端,建立环境配置中心。当 BrowserContext 创建时,统一下发操作系统、UA、屏幕分辨率、时区等配置。C++ 层的 Hook 模块根据这些配置,在 V8 上下文初始化时,一次性完成所有相关属性(navigator.platform, navigator.userAgent, screen.width 等)的原生级覆写,确保整个数字身份在物理法则上的绝对自洽。

结语:夺回控制权,重构数字法则

从盲目依赖 --disable-blink-features,到利用脆弱的 Object.defineProperty 打补丁,再到深入 Chromium 源码重构 Navigator 实现,并在 CDP 中台层拦截一切危险指令与框架特征。

Puppeteer/Playwright 自动化特征的隐匿历程,本质上是一场控制权的争夺战。

当我们能够在 V8 引擎底层静默执行脚本,在原型链源头抹除自动化标记,在事件循环中注入符合物理规律的拟态噪声时,我们实际上已经夺回了浏览器的绝对控制权。风控系统试图通过 navigator.webdriver、调用栈探测或时序侧信道来猎杀自动化的企图,在底层 C++ Hook 与自定义 CDP 架构面前化为乌有。

在这套架构下,自动化框架不再是风控眼中的靶子,而是被我们完全接管的重塑数字法则的武器。每一次指令的下发,每一个属性的读取,都经过了精心的伪装与拟态,让机器在风控的凝视下,呈现出真实人类般的呼吸与温度。这不仅是技术的巅峰,更是对抗哲学的终极演化。