CreepJS 的无头检测:三个彼此独立的结论
一支探针读 CreepJS:软性线索、硬性无头位与包装检查分三组各自评分,所以清理过的页面仍会报出一个百分比。
BotBrowser Team
BotBrowser 支持在已声明 profile 下回放授权的同源夹具,并保存一份有界的记录,说明页面就自身环境报告了什么,但它不能让单薄的表面显得厚实,不能把被恢复的表面变成一个从未被检查过的信号,也不能改变一个库对自己发现的归组方式。本评测从 CreepJS 的公开源码出发,复现它计算的三组结论,记录单条信号在每组里值多少、一个页面要触发多少条软性线索才会在软组里读成无头、哪些硬位能在清理过的页面上存活,以及为什么一个组的百分比不能与另一个组的百分比相比。
摘要
- CreepJS 把无头报成三个独立百分比而不是一个:
likeHeadless覆盖 16 条软性线索,headless覆盖 3 个硬位,包装组覆盖 5 项检查。 - 有真实窗口管理器的有头桌面评出
likeHeadless=0% headless=0% wrapper=0%。 - 典型无头 profile 在软组到
likeHeadless=44% (7/16),而硬组停在0%,因为软线索与硬位测的是不同东西。 - 泄漏标志与令牌的驱动器评出
headless=100% (3/3),是本轮唯一满分的组。 - 单条信号在软组值 6%、在硬组值 33%、在包装组值 20%,同一个发现在不同组里权重差很大。
- 恢复了 chrome 对象、插件、MIME 类型、UA 数据与 PDF 查看器的页面仍报
likeHeadless=13% (2/16),因为屏幕几何没动。 - 设一个合理的窗口尺寸后降到
likeHeadless=6% (1/16),只剩软件渲染器一条软线索。 - 一个把 chrome 对象放错位置、破坏其 runtime、并改掉
Function.prototype.toString的包装,评出wrapper=60% (3/5),另外两组都是零。 noTaskbar那条就是几何相等,所以真实桌面上最大化的窗口也会触发它。
目录
为什么三组要分开
无头检测常被描述成一个问题一个答案,而 CreepJS 刻意报三个答案。三组来自同一个模块,测的是不同种类的证据:
headed-desktop -> likeHeadless=0% (0/16) headless=0% (0/3) wrapper=0% (0/5)
第一组是十六条软性线索:没有 chrome 对象、没有插件、没有 MIME 类型、client hints 平台为空、PDF 查看器被禁用、屏幕没有任务栏、视口等于屏幕、软件渲染器,以及若干能力缺口。每一条都是一个看起来单薄的表面,而每一条合法部署也可能具备。
这组里的驱动器标志,也是完整性检查会去读的那个属性,而那条检查能看见什么取决于它读的是源码文本还是描述符。第二组是三个硬位,它们的「硬」是另一种意思:驱动器标志、user agent 里的 HeadlessChrome 令牌,以及 Worker 里同样的令牌。这些描述的不是单薄表面,而是浏览器自己报了名。
由多个分组拼出的判定串,评分与设门方式与这三个百分比不同,为什么一条异常信号定不了罪 记录了这一点。第三组是五项包装检查:能否探测到 iframe 代理、chrome 对象是否落在 window 键序里不合理的位置、chrome.runtime 是否像真对象、Function.prototype.toString 是否被改过,以及主线程与 Worker 的 GPU 是否不一致。这些描述的是一个改过自身环境的页面,而不是一个只是看起来单薄的页面。
三组、三种主张。把它们压成一个数字,就会丢掉「这个环境很精简」「这个浏览器说自己被自动化」「这个页面改过自己」之间的区别,而这三者需要完全不同的应对。
单条信号在各组里值多少
百分比不能跨组比较,因为每个都是不同分母的占比。探针给出单条发现在各组的权重:
weight:likeHeadless -> one signal = 6%
weight:headless -> one signal = 33%
weight:wrapper -> one signal = 20%
一条软线索让软组动 6 个点,一个硬位让硬组动 33 个点。一份把「44%」和「33%」并排印出来的报告,会诱导读者把它们当成同一把尺子,而它们不是:软组的 44% 是七个单薄表面,硬组的 33% 是一个浏览器报了名。
软组还需要很多信号才会读成「基本无头」:
likeHeadless:1-of-16 -> 6%
likeHeadless:4-of-16 -> 25%
likeHeadless:8-of-16 -> 50%
likeHeadless:12-of-16 -> 75%
likeHeadless:16-of-16 -> 100%
要读到一半需要同时八个单薄表面。这对一个页面来说是很难偶然踩到的门槛,也正是为什么软组百分比是在描述环境有多精简,而不是它是无头的概率。
那条对照把同一件事从另一面说清:
compare -> all-16-soft=100% vs one-hard=33% (groups are reported separately)
十六条软线索全中的页面在自己那组读作 100%,一个硬位中的页面在自己那组读作 33%。两个数字在任何有意义的意义上都不存在谁更大,因为它们回答不同的问题。把两个百分比排序的人,是在给两种不同单位排序。
哪些线索能在清理过的页面上存活
有意思的是页面把明显信号整理干净之后会发生什么。探针喂入一个状态:chrome 对象、插件、MIME 类型、client hints 平台与 PDF 查看器都恢复了,只有屏幕几何没动:
cleaned-obvious-tells -> likeHeadless=13% (2/16) headless=0% (0/3) wrapper=0% (0/5)
cleaned-plus-window -> likeHeadless=6% (1/16) headless=0% (0/3) wrapper=0% (0/5)
恢复五个表面之后还剩 13%,因为两条几何线索根本没被碰过。设一个合理的窗口尺寸后降到 6%,最后一条软线索是软件渲染器。硬组与包装组全程为零,这才是有用的部分:清理表面不会产生包装发现,它只会移除软线索。
这个不对称对任何在改动之后读报告的人都很重要。一个从 44% 降到 6% 的页面既没有变得更容易被判定为无头,也没有变得更不容易,它只是变得不那么精简。硬组(那组描述浏览器是否自报身份)完全没动,而只盯着总数的人会以为有改善,尽管底层主张没变。
反过来也成立。包装组可以在没有任何软线索的情况下升高:
wrapper-wrapped -> likeHeadless=0% (0/16) headless=0% (0/3) wrapper=60% (3/5)
一个让每个表面都保持厚实、却把 chrome 对象放错位置、破坏其 runtime、改掉 Function.prototype.toString 的页面,在包装组拿到 60%,另外两组为空。这与单薄环境是完全不同的发现,而它之所以可见,正因为三组是分开的。
软性线索在哪里高估了情况
有几条软线索是合法部署同样会产生的普通状况,探针把其中两条钉住。
权限那条需要两个独立条件同时成立才会触发:
permissions-bug -> prompt+denied=true prompt+default=false granted+denied=false
只有当权限查询报告 prompt、而通知权限是 denied 时才触发。任一条件单独都不触发,这是刻意的收窄:一个按策略直接拒绝通知的浏览器不会被标记,只有那对不一致的组合才会。
任务栏那条正相反,是这组里最弱的信号:
no-taskbar -> 1080/1080+1920/1920=true 1080/1040+1920/1920=false
它就是屏幕与其可用区域之间的相等判断,所以真实桌面上任何最大化且隐藏了任务栏的窗口都会触发它,kiosk 或全屏设备也一样。软组里的一点单独看本来就不值多少,而这一点比它那 6% 暗示的还要不值。
对部署的教训在于该把力气花在哪些表面。恢复插件与 MIME 类型会推动软组,但几何和渲染器名字才是把其它都整理好之后仍然剩下的那两个。一个把容易的表面都做对、却放着几何不管的 profile 读作 6% 软,而不知道各组分母大小的读者会把它读成「几乎没有」,实际上它是一条具体的线索。
记录一次无头读数
一条无头读数支撑的是一个窄结论:这个库版本、这些信号状态,产出了这三个百分比。四个字段让它保持可核对:
- 库的提交与三份信号清单,因为清单多一个或少一个键就会改变每个百分比,而页面没有任何变化。
- 各组命中的信号名字,因为 6% 与 33% 描述的是不同种类的证据,数字本身把是哪一种藏了起来。
- 各组的分母,因为拿软组百分比和硬组百分比作比较的读者,是在比两种不同单位。
- 没有被评估的信号,因为有几条线索需要活的页面,而静态复现是把它们当作输入喂进去的。
公开来源
- CreepJS
- CreepJS 无头检测模块
- MDN:Navigator.webdriver
- MDN:Navigator.plugins
- MDN:Permissions API
- BotBrowser:自动化一致性参考
实践结论
把三个百分比读成三个独立发现而不是一个分数,因为软组百分比描述环境有多精简,硬组百分比描述浏览器是否自报身份,包装组百分比描述页面是否改过自己。把从 44% 降到 6% 看成「暴露了多少环境」的变化,而不是「是不是无头」的变化,因为硬组完全没动。当报告显示一组升高而其它两组为空时,去看包装检查,而不是去找缺失的表面。一个库如何把分组结果折成一条有序判定,见CreepJS 的信号分组与判定串如何拼起来;单条源码检查能看见什么,见原生函数被改写与 toString 白名单能看见什么。
本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界