Canvas 指纹的原理:渲染差异即身份
Canvas 指纹利用一个朴素的事实:同一段绘图代码,在不同硬件/系统/浏览器上渲染出的像素有微小差异。差异来自 GPU 渲染管线、字体渲染引擎、抗锯齿算法、色彩管理——这些因素组合起来,几乎能唯一标识一台设备。
风控 JS 的标准做法:
function getCanvasFingerprint() {
var canvas = document.createElement('canvas');
canvas.width = 200; canvas.height = 50;
var ctx = canvas.getContext('2d');
// 绘制特征内容:文本 + 渐变 + 图形,最大化渲染差异
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(10, 10, 100, 30);
ctx.fillStyle = '#069';
ctx.fillText('Cwm fjordbank 😃', 15, 15); // 特殊字符+emoji 放大字体渲染差异
// 渐变和弧形进一步放大 GPU 差异
var grad = ctx.createLinearGradient(0, 0, 200, 0);
grad.addColorStop(0, 'red');
grad.addColorStop(1, 'blue');
ctx.fillStyle = grad;
ctx.fillRect(0, 30, 200, 20);
// 导出像素数据算 hash
var dataURL = canvas.toDataURL();
return md5(dataURL); // 或对像素数据做 CRC
}同型号同系统的两台手机,这个 hash 大概率相同;不同显卡的两台电脑,几乎必然不同。
逆向视角:先确认它采没采、采了什么
分析一个风控 SDK 是否采集 canvas 指纹,hook 两个 API:
// hook toDataURL
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function() {
console.log('[canvas] toDataURL called, size:', this.width, 'x', this.height);
console.trace(); // 调用栈:谁在采指纹
return origToDataURL.apply(this, arguments);
};
// hook getImageData(另一种采集方式)
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
CanvasRenderingContext2D.prototype.getImageData = function() {
console.log('[canvas] getImageData called');
console.trace();
return origGetImageData.apply(this, arguments);
};调用栈会直接告诉你采集代码在哪个 JS 文件哪一行。
对抗方案一:固定返回值(协议模拟场景)
Node 补环境或协议模拟时,canvas 指纹的正确处理是从真实浏览器采集后固定:
// 补环境时的 canvas 存根
window.document.createElement = function(tag) {
if (tag === 'canvas') {
return {
width: 300, height: 150,
getContext: () => fakeContext, // 实现被调用的方法
toDataURL: () => 'data:image/png;base64,iVBORw0KG...' // 真实采集值
};
}
return {};
};关键原则:指纹值与 ua 声称的平台必须匹配。ua 说是 Windows Chrome,canvas hash 却是 Apple Silicon 的渲染结果——这种自相矛盾比没有指纹更可疑。采集时连 ua 一起记录,成套使用。
对抗方案二:加噪干扰(浏览器自动化场景)
用 Selenium/CDP 起真实浏览器时,指纹是真实渲染的,但同一台机器的多个浏览器实例指纹相同——多开场景会被关联。这时要加噪:
// 在页面加载前注入(CDP 的 Page.addScriptToEvaluateOnNewDocument)
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(type) {
var ctx = this.getContext('2d');
if (ctx) {
// 读一个像素写回带噪声的值,改变渲染结果
var imageData = ctx.getImageData(0, 0, this.width, this.height);
for (var i = 0; i < 100; i++) {
var idx = Math.floor(Math.random() * imageData.data.length);
imageData.data[idx] = imageData.data[idx] ^ 1; // 最低位翻转,视觉不可见
}
ctx.putImageData(imageData, 0, 0);
}
return origToDataURL.apply(this, arguments);
};噪声的讲究:最低位翻转(±1 的像素变化)视觉上完全不可见,但 hash 完全不同。每个浏览器实例用不同的噪声种子,指纹就各不相同。
精髓:检测方也在进化
- 一致性校验:风控会对比 canvas 指纹和其他字段(ua、webgl、字体列表)的统计相关性。你的 canvas 指纹值如果在真实设备群体中从未出现过(随机生成的),会被标记"指纹异常"
- 多次绘制校验:高级风控在同一页面用不同内容画两次,验证两次 hash 的"差异模式"是否符合真实 GPU 的特征——固定返回值方案在这里会露馅(两次都返回固定值,差异模式为 0)
- 性能计时侧信道:真实 GPU 渲染有耗时特征,
performance.now()前后打点的耗时分布也是指纹的一部分。Node 环境里"渲染"耗时为 0,是补环境的破绽
总结
- canvas 指纹 = 渲染差异的 hash,GPU/字体/抗锯齿共同决定
- 逆向先 hook toDataURL/getImageData 确认采集点和代码位置
- 协议模拟用真实采集值固定,且与 ua 成套
- 多开场景用最低位噪声干扰,注意一致性校验和多次绘制校验
交流微信:run1255