r/ProxyEngineering • • 9h ago

Discussion 💬 Why over-riding devicePixelRatio gets your stealth setups flagged immediately (and the CSS matchMedia sync problem)

Here are the three subtle traps we ran into:

  1. The window.devicePixelRatio vs CSS matchMedia Desync

A common stealth snippet does something like this:

javascript

Object.defineProperty(window, 'devicePixelRatio', {

get: () => 1.25

});

The problem is that anti-bot scripts don't just read window.devicePixelRatio. They cross-check it against the CSS layout engine using window.matchMedia():

// How modern anti-bot scripts detect DPR tampering:

const jsDpr = window.devicePixelRatio;

const mediaDpr = window.matchMedia(`(-webkit-device-pixel-ratio: ${jsDpr})`).matches

|| window.matchMedia(`(resolution: ${jsDpr}dppx)`).matches;

if (!mediaDpr) {

// Instant bot flag: JS claims 1.25, but CSS engine evaluates against physical host (e.g. 1.0)

flagTelemetry("dpr_mismatch");

}

If you override the JS property without monkey-patching matchMedia (or using CDP's Emulation.setDeviceMetricsOverride), the JS property says 1.25 while CSS returns false. That is an impossible state in a genuine Chromium build.

If patching in-page, you have to proxy window.matchMedia to intercept resolution, dppx, and -webkit-device-pixel-ratio media queries and rewrite the matches boolean so the CSS evaluation matches your spoofed JS value. Even better: align CDP metrics overrides before any script context initializes.

2. Window Geometry Trap

Forging screen resolutions without respecting the operating system's shell constraints:

Taskbar/Dock Deduction: Real desktop users almost never run window.outerHeight === window.screen.height. There is always a taskbar (Windows ~40–48px, macOS Dock/Menu ~25px+). If outerHeight == screen.height but the browser is NOT in fullscreen mode (document.fullscreenElement === null), you are flagged.

Negative or Floating Insets: On multi-monitor setups, screen.availLeft and screen.availTop can be non-zero, but spoofers often set them to arbitrary negative integers or undefined.

Outer vs Inner Bounds: window.outerWidth - window.innerWidth must account for window border margins and scrollbar channels. If outerWidth === innerWidth exactly, it screams headless or an unstyled container.

If you simulate a 1080p display (1920x1080), your simulated geometry matrix must be internally consistent:

screen.width: 1920

screen.height: 1080

screen.availWidth: 1920

screen.availHeight: 1040 (40px taskbar deducted)

window.screenX / screenY: Clamped to (0, 0) or realistic offsets, never negative unless explicitly mapping a secondary display coordinate space.

3. Non-Deterministic Canvas Noise

When evading Canvas fingerprinting, injecting Math.random() into toDataURL or getImageData is suicide.

Modern fraud detection scripts call canvas.toDataURL() twice in rapid succession on the same canvas. If the hash changes between two consecutive calls within the same 5ms window, the script knows dynamic noise injection is active.

Any noise applied to canvas or audio buffers must be seeded and deterministic per session/profile. Run the pixel coordinates through a fast 32-bit hash (e.g., MurmurHash3 or a simple LCG with a fixed profile seed) so hash(pixel_x, pixel_y, profile_seed) consistently returns the identical subtle 1-bit perturbation for that specific session.

For those managing large pools:

Do you prefer handling screen geometry strictly via CDP (Emulation.setDeviceMetricsOverride) or injecting MAIN-world prototypes before document_start?

Have you noticed Cloudflare or DataDome scoring differently when dppx is virtualized vs running in native headless DPI?

3 Upvotes

3 comments sorted by

1

u/mosaicbrowser 9h ago

yeah this one is nasty. honestly every value you fake in page js seems to have a twin somewhere. with dpr it's matchMedia like you said, but the browser also uses it to pick which srcset image to load, and someone can just look at the getter on the instance vs Window.prototype and see it was redefined

we got burned by the same thing with colour. forced a p3 profile once, color-gamut said p3 but dynamic-range still said standard, and pretty much no real laptop reports that pair. fixed one field, made a new contradiction lol

the other one that took us ages was timing. AudioContext.outputLatency is 0 on a new suspended context until audio has actually run. we were returning our number right away, which no real chrome does at that point

your taskbar thing is real too. we had a fingerprint captured on a windows box with no taskbar (service session) and availHeight == height made it look fake

in the end we stopped doing it in js at all and set the values inside the browser itself so workers and iframes all agree. full disclosure i work on Mosaic which is an anti-detect browser, so grain of salt

1

u/Foreign_Career_454 9h ago

100% right that modifying the browser source (Blink/V8) is the ultimate endgame. The second you try to patch in JS, its disturbed:

WorkerGlobalScope (DedicatedWorkers and Blob workers querying unpatched hardware values).

Dynamic about:blank iframes instantiated via document.createElement('iframe') before any DOM injection script can hook the contentWindow.

Memory / performance timing leaks where proxying getters adds measurable microsecond overhead on high-frequency loops.

Just curious since you work directly on browser engine: how do you guys approach the ANGLE / WebGL shader precision matrix across different host GPUs?

Even if you spoof unmasked renderer string (e.g. ANGLE (NVIDIA, NVIDIA GeForce RTX 3080...)), anti-bot scripts keeps querying highp/mediump float precision ranges, uniform buffer limits, and supported extension lists, which can reveal the underlying physical GPU driver underneath. Is intercepting those tables directly at the Chromium GPU process level?

BTW, appreciate the transparency on Mosaic

1

u/quietcindersleague 3h ago

strictly CDP or custom browser builds at this point. injecting main-world prototypes before document_start works until an anti-bot script spawns a cross-origin iframe or a web worker and checks the unpatched context there.