Privacy leak: detecting anti-canvas fingerprinting browser extensions

In one of my PhD papers, I discussed the potential counter-productivity of certain privacy/anti-fingerprinting countermeasures. It may seem like a paradox, but by trying to protect your privacy, you may stand out of the crowd and may become potentially more trackable. Indeed, if you use a countermeasure that is not used by a lot of users and whose presence can be easily detected, then you may end up being more unique compared to users that leverage more mainstream technologies or countermeasures.

I wanted to revisit this topic in 2024 for two main reasons:

  1. Is it still true? Can we easily detect certain anti-fingerprinting extensions? If yes, how?
  1. Detecting anti-fingerprinting countermeasures is also useful when it comes to bot and fraud detection since you want to know whether or not a fingerprint has been altered.

In this article, we focus on canvas fingerprinting, a technique that leverages the HTML canvas API to draw different shapes and text. The value of the canvas fingerprint is not necessarily unique, but is correlated with your browser (version), your OS, and the characteristics of your device.

Since several studies showed that it has a high entropy, i.e. it makes users more identifiable, that’s often an attribute modified by bots or people that want to modify their fingerprints in general.

There exist different types of canvas fingerprinting challenges. The one used on this site looks as follows:

You can find more examples of canvas fingerprints on this page.

How can you modify your canvas fingerprint?

There exist different techniques to modify your canvas fingerprint:

These tools may use different approaches to modify the canvas: some of them may decide to fully block it, e.g. Tor browser with certain options, while others may decide to randomize the value of certain pixels of a canvas. In this article, we focus on browser extensions.

Approach 1: Crafting a JS challenge to detect anti-canvas fingerprinting extensions

We create a JS challenge that aims to detect whether or not a user has a browser extension that modifies his canvas fingerprint. We start by generating an array of 20 different colors using the r, g, b format:

function generateRandomColors(n) {
    const colors = [];
    for (let i = 0; i < n; i++) {
        const r = Math.floor(Math.random() * 256);
        const g = Math.floor(Math.random() * 256);
        const b = Math.floor(Math.random() * 256);
        colors.push({
            r: r,
            g: g,
            b: b
        })
    }
    return colors;
}

const colorsInfo = generateRandomColors(20);

We obtain an array that looks as follows:

[
  {
    "r": 207,
    "g": 131,
    "b": 247
  },
  {
    "r": 9,
    "g": 226,
    "b": 220
  },
  // ...
  {
    "r": 2,
    "g": 159,
    "b": 183
  }
]

Then, we create an HTML canvas element that we will use to run our verification test. We define its size (400x80) and draw 20 squares on the canvas. Each row contains 10 squares of the same size. Note that the number of squares, the size of the canvas, and the colors don’t really matter, it can be adapted to your needs.

const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 80;
const ctx = canvas.getContext('2d');

// Calculate the size of each square (20 squares, 2 rows, 10 per row)
const numRows = 2;
const numCols = 10;
const squareSize = canvas.width / numCols;

// Draw the squares
for (let row = 0; row < numRows; row++) {
    for (let col = 0; col < numCols; col++) {
        const colorIndex = row * numCols + col;
        const colorInfo = colorsInfo[colorIndex];;
        // The ith square has the color of the ith element in the colorsInfo
        // array computed i the previous step
        ctx.fillStyle = `rgb(${colorInfo.r} ${colorInfo.g} ${colorInfo.b})`;
        ctx.fillRect(col * squareSize, row * squareSize, squareSize, squareSize);
    }
}

We obtain a canvas that looks as follows. The colors of the squares depend on the output of the generateRandomColors function:

The next steps consist of iterating over each square drawn in our canvas, and collecting their color to verify if it is consistent with those defined in colorsInfo. Indeed, in case an anti-canvas fingerprinting extension added noise to the canvas, the value of the square may differ from the one we instructed to use when drawing the canvas. We leverage the getImageData to access the value of the pixel at the center of each square, but we could collect any pixel’s value. We store the results in the outputColors array.

const outputColors = [];
for (let row = 0; row < numRows; row++) {
    for (let col = 0; col < numCols; col++) {
        // Get the pixel data at the center of each square
        const pixelData = ctx.getImageData(col * squareSize + squareSize / 2, row * squareSize + squareSize / 2, 1, 1).data;
        // Convert the pixel data to a hex color string
        const r = pixelData[0];
        const g = pixelData[1];
        const b = pixelData[2];
        outputColors.push({
            r: r,
            g: g,
            b: b
        });
    }
}

We compare the values of the outputColors array with the original colors from the colorsInfo array. For each square, we test whether or not there is a pixel difference. If there is a difference, we store the total pixel deviation, i.e. if the original color is rgb(225, 105, 106) and we measure rgb(227, 104, 106), the total pixel deviation is 2 + 1 = 3 (we use the absolute value to sum the differences).

let numDifferences = 0;
let totalPixelDeviation = 0;
for (let i = 0; i < outputColors.length; i++) {
    const originalColor = colorsInfo[i];
    const outputColor = outputColors[i];

    if (originalColor.r != outputColor.r) {
        numDifferences++;
        totalPixelDeviation += Math.abs(originalColor.r - outputColor.r);
    }

    if (originalColor.g != outputColor.g) {
        numDifferences++;
        totalPixelDeviation += Math.abs(originalColor.g - outputColor.g);
    }

    if (originalColor.b != outputColor.b) {
        numDifferences++;
        totalPixelDeviation += Math.abs(originalColor.b - outputColor.b);
    }
}

let hasAntiCanvasExtension = false;

// We could use a higher threshold to be more conservative
if (numDifferences > 0) {
    hasAntiCanvasExtension = true;
}

Why such a complex challenge?

We could argue that we could simply draw a single square and read whether or not the value has been modified. That’s true, and it will work in a lot of cases, However, certain browser extensions avoid applying noise on canvases that are too small or not complex enough.

Moreover, here the approach used is static. We could make it dynamic, e.g. by generating an array of colors that depends on a seed or any other signal to make the challenge 1) dynamic and 2) specific for each user to avoid replay attacks for example.

Finally, I just wanted to draw a lot of squares on a canvas. I guess that’s a good enough reason.

Evaluating approach 1

Approach 1 vs Canvas fingerprint defender (Chrome)

Canvas fingerprint defender is a Chrome browser extension.

When we run the previous challenge with this extension activated, we detect a significant number of pixel differences (>50) and a high total pixel deviation (> 380) making it explicit that the user has an anti-canvas fingerprinting extension adding noise to the canvas.

Approach 1 vs Canvas blocker (Chrome)

Canvas blocker is a Chrome browser extension. The extension proposes a different strategy to randomize the canvas. We keep the strategy active by default (highlighted in the screenshot below)

Even though the canvas is modified (I verified that my hash was changing on a classical canvas fingerprinting test), our previous challenge doesn’t detect any pixel difference. Thus, the challenge we create can’t reveal the presence of Canvas Blocker on Chrome. Note that in the next section of this article, we will go more into the details of canvas blocker and develop an approach capable of detecting it.

Approach 1 vs Canvas defender (Firefox)

Canvas defender is a Firefox browser extension.

When we run the previous challenge with this extension activated, we detect a significant number of pixel differences (>60) and a high total pixel deviation (> 250) making it explicit that the user has an anti-canvas fingerprinting extension adding noise to the canvas.

Approach 1 vs CanvasBlocker (Firefox)

CanvasBlocker is a Firefox browser extension. When we run the previous challenge with this extension activated, we detect a small, but still significant number of pixel differences and total pixel deviation. What’s interesting in the case of this extension is that numDifferences = totalPixelDeviation , making it a sort of unique fingerprint of this extension.

Approach 2: Detecting Canvas blocker (Chrome)

Approach #1 wasn’t able to detect Canvas blocker on Chrome. Let’s have a look at the extension code to see how we can detect it.

As for every browser extension, the code is present on your machine. In our case, the most interesting file is data/inject/main.js . It contains the logic to override canvas-related functions. In this file, we observe that the extension overrides all the canvas functions that can be used to retrieve the value of the canvas:

  • toBlob
  • toDataURL
  • getImageData
HTMLCanvasElement.prototype.toBlob = new Proxy(HTMLCanvasElement.prototype.toBlob, {
    apply(target, self, args) {
      if (port.dataset.enabled === 'true') {
        try {
          manipulate(self);
        }
        catch (e) {}
      }
      return Reflect.apply(target, self, args);
    }
  });
  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);
    }
  });
  CanvasRenderingContext2D.prototype.getImageData = new Proxy(CanvasRenderingContext2D.prototype.getImageData, {
    apply(target, self, args) {
      if (port.dataset.enabled === 'true') {
        try {
          manipulate(self.canvas);
        }
        catch (e) {}
      }
      return Reflect.apply(target, self, args);
    }
  });

All these functions call manipulate(self) , which looks as follows:

const manipulate = canvas => {
    port.dispatchEvent(new Event('manipulate'));
    // already manipulated
    if (map.has(canvas)) {
      return;
    }
    const {width, height} = canvas;
    const context = canvas.getContext('2d');
    const matt = getImageData.apply(context, [0, 0, width, height]);
    map.set(canvas, matt.data);
		
		// Computes the r, g, b noise to apply on the canvas
    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;
		
		// Apply the noise, but not on all pixels
		// Iterate using i, j but with an increment = Math.max(1, parseInt(height / 10))
    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);
};

Thus, in the code above we observe that the r, g, b components are not applied on all pixels since the for loops that apply the noise iterate as follows:

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))) {
	     ...
	    }
}

Thus, in our case, with height = 80 and width = 400 , i will increase by Math.max(1, parseInt(height / 10)) = 8 and j by Math.max(1, parseInt(width / 10)) = 40.

Thus, only the positions of the canvas linked to these indexes will be modified. Therefore, to detect Canvas blocker we could update our approach #1 to:

  1. Look specifically at the pixels of the canvas located at n = ((i * (width * 4)) + (j * 4)) with i, j computed based on our canvas width/height
  1. Look at all the pixels of the canvas

Instead, we’ll go another way to diversify our approaches. By reading the extension code, we noticed that it overrides functions such as getImageData related to the canvas. In the dev tools, if we type HTMLCanvasElement.prototype , we notice that the different functions look as follows:

We observe the presence of Proxy(Function) , which is not expected for a native function. Indeed, without Canvas Blocker, it looks as follows:

Thus, detecting that a native function has been replaced by a Proxy (or overridden) indicates the presence of an anti-canvas fingerprinting extension. As you can see in the Stack overflow thread, there is no clean way to do it in JavaScript. To do it, we proceed as follows:

try {
    ctx.getImageData(canvas);   
} catch (e) {
    hasAntiCanvasExtension = e.stack.indexOf('chrome-extension') > -1;
    hasCanvasBlocker = e.stack.indexOf('nomnklagbgmgghhjidfhnoelnjfndfpd') > -1;
}

We call getImageData in a broken way to throw an exception on purpose. Then, we can look at the stack trace using e.stack :

TypeError: Failed to execute 'getImageData' on 'CanvasRenderingContext2D': 4 arguments required, but only 1 present.\n    at Object.apply (chrome-extension://nomnklagbgmgghhjidfhnoelnjfndfpd/data/inject/main.js:83:22)\n    at collectFingerprint (http://localhost:3000/javascript/device_info.js:688:17)\n    at render (http://localhost:3000/javascript/device_info.js:1429:5)\n    at http://localhost:3000/javascript/device_info.js:1452:9

In particular, we notice the presence of the Canvas blocker extension thanks to this substring:

at Object.apply (chrome-extension://nomnklagbgmgghhjidfhnoelnjfndfpd/data/inject/main.js:83:22)

Indeed, nomnklagbgmgghhjidfhnoelnjfndfpd is the Canvas blocker identifier. It means that by looking at the stack trace, we can infer that the user is using Canvas Blocker specifically, which is a serious privacy leak as it’s not used by a lot of users (~20K users as of June 2024). Thus, it could potentially make you more trackable than without any countermeasures or compared to using a more mainstream countermeasure.



In this article, we showed that the most popular anti-canvas fingerprinting browser extensions can be detected using different approaches:

  1. Verifying the color of certain canvas pixels
  1. By testing if certain native canvas functions, such as getImageData , have been overridden by looking at the stack trace.

This can be a serious privacy leak as these extensions tend to be used by a small number of users. For example, as of June 2024, Canvas Blocker (Chrome) has ~20k users. It could potentially make you more trackable than without any countermeasures or compared to using a more mainstream countermeasure.

However, these detection techniques can also be used more positively to detect bots or fraudsters that detect their canvas fingerprint to bypass certain detection mechanisms.

The tests discussed in this article have been added to the fingerprinting test of device and browser info. The results are visible on the Has modified canvas fingerprint row.

Other recommended articles

Investigating the Selenium Chrome mode of Open Bullet 2

Fourth article of a series about Open Bullet 2, a credential stuffing tool. We analyze the the Selenium Chrome mode to better understand how it works, its browser fingerprint, and how it can be detected.

Read more

Published on: 05-09-2024

Investigating the Puppeteer mode of Open Bullet 2 (credential stuffing tool)

Third article of a series about Open Bullet 2, a credential stuffing tool. We analyze the the Puppeteer mode to better understand how it works, its browser fingerprint, and how it can be detected.

Read more

Published on: 08-08-2024

Fraud detection: how to detect if a user lied about its OS and infer its real OS?

In this article, we explain how we explain how you can detect that a user lied about the real nature of its OS by modifying its user agent. We provide different techniques that enable you to retrieve the real nature of the OS using JavaScript APIs such as WebGL and getHighEntropyValues.

Read more

Published on: 11-06-2024