BotD 检测器注册表:哪些检查真的会运行
读取固定版本 FingerprintJS BotD 的注册表,并在显式组件状态上运行它的检测器,看哪些自动化检查会命中、哪些保持沉默、哪些在缺值时放过。
BotBrowser Team
BotBrowser 支持在声明的 Profile 下重复运行经授权的同源页面 fixture,并保留范围受限的观察:页面能读到哪些自动化表面;它不能运行第三方检测服务,也不能裁定某个供应商的结论或保证清理一个表面就会清理其余表面。本文跟随 FingerprintJS BotD 在 commit 193c471ac70530afcebe5b09664c09f611d19218 的公开源码,读取两个注册表文件,然后在显式组件状态上执行它自己的检测函数,让下面的结论来自实际输出,而不只是读代码。
TL;DR
- BotD 采集 23 个具名来源并暴露 18 个检测器;固定版本里有一个检测器文件被导出却从未注册,因此它的分支永远到不了结论。
- 结论是对检测器的折叠而不是投票:最后一个给出机器人名称的检测器会覆盖前一个,所以最终名称取决于注册表顺序。
- 来源抛错的组件会保留非成功状态,每个检测器都先检查该状态,所以缺失值通常得到"无结论"而不是猜测。
- 在合成状态上运行固定版本函数,能看到一个表面被清理后另一个仍在报告自动化、两处精确的漏报分支,以及一个丢掉检测器名字的裸
true结果。 - 观察到的行为属于这个 commit,不属于所有检测器,也绝不等于托管服务的生产判定。
Contents
- 一个固定版本的注册表实际包含什么
- 在显式组件状态上运行检测器
- 在哪里清理一个表面会留下另一个信号
- 哪些检查在缺值时放过
- 哪些分支把结论收窄到一个引擎或一个值
- 如何让运行记录可复现
- 如何在不夸大的前提下读结论
一个固定版本的注册表实际包含什么
src/sources/index.ts 注册 23 个来源函数:android、browserKind、browserEngineKind、documentFocus、userAgent、appVersion、rtt、windowSize、pluginsLength、pluginsArray、errorTrace、productSub、windowExternal、mimeTypesConsistent、evalLength、webGL、webDriver、languages、notificationPermissions、documentElementKeys、functionBind、process 和 distinctiveProps。每个来源都是普通函数,返回一个值或抛出错误。读不到自身表面的来源会抛出携带 State 的 BotdError,而 src/api.ts 里的 collect() 把这次抛出转成 { state, error } 组件,而不是一个值。
src/detectors/index.ts 按固定声明顺序注册 18 个检测器函数:detectAppVersion、detectDocumentAttributes、detectErrorTrace、detectEvalLengthInconsistency、detectFunctionBind、detectLanguagesLengthInconsistency、detectNotificationPermissions、detectPluginsArray、detectPluginsLengthInconsistency、detectProcess、detectUserAgent、detectWebDriver、detectWebGL、detectWindowExternal、detectWindowSize、detectMimeTypesConsistent、detectProductSub 和 detectDistinctiveProperties。
检测器目录里有 19 个文件。src/detectors/rtt.ts 导出 detectRTT,而 detectRTT 在这个注册表对象里完全不存在。用同一 checkout 的导出名对比注册表键,得到 19 个文件和 18 个注册名,rtt.ts 是唯一一个导出永远到不了的文件。这是某个 commit 的源码事实;它对后续版本没有任何说明,对直接 import 该函数的托管服务也没有任何说明。
src/api.ts 的折叠决定单个结论如何产生。detect() 按声明顺序遍历 detectors,用组件字典调用每个检测器,每当某个检测器报告机器人就覆盖 finalDetection。返回字符串的检测器把该字符串记为 botKind;返回裸 true 的检测器记为 BotKind.Unknown。由于赋值胜过累积,最终名称属于注册表中最后一个命中的检测器,而不是最具体或最自信的那个。collect() 通过 Promise.all 并发执行来源,所以任何检测器看到字典前它都是完整的,只有检测器顺序决定名称。
在显式组件状态上运行检测器
组件字典是每个检测器唯一读取的输入,所以固定版本的函数可以在手写状态上执行,不需要浏览器、Profile 或网络请求。下面的命令用 esbuild 打包固定树并调用导出的检测器,每个用例打印一行:
npx esbuild src/detectors/index.ts src/api.ts \
--bundle --format=cjs --platform=node --outdir=/tmp/botd-build
node /tmp/botd-probe.mjs
探针给出基础字典 android: false、browserKind: 'chrome'、browserEngineKind: 'chromium'、documentFocus: true,再按用例替换一到两个组件。成功组件带 { value, state: 0 },不可读组件带 { state: -1, error },与 src/types.ts 的 State 枚举一致(Success = 0、Undefined = -1、UnexpectedBehaviour = -3)。固定版本函数的实际输出:
case 1 [detectWebDriver] bot: headless_chrome - driven browser: webdriver flag true
case 2 [detectDistinctiveProperties] bot: webdriver - sanitized flag but webdriver-shaped window property present
case 3 [detectDistinctiveProperties] bot: false - flag layer removed only, bridge gone too
case 4 [detectWindowSize] bot: headless_chrome - headless default window 0x0 while document has focus
case 5 [detectWindowSize] bot: false - headless report 0x0 but tab never focused
case 6 [detectPluginsArray] bot: headless_chrome - empty plugin list
case 7 [detectRTT] not-runnable: not registered in detectors/index.ts - connection rtt reported as zero
case 8 [detectWebDriver] bot: false - webdriver source unreadable instead of false
上面八个用例由四个小函数裁决,值得直接读,而不是从输出里推断。src/detectors/webdriver.ts 只有一行:仅当 webDriver.state === State.Success && webDriver.value 时返回 BotKind.HeadlessChrome。src/detectors/window_size.ts 先检查 windowSize 与 documentFocus 都在 State.Success,若 documentFocus.value 为假则提前返回,之后才把 outerWidth 和 outerHeight 与零比较。src/detectors/plugins_array.ts 在 pluginsArray.state === State.Success && !pluginsArray.value 时返回 BotKind.HeadlessChrome。src/detectors/distinctive_properties.ts 遍历返回对象,返回第一个标志为真的机器人名称。
当一个检测器被清理而另一个没有时,同样的阅读纪律仍然适用。记录的字段来自FingerprintJS OSS 的采集与来源那种组件字典,而检查范围来自自动化检测与预防列出的页面表面,所以单个函数的结论从来不是全貌。
在哪里清理一个表面会留下另一个信号
用例 1 到 3 是运行里最清楚的结果。webDriver 为成功的 true 时,detectWebDriver 报告 headless_chrome。webDriver 为成功的 false 但 distinctiveProps 带 { webdriver: true } 时,detectWebDriver 什么都不报,而 detectDistinctiveProperties 改报 webdriver。两个表面都干净时,detectDistinctiveProperties 什么都不报,用例落到 bot: false。
由此有两个后果,对任何引用该库结论的人都很重要。两个检查彼此独立:清理标志不会清理属性列表,因为列表由 src/sources/distinctive_properties.ts 里的 Object.getOwnPropertyNames(window) 和 Object.getOwnPropertyNames(document) 读取,不来自标志。并且最终名称随存活的表面而变:headless_chrome 和 webdriver 是同一个库从同一字典产出的两个不同标签,所以一份只引用其中一个而另一个确实存在的报告,引用的是注册顺序,而不是存在的信号集合。
属性列表足够具体,可以手工核对。它覆盖 14 种机器人类型,列出 26 个 window 条目(其中一个是模式而非字面名称)和 17 个 document 条目,从 CEF 一侧的 awesomium 和 RunPerfTest,到 PhantomJS 的 callPhantom 和 _phantom。仅 WebDriver 这一组就在 window 侧带 webdriver、__webdriverFunc、__lastWatirAlert、_WEBDRIVER_ELEM_CACHE 和 ChromeDriverw,在 document 侧带 __webdriver_script_fn、__driver_evaluate、__fxdriver_unwrapped、$cdc_asdjflasutopfhvcZLmcfl_ 和 $chrome_asyncScriptInfo。只看 window 的复核会漏掉 document 组,只看标志的复核会漏掉两组。
哪些检查在缺值时放过
用例 8 展示了第一处漏报分支。当 webDriver 组件处于 State.Undefined 时,detectWebDriver 返回 false,因为守卫要求先满足 State.Success 才读值。一个不可读的 navigator.webdriver,例如屏蔽该属性的页面、读取会抛错的 realm、返回空值的包装,因而被记为"缺少自动化证据",而不是正向信号。对一个宁可沉默也不猜测的库来说这是可辩护的设计,但它意味着缺失值和干净值在结论里无法区分。
用例 5 展示了第二处漏报分支,更容易漏掉,因为组件都存在且有效。src/detectors/window_size.ts 在 documentFocus.value 为假时提前返回,早于零尺寸比较。只有在文档获得焦点时,报告 outerWidth 与 outerHeight 为 0 的无头窗口才会被标记。在后台打开的标签页,或在焦点到达前执行的截图,给出完全相同的测量值却没有任何结论。源码注释说明了意图,即新的后台标签页也报告外部尺寸为零,所以提前返回是刻意的,但对读者的后果是同样的数字会因焦点时机不同得出不同结论。
这轮运行的第三个缺口属于另一类:不是检测器内部的漏报,而是一条根本到不了结论的分支。detectRTT 里有一个真实条件在 rtt.value === 0 时返回 BotKind.HeadlessChrome,而固定版本的注册表从不调用它。rtt 来源仍然被采集,值被测量并放进组件字典,但没有任何已注册检测器去读它。只读 rtt.ts 会高估这个库;只读 detectors/index.ts 会隐藏这条分支。只有两个文件一起看,才显示一个被采集却到不了结论的信号。
哪些分支把结论收窄到一个引擎或一个值
其余用例让每条分支的具体性可见,而具体性正是读者判断某个结论是否适用的依据。下表每一行都是探针实际运行的用例:
| 检测器 | 运行时提供的条件 | 结果 |
|---|---|---|
detectUserAgent | 含 HeadlessChrome 的 user agent 字符串 | headless_chrome |
detectUserAgent | 普通 Windows 桌面 Chrome 字符串 | false |
detectWebGL | vendor Brian Paul、renderer Mesa OffScreen | headless_chrome |
detectWebGL | 基于 NVIDIA 适配器的 ANGLE renderer | false |
detectProcess | 类型为 renderer 且带 electron 版本的进程对象 | electron |
detectNotificationPermissions | 权限值为真、浏览器类型为 chrome | headless_chrome |
detectEvalLengthInconsistency | chromium 引擎上的 evalLength 33 | false |
detectEvalLengthInconsistency | chromium 引擎上的 evalLength 37 | true |
detectLanguagesLengthInconsistency | 空语言数组 | headless_chrome |
detectPluginsLengthInconsistency | 零插件、chromium chrome | headless_chrome |
detectWindowExternal | 含 Sequentum 的外部字符串 | sequentum |
detectMimeTypesConsistent | 一致性值为假 | unknown |
detectDocumentAttributes | 含 selenium 的 document 键列表 | selenium |
detectProductSub | chrome 下 productSub 值为 20100101 | unknown |
有两行值得单独说明,因为如果只看检测器名字,它们会显得反直觉。detectEvalLengthInconsistency 这一对显示同一个函数在同一引擎上对一个长度什么都不返回、对另一个长度返回机器人:33 被接受,因为 Chromium 在该长度的允许列表里;37 被拒绝,因为 Chromium 不在那一个长度的允许列表里。这个函数问的不是"值看起来是否奇怪",而是"值对已检测到的引擎是否奇怪"。
detectNotificationPermissions 这一行显示了一个容易被忽视的浏览器门槛。src/detectors/notification_permissions.ts 除非 browserKind.value === BrowserKind.Chrome 否则什么都不返回,所以同一个权限值只能在一种浏览器家族里产生结论。在报告其他品牌的 Profile 里测试该表面的人会看不到结论,并可能错误地认为该值被接受。
裸 true 的行与具名行行为不同,因为 src/api.ts 把 true 映射成 BotKind.Unknown。detectEvalLengthInconsistency 在长度 37 时在用例列表里打印 bot: true,到最终结果变成 unknown;而 detectMimeTypesConsistent 在不一致列表时直接打印 bot: unknown,因为它返回的是名称本身。因此只显示 unknown 的报告携带的信息少于产生它的检测器,而检测器名字是经受这次丢失后仍保留的部分。
如何让运行记录可复现
只有另一个人能重跑并得到同样的行,探针才有用。记录里的四个字段让这成为可能,每一个都补上其他字段留出的缺口。
第一个是 commit 193c471ac70530afcebe5b09664c09f611d19218,因为 detectors/index.ts 和 sources/index.ts 都会在版本之间被修改,今天存在的注册键明天可能消失。第二个是打包步骤,因为 esbuild 输出在不同版本之间并非逐字节相同,记录输出摘要能把源码改动和工具改动分开。第三个是状态约定,因为探针用 state: 0 表示成功、state: -1 表示不可读来源,假设了另一种映射的读者会把每个结果读反。第四个是用例列表本身,因为 detectWebDriver 的 false 在用例 8 里表示"守卫看到了非成功状态",在用例 3 里表示"标志为假",只有用例标签能区分两者。
两个习惯让记录保持诚实。优先用一对可区分的用例而不是单个用例:用例 3 和 2 一起显示清理标志会改变哪个检测器说话,而单独的用例 3 显示不了;用例 1 和 8 一起显示"存在且为真"与"缺失"在一条分支上分叉、在另一条上重合。并且绝不要把探针描述成浏览器测试。它是在审查者手写的字典上运行固定版本的纯函数;它不读页面、不开 Profile、不连服务,所以关于真实会话报告什么的断言仍然需要单独一次经授权的运行。
源码升级时同样如此。在新 commit 重跑打包步骤、重读两个注册表文件、比较用例列表而不是摘要行。注册表改动改变哪些检测器能说话,某个检测器内部改动改变单个用例返回什么,某个来源改动改变哪些状态可达。只比较最后的 bot: true 或 bot: false 行会把三类改动混成一个,下一位读者就得重新发现到底发生了哪一种。
如何在不夸大的前提下读结论
这个库的结论支持的是一句窄断言:在这个 commit 里,某个已注册检测器在收到给定的组件状态后,返回了该名称。它不支持关于某个人、关于意图、或关于托管服务会如何对待这次会话的断言。由于折叠是覆盖,最终名称可能指向一个不如另一个也已命中信号具体的类型;由于多个检测器返回裸 true,一个 unknown 类型可能顶替了一个有具体理由的检测器。
在复核记录里保留三样东西,记录就会持续有用。不可变的 commit,因为注册顺序和属性列表都会在版本之间变化。组件状态,因为每个检测器先检查状态再读值,漏报分支就活在这道守卫里。检测器名字,因为一次运行可能产生不同类型,只有名字说明是哪一个产出了结论。当问题针对某个具体表面时,第四个字段有帮助:采集到的来源数量,让读者看见一个缺失信号是从未测量过,还是测量后被忽略。
关于自动化一致性的那篇文档介绍了 BotBrowser 如何让 navigator.webdriver 与加载的 Profile 保持一致,以及如何在受控页面确认该设置。本文补上读取一侧:一个公开检测器对该值做什么、它同时读取哪些伴随表面、以及它自己的哪些分支保持沉默。两半回答不同的问题,任何一半都不能替代另一半。
对真实服务的任何结论,都应使用该服务经授权的集成结果,而不是从这次源码复核外推。fixture 里记录的组件状态和生产结论是两类不同的证据,夸大恰好发生在两者的落差里。
Practical conclusion
固定 commit,在显式组件状态上运行检测函数,并把检测器名字写在结论旁边。在这个 commit 里注册表提供 18 个检测器覆盖 23 个来源,detectRTT 被导出但未注册,清理 WebDriver 标志仍会留下属性列表,而两道守卫,一个不可读组件和一个无焦点文档,都会得到无结论。
Sources
- 固定 commit 的 BotD 检测器注册表
- 固定 commit 的 BotD 来源注册表
api.ts中的结论折叠webdriver.ts检测器window_size.ts检测器rtt.ts检测器distinctive_properties.ts来源- BotBrowser 自动化一致性文档
Next reading
需要同一供应商的采集一侧时,阅读 FingerprintJS OSS 的采集与来源;当问题是页面究竟能读到哪些表面时,阅读自动化检测与预防。
本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界