# Pixelscan masking detected: it was the fonts

> We bisected Pixelscan's masking detected verdict on one profile. Canvas, WebGL, audio and client rects were fine. Hiding about 2% of font families caused it.

Source: https://mosaicbrowser.com/blog/pixelscan-masking-bisected

## Pixelscan's masking verdict, bisected to one surface

We bisected Pixelscan's masking detected verdict on one profile. Canvas, WebGL, audio and client rects were fine. Hiding about 2% of font families caused it.

![1 of 6: One surface off, and the masking verdict went](https://mosaicbrowser.com/covers/pixelscan-masking-bisected.webp)

When Pixelscan says masking detected and fonts are involved, the likely cause is a font list with gaps that the claimed machine would not have. That was our answer, found by bisecting one profile on our own engine: canvas, WebGL, audio and client-rect noise were all fine, and the verdict came entirely from hiding a small share of font families. Jittering glyph widths was not the signal.

## The first run on our own engine

Once Tesseract Chrome, our Chromium, ran at all, the question became what a detector sees. The first pass was one fresh profile with no proxy:

| Detector | Result |
| --- | --- |
| BrowserScan | pass, 95% |
| IPHey | attention, Unreliable, MX 90/100 |
| Pixelscan | fail, fingerprint inconsistent, masking detected |
| CreepJS | unread, no trust score rendered |

Two of those were worse than the stock-browser build had scored, so something in our own patches was costing us. The engine has a switch for exactly this case: `--disable-spoofing` takes a list of surfaces and turns each one back to the real value. That makes a bisect possible.

## Bisecting one profile, with a control at both ends

The discipline matters more than the switch. We used one profile, patched in place through its extra launch arguments and restarted between rows, because two freshly generated profiles differ in every column and comparing them tells you nothing. And we ran the unchanged profile first and last, so we would know if Pixelscan itself drifted during the run.

| Run | Pixelscan |
| --- | --- |
| baseline (control) | fail |
| every surface off | pass |
| baseline again (control) | fail |

The controls agreed with each other, so the middle row meant something. Then the split:

| Surfaces turned off | Pixelscan |
| --- | --- |
| audio, canvas, clientrects, font, gpu, screen | pass |
| everything else | fail |
| font only | pass |
| canvas only | fail |
| gpu only | fail |
| audio only | fail |
| screen only | fail |
| client rects only | fail |

One row passes with a single surface off, and it is fonts. The surfaces we had spent months on, canvas, WebGL, audio and client rects, are all still on in the row that passes. The noise was fine. The font list was not.

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)

## Two font patches behind one switch

We nearly stopped there and deleted the wrong thing. The engine had two font patches, and both sat behind the one `font` token: one hid a small share of installed families per profile, about 2%, and the other jittered the advance width of glyphs. Turning `font` off turned off both.

The jitter has its own off switch by accident. It reads a seed from `--fingerprint-font-spacing-seed`, and a seed that is not a number fails to parse, so the function returns the real width. That let us hold the two apart:

| Glyph jitter | Family hiding | Pixelscan |
| --- | --- | --- |
| off | on | fail |
| off | off | pass |
| on | on | fail |
| off | on | fail (repeat) |

The hiding is the signal and the jitter is not. Patch `0131` removes the hiding and keeps the jitter. That is the better trade both ways: the jitter still gives each profile its own measured text widths, and it does not change which fonts exist.

## Why hiding fonts reads as masking

Pixelscan's collector, `fptc.min.js`, is only lightly obfuscated, and it declares what it gathers: `audio`, `canvasHash`, `fonts`, `hardwareConcurrency`, `language`, `navigatorPlatform`, `screenResolution`, `secCh`, `timezone`, `ua`, `webDriver`, `webglHash` and `webglMeta`. The verdict is computed on their server, so we can say what it reads, not exactly how it weighs it.

What the bisect says is enough, though. A macOS font list missing two families at random is not a rare Mac. It is a Mac nobody owns, because the families macOS ships are the same on every Mac. Each individual omission was plausible, and together they described no machine. It is the same mistake as an Apple M3 Max GPU beside a 13-inch MacBook Air screen, made two per cent at a time.

The same check explains the larger version of this verdict. A profile that claims a different operating system from the computer it runs on gets that computer's fonts, because fonts come from the real machine and no switch reaches them. On a Mac, claiming Linux read masking detected with 1 font against 159, which is the case in [a Mac makes Mac profiles](https://mosaicbrowser.com/blog/a-mac-makes-mac-profiles).

## What it cost, and what it did not

Removing the hiding has a price we accepted on purpose. Two profiles on one Mac now report the same installed fonts, as they already report the same GPU vendor, operating system and TLS handshake. A shared truth is better than a private contradiction, and what still separates profiles is the canvas and audio seeds, screen, locale, timezone, the glyph widths, and above all the cookie jar.

How a different detector, CreepJS, decides that a value is a lie is in [what a CreepJS lie is](https://mosaicbrowser.com/blog/what-a-creepjs-lie-is). The values Tesseract Chrome changes, and the ones it leaves true, are on [real fingerprints](https://mosaicbrowser.com/features/real-fingerprints). A detector verdict is one dated measurement on one machine, and we treat it that way, which is why this one was settled with a bisect rather than a theory.

## Questions

### Why does Pixelscan say masking detected?

In our case it was the font list. The engine hid a small share of installed font families per profile, and a Mac missing families every Mac has read as masked. With the hiding off and every other surface still noised, the verdict cleared.

### Does canvas noise cause Pixelscan's masking verdict?

Not in our bisect. With canvas, WebGL, audio and client-rect noise all on and only fonts off, Pixelscan passed. Turning canvas alone off, with fonts still hidden, still failed.

### Is font fingerprint noise detected by Pixelscan?

Hiding families was. Jittering glyph widths was not: with the jitter off and the hiding on the verdict still failed, and with both off it passed. So we kept the jitter and removed the hiding.

### Does Pixelscan read fonts at all?

Yes. Its collector script declares fonts as one of the values it gathers, beside canvasHash, webglHash, webglMeta, audio, timezone and the user agent.

## Related

[Why OS spoofing fails on fonts: a Mac makes Mac profiles](https://mosaicbrowser.com/blog/a-mac-makes-mac-profiles): Why OS spoofing fails on fonts: they come from the computer, not the claim. A Linux profile made on our Mac showed 1 font where macOS showed 159.

[What a CreepJS lie is, and why stock Chrome has none](https://mosaicbrowser.com/blog/what-a-creepjs-lie-is): CreepJS lies detected means an API behaved unlike the native one: wrong toString, wrong descriptor, or a worker that disagrees. Stock Chrome has none.

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

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

[A detector that never answered because its tab was in the background](https://mosaicbrowser.com/blog/requestanimationframe-background-tab): Background tab throttling stops requestAnimationFrame. Our test tab was in the background, two detectors never answered, and Page.bringToFront fixed both.

[When the FingerprintJS demo can't name your browser](https://mosaicbrowser.com/blog/fingerprintjs-cannot-name-browser): FingerprintJS read 'Chromium-Based Browser Not Available' for our engine. We blamed the version; the real variable was Google's branding.

[IPHey's "(whale)" signal, and what it was really about](https://mosaicbrowser.com/blog/iphey-whale-signal): IPHey's inconsistent browser fingerprint (whale) looked like a version problem. We followed it through branding and JavaScript to a Widevine certificate.

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

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