Detecting noise in canvas fingerprinting
In a previous blog post, we talked about canvas fingerprinting, a technique commonly used to detect fraudsters and bots.
In this post we'll go deeper on how fraudsters can forge or create fake canvas fingerprints to stay under the radar for typical device fingerprinting techniques. Plus cover some techniques for detecting this.
Quick reminder: a canvas fingerprint is a JavaScript challenge that leverages the HTML canvas API to draw different shapes and text. The images drawn are completely invisible to the users since the process happens in the background. Canvas fingerprint is valuable for fraud detection because there exist subtle differences in the rendering of the canvas image depending on your browser and your device (OS, GPU, list of fonts installed). Thus, canvas fingerprints are quite unique and stable, which can be used to keep track of fraudsters, even if they delete their cookies. Moreover, since the canvas values depend on the browser and the OS, it’s helpful to detect users that lie about the nature of their device.
The image below shows a sample of common canvas fingerprinting challenges found in the wild.
Since canvas fingerprints are images, their size can be significant. Thus, most anti-fraud scripts store a hash of the canvas fingerprint image. However, storing only a hash comes at a cost: it makes it easier for attackers to lie about their canvas fingerprint. Indeed, it’s difficult to verify the consistency of a hash since we lose information about the canvas image content.
Fraudsters like to modify their canvas fingerprint
Canvas fingerprints are often modified by anti-detect bot frameworks and fraudsters to avoid being detected. In this section we analyze a few techniques used in the wild to modify a canvas fingerprint.
The first technique comes from Zenrows, a scraping-as-a-service platform. They discuss a few canvas modification techniques in this blog post.
The example below, taken from their blog shows how a bot based on Puppeteer or Playwright can override the toDataURL function, commonly used to obtain the value of a canvas. The bot executes the script using page.evaluateOnNewDocument, which means that it executes before any real JS program running on the website (like a bot detection script for example).
They override the toDataURL function so that if the width of the canvas is 209 and the height is 25, then they return a fake image (the base 64 field). The idea of their approach is to randomize the value only when the call comes from a canvas fingerprinting script. The filter can be adapted depending on the bot detection script.
await page.evaluateOnNewDocument(() => {
const mainFunction = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function (type) {
// check if this is a fingerprint attempt
if (type === 'image/png' && this.width === 209 && this.height === 25) {
// return fake fingerprint
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAANwAAAAeCAAAAABiES/iAAACeElEQVRYw+2YzUtUURjGf47OmDPh5AyFomUiEeEmyghXtWsh4dcswlYV2KYWfZh/QRBUVLhTCCJXEgmKUCIkFhJREARBkbkyKBlTRmUC82lxZ7z3TjM4whwXwz2ry3vO87znx33Pey4XFfHAg/PgPDgPzoPz4Dy4rFIKscSkAfmnsUY+iTfXFhxue4Zm4QpfaKbg8k+EsZNsGG6iNVzRMrkZeRPmjp6eCgcae5f+3wJIgtWLldG+DUnfzoail1etaVsEa1f2lUqw2hPd3T7nCrkMtlkQ24YDwP8+FZkI+gY3uq2cTcu54GIA/dJCDUAnSE4RdAESdALUxZ0hl4E5OMs49iE528E5a+cj5YFhDVI3vLA2c4K+zLXpvR37tNRDs3STg1OJqXqQSwS14wlJUD+VeHWAW86Qy8BwQ5Ek/WK/JBgqC72UTvJakmY5lAvurTRPSDrMmKRRcIvgeUo2KmmEI86Qy8DwmVu/ezQIBCSBLzwjKZhujv5cZZmUNkAq57ekRXCLYDG12pre5Qy5DAzDXbPfIOB/JqmCzNafCZd+dMA5RfZxdsBlNTAMF+FJfD2eSvSI0iGpmXe5GnbG3qyyHAO3yCZxlGV2uBLWDcJVMZKc7UrnfIBvQI+pHpxbS34ZaNkK7gYN0yvTDSCXyCZxNJTscFFe/DUH1w3QvpnzPiUPdTXfsvxZDdBGmeQU2SQd9lWQHS5m9J6Ln4/suZCwc96D25qM1formq5/3ApOX1uDkZ7P7JXkENkkK5eqQm3flRtuvitSYgCucKOf0zv01bazcG3Tyz8GKukvSjjrlB3/U5Rw42dqAo29yypKOO8figeX1/gH+zX9JqfOeUwAAAAASUVORK5CYII=';
}
// otherwise, just use the main function
return mainFunction.apply(this, arguments);
};
The base 64 of the image provided in the code snippet below corresponds to the following image. Thus, if a bot detection script executes a canvas fingerprinting challenge and calls the toDataURL function to obtain the value, it will obtain a fake image instead of the real canvas fingerprint.
Another solution to randomize a canvas fingerprint is to use an existing anti-canvas fingerprinting browser extension such as Canvas Blocker - Fingerprint Protect and Canvas Fingerprint Defender. The extensions can be used manually to browse the web or through a bot.
For example, Canvas blocker alters the value of the canvas fingerprint as follows. It defines a manipulate function that injects noise into the canvas pixels on the r, g, b components. To apply the noise, it gets the unmodified pixel value using getImageData and applies a random noise defined using Math.floor(Math.random() * 10) - 5 .
const manipulate = canvas => {
port.dispatchEvent(new Event('manipulate'));
// already manipulated
if (map.has(canvas)) {
return;
}
const {width, height} = canvas;
const context = canvas.getContext('2d', {willReadFrequently: true});
const matt = getImageData.apply(context, [0, 0, width, height]);
map.set(canvas, matt.data);
const shift = (port.dataset.mode === 'session' && gshift) ? gshift : {
'r': port.dataset.mode === 'random' ? Math.floor(Math.random() * 10) - 5 : Number(port.dataset.red),
'g': port.dataset.mode === 'random' ? Math.floor(Math.random() * 10) - 5 : Number(port.dataset.green),
'b': port.dataset.mode === 'random' ? Math.floor(Math.random() * 10) - 5 : Number(port.dataset.blue)
};
gshift = gshift || shift;
for (let i = 0; i < height; i += Math.max(1, parseInt(height / 10))) {
for (let j = 0; j < width; j += Math.max(1, parseInt(width / 10))) {
const n = ((i * (width * 4)) + (j * 4));
matt.data[n + 0] = matt.data[n + 0] + shift.r;
matt.data[n + 1] = matt.data[n + 1] + shift.g;
matt.data[n + 2] = matt.data[n + 2] + shift.b;
}
}
context.putImageData(matt, 0, 0);
// convert back to original
setTimeout(revert, 0, canvas);
};
Note that the noise is only applied dynamically when a script tries to obtain the canvas value, i.e. it doesn’t modify the value of the canvas at rest. Indeed, the manipulate function is called to override the behavior of the toDataURL function, a native function used to retrieve the value of a canvas, cf snippet below:
HTMLCanvasElement.prototype.toDataURL = new Proxy(HTMLCanvasElement.prototype.toDataURL, {
apply(target, self, args) {
if (port.dataset.enabled === 'true') {
try {
manipulate(self);
}
catch (e) {}
}
return Reflect.apply(target, self, args);
}
});
We notice that the extension overrides the toDataURL function using a JavaScript proxy object and not a function. Proxy objects are more transparent than functions and can’t be detected directly. It requires triggering side effects as we will show in the next section.
Canvas Fingerprint Defender does something similar. They define a noisify function that randomizes the r, g, b, a components of the canvas image.
const 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-defender-alert", '*');
context.putImageData(imageData, 0, 0);
}
}
};
It applies the lies function to native canvas functions like toDataURL using a JavaScript proxy object.
HTMLCanvasElement.prototype.toDataURL = new Proxy(HTMLCanvasElement.prototype.toDataURL, {
apply(target, self, args) {
noisify(self, self.getContext("2d"));
//
return Reflect.apply(target, self, args);
}
});
How can we detect canvas fingerprint modifications
There are no silver bullets to detect if a canvas has been artificially modified. Indeed, it will depend on the way the attacker lies about the value. Thus, it’s best to run several approaches to add redundancy.
We distinguish two categories of approach:
- A proof of work approach where we ask the user to draw a canvas and verify the values of certain known pixels;
- A function consistency check approach where we detect that a function has been overridden by looking at its prototype, or other side effects like error stack traces
There are thousands of ways to implement the first approach. The idea is to generate a canvas with specific colors and verify if the values of specific pixels are the ones we expect. The code snippet below shows a simple example where we fill a canvas with the color rgba(0, 127, 255, 1). Then, we iterate on each pixel and verify if the r, g, b, a components have the expected value (hence the % 4 in the code).
var canvas = document.createElement("canvas");
canvas.height = size;
canvas.width = size;
var context = canvas.getContext("2d");
context.fillStyle = "rgba(0, 127, 255, 1)";
var pixelValues = [0, 127, 255, 255];
// We apply the color rgba(0, 127, 255, 1) to the whole canvas
context.fillRect(0, 0, canvas.width, canvas.height);
var pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
for (var i = 0; i < pixels.length; i += 1) {
if (pixels[i] !== pixelValues[i % 4]) {
// we test if the pixel has the expected value
// If that's not the case it means the canvas value has been modified
console.log('Canvas has been overridden!')
}
}
For the second approach, we want to detect that a native function has been overridden. Usually, we want to look at the function's main properties like its prototype or its toString representation. Indeed, native functions have a different toString representation than non-native functions.
For example, the toDataURL function toString representation looks as follows when it is not overridden:
In the presence of Canvas Defender, it looks different:
Unfortunately, we can’t detect the presence of a JS proxy object with toString. The difference is only visible in the dev tools.
Instead, we need to trigger a side effect that reveals the presence of the JS proxy object. A common technique to observe interesting side effects is to trigger errors. For example, we can run the following code:
let isOverridden = true;
try {
Object.setPrototypeOf(HTMLCanvasElement.prototype.toDataURL, HTMLCanvasElement.prototype.toDataURL)
} catch (e) {
if (e.message.indexOf('Cyclic') > -1) {
isOverridden = false;
}
}
It tries to set the prototype of HTMLCanvasElement.prototype.toDataURL to itself
When the toDataURL function is not overridden, it triggers a as shown in the screenshot below.
However, when the toDataURL function is overridden, it doesn’t trigger any exception, cf screenshot below. Thus, we can leverage this difference to infer the presence of a Proxy object.
We can also detect specifically which extension is used to override the canvas by looking at the error stack. The code below calls the getImageData with invalid parameters, which triggers an error. The error stack trace contains information about the browser extension, see the screenshot below.
var canvas = document.createElement('canvas');
var context = canvas.getContext("2d");
try {
context.getImageData(canvas);
} catch (e) {
hasAntiCanvasExtension = e.stack.indexOf('chrome-extension') > -1;
hasCanvasBlocker = e.stack.indexOf('nomnklagbgmgghhjidfhnoelnjfndfpd') > -1;
}
Note that we use JSON.stringify so that Chrome doesn’t convert the Chrome extension URL to a proper link in the dev tools, but this is not needed in a normal JS program.
We observe the presence of chrome-extension://lanfdkkpgfjfdikkncbnojekcppdebfp/data/content_script/page_context/inject.js in the stack trace, which is the script injected by Canvas defender to override canvas-related function. lanfdkkpgfjfdikkncbnojekcppdebfp is Canvas Defender’s extension identifier on the store as you can see in its Chrome store URL.
Conclusion
While canvas fingerprint is effective in tracking fraudsters and detecting bots, it’s often modified by attackers to bypass detection.
Attackers can override native functions related to the canvas, such as getImageData and toDataURL to alter the value of their canvas. They can either do it in the code of their bots or by using free browser extensions such as Canvas Defender.
Thus, it’s important to detect whether a canvas value is genuine or if it has been altered. This can be done using two types of approaches:
- Ask the browser to draw a canvas and verify the values of certain known pixels
- Detect that canvas-related functions have been overridden by looking at their prototypes, or other side effects like error stack traces.
These approaches can help to detect that canvas functions have been overridden, and leverage this information to detect bots and fraudsters.