# WebGL readPixels fingerprint: two routes, one image

> 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.

Source: https://mosaicbrowser.com/blog/webgl-readpixels-vs-todataurl

## readPixels and toDataURL read a canvas from opposite ends

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.

![4096: Two ways to read one WebGL image, beside a screenshot: mosaic settings showing the installed Tesseract Chrome engine and its version](https://mosaicbrowser.com/covers/webgl-readpixels-vs-todataurl.webp)

A WebGL readPixels fingerprint is a hash of the pixels a page rendered: draw a fixed scene, copy the framebuffer out with `gl.readPixels`, hash the bytes. The GPU, the driver and the operating system each leave their mark on the result, so the hash names a kind of machine. There is a second way to get the same picture out, `canvas.toDataURL()`, and the two routes number their rows from opposite ends. That detail cost us a Pixelscan verdict.

## Two routes out of one framebuffer

`readPixels(x, y, width, height, format, type, buffer)` follows OpenGL's convention: the origin is the bottom left, so the first row in the buffer is the bottom row of the image. `toDataURL` encodes a PNG, and an image file starts at the top. Read the same frame both ways and you get the same pixels with the rows in reverse order.

A fingerprinting script does not care, as long as it uses one route every time. A script that wants to know whether the picture is honest can use both: read the frame through `readPixels`, decode the PNG from `toDataURL`, flip one of them, and compare. In a real browser the two agree pixel for pixel, every time, because they are the same framebuffer.

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)

## How position-keyed noise fails that test

Noise is how a browser stops its WebGL image from being the same on every profile. Without it the image is identical for every profile on one machine, which is shared entropy on a surface fingerprinters weight heavily. For a long time we did not noise it, and the reason was a failed first attempt.

That attempt keyed its stamp on coordinates. Pick pixels by where they sit, nudge those. Through `toDataURL`, "pixel (10, 3)" counts from the top. Through `readPixels`, the same pixel is counted from the bottom. So the stamp landed on different pixels in the two copies, they disagreed after the flip, and Pixelscan called the profile masked. It was right to: we had produced a picture no real GPU produces, two readouts of one frame that do not match.

The 2D canvas had had exactly the same bug, in a different form. There, the same shape drawn at two offsets on one canvas came out with different stamps, which is the four-line probe in [the offset probe that catches position-keyed noise](https://mosaicbrowser.com/blog/canvas-offset-probe). That bug had been fixed by keying on the pixel's colour. Nobody had carried the fix across to WebGL.

There was also a setting that did nothing. `webglImage` was a per-profile option the editor offered and the launcher passed along, and the script that should have acted on it read it nowhere. It looked like a working control for as long as it existed.

## Colour-keyed noise agrees with itself

The fix was the 2D one, applied to WebGL. Decide whether to stamp a pixel from its colour, not from its position. A colour is the same whether you count rows from the top or the bottom, and whatever rectangle you read, so the rule picks the same pixels on every route by construction.

Measured after the change:

```
readPixels vs decoded toDataURL   4096 of 4096 pixels agree
repeat reads, each route          stable
Pixelscan's own scene hash        76edd36b (stock) -> f5337a44
geometry                          byte-identical to stock

```

The hash moved, which is the point of noise. The geometry did not, so nothing about the shape of what was drawn changed. Both routes returned the same image, and the same image again on a second read. Our stock comparison reported no unexplained differences.

That was the injected-script version, on a stock browser. On Tesseract Chrome, Mosaic's own Chromium, the rule lives in C++. The engine's `readPixels` patch calls the same colour-keyed function that the 2D canvas readbacks (`getImageData`, `toDataURL`, `toBlob`) go through, so there is one rule and no second copy of it to drift. It stamps one distinct colour in 32 and leaves the other 31 exactly as drawn, and it stamps rather than flips, so a second read finds the change already there instead of a new one.

## What the hash says, and what it does not

The pixels are only half of a WebGL fingerprint. The other half is the record a page reads with `getParameter`: limits, precision formats, the extension list, the renderer string. Noise in the image does nothing for that record, and it should not try: [what a canvas hash reveals](https://mosaicbrowser.com/blog/what-a-canvas-hash-reveals) covers what the rendering stack gives away and why. In Mosaic both halves are drawn together for one machine, as [real fingerprints](https://mosaicbrowser.com/features/real-fingerprints) explains.

If you are testing your own setup, the check is cheap. Read one WebGL frame both ways, flip the `readPixels` rows, and diff. Zero differing pixels is what a real browser gives. Anything else is noise that knows where it is, and a page that asks the question twice will find it.

## Questions

### What is a WebGL readPixels fingerprint?

A page draws a fixed scene with WebGL, copies the result out with gl.readPixels and hashes the bytes. The GPU, driver and OS change the pixels slightly, so the hash says what kind of machine rendered it.

### Why do readPixels and toDataURL give rows in a different order?

readPixels follows OpenGL's convention, where row 0 is the bottom of the image. toDataURL encodes an image file, where row 0 is the top. The same picture comes out of the two routes upside down relative to each other.

### Can a site tell that WebGL noise was added?

It can if the noise disagrees with itself. Read the same frame through readPixels and through toDataURL, flip one, and compare. If the noise was keyed on pixel position, the two copies differ, and no real browser does that.

### Does Mosaic add noise to WebGL images?

Yes. Tesseract Chrome stamps a sparse, colour-keyed change into the image in C++, so readPixels, getImageData and toDataURL all see the same picture, and each profile's image differs from the others.

## Related

[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.

[What a canvas hash actually tells a site about your machine](https://mosaicbrowser.com/blog/what-a-canvas-hash-reveals): A canvas fingerprint reveals your rendering stack: GPU, driver, fonts, the OS's text rasteriser and antialiasing. It names a kind of machine, not a person.

[Machines that exist: fingerprints drawn from real hardware](https://mosaicbrowser.com/features/real-fingerprints): Mosaic draws each profile's GPU, screen, CPU, memory and fonts from one real device, so the parts of a fingerprint agree with each other and with the host.

[The canvas method nobody noised: what a worker test found](https://mosaicbrowser.com/blog/the-canvas-method-nobody-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.



[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.



## 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.
