浏览器补丁插件:改得动什么,够不到什么
读 puppeteer-extra-plugin-stealth 公开的 evasion 目录,用值、属性描述符与 toString 三种读法区分改写与原生,并看清页面脚本够不到的 TLS、进程与判定模型三层。
BotBrowser Team
这类插件本身就是一组页面 JavaScript 补丁,交付形态同时决定了它能改什么、改不了什么。puppeteer-extra-plugin-stealth 发布一个 evasion 目录,每一项都是一个小的 evaluateOnNewDocument 脚本,在页面自身代码运行之前改写一个 navigator 属性、一个 chrome 命名空间成员或一个 WebGL getter。这是真实且可检查的机制,也有明确边界:在页面内部运行的补丁可以从页面内部读出来,而三个表面根本不经过页面 JavaScript。该插件的公开源码足以逐项列出它修补的表面,指出它够不到的三层,并支撑一个并排读取值、描述符与 toString 的 fixture,用来判断补丁是否真的生效。Puppeteer 与 Playwright 两个插件变体是各自独立发布节奏的第三方项目,因此两者都不被推荐。通过率不是任何本地改动能够承诺的东西。
BotBrowser 提供另一条交付路径来产生同样的文档值:Profile 设置在浏览器内核内应用,值与产生它的代码路径来自同一层,自动化一致性文档记录了这一点。这一层仍只覆盖下文所述边界的一侧,因此它不保证任何第三方判定,网络与进程表面依然在它之外。
TL;DR
- 插件的 evasion 是注入到文档自身代码之前的页面脚本,不是浏览器配置。每一项改写一个 JavaScript 可见的值,页面可以把结果读回来。
- 公开的 evasion 目录共 19 项:
chrome.app、chrome.csi、chrome.loadTimes、chrome.runtime、defaultArgs、iframe.contentWindow、media.codecs、navigator.hardwareConcurrency、navigator.languages、navigator.permissions、navigator.plugins、navigator.vendor、navigator.webdriver、sourceurl、user-agent-override、webgl.vendor、window.outerdimensions,外加共享的_template与_utils两个文件夹。 navigator.webdriver被处理两次:参数层加上--disable-blink-features=AutomationControlled,页面脚本则在属性仍然存在的旧 Chromium 构建上删除原型上的该属性。- 改写后的值与原生值在
console.log下无法区分。读Object.getOwnPropertyDescriptor和Function.prototype.toString,才能把被改写的属性和原生属性分开。 - 三个表面永远到不了页面 JavaScript:网络层指纹、TLS 握手与 HTTP/2 设置;浏览器自身进程与命令行;以及服务器依据连接而非文档做出的任何判断。
目录
公开的 evasion 目录实际包含什么
插件的源码就是一个名为 evasions 的目录。读它足以知道插件碰了哪些表面,因为每个子目录就是一个具名 evasion,各有自己的 index.js。
| Evasion | 目标表面 | 脚本改了什么 |
|---|---|---|
navigator.webdriver | 自动化标志 | 在旧构建上删除原型属性;在新构建上加启动参数 |
navigator.vendor | Navigator 身份 | 改写 vendor 字符串 |
navigator.plugins | 插件枚举 | 替换插件数组的内容 |
navigator.languages | 语言列表 | 改写报告的语种数组 |
navigator.permissions | 权限查询 | 包装查询结果 |
navigator.hardwareConcurrency | 核心数 | 改写报告的整数 |
webgl.vendor | WebGL 参数 | 为 vendor 与 renderer 字符串包装 getParameter |
chrome.app、chrome.csi、chrome.loadTimes、chrome.runtime | chrome 命名空间 | 增加或改写页面可读的成员 |
media.codecs | 编解码支持 | 改写编解码器的回答 |
iframe.contentWindow | 跨帧身份 | 对齐 iframe 窗口报告的值 |
window.outerdimensions | 窗口几何 | 报告普通窗口应有的外部尺寸 |
user-agent-override | User-Agent 请求头与值 | 设置请求头与页面可见字符串 |
sourceurl | 脚本源码 URL | 去掉 Puppeteer 给注入脚本加的 sourceURL 标记 |
defaultArgs | 启动参数 | 去掉让浏览器更容易被识别的 Puppeteer 默认值 |
有两条结构性事实比具体行更重要。第一,每一行都是对一个页面可读值的改写,这意味着改写后的值同样可以被页面检查。第二,清单有限且公开,检测器作者可以读同一个目录,并精确探测这些表面。
交付机制本身就是约束
evaluateOnNewDocument 在任何页面脚本执行前运行一个函数。正是这个时机让补丁对朴素读取不可见:等到页面代码运行时,属性上已经是改写后的值。但补丁改变的不是浏览器,而是浏览器产生值之后文档所看到的内容。
这个区分解释了整类失效。依据 TLS 握手做判断的服务器从不运行页面脚本,再多的 evaluateOnNewDocument 也到不了它那里。检查目标命令行参数的 CDP 客户端读的是进程参数而不是文档,页面级改写在那里同样无关。
两个插件家族不共享代码
puppeteer-extra 与 playwright-extra 是同一仓库里的两个独立包,插件接口各自不同。为其中一个写的插件不加适配层无法在另一个里加载,两个生态更新注入点的节奏也不一样。这一点对比较很关键:从 Puppeteer 包里读到的 evasion 列表描述的是该包在 Puppeteer 启动路径上修补的内容,Playwright 变体则是另一个产物,有自己的列表。
实际后果是,任何插件主张在带上家族与版本之前都不成立。两个团队都可以说自己的浏览器运行了这个名字的插件,实际跑的却是覆盖面不同的不同代码,而差异从外部看不见。
webdriver evasion 实际做了什么
navigator.webdriver 这个 evasion 有两半,两半都值得读。beforeLaunch 钩子修改启动参数:如果 --disable-blink-features= 已经存在,就追加 AutomationControlled,否则加入完整标志。随后 onPageCreated 钩子运行一个页面脚本,检查当前值,并且只在属性仍然存在且为 true 的构建上删除 Object.getPrototypeOf(navigator).webdriver。
所以插件对自己的边界是诚实的:在标志已经产生 false 或 undefined 的 Chromium 构建上,页面脚本什么也不做。对读插件主张的人来说这是个有用的设计细节,因为它表明连插件作者也把启动参数当作主要修复,把页面脚本当作兼容性补丁。
页面 JavaScript 补丁够不到什么
三组表面位于文档之下或旁边,任何 evaluateOnNewDocument 脚本都改变不了它们。
网络与 TLS 层
TLS ClientHello、密码套件顺序、扩展、ALPN 协议列表、HTTP/2 SETTINGS 帧与头部顺序,都在任何文档存在之前由浏览器的网络栈产生。对连接做指纹的服务器看到的是一个形状,页面脚本从未接触过它。在 navigator 上增删一个属性,不会改变那次握手中的任何一个字节。
同样的推理覆盖 DNS 行为与连接复用。页面级补丁脚本无法改变是哪个解析器回答、连接是否被复用、TCP 握手花了多久,这些事件在第一行页面代码执行之前就已经结束。
浏览器进程与命令行
进程树、可执行文件路径、Linux 上 /proc/<pid>/cmdline 里的参数、启动器与浏览器之间的父子关系,都能从文档外部观察到。页面脚本可以改写文档对自身的报告,但改不了启动进程的那个参数向量。
这也解释了插件为什么在 beforeLaunch 里修改 args,而不是只在页面上把标志藏起来。凡是能读进程的人都能看到这些标志,插件自己关于 Chrome 信息栏的注释也承认该参数有可见后果。
服务端判定
最后一组根本不是指纹,而是消费这些读数的模型。挑战服务可以把网络形状、请求头、时序、账号历史和页面报告值合成一个分数,其中没有任何一项以页面脚本可编辑的值的形式经过页面脚本。即便文档内的补丁完美,分数仍归供应商。某个产品具体采样哪几项,在检测器实际读取什么里有清单,而两个产品不必采样同一组。
这是最深的边界,也正是它让“未被检测的浏览器”这个说法变成关于模型的断言,而不是浏览器自身的属性。
并排读取值、描述符与 toString 的 fixture
只读 navigator.webdriver 的 fixture 无法区分改写值与原生值,因为两者都返回布尔。并排读三样东西才能把它们分开。
三种读法
第一是值本身:navigator.webdriver,或 navigator.vendor,或 WebGLRenderingContext.getParameter 的返回值。这是朴素检测器读的东西。
第二是属性描述符:Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator), 'webdriver')。原生访问器的 get 函数,其源码文本会写明这是内置 getter。被删除的属性返回 undefined;被重新定义的属性返回的描述符里,get 指向一个包装函数,它可能带有、也可能不带有与原生访问器相同的标志。
第三是对该 getter 调用 Function.prototype.toString。原生访问器的源码文本带自己的签名并以 [native code] 结尾;在 Chromium 构建上,Navigator.prototype.webdriver 的 getter 读作 function get webdriver() { [native code] }。用 JavaScript 定义的包装函数会显示自己的函数体。插件自己的 _utils 目录里有一个 withUtils 助手,正是为此修补 toString,这本身就是该读法有效的证据。被列入白名单的 toString 仍能暴露什么,另有原生函数被改写与 toString 白名单逐项梳理,其中列出在源码文本被改写后依然成立的检查。
| 读法 | 原生答案 | 改写后的答案 | 无法证明什么 |
|---|---|---|---|
| 值读取 | 一个布尔值、一个 vendor 字符串、一个 renderer 字符串 | 改写后的值 | 是哪一个产生了它 |
getOwnPropertyDescriptor | 带 get 与 set 的原生访问器 | undefined,或重新定义的访问器 | 页面在其他方面是否一致 |
Function.prototype.toString | [native code] | 包装函数自己的源码 | 包装函数是否可信 |
fixture 为什么必须跑在自有页面上
读取必须在一个操作方控制的文档上运行,因为目的是把已知配置和已知文档做比较,而不是探测别人的站点。本地 data: URL 或磁盘上的静态文件就够:fixture 脚本读三个值,以 JSON 打印,并把结果和运行记录存在一起。
比较此时很直接。如果值与该浏览器构建原生会报告的不同,描述符会说明属性是被删除还是被替换,toString 会说明包装函数是否自我暴露。若包装函数所在处留着 [native code] 字符串,说明补丁同时修补了 toString,而这正是插件的 _utils 助手存在要处理的情形。
fixture 为什么需要干净的基线
比较只有在 fixture 有对照物时才成立。在同一构建上关闭插件,读同样的三个值,把这份基线存在改写后的运行记录旁边。缺了它,[native code] 字符串什么也证明不了,因为读者没有这个具体构建上未修补访问器的记录。
第二份基线也值得保留:同一主版本的原始 Chromium 在不加补丁的普通启动下做同样读取。这一对能显示哪些差异来自插件、哪些来自构建本身,因为一次 Chromium 版本升级可以在没有任何插件参与的情况下改变某个访问器的描述符标志位。
读取一个声称自己是原生的补丁
有意思的情形是包装函数的 toString 也被修补成返回 [native code]。这不会让读取失效,只是改变了它证明的东西。get 不是引擎自身 getter 的描述符,仍与同一构建上取到的未打补丁基准不同;而一个声称 [native code]、在另一个 realm 读到的源码文本却与未打补丁基准不同的包装,与它自身不一致。跨 realm 的身份比较不适用于此:即便浏览器上什么都没打补丁,新建 iframe 的 getter 也是另一个函数对象,这种比较在两种情况下都会报出差异。
因此 fixture 还应记录描述符标志:configurable、enumerable、writable,以及 set 是否存在。原生 navigator.webdriver 是不可写访问器,标志组合稳定,但标志值得记录却不是判据:删除型 evasion 根本不留下描述符,而插件里的 replaceProperty 助手会展开已有描述符、只替换 get,所以被替换的 getter 保留了原生标志。这样记录不要求页面对 toString 诚实。
记录 fixture 看到的内容
每次运行存一个 JSON 对象,包含三个读数、浏览器构建标识、注入脚本版本和文档 URL。同一配置的两次运行应得到相同读数;两者不同说明注入在其中一次没有生效,那是配置缺陷,不是检测器结果。
fixture 无法裁定的部分
fixture 描述的是文档,不是连接、进程或模型。在自有页面上完美读取一个被改写的 navigator.webdriver,对供应商会如何给这次会话打分毫无说明,这个缺口正是每份运行记录必须写明它测的是哪个表面的原因。
把这条边界留在记录里。标成 surface: document 的字段是诚实的;同样读数标成 verdict: pass 就不是,因为 fixture 从未运行供应商的模型。
FAQ
页面级补丁会改变浏览器的网络指纹吗?
不会。它的 evasion 是注入到文档代码之前的页面脚本,外加一处启动参数改动。TLS ClientHello 字节、HTTP/2 SETTINGS 与头部顺序在任何文档存在之前就由网络栈产生,页面级脚本够不到。
改好的 navigator.webdriver 足以通过检测器吗?
不足够。检测器可以读值、属性描述符、toString、启动参数或网络形状,读哪几项由供应商的模型决定。这个 evasion 用两条代码路径只改动原型上的一个属性,而描述符标志、在另一个 realm 读到的源码文本,以及连接形状,仍是启动时产生的那样。
如何区分被改写的属性和原生属性?
在自有页面上读三样:值、原型上的 Object.getOwnPropertyDescriptor、描述符 get 上的 Function.prototype.toString。先把描述符标志记入这次运行,再在 iframe realm 内用该 realm 自己的 Function.prototype.toString 读出源码文本,与同一构建上取到的未打补丁基准比较。toString 被修补的包装,在比较记录的源码文本时仍与基准不同。
实践结论
从 evasion 清单出发,而不是从标签出发。公开目录写明了插件碰过的每个表面,而每一个都是页面能读回的值。然后接受文档之下的三个表面:网络与 TLS 层、浏览器进程与命令行、以及消费所有读数的服务端模型。
下一步是一次自有页面上的 fixture 运行。读 navigator.webdriver 的值、描述符与 toString,连同浏览器构建和注入脚本版本以 JSON 打印,然后把这次运行重复一遍。两次一致说明补丁稳定生效;两次不同,要排查的是注入而不是检测器。相邻材料:检测器实际读取什么 从检测器一侧覆盖表面清单,BotD 检测器注册表 展示一个开源检测器运行哪些检查。
Sources
本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界