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.
Every profile is drawn from one real computer, not from separate listsIn Mosaic: Machines that exist. GPU, screen, CPU and fonts from one real device. 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.
How it works: Machines that exist
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
296ab2eamatched stock's because the window was stock. - The control is a stock Chrome of a matching build, run the same way. Without stock's
eff69e17beside 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. Why Mosaic ships its own Chromium rather than a script in the page is in Introducing Mosaic, and the category it belongs to is explained in what an anti-detect browser is.





