A CreepJS lie is a browser API that does not behave like the native one it pretends to be. CreepJS calls a getter such as navigator.hardwareConcurrency, then inspects the function behind it: its source text, its property descriptor, the error it throws when misused, and the value it gives in a worker. When "lies detected" is above zero, at least one of those disagreed with an unmodified browser. Stock Chrome has none, because there is nothing in it that was replaced.

What CreepJS checks when it hunts for lies

A spoof done in page JavaScript has to replace a function the browser wrote in C++. The replacement can return any value it likes, but it is a different kind of object, and CreepJS asks it questions only a native function answers correctly.

  • Call toString() on it. A native getter prints function get hardwareConcurrency() { [native code] }. A plain replacement prints its own source.
  • Read its own properties. Native methods have no prototype and no arguments or caller; ordinary JavaScript functions often do.
  • Misuse it. Calling a native getter on the wrong object throws a TypeError, and calling a method with new throws too. A replacement that forgets to throw, or throws the wrong error, is caught.
  • Look for a Proxy. Wrapping the native in a Proxy fixes toString, so CreepJS pokes at the edges a proxy cannot hide: cyclic prototype errors, odd stack traces, and how the object looks when reached through a second realm.
  • Ask again somewhere else. CreepJS re-reads values from an iframe and from a dedicated worker, a shared worker and a service worker, and compares them with the page.

The last one catches spoofs that pass every other test. A script injected into the page often never reaches a service worker, so the page says 8 threads and the worker says the machine's real 12. Each of those disagreements is listed as a lie against the API it was found on.

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.

How it works: Machines that exist

Why lies detected on CreepJS is worse than a low score

Our detector catalogue in Mosaic has a main CreepJS row, and its pass rule is written like this: a trust score above 70%, "and crucially lies at 0. A high score with lies detected is worse than a mediocre score without: it means the spoofing itself is the signal."

The reason is what a lie is evidence of. A middling trust score says the browser is unusual, which plenty of real browsers are. A lie says somebody rewrote this browser's functions, and real browsers almost never look like that. A site that sees one does not need to know what the true value was. It already knows the browser is wearing something.

Why stock Chrome has none

There is no lie to catch in an unmodified Chrome because every getter is the real binding. Its toString is native because it is native. Its descriptor, its errors and its missing prototype are whatever Chrome ships, because Chrome is the thing shipping them. And every realm reads one value from the same C++ code, so the worker cannot disagree with the page.

That is the whole reason Mosaic changes fingerprint values inside its engine, Tesseract Chrome, and not in page JavaScript. When the engine reports a different thread count, the getter a page inspects is still Chrome's own, and the page, its iframes and its workers get the same number. The values a page can read, and which of them a detector uses first, are on our fingerprint checker.

Where we found one in our own engine

Changing values in C++ removes the toString class of lie. It does not remove the cross-realm class, and we found that out the slow way.

CreepJS's worker page hashed each realm's canvas output. A stock Chrome of the same major agreed with itself everywhere. Ours did not:

              Window      Dedicated   Shared      Service
stock         296ab2ea    296ab2ea    296ab2ea    296ab2ea
Tesseract     296ab2ea    41f7870c    41f7870c    41f7870c

The window matching stock looked like a clean window. It was the opposite. On the main thread, Chrome encodes a PNG through a progressive idle encoder that reads the canvas rows directly and never reached the code where our canvas noise lived, so toBlob in the page returned a file byte-identical to stock Chrome's. Workers took the other branch and were noised. Patch 0136 now stamps the pixels once, where every encode path reads them, and the worker page then reported 3 worker realms agreeing with the window across 45 fields. How canvas output is hashed and compared is on the canvas fingerprint page.

Two smaller lessons came with it. CreepJS paints its worker panels red when the whole-block hashes differ, and the window's block carries fields no worker has, so the panels are red on a browser with nothing wrong. Our extractor compares realms field by field instead. And a value that matches stock in one realm proves only that it matches stock; it may mean nothing was applied there at all.

How to read your own CreepJS result

Read the lies count before the trust score. If it is above zero, find which API CreepJS named and ask what changed it: an extension, a stealth plugin, or a browser that spoofs in JavaScript. Then run the page twice, because one reading of a live detector is one observation.

If you are comparing products, what an anti-detect browser is covers the difference between changing values in the engine and injecting them into the page, and our launch post explains why we chose the engine.