CreepJS: fingerprinting the fingerprint blockers

March 22, 2024

|repo-review

by Florian Narr

CreepJS: fingerprinting the fingerprint blockers

CreepJS is a browser fingerprinting suite that targets privacy tools — Tor Browser, Brave, uBlock Origin, FakeBrowser, JShelter — and measures how effectively they block or fake fingerprint signals. It collects ~20 independent signals, detects when APIs are being tampered with, and produces a confidence-weighted fingerprint hash.

Why I starred it

Most fingerprinting demos show what data browsers expose. CreepJS is the opposite: it starts from the assumption that the browser is lying and then works out how consistent those lies are.

That reframing matters. A Brave user with strict fingerprinting on looks like a real browser from the outside. CreepJS asks: does the Canvas API output match what a real Blink engine would produce? Does the font metric match the reported OS? Do the prototype methods behave like native code — or like a Proxy?

It's a research tool for measuring privacy posture, not a production SDK. The live demo runs in your browser and shows you exactly what it found.

How it works

The entry point is src/creep.ts. Everything runs in one big async IIFE that fans out into ~20 parallel signal collectors via Promise.all, then merges the results into a fingerprint object.

// src/creep.ts
const [
  workerScopeComputed,
  voicesComputed,
  offlineAudioContextComputed,
  canvasWebglComputed,
  // ... 15 more
] = await Promise.all([
  getBestWorkerScope(),
  getVoices(),
  getOfflineAudioContext(),
  getCanvasWebgl(),
  // ...
])

The most interesting module is src/lies/index.ts — 953 lines dedicated to detecting API tampering. It builds on a concept called "prototype lies": if a privacy extension patches navigator.hardwareConcurrency to return a fake value, the patched getter no longer behaves identically to a native function. CreepJS catches this by checking .toString() output, prototype chain consistency, and whether the function produces TypeError under the conditions a real native implementation would.

The queryLies function takes an API function, its prototype, and a lieProps record, then runs a battery of checks:

let lies: Record<string, boolean> = {
  ['failed illegal error']: !!obj && failsTypeError({
    spawnErr: () => obj.prototype[name],
  }),
  ['failed undefined properties']: (
    !!obj && /^(screen|navigator)$/i.test(objName) && !!(
      Object.getOwnPropertyDescriptor(self[objName.toLowerCase()], name)
    )
  ),
  // ... 10+ more checks
}

When lieProps['Function.toString'] or lieProps['Permissions.query'] are already flagged as suspicious, it escalates to Proxy detection — creating three separate Proxy objects around the function and testing for recursion errors and prototype cycle behavior that real functions handle differently than Proxied ones.

The src/resistance/index.ts module detects timer precision clamping. Firefox with RFP enabled rounds Date.now() to the nearest 100ms. CreepJS measures this by firing 10 delayed timestamps and checking whether the last digit is always the same:

const protection = (
  lastCharA == lastCharB &&
  lastCharA == lastCharC &&
  // ... through lastCharJ
)

Ten matching last digits means the timer is clamped — a strong signal that RFP is active.

The src/headless/index.ts module is where the bot detection happens. It checks for Chromium-specific signals that headless environments can't fake cleanly: whether ActiveText renders as rgb(255, 0, 0) (a known headless default), whether the permissions API returns a contradictory state, whether there's a taskbar (comparing screen.height to screen.availHeight). These are the kind of checks that catch automation frameworks even when they spoof the user agent.

Hashing uses two strategies. Quick hashes use hashMini — a custom FNV-derived function that JSON-stringifies any value and runs it through a 32-bit multiply-and-XOR chain. Async hashes use SubtleCrypto.digest('SHA-256') for the full fingerprint. The final botHash and fuzzyHash are composites built from subset signals, so you get both a stable identity and a distance metric for grouping similar browsers.

Using it

It's a browser demo, not a library. Clone it, run pnpm install && pnpm build:dev, and open the output in a browser. To inspect the fingerprint objects directly:

// Available on window after the page runs
window.Fingerprint  // full signal data per collector
window.Creep        // final merged hash and lies summary

If you want to poke at lie detection specifically, open DevTools on the live demo and inspect window.Creep.lies — it shows each API that was caught lying, with the specific failure mode.

Rough edges

The project is a demo, not an SDK. There's no public API, no npm package, no way to embed it in your own page without forking. The src/ modules export typed functions but they're tightly coupled to the full fingerprint pipeline — pulling out just the lie detector would require significant work.

Documentation is sparse beyond the README and the live demo output. The code is well-structured but the detection logic is dense; some checks have no comments explaining why a specific condition maps to a lie.

There are no tests. For a project that's specifically about correctness — detecting when a browser is returning wrong values — the absence of a test suite is noticeable. The maintainer has been active through early 2026, so it's not abandoned, but test coverage remains zero.

The trademark policy in the README is unusually strict for an MIT-licensed project: you can fork the code but cannot use the name "CreepJS" publicly. Worth reading before you host a variant.

Bottom line

If you're building privacy tooling, researching fingerprinting defenses, or evaluating how well hardened browsers hold up — run your browser against the live demo. If you want to understand how lie detection works at the API level, src/lies/index.ts is the most thorough implementation I've seen in a public codebase.

abrahamjuliot/creepjs on GitHub
abrahamjuliot/creepjs