canvas指纹攻防

1. 什么是canvas指纹

随着互联网的广泛普及,数以亿计网民的网络行为数据早已成为最宝贵的资源,企业通过五花八门的各种手段了解网民的行为和隐私数据,用于广告投递、用户兴趣分析等,进而作为决策的依据。利用Web客户端对用户行为进行收集和追踪是重要手段之一。浏览器指纹类似于人的外貌和指纹,指通过浏览器的各种信息,如系统字体、屏幕分辨率、浏览器插件,将这些信息综合分析计算后,可对客户端进行唯一识别,就能近乎绝对定位一个用户。而canvas指纹就是这么一种浏览器指纹的生成方式。

大部分业务使用cookie来标识用户,但是对于未登录的用户可以删除或者修改cookie信息,登录用户也可以重置cookie,所以cookie在用户唯一标识上有很大的局限性。

canvas当今也是一种常用的反爬虫手段。根据当前对抗的结果观测来看,与特征识别反爬虫不同的是canvas反爬虫的思路是通过绘制的图片生成唯一标识来标记设备访问频率,最终根据频率对访问进行限制。

2. canvas指纹生成原理

Canvas指纹的原理大致如下:

相同的HTML5 Canvas元素绘制操作,在不同操作系统、不同浏览器上,产生的图片内容不完全相同。在图片格式上,不同浏览器使用了不同的图形处理引擎、不同的图片导出选项、不同的默认压缩级别等。在像素级别来看,操作系统各自使用了不同的设置和算法来进行抗锯齿和子像素渲染操作。即使相同的绘图操作,产生的图片数据的CRC检验也不相同。

canvas即h5中的“画布”,通过画图,最终得到图片的base64编码,随后经过hash运算后得到一个标识。下面的getCanvasFp会生成一个图片,图片上的内容是三个重叠的圆形。绘图的方式很灵活,任何人可以绘制任何不同的图片。

下面的getCanvasFp会生成一个图片,图片上的内容是三个重叠的圆形。绘图的方式很灵活,任何人可以绘制任何不同的图片。

<!-- html -->
<body>
    <canvas
      id="canvas_xy"
      width="500"
      height="400"
      style="box-shadow: 0 0 20px black"
    >
      当前浏览器不支持 canvas
    </canvas>
  </body>

<!-- js -->
<script type="text/javascript">
  

var getCanvasFp = function (options) {
    var result = []
    // 获取 canvas 元素对应的 DOM 对象
    var canvas = document.getElementById("canvas_xy");
    canvas.style.display = 'inline'
    var ctx = canvas.getContext('2d')
    // detect browser support of canvas winding
    // http://blogs.adobe.com/webplatform/2013/01/30/winding-rules-in-canvas/
    // https://github.com/Modernizr/Modernizr/blob/master/feature-detects/canvas/winding.js
    ctx.rect(0, 0, 10, 10)
    ctx.rect(2, 2, 6, 6)
    result.push('canvas winding:' + ((ctx.isPointInPath(5, 5, 'evenodd') === false) ? 'yes' : 'no'))
    ctx.textBaseline = 'alphabetic'
    ctx.fillStyle = '#f60'
    ctx.fillRect(125, 1, 62, 20)
    ctx.fillStyle = '#069'
    ctx.font = '11pt Arial'
    ctx.fillText('Cwm fjordbank glyphs vext quiz, \ud83d\ude03', 2, 15)
    ctx.fillStyle = 'rgba(102, 204, 0, 0.2)'
    ctx.font = '18pt Arial'
    ctx.fillText('Cwm fjordbank glyphs vext quiz, \ud83d\ude03', 4, 45)
    ctx.globalCompositeOperation = 'multiply'
    ctx.fillStyle = 'rgb(255,0,255)'
    ctx.beginPath()
    ctx.arc(50, 50, 50, 0, Math.PI * 2, true)
    ctx.closePath()
    ctx.fill()
    ctx.fillStyle = 'rgb(0,255,255)'
    ctx.beginPath()
    ctx.arc(100, 50, 50, 0, Math.PI * 2, true)
    ctx.closePath()
    ctx.fill()
    ctx.fillStyle = 'rgb(255,255,0)'
    ctx.beginPath()
    ctx.arc(75, 100, 50, 0, Math.PI * 2, true)
    ctx.closePath()
    ctx.fill()
    ctx.fillStyle = 'rgb(255,0,255)'
    ctx.arc(75, 75, 75, 0, Math.PI * 2, true)
    ctx.arc(75, 75, 25, 0, Math.PI * 2, true)
    ctx.fill('evenodd')
    if (canvas.toDataURL) { result.push('canvas fp:' + canvas.toDataURL()) }
    return result
  }
  
  console.log(getCanvasFp());

</script>

2022-05-26T05:31:39.png

3. canvas指纹修改

3.1 初级修改

Chrome有一个声称能防止 canvas 指纹泄露的插件 Canvas Fingerprint Defender,安装后,找到 crx 安装目录,发现他的逻辑主要是在 data/content_script/inject.js 中,核心逻辑如下:

var inject = function () {
  const toBlob = HTMLCanvasElement.prototype.toBlob;
  const toDataURL = HTMLCanvasElement.prototype.toDataURL;
  const getImageData = CanvasRenderingContext2D.prototype.getImageData;
  //
  var noisify = function (canvas, context) {
    if (context) {
      const shift = {
        'r': Math.floor(Math.random() * 10) - 5,
        'g': Math.floor(Math.random() * 10) - 5,
        'b': Math.floor(Math.random() * 10) - 5,
        'a': Math.floor(Math.random() * 10) - 5
      };
      //
      const width = canvas.width;
      const height = canvas.height;
      if (width && height) {
        const imageData = getImageData.apply(context, [0, 0, width, height]);
        for (let i = 0; i < height; i++) {
          for (let j = 0; j < width; j++) {
            const n = ((i * (width * 4)) + (j * 4));
            imageData.data[n + 0] = imageData.data[n + 0] + shift.r;
            imageData.data[n + 1] = imageData.data[n + 1] + shift.g;
            imageData.data[n + 2] = imageData.data[n + 2] + shift.b;
            imageData.data[n + 3] = imageData.data[n + 3] + shift.a;
          }
        }
        //
        window.top.postMessage("canvas-fingerprint-defender-alert", '*');
        context.putImageData(imageData, 0, 0);
      }
    }
  };
  //
  Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
    "value": function () {
      noisify(this, this.getContext("2d"));
      return toBlob.apply(this, arguments);
    }
  });
  //
  Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
    "value": function () {
      noisify(this, this.getContext("2d"));
      return toDataURL.apply(this, arguments);
    }
  });
  //
  Object.defineProperty(CanvasRenderingContext2D.prototype, "getImageData", {
    "value": function () {
      noisify(this.canvas, this);
      return getImageData.apply(this, arguments);
    }
  });
  //
  document.documentElement.dataset.cbscriptallow = true;
};
inject();

代码基本不用解释,主要就做了一件事情:重写 Canvas 的 toBlob,toDataURL 方法和 Context 的 getImageData 方法,使他们在生成图片时加一些噪点。

使用https://fingerprintjs.github.io/fingerprintjs/来测试,发现canvas指纹确实变了。

3.2 中级修改

使用上面的脚本,然后使用http://f.vision/来测试,发现被检测到我们修改了canvas指纹。

网站做了以下检测:

检测点一

CanvasRenderingContext2D.prototype.getImageData.length !== 4 
|| !CanvasRenderingContext2D.prototype.getImageData.toString().match(/^\s*function getImageData\s*\(\)\s*\{\s*\[native code\]\s*\}\s*$/) 
|| (CanvasRenderingContext2D.prototype.getImageData.name !== "getImageData" && !ie)

原来他check了一下关键对象的 prototype 的属性,我们来看下当前实际的结果:

CanvasRenderingContext2D.prototype.getImageData.length
0
 
CanvasRenderingContext2D.prototype.getImageData.toString()
"function () {\n      noisify(this.canvas, this);\n      return getImageData.apply(this, arguments);\n    }"
 
CanvasRenderingContext2D.prototype.getImageData.name
"value"

这里果然和没有改过的不一样。

检测点二

function cKnownPixels(size) {
    "use strict";
 
    var canvas = document.createElement("canvas");
    canvas.height = size;
    canvas.width = size;
    var context = canvas.getContext("2d");
    if (!context)
        return false;
 
    context.fillStyle = "rgba(0, 127, 255, 1)";
    var pixelValues = [0, 127, 255, 255];
    context.fillRect(0, 0, canvas.width, canvas.height);
    var p = context.getImageData(0, 0, canvas.width, canvas.height).data;
    for (var i = 0; i < p.length; i += 1) {
        if (p[i] !== pixelValues[i % 4]) {
            return false;
        }
    }
    return true;
}

这个是个很容易想到的检测方法,输入一个已知的稳定像素图(由于不存在字体、复杂计算等,因此不同机器的渲染几乎不会有差异),如果输出的结果和已知输入不一样,那肯定是人为加了噪点。

检测点三

function cReadOut() {
 
    "use strict";
 
    var canvas = document.createElement("canvas");
    var context = canvas.getContext("2d");
 
    if (!context)
        return false;
 
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < imageData.data.length; i += 1) {
        if (i % 4 !== 3) {
            imageData.data[i] = Math.floor(256 * Math.random());
        } else {
            imageData.data[i] = 255;
        }
    }
    context.putImageData(imageData, 0, 0);
 
    var imageData1 = context.getImageData(0, 0, canvas.width, canvas.height);
    var imageData2 = context.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < imageData2.data.length; i += 1) {
        if (imageData1.data[i] !== imageData2.data[i]) {
            return false;
        }
    }
    return true;
}

这也是个比较容易想到的检测方法,将同一份像素点连续输出两次,如果这两次的结果不一样,那肯定是加了某些随机的噪点。

检测点四

function cDoubleReadOut() {
 
    "use strict";
 
    var canvas = document.createElement("canvas");
    var context = canvas.getContext("2d");
 
    if (!context)
        return false;
 
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < imageData.data.length; i += 1) {
        if (i % 4 !== 3) {
            imageData.data[i] = Math.floor(256 * Math.random());
        } else {
            imageData.data[i] = 255;
        }
    }
 
    var imageData1 = context.getImageData(0, 0, canvas.width, canvas.height);
 
    var canvas2 = document.createElement("canvas");
    var context2 = canvas2.getContext("2d");
    context2.putImageData(imageData1, 0, 0);
    var imageData2 = context2.getImageData(0, 0, canvas.width, canvas.height);
 
    for (let i = 0; i < imageData2.data.length; i += 1) {
        if (imageData1.data[i] !== imageData2.data[i]) {
            return false;
        }
    }
    return true;
}

和上面的检测挺像,但是检测出来的结果比较trick,没太搞懂这是检测的啥,不过实践中发现如果 a 通道随机到的噪点偏移值不太好,很容易检测不通过。

解决思路如下:

  • 再次 mock 出对应prototype 的正确 length、toString、name 属性。
  • 对于没有添加文字、没有复杂位图变换的图,不进行随机填充。
  • 随机参数在启动时就生成且只生成一次,保证相同的图多次处理结果也一样。

具体操作可以参考下面的代码:

function random(list) {
    let min = 0;
    let max = list.length
    return list[Math.floor(Math.random() * (max - min)) + min];
}
 
let rsalt = random([...Array(7).keys()].map(a => a - 3))
let gsalt = random([...Array(7).keys()].map(a => a - 3))
let bsalt = random([...Array(7).keys()].map(a => a - 3))
let asalt = random([...Array(7).keys()].map(a => a - 3))
 
const rawGetImageData = CanvasRenderingContext2D.prototype.getImageData;
 
let noisify = function (canvas, context) {
    let ctxIdx = ctxArr.indexOf(context);
    let info = ctxInf[ctxIdx];
    const width = canvas.width, height = canvas.height;
    const imageData = rawGetImageData.apply(context, [0, 0, width, height]);
    if (info.useArc || info.useFillText) {
        for (let i = 0; i < height; i++) {
            for (let j = 0; j < width; j++) {
                const n = ((i * (width * 4)) + (j * 4));
                imageData.data[n + 0] = imageData.data[n + 0] + rsalt;
                imageData.data[n + 1] = imageData.data[n + 1] + gsalt;
                imageData.data[n + 2] = imageData.data[n + 2] + bsalt;
                imageData.data[n + 3] = imageData.data[n + 3] + asalt;
            }
        }
    }
    context.putImageData(imageData, 0, 0);
};
 
let ctxArr = [];
let ctxInf = [];
 
(function mockGetContext() {
    let rawGetContext = HTMLCanvasElement.prototype.getContext
 
    Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
        "value": function () {
            let result = rawGetContext.apply(this, arguments);
            if (arguments[0] === '2d') {
                ctxArr.push(result)
                ctxInf.push({})
            }
            return result;
        }
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.constructor, "length", {
        "value": 1
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.constructor, "toString", {
        "value": () => "function getContext() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.constructor, "name", {
        "value": "getContext"
    });
})();
 
(function mockArc() {
    let rawArc = CanvasRenderingContext2D.prototype.arc
    Object.defineProperty(CanvasRenderingContext2D.prototype, "arc", {
        "value": function () {
            let ctxIdx = ctxArr.indexOf(this);
            ctxInf[ctxIdx].useArc = true;
            return rawArc.apply(this, arguments);
        }
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.arc, "length", {
        "value": 5
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.arc, "toString", {
        "value": () => "function arc() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.arc, "name", {
        "value": "arc"
    });
})();
 
(function mockFillText() {
    const rawFillText = CanvasRenderingContext2D.prototype.fillText;
    Object.defineProperty(CanvasRenderingContext2D.prototype, "fillText", {
        "value": function () {
            let ctxIdx = ctxArr.indexOf(this);
            ctxInf[ctxIdx].useFillText = true;
            return rawFillText.apply(this, arguments);
        }
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.fillText, "length", {
        "value": 4
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.fillText, "toString", {
        "value": () => "function fillText() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.fillText, "name", {
        "value": "fillText"
    });
})();
 
 
(function mockToBlob() {
    const toBlob = HTMLCanvasElement.prototype.toBlob;
 
    Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
        "value": function () {
            noisify(this, this.getContext("2d"));
            return toBlob.apply(this, arguments);
        }
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toBlob, "length", {
        "value": 1
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toBlob, "toString", {
        "value": () => "function toBlob() { [native code] }"
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toBlob, "name", {
        "value": "toBlob"
    });
})();
 
(function mockToDataURL() {
    const toDataURL = HTMLCanvasElement.prototype.toDataURL;
    Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
        "value": function () {
            noisify(this, this.getContext("2d"));
            return toDataURL.apply(this, arguments);
        }
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toDataURL, "length", {
        "value": 0
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toDataURL, "toString", {
        "value": () => "function toDataURL() { [native code] }"
    });
 
    Object.defineProperty(HTMLCanvasElement.prototype.toDataURL, "name", {
        "value": "toDataURL"
    });
})();
 
 
(function mockGetImageData() {
    Object.defineProperty(CanvasRenderingContext2D.prototype, "getImageData", {
        "value": function () {
            noisify(this.canvas, this);
            return rawGetImageData.apply(this, arguments);
        }
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.getImageData, "length", {
        "value": 4
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.getImageData, "toString", {
        "value": () => "function getImageData() { [native code] }"
    });
 
    Object.defineProperty(CanvasRenderingContext2D.prototype.getImageData, "name", {
        "value": "getImageData"
    });
})();

3.3 高级修改

本来以为上面的代码足够应对绝大多数场景了,但是回头跑了一下 https://coveryourtracks.eff.org/ 这个(检测时可能要翻墙),忽然发现他竟然还是能检测出来我做了伪造。

回想到他在检测时似乎有刷新页面的操作,因此猜测他也是通过刷新页面的方式,比较两次生成的指纹是否相同来检测伪造。于是我尝试将随机性从 js 脚本中提取到 python 代码里,保证相同会话无论刷新多少次都是用的同一套随机数。结果果然印证了我的猜想。

4.参考