Skip to content

Project · Defensive PoC

Afterimage

Two ways a site can recognise you with no cookies at all. One is stateless: a fingerprint recomputed from your GPU every time, stored nowhere, so there is nothing to clear. The other is stateful: a counter hidden in the GPU's compiled-shader cache, a place the privacy panel doesn't reach. The sensor's own logic is compiled to bytecode and run inside a small obfuscated virtual machine — the same way production anti-bot sensors hide what they do.

Inspired by ShaderGhost (shaderghost.gg) — my own implementation of the same ideas, extended to re-identify the device across different browsers via a stateless GPU fingerprint. ShaderGhost's shader-cache supercookie is confined to a single browser's cache; here that cache trick is just one of the three signals.

This is a single-origin awareness demo, not a tracker. Everything is computed on your own device, describes only this one page, is shown only to you, and is sent to no server or third party. The obfuscation is here to show how such sensors are built and protected, not to hide anything from you: the bytecode's full disassembly is printed further down.

Stateless · GPU fingerprint

computing…

Derived from stable graphics-hardware attributes, stored nowhere. Open this page in a private window, in a different browser, or behind a VPN: the fingerprint stays the same, because your GPU didn't change. That is cross-browser, cross-session, cross-IP re-identification with zero stored state.

Fingerprint components (compare across browsers)

Lines marked * form the cross-browser key. The render-hash and extension count are shown but excluded, because they can differ between browsers on the same GPU. If the starred lines match in Edge and Chrome, the counter carries over.

 

Three counters, three tiers of persistence

Ordinary

localStorage. Resets when you clear cookies / site data.

Afterimage

GPU shader cache. Survives clearing site data; resets in another browser.

Cross-browser

Server, keyed to the fingerprint above. Continues across browsers on this device.

Only the cross-browser counter follows you from, say, Edge to Chrome, because it is the only one not stored inside a single browser. It lives on a server under an opaque key derived from your GPU fingerprint — never the raw fingerprint, no IP, no user agent. Use “Forget this device” to delete that record.

Biggest limitation — both browsers must render on the same GPU

The cross-browser link is a graphics fingerprint. If one browser has hardware acceleration on and another falls back to software rendering, the render differs, the fingerprints no longer match, and the tracker fails to connect them. Both browsers need GPU acceleration enabled — or both disabled — to be linked to the same device.

Idle.

Try to escape it
  • Clear cookies / site data. The ordinary counter resets to zero. The afterimage and cross-browser counters carry on — neither ever lived in the storage you cleared.
  • Change your network or turn on a VPN. Nothing here uses your IP. The fingerprint is hardware-derived and the server keys the count by that fingerprint alone, so a new IP changes neither.
  • Open a different browser. The per-browser afterimage counter starts fresh (each browser has its own shader cache), but the fingerprint is the same, so the cross-browser counter picks up exactly where the other browser left off.
  • Open a private window. The shader-cache counter usually resets, but the fingerprint and its server-side count do not — private mode changes your hardware not at all.
  • Want out? “Forget this device” deletes your server record. There is no button that can clear the GPU shader cache from a web page, though — only your browser can.

The obfuscated VM

Production sensors rarely ship readable logic. They compile it to bytecode for a custom interpreter and bridge the platform calls out as host functions, so a reverse-engineer sees an opaque blob and a dispatch loop instead of named steps. Afterimage does the same: both routines are XOR-encoded, base64-packed bytecode, decoded at runtime and executed on a 16-opcode stack machine. Only the WebGL primitives — compile a shader, render the fingerprint scene — live outside the VM, reached through a numbered syscall, exactly as a real sensor bridges to the DOM and WebGL.

How the fingerprint works

Every GPU and driver reports itself a little differently. The VM reads your WebGL renderer and vendor strings plus a set of hardware limits and numeric-precision ranges, and folds them into a stable ID. Nothing is saved; the same value is recomputed from the hardware every visit, which is why clearing storage doesn't shake it.

To make one browser's fingerprint match another's, the key uses only cross-browser-stable inputs. The GPU model string is normalised to drop driver and Direct3D version numbers, and three browser-sensitive signals are deliberately left out of the key: the render pixel-hash (rounding can differ between graphics backends), the extension list, and the version-bearing WebGL version strings. That trades some uniqueness for stability, so identical hardware collides more readily — the honest cost of a fingerprint that survives a browser switch. The components panel above shows exactly which values feed the key, so you can compare them between Edge and Chrome.

How the counter works

Compiling a GPU shader is expensive, so the browser caches the compiled result on disk, keyed by the exact source. A cache hit compiles in a fraction of a millisecond; a miss takes tens of milliseconds — a readable bit. The counter's bytecode walks a row of slot shaders until the first miss, and that compile caches the slot, advancing the count by one. Reading and writing are the same act, which is what a cache you can't selectively erase allows.

The cross-browser count

The shader-cache counter can't leave its browser, so the cross-browser count lives on a server instead, keyed to your GPU fingerprint. Because two browsers on one machine compute the same fingerprint, they map to the same record and the count continues between them. It is keyed to the fingerprint alone — never your IP — so a VPN or a new network doesn't detach it.

Privacy is built in and worth stating plainly. The raw fingerprint never reaches the server; the key is a keyed hash (HMAC-SHA256 with a server secret), so the stored id is opaque and can't be reversed or precomputed by anyone without that secret. Only a count is stored — no IP, no user agent, no raw fingerprint. “Forget this device” deletes the record. Two honest caveats: the record is still a device-linked pseudonym, and GPU fingerprints are low-entropy, so two identical machines can land on the same key and share a count.

Isolation

None of this touches the page's JavaScript context. The VM, the bytecode, and WebGL all run in a Web Worker created from a Blob — its own realm and thread — with WebGL on an OffscreenCanvas transferred into it. The page only asks for a fingerprint or a visit and renders what comes back.

Honest limits

Results depend on GPU, driver, and browser. A browser with hardware acceleration off falls back to a software renderer (Microsoft Basic Render Driver, SwiftShader, llvmpipe), whose fingerprint reflects the rasterizer rather than the device and is shared across many machines — so it won't match a browser on the same computer that is using the real GPU. The shader-cache counter does not cross browsers or, usually, into private windows. This proves the mechanisms; it is not a hardened tracker.

Under the hood — ISA & disassembly

Instruction set (stack machine)

0 PUSH v   1 POP    2 DUP    3 ADD    4 SUB    5 MUL
6 DIV      7 MOD    8 XOR    9 LT    10 JMP a 11 JZ a
12 LOAD s 13 STORE s 14 SYS fn,argc 15 RET
syscalls: 0 missRef  1 hitRef  2 compileSlot(i)  5 fingerprint

record-a-visit — disassembly

SYS 0        ; missRef -> L0
SYS 1        ; hitRef  -> L1
L1 + (L0-L1)*0.5 -> L2       ; threshold
0 -> L3 (prior)   0 -> L4 (i)
LOOP:
  if !(i < 48) goto SAT
  t = compileSlot(i) -> L5   ; the compile also primes the slot
  if !(threshold < t) goto HIT
  return prior + 1           ; MISS = this visit's increment
HIT:
  prior++ ; i++ ; goto LOOP
SAT:
  return 48                  ; counter saturated

shipped bytecode (what a reverse-engineer sees first)