Skip to content

Add five signals and three detection rules - #82

Closed
proofofbots wants to merge 2 commits into
antoinevastel:masterfrom
proofofbots:hardware-and-tamper-signals
Closed

proofofbots wants to merge 2 commits into
antoinevastel:masterfrom
proofofbots:hardware-and-tamper-signals

Conversation

@proofofbots

Copy link
Copy Markdown

Add five signals and three detection rules

Three of these lean on state that follows the machine rather than the browser build, which is
awkward to spoof from a page, and one is a tamper probe.

Detection rules

hasTamperedNativeGetter (high) takes the hardwareConcurrency accessor off
Navigator.prototype, deletes the global Function and Object, and calls the getter's
toString(). Native code needs neither global to stringify itself. A getter installed from
JavaScript usually hides behind a hooked or proxied Function.prototype.toString, and those hooks
reach for the globals by name while they run, so the call throws.

Measured against a stock Playwright Chromium with different patches injected before page load:

Patch applied Flagged navigatorPropertyDescriptors
none no 00000
defineProperty getter, nothing else no 00000
getter wrapped in a Proxy no 00000
getter plus a hooked Function.prototype.toString yes 00000
getter plus a proxied Function.prototype.toString yes 00000

So it catches the shape puppeteer-extra-stealth ships and misses a bare defineProperty, which is
the honest boundary. Worth noting from the same runs: navigatorPropertyDescriptors reads 00000
on stock Chromium and on every patched variant, since those navigator properties are accessors and
the check tests res.value. It is not distinguishing anything on Chromium today.

The probe is synchronous on purpose, because the globals are missing until it returns, and it
restores them with defineProperty from a captured reference rather than by assignment. Assignment
would leave Function and Object enumerable on window, which is observable, and the restore
path cannot say Object.defineProperty while Object is deleted. Verified after collection:
globals back, non-enumerable, new Function(...) still works.

hasPdfViewerMismatch (medium) compares navigator.pdfViewerEnabled with the plugin array.
Chromium and Gecko both tie the two together: viewer on and five hardcoded PDF entries, or viewer
off and an empty list. Patches routinely rebuild one and leave the other. Headless Chromium reports
false with 0 plugins and headed reports true with 5, so neither trips it.

hasNoPlatformAuthenticator (low) reads whether the machine can answer a passkey itself, which
follows the hardware and the OS rather than the browser build. Scoped to Chromium desktop in a
secure context. Deliberately low severity: an Intel Mac without Touch ID and a Windows install
without Hello answer no as well.

Signals

  • device.audio — baseLatency, sampleRate, maxChannelCount from an AudioContext that is
    closed immediately. Nothing is rendered through it, no user gesture involved.
  • device.battery — getBattery() state, with the infinite times normalised to -1. Kept out
    of the fsid on purpose, since level and charge times move during a session.
  • browser.webauthn — getClientCapabilities() as a fixed-order bitmask, with
    isUserVerifyingPlatformAuthenticatorAvailable() as the fallback for browsers without it, plus
    the secure-context flag, without which an absent API says nothing.
  • browser.mediaCapabilities — decodingInfo for four video and two audio configurations, one
    digit each (1 supported, 2 smooth, 4 power efficient). canPlayType answers what the build can
    decode; this answers what the machine can decode in hardware. On the same laptop, headless
    Chromium reports 3033 and headed reports 7777.
  • browser.plugins.pdfViewerEnabled and five feature bits (sumPrecise, which was already
    collected but missing from the type, plus managed, storageBuckets, presentationRequest,
    virtualKeyboard).

fsid

New booleans are appended, so existing bit positions hold: detection 21 to 24 bits, automation 5 to
6, plugins 4 to 5, browser features 28 to 33. The section hashes do change, since the new signals
feed them, so fsids are not comparable across this boundary. Happy to gate that behind FS2 instead
if you would rather keep FS1 stable. The README bitmask reference is updated to match.

Testing

npx tsc --noEmit clean, 91 vitest tests pass (12 new, covering the three rules), and the Playwright
suite passes 72 across chromium, firefox, webkit and mobile-chromium.

@proofofbots proofofbots closed this by deleting the head repository Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant