百趣云 百趣云的博客

Canvas 指纹:一块画布怎么认出你的浏览器

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 完全不同。每个浏览器实例用不同的噪声种子,指纹就各不相同。

精髓:检测方也在进化

  1. 一致性校验:风控会对比 canvas 指纹和其他字段(ua、webgl、字体列表)的统计相关性。你的 canvas 指纹值如果在真实设备群体中从未出现过(随机生成的),会被标记"指纹异常"
  2. 多次绘制校验:高级风控在同一页面用不同内容画两次,验证两次 hash 的"差异模式"是否符合真实 GPU 的特征——固定返回值方案在这里会露馅(两次都返回固定值,差异模式为 0)
  3. 性能计时侧信道:真实 GPU 渲染有耗时特征,performance.now() 前后打点的耗时分布也是指纹的一部分。Node 环境里"渲染"耗时为 0,是补环境的破绽

总结

  1. canvas 指纹 = 渲染差异的 hash,GPU/字体/抗锯齿共同决定
  2. 逆向先 hook toDataURL/getImageData 确认采集点和代码位置
  3. 协议模拟用真实采集值固定,且与 ua 成套
  4. 多开场景用最低位噪声干扰,注意一致性校验和多次绘制校验

交流微信:run1255

By 百趣云 阅读量:3 On