# Canvas toBlob fingerprint: the route we never noised

> Our canvas noise reached toDataURL and missed toBlob, so a page calling toBlob got a PNG byte-identical to stock Chrome's. A worker test caught it.

Source: https://mosaicbrowser.com/blog/the-canvas-method-nobody-noised

## The canvas method nobody noised: what a worker test found

Our canvas noise reached toDataURL and missed toBlob, so a page calling toBlob got a PNG byte-identical to stock Chrome's. A worker test caught it.

![2 of 4: Two of four canvas routes were never noised, beside a screenshot: mosaic settings showing the installed Tesseract Chrome engine and its version](https://mosaicbrowser.com/covers/the-canvas-method-nobody-noised.webp)

A canvas fingerprint can be read through `toBlob`, and for most of our engine's life that was the one door our canvas protection did not cover. A page that called `toDataURL` saw a profile's own canvas. A page that called `toBlob` on the same drawing got a PNG byte-identical to the one a stock Chrome on the same machine produced. We found it because a worker test kept reporting three fields we could not explain.

## Three canvas fields that would not agree

CreepJS has a page that reads the fingerprint in the window and again in a dedicated worker, a shared worker and a service worker. A real browser answers the same in every realm. Once our reader of that page was fixed, it reported 3 disagreeing fields out of 45, all of them `canvas`. The control was a stock Chrome, run the same way:

```
               Window      Dedicated   Shared      Service
stock          296ab2ea    296ab2ea    296ab2ea    296ab2ea
our engine     296ab2ea    41f7870c    41f7870c    41f7870c

```

Stock agreed with itself. Ours did not, and our window's hash was identical to stock's. We read that as good news for the window: whatever was wrong, it was in the workers.

## Two theories that died

The obvious suspects were the canvas API and the realm boundary, and we measured both before touching anything.

The first was that `<canvas>` and `OffscreenCanvas` disagree. They did not. `getImageData` on identical content agreed within one realm, on our engine and on stock.

The second was that our change simply did not reach across realms the same way. We drew one `OffscreenCanvas` scene in the page, in a blob worker and in a SharedWorker and hashed `getImageData` in each. Eleven fields out of eleven agreed. That probe is now part of our live engine check, and it is worth saying why: ten fields of agreement had not revealed this, so agreement on `getImageData` alone proves less than it looks.

What was left was the route neither probe touched: encoding. A canvas has four ways out, and we had only been reading one of them.

Mosaic is a desktop app

Email yourself the link and open it on your computer.

[How it works: Machines that exist](https://mosaicbrowser.com/features/real-fingerprints)

## The canvas toBlob fingerprint was stock

We drew one scene and took it out through every route, on our engine and on a stock Chrome on the same Mac. The hash is over the encoded bytes, so an identical hash is an identical file:

| Route | Stock | Ours |
| --- | --- | --- |
| `canvas.toDataURL()` | `eff69e17` | `ef122151` |
| `canvas.toBlob()` | `eff69e17` | `eff69e17` |
| `OffscreenCanvas.convertToBlob()`, page | `eff69e17` | `eff69e17` |
| `OffscreenCanvas.convertToBlob()`, worker | `eff69e17` | `ef122151` |

Rows two and three are the finding. In the page, our `toBlob` and `convertToBlob` output was the real machine's canvas. Every property we had worked to get right in the noise, its sparseness, its stability across reads, its indifference to where a shape was drawn, was skipped by calling a different method name. And the page and a worker disagreed about `convertToBlob`, which is what CreepJS had been saying all along.

One condition in Chromium's encoder explains every cell:

```
bool use_idle_encoding = IsMainThread() && (mime_type_ != kMimeTypeWebp) && …

```

On the main thread, a PNG from `toBlob` takes a progressive idle encoder that reads rows straight out of the source pixels. It never passes through the function where our change was applied. Off the main thread, the encoder takes the other branch, which does. So the canvas was changed in the realm almost nobody measures, and left alone in the two calls they use most.

## What patch 0136 does

The fix was not to add noise to the idle encoder as well. That would have made four routes each changed separately, and the requirement is stronger: all four must produce the same file, because that is what a stock browser does. Patch `0136` applies the change once, at the point where the source pixels are acquired, into a buffer the encoder owns. Every route downstream encodes those same pixels, and nothing is written back to the page's own canvas. After it, on one profile:

```
page       getImageData fbb831ab   convertToBlob 940a46c3
page       toDataURL    940a46c3   toBlob        940a46c3
dedicated  getImageData fbb831ab   convertToBlob 940a46c3

```

The CreepJS worker row then read `3 worker realms agree with the window across 45 fields`. We did not get there by loosening the reader. The note we left ourselves while the cause was unknown said not to close this by relaxing the extractor, and it turned out the extractor had been right the whole time.

The shared worker's canvas still differs from the page's, on both engines, ours and stock. That is Chrome's own behaviour, so it is not a tell, and we print it without asserting it. You only know which kind of difference you are looking at by running a stock browser beside yours.

## Three rules we took from it

- List every route out of a surface, then measure each one. Canvas has four. We had assumed it was the surface where that work was already done.
- A hash two realms share is not proof both were changed. It fits equally well with neither being changed. Our window's `296ab2ea` matched stock's because the window was stock.
- The control is a stock Chrome of a matching build, run the same way. Without stock's `eff69e17` beside ours, our own hashes look like three changed values and the bug is invisible.

Our engine changes these values in C++ for this reason. A page, an iframe and a worker should all be reading one machine, and a route that bypasses the change is a contradiction a detector can find in a few lines. You can see what your own browser's canvas gives away with [our canvas fingerprint test](https://mosaicbrowser.com/tools/canvas-fingerprint). Why Mosaic ships its own Chromium rather than a script in the page is in [Introducing Mosaic](https://mosaicbrowser.com/blog/introducing-mosaic), and the category it belongs to is explained in [what an anti-detect browser is](https://mosaicbrowser.com/guides/what-is-an-anti-detect-browser).

## Questions

### Can a site fingerprint a canvas with toBlob instead of toDataURL?

Yes. toBlob encodes the same pixels to a file, PNG by default, so a page can hash the blob exactly as it hashes a data URL. A real browser gives the same file from both.

### Why would toBlob and toDataURL give different results?

In a real browser they should not. In ours they did, because on the main thread Chromium encodes a toBlob PNG on a separate idle path that skipped the code where our canvas change lived.

### What is the safe test for canvas noise?

Draw one scene, take it out through getImageData, toDataURL, toBlob and OffscreenCanvas.convertToBlob, in the page and in a worker, and compare the files against a stock Chrome on the same machine.

### How did Mosaic fix it?

Patch 0136 applies the canvas change once, where the pixels are first read, into a copy the encoder owns. Every route downstream now encodes the same pixels, and the page's own canvas is never written to.

## Related

[Introducing Mosaic, an anti-detect browser that lives on your machine](https://mosaicbrowser.com/blog/introducing-mosaic): Mosaic runs its own Chromium, draws each profile from a real machine and matches it to the proxy exit. Profiles stay on disk. One plan, no per-profile fees.

[What is an anti-detect browser?](https://mosaicbrowser.com/guides/what-is-an-anti-detect-browser): An anti-detect browser gives each profile its own fingerprint, proxy and cookies, so sites see separate visitors. What it changes, how, and its limits.



[Canvas fingerprinting: how a drawing identifies your computer](https://mosaicbrowser.com/glossary/canvas-fingerprinting): Canvas fingerprinting draws hidden text and shapes, reads the pixels back and hashes them. Small rendering differences make the hash a mark of your machine.

[Same fonts, different measurements: per-profile glyph advances](https://mosaicbrowser.com/blog/glyph-advances-per-profile): Font fingerprint randomization can hide fonts or change how wide glyphs measure. Hiding read as masking; per-profile glyph advances did not.

[Every profile on one Mac drew the same WebGL image](https://mosaicbrowser.com/blog/webgl-hash-same-every-profile): A WebGL fingerprint is the same in every browser profile on one machine because the GPU draws it. Ours was too, until per-profile noise moved the hash.

[readPixels and toDataURL read a canvas from opposite ends](https://mosaicbrowser.com/blog/webgl-readpixels-vs-todataurl): readPixels starts at the bottom row and toDataURL at the top, so position-keyed WebGL noise disagreed with itself. Keyed on colour, 4096 of 4096 pixels agree.

[The four-line probe that catches position-keyed canvas noise](https://mosaicbrowser.com/blog/canvas-offset-probe): To detect canvas noise injection, draw one shape twice at two offsets and diff them. A real browser gives 0 bytes; our old noise gave 76.

## Mosaic, on your own computer

The browser this blog is about: its own Chromium, fingerprints from real machines and a proxy exit per profile. Free holds 3 profiles. Pro is $49 a month for any number.

Mosaic is a desktop app

Email yourself the link and open it on your computer.
