资讯中心

CreepJS Canvas 2D 反检测实战:逐项触发与绕过

📅 2026/7/18 3:11:08
CreepJS Canvas 2D 反检测实战:逐项触发与绕过
上一篇我们从源码角度梳理了 CreepJS Canvas 2D 模块的 6 道检测防线。本文进入实操用一个浏览器扩展逐项触发检测观察亮灯效果再通过修改 Firefox 源码在 C 层实现真正透明的指纹扰动让所有检测静默通过。前置文章《Creepjs学习–Canvas 2D 指纹采集》CreepJS Canvas 2D 的 6 道检测防线先回顾一下 CreepJS 在 Canvas 2D 上设置了哪些检测。理解这张表是后续所有攻防的基础#检测名称原理被抓到会怎样1liePropsAPI 谎言预检检查toString()是否返回[native code]卡片rejected/ hashlies2getPixelMods像素读写一致性写→读→镜像→再读对比两次结果xx% rgba noise 差异圆3clearRect 后残像素清空画布后 getImageData 仍有非零像素pixel data modifiedlied4低熵已知像素库2×2固定绘制 → 像素串比对 Blink/Gecko/WebKit 白名单LowerEntropy.CANVAS/bold-fail5TextMetrics 浮点噪声measureText()包围盒出现小数metric noise detectedlied6上下文不可用getContext(2d)返回 null整项undefined/ UI BLOCKED这 6 项从上到下覆盖了从有没有人动过 API到像素数据对不对再到API 能不能用的完整链条。思路总览为什么 JS 层 hook 必然被抓CreepJS 的第一道防线检测1会检查关键 API 的Function.prototype.toString()是否返回[native code]。只要你在 JS 层做了CanvasRenderingContext2D.prototype.getImageDatafunction(...){...}无论你是否改了数据toString()立刻暴露。这意味着——JS 层任何 hook 都会连带触发检测1。浏览器层改动为什么能透明通过在 Firefox 的 C 源码中修改像素 bufferJS 调用的仍然是真正的 native 函数toString()正常返回[native code]→ 检测1 通过getImageData返回原始像素 → 检测2/3/4 通过measureText没被动过 → 检测5 通过getContext没被动过 → 检测6 通过唯一变化的是toDataURL()输出的图片编码指纹来源而 CreepJS 的一致性校验全走getImageData——两条路径互不干扰。核心设计原则噪声只加在不可逆路径图片编码保持可逆路径像素读回干净。实验环境组件说明Firefox 源码F:\mcGecko 主干触发扩展creapjs_plug/inject.js注入页面主世界检测页面CreepJS 官方演示构建命令mach build或mach build dom/canvas增量编译 canvas 模块触发扩展通过在页面加载前注入 JShook Canvas 相关 API 来模拟各种篡改场景。每次只开启一项观察 CreepJS 对应的检测反应。第1项liePropsAPI 谎言预检触发扩展注入以下代码对getImageData和toDataURL做了一层透明包装——数据完全没改只是替换了函数引用constctxProtoCanvasRenderingContext2D.prototypeconstorigGetImageDatactxProto.getImageDataconstorigToDataURLHTMLCanvasElement.prototype.toDataURL// 纯 pass-through不改任何数据ctxProto.getImageDatafunctionpatchedGetImageData(...args){returnorigGetImageData.apply(this,args)}HTMLCanvasElement.prototype.toDataURLfunctionpatchedToDataURL(...args){returnorigToDataURL.apply(this,args)}就这么简单。没改一个像素没动一个返回值。但 CreepJS 立刻报警了——因为lieProps检查的不是数据对不对而是函数本身有没有被替换// CreepJS 内部的检测逻辑简化CanvasRenderingContext2D.prototype.getImageData.toString()// 原生函数返回: function getImageData() { [native code] }// 被 hook 后返回: function patchedGetImageData(...args) { ... }预期效果卡片显示rejected/ hashlies。效果如图卡片变为粉色背景rejectedhashb413eb9e绕过不需要任何 Firefox 代码改动。卸载扩展或关闭注入刷新页面即可——没有人动过 prototypetoString()自然返回[native code]。卸载扩展后卡片恢复白色背景hash 变为c0421881正常状态小结这项检测的存在意义它是所有后续检测的前哨。如果一个反指纹方案连 lieProps 都过不了后面的检测压根不需要看。这也是为什么浏览器层改动是正道——从根上消除了 JS 可观测的痕迹。第2项像素读写一致性getPixelMods检测原理CreepJS 的getPixelMods()做了一个巧妙的一致性测试8×8 画布随机填充像素 ↓ getImageData 读出 镜像画布用读出的颜色再画一遍 ↓ getImageData 再读 对比两次 → 不一致则判定有噪声注入如果有人 hook 了getImageData并注入随机噪声两次读取同一 buffer 会返回不同结果——暴露无遗。触发扩展注入以下代码hookgetImageData在每次读取时对非零像素注入噪声constctxProtoCanvasRenderingContext2D.prototypeconstorigGetImageDatactxProto.getImageData ctxProto.getImageDatafunctionpatchedGetImageData(...args){constimageDataorigGetImageData.apply(this,args)constdataimageDataimageData.dataif(datadata.length4){// 检查是否有非零像素避免对空画布误注入lethasNonZerofalsefor(leti0;idata.length;i){if(data[i]!0){hasNonZerotrue;break}}if(hasNonZero){// 每隔4个像素对第一个字节(B通道) 1for(leti0;idata.length;i16){data[i](data[i]1)%256}}}returnimageData}CreepJS 的getPixelMods先随机写入像素然后读出来。因为我们的 hook 在每次读取时都注入不同偏移写→读→镜像→再读两次结果必然不一致。预期效果卡片显示rendering: 16% rgba noise 差异圆可视化图。同时 lieProps检测1连带亮灯——因为 hook 了原型方法。效果如图卡片粉色背景显示rendering: 16% rgba noise ●hash 变为8fc3119adata 变为23ea1c0c为什么 要修改FireFox源码你可能想到用确定性噪声——每次对同一 buffer 加同样的偏移这样两次读结果一致。但问题在于CreepJS 先写入随机像素然后用读出的结果再画一遍。如果你的 hook 改了第一次读的值第二次画的内容就和原始不同了。无论怎么设计只要 hook 了getImageDatalieProps 都会先亮灯。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_tbufLenout_imageSize-width*out_imageSize-height*4;uint8_t*bufret.get();// Simple hash seed from first 64 bytes of pixel datauint32_tseed0x5A3C6B2D;uint32_thashLenstd::min(bufLen,(uint32_t)64);for(uint32_ti0;ihashLen;i){seed^(uint32_t)buf[i]((i%4)*8);seed(seed13)|(seed19);seed*0x85EBCA6B;}// Perturb a small number of pixels (every 4th pixels B channel, ±1)for(uint32_ti0;ibufLen;i16){uint32_tnoiseseed^(seed17);seedseed*0xACAB32510x12345;buf[i](uint8_t)(buf[i](noise1?1:-1));}}改动要点设计决策原因确定性种子从buffer内容派生同一画布内容多次 toDataURL 产出相同 hash不会被调用对比检出不同画布内容 → 不同 seedPicasso 图、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/rejectedlieProps 全绿效果如图卡片白色背景通过hash 为932421c6不同于未修改 Firefox 的c0421881说明噪声生效data 为5e874131无任何 noise 提示小结检测2 的本质是对getImageData做一致性测试。绕过的关键不是怎么让 getImageData 的噪声更隐蔽而是根本不动 getImageData——把噪声放在下游的编码路径。这个设计同时免费搞定了检测3 和检测4它们也走 getImageData 校验后文会展示。为什么一处改动能同时绕过检测 2/3/4三项检测的共同点校验逻辑都走 getImageData。检测2getImageData 读两次比对一致性 检测3clearRect 后 getImageData 读检查是否全零 检测4固定绘制后 getImageData 读 2×2比对白名单我们的 C 改动只影响GetImageBuffer()toDataURL 路径GetImageDataArray()getImageData 路径原封不动。所以检测2两次 getImageData 返回相同值 → 通过检测3clearRect 后 getImageData 返回全零 → 通过检测42×2 像素串是原生渲染结果 → 命中 Gecko 白名单 → 通过这正是只动编码路径策略的优雅之处——一处改动三项检测免费通过。后续预告后续将继续演示第3项clearRect 残像素触发效果与验证第4项2×2 低熵白名单触发效果与验证第5项TextMetrics 浮点噪声触发效果与验证第6项getContext 硬失败触发效果与验证相关代码文件用途F:\mc\dom\canvas\CanvasRenderingContext2D.cppFirefox 源码改动位置F:\mc\dom\html\HTMLCanvasElement.cpptoDataURL 入口调用 GetImageBufferF:\mc\dom\base\ImageEncoder.cpp图片编码流程参考前置文章《Creepjs学习–Canvas 2D 指纹采集》CreepJS 源码github.com/AbrahamJuliot/creepjsFirefox 源码Geckosearchfox.org版本Firefox Nightly 151.0a1