上一篇我们从源码角度梳理了 CreepJS Canvas 2D 模块的 6 道检测防线。本文进入实操:用一个浏览器扩展逐项触发检测,观察"亮灯"效果;再通过修改 Firefox 源码,在 C++ 层实现真正透明的指纹扰动,让所有检测静默通过。
前置文章:《Creepjs学习–Canvas 2D 指纹采集》
CreepJS Canvas 2D 的 6 道检测防线
先回顾一下 CreepJS 在 Canvas 2D 上设置了哪些检测。理解这张表是后续所有攻防的基础:
| # | 检测名称 | 原理 | 被抓到会怎样 |
|---|---|---|---|
| 1 | lieProps(API 谎言预检) | 检查toString()是否返回[native code] | 卡片rejected/ hashlies |
| 2 | getPixelMods(像素读写一致性) | 写→读→镜像→再读,对比两次结果 | xx% rgba noise+ 差异圆 |
| 3 | clearRect 后残像素 | 清空画布后 getImageData 仍有非零像素 | pixel data modified+lied |
| 4 | 低熵已知像素库(2×2) | 固定绘制 → 像素串比对 Blink/Gecko/WebKit 白名单 | LowerEntropy.CANVAS/bold-fail |
| 5 | TextMetrics 浮点噪声 | measureText('')包围盒出现小数 | metric noise detected+lied |
| 6 | 上下文不可用 | getContext('2d')返回 null | 整项undefined/ UI BLOCKED |
这 6 项从上到下,覆盖了从"有没有人动过 API"到"像素数据对不对"再到"API 能不能用"的完整链条。
思路总览
为什么 JS 层 hook 必然被抓?
CreepJS 的第一道防线(检测1)会检查关键 API 的Function.prototype.toString()是否返回[native code]。只要你在 JS 层做了:
CanvasRenderingContext2D.prototype.getImageData=function(...){...}无论你是否改了数据,toString()立刻暴露。这意味着——JS 层任何 hook 都会连带触发检测1。
浏览器层改动为什么能透明通过?
在 Firefox 的 C++ 源码中修改像素 buffer,JS 调用的仍然是真正的 native 函数:
toString()正常返回[native code]→ 检测1 通过getImageData返回原始像素 → 检测2/3/4 通过measureText没被动过 → 检测5 通过getContext没被动过 → 检测6 通过
唯一变化的是toDataURL()输出的图片编码(指纹来源),而 CreepJS 的一致性校验全走getImageData——两条路径互不干扰。
核心设计原则
噪声只加在不可逆路径(图片编码),保持可逆路径(像素读回)干净。实验环境
| 组件 | 说明 |
|---|---|
| Firefox 源码 | F:\mc(Gecko 主干) |
| 触发扩展 | creapjs_plug/inject.js,注入页面主世界 |
| 检测页面 | CreepJS 官方演示 |
| 构建命令 | mach build或mach build dom/canvas(增量编译 canvas 模块) |
触发扩展通过在页面加载前注入 JS,hook Canvas 相关 API 来模拟各种篡改场景。每次只开启一项,观察 CreepJS 对应的检测反应。
第1项:lieProps(API 谎言预检)
触发
扩展注入以下代码,对getImageData和toDataURL做了一层透明包装——数据完全没改,只是替换了函数引用:
constctxProto=CanvasRenderingContext2D.prototypeconstorigGetImageData=ctxProto.getImageDataconstorigToDataURL=HTMLCanvasElement.prototype.toDataURL// 纯 pass-through,不改任何数据ctxProto.getImageData=functionpatchedGetImageData(...args){returnorigGetImageData.apply(this,args)}HTMLCanvasElement.prototype.toDataURL=functionpatchedToDataURL(...args){returnorigToDataURL.apply(this,args)}就这么简单。没改一个像素,没动一个返回值。
但 CreepJS 立刻报警了——因为lieProps检查的不是数据对不对,而是函数本身有没有被替换:
// CreepJS 内部的检测逻辑(简化)CanvasRenderingContext2D.prototype.getImageData.toString()// 原生函数返回: "function getImageData() { [native code] }"// 被 hook 后返回: "function patchedGetImageData(...args) { ... }"预期效果:卡片显示rejected/ hashlies。
效果如图,卡片变为粉色背景(rejected),hashb413eb9e:
绕过
不需要任何 Firefox 代码改动。卸载扩展(或关闭注入),刷新页面即可——没有人动过 prototype,toString()自然返回[native code]。
卸载扩展后,卡片恢复白色背景,hash 变为c0421881(正常状态):
小结
这项检测的存在意义:它是所有后续检测的"前哨"。如果一个反指纹方案连 lieProps 都过不了,后面的检测压根不需要看。这也是为什么浏览器层改动是正道——从根上消除了 JS 可观测的痕迹。
第2项:像素读写一致性(getPixelMods)
检测原理
CreepJS 的getPixelMods()做了一个巧妙的一致性测试:
8×8 画布:随机填充像素 ↓ getImageData 读出 镜像画布:用读出的颜色再画一遍 ↓ getImageData 再读 对比两次 → 不一致则判定有噪声注入如果有人 hook 了getImageData并注入随机噪声,两次读取同一 buffer 会返回不同结果——暴露无遗。
触发
扩展注入以下代码,hookgetImageData,在每次读取时对非零像素注入噪声:
constctxProto=CanvasRenderingContext2D.prototypeconstorigGetImageData=ctxProto.getImageData ctxProto.getImageData=functionpatchedGetImageData(...args){constimageData=origGetImageData.apply(this,args)constdata=imageData&&imageData.dataif(data&&data.length>=4){// 检查是否有非零像素(避免对空画布误注入)lethasNonZero=falsefor(leti=0;i<data.length;i++){if(data[i]!==0){hasNonZero=true;break}}if(hasNonZero){// 每隔4个像素,对第一个字节(B通道) +1for(leti=0;i<data.length;i+=16){data[i]=(data[i]+1)%256}}}returnimageData}CreepJS 的getPixelMods先随机写入像素,然后读出来。因为我们的 hook 在每次读取时都注入不同偏移,"写→读→镜像→再读"两次结果必然不一致。
预期效果:卡片显示rendering: 16% rgba noise+ 差异圆可视化图。同时 lieProps(检测1)连带亮灯——因为 hook 了原型方法。
效果如图,卡片粉色背景,显示rendering: 16% rgba noise ●,hash 变为8fc3119a,data 变为23ea1c0c:
为什么 要修改FireFox源码?
你可能想到"用确定性噪声"——每次对同一 buffer 加同样的偏移,这样两次读结果一致。但问题在于:
- CreepJS 先写入随机像素,然后用读出的结果再画一遍。如果你的 hook 改了第一次读的值,第二次画的内容就和原始不同了。
- 无论怎么设计,只要 hook 了
getImageData,lieProps 都会先亮灯。 - CreepJS 还有 clearRect 后的二次确认(检测3)交叉验证。
绕过:修改 Firefox 源码
核心洞察:CreepJS 的指纹 hash 来自toDataURL(),而检测2 的一致性校验走getImageData()。这是两条完全独立的代码路径:
toDataURL() → GetImageBuffer() → ImageEncoder → base64 字符串 getImageData() → GetImageDataArray() → Uint8ClampedArray只要噪声加在GetImageBuffer()(toDataURL 专用路径),getImageData完全不受影响。
修改文件:dom/canvas/CanvasRenderingContext2D.cpp
修改位置:CanvasRenderingContext2D::GetImageBuffer()函数,在像素 buffer 返回之前
完整改动:
// 在 RandomizePixels 之后、return ret 之前插入:// --- Canvas fingerprint noise (toDataURL only) ---// Deterministic per-session noise: XOR a few pixels based on a hash seed// derived from buffer content. getImageData is not affected.{uint32_tbufLen=out_imageSize->width*out_imageSize->height*4;uint8_t*buf=ret.get();// Simple hash seed from first 64 bytes of pixel datauint32_tseed=0x5A3C6B2D;uint32_thashLen=std::min(bufLen,(uint32_t)64);for(uint32_ti=0;i<hashLen;i++){seed^=(uint32_t)buf[i]<<((i%4)*8);seed=(seed<<13)|(seed>>19);seed*=0x85EBCA6B;}// Perturb a small number of pixels (every 4th pixel's B channel, ±1)for(uint32_ti=0;i<bufLen;i+=16){uint32_tnoise=seed^(seed>>17);seed=seed*0xACAB3251+0x12345;buf[i]=(uint8_t)(buf[i]+(noise&1?1:-1));}}改动要点:
| 设计决策 | 原因 |
|---|---|
| 确定性种子(从buffer内容派生) | 同一画布内容多次 toDataURL 产出相同 hash,不会被调用对比检出 |
| 不同画布内容 → 不同 seed | Picasso 图、emoji、字母 A 各自产生不同指纹 |
| 步长 16(每 4 像素改 1 字节) | 改动量约 6%,肉眼不可见但 hash 完全不同 |
| 只在 GetImageBuffer 中操作 | 仅影响 toDataURL 路径,getImageData 完全不经过这里 |
编译:
cdF:\mc mach build dom/canvas验证
卸载触发扩展,用编译后的 Firefox 打开 CreepJS 检测页面。
预期结果:
- ✅ Canvas hash 与原版 Firefox 不同(指纹确实被扰动了)
- ✅ 无
rgba noise提示(getImageData 读写一致) - ✅ 无
lied/rejected(lieProps 全绿)
效果如图,卡片白色背景(通过),hash 为932421c6(不同于未修改 Firefox 的c0421881,说明噪声生效),data 为5e874131,无任何 noise 提示:
小结
检测2 的本质是对getImageData做一致性测试。绕过的关键不是"怎么让 getImageData 的噪声更隐蔽",而是根本不动 getImageData——把噪声放在下游的编码路径。这个设计同时免费搞定了检测3 和检测4(它们也走 getImageData 校验),后文会展示。
为什么一处改动能同时绕过检测 2/3/4?
三项检测的共同点:校验逻辑都走 getImageData。
检测2:getImageData 读两次,比对一致性 检测3:clearRect 后 getImageData 读,检查是否全零 检测4:固定绘制后 getImageData 读 2×2,比对白名单我们的 C++ 改动只影响GetImageBuffer()(toDataURL 路径),GetImageDataArray()(getImageData 路径)原封不动。所以:
- 检测2:两次 getImageData 返回相同值 → 通过
- 检测3:clearRect 后 getImageData 返回全零 → 通过
- 检测4:2×2 像素串是原生渲染结果 → 命中 Gecko 白名单 → 通过
这正是"只动编码路径"策略的优雅之处——一处改动,三项检测免费通过。
后续预告
后续将继续演示:
- 第3项(clearRect 残像素)触发效果与验证
- 第4项(2×2 低熵白名单)触发效果与验证
- 第5项(TextMetrics 浮点噪声)触发效果与验证
- 第6项(getContext 硬失败)触发效果与验证
相关代码
| 文件 | 用途 |
|---|---|
F:\mc\dom\canvas\CanvasRenderingContext2D.cpp | Firefox 源码改动位置 |
F:\mc\dom\html\HTMLCanvasElement.cpp | toDataURL 入口(调用 GetImageBuffer) |
F:\mc\dom\base\ImageEncoder.cpp | 图片编码流程 |
参考
- 前置文章:《Creepjs学习–Canvas 2D 指纹采集》
- CreepJS 源码:github.com/AbrahamJuliot/creepjs
- Firefox 源码(Gecko):searchfox.org,版本:Firefox Nightly 151.0a1