{"id":492099,"date":"2026-08-25T09:11:44","date_gmt":"2026-08-25T09:11:44","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=492099"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=492099","title":{"rendered":"I Built a Browser That Passes CreepJS \u2014 Here&#8217;s What Still Catches Me"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>CreepJS is\u00a0the test everyone in\u00a0this space quotes and almost nobody reads carefully. It\u2019s an\u00a0open-source page that fingerprints your browser and then\u00a0\u2014 this is\u00a0the part that matters\u00a0\u2014 checks whether your browser is\u00a0<em>lying<\/em>. Not whether your values are unusual. Whether they\u2019re internally possible.<\/p>\n<p>I\u00a0spent a\u00a0long stretch getting a\u00a0browser through\u00a0it. It\u00a0passes now, mostly. I\u00a0want to\u00a0write down what actually moved the needle, and then be\u00a0specific about the part that still catches\u00a0me, because a\u00a0post that ends with \u00aband then it\u00a0was perfect\u00bb is\u00a0a\u00a0post you shouldn\u2019t believe.<\/p>\n<h3>What CreepJS is\u00a0really testing<\/h3>\n<p>Most fingerprint pages collect values and show you a\u00a0hash. CreepJS collects values and then attacks them:<\/p>\n<p>\u2014\u00a0<strong>Native function integrity.<\/strong> If\u00a0you replace <code>HTMLCanvasElement. prototype. toDataURL<\/code> with your own function, the replacement\u2019s <code>toString()<\/code> no\u00a0longer produces the native code string, its <code>name<\/code> may be\u00a0wrong, its property descriptor differs, and the prototype chain can be\u00a0walked to\u00a0compare. There\u2019s an\u00a0entire family of\u00a0these checks. \u2014\u00a0<strong>Cross-realm consistency.<\/strong> It\u00a0asks the same questions from the main thread, from a\u00a0Web Worker, and from an\u00a0iframe. Each of\u00a0those is\u00a0a\u00a0fresh set of\u00a0native objects. An\u00a0override that lives in\u00a0one realm is\u00a0absent in\u00a0the others, and the disagreement is\u00a0the finding. \u2014\u00a0<strong>Derived-value coherence.<\/strong> Fonts, screen metrics, <code>Intl<\/code> output, math implementations, audio and canvas rendering are all products of\u00a0the machine underneath. It\u00a0cross-checks the story they collectively tell against the one your\u00a0UA claims. \u2014\u00a0<strong>Lie tallies.<\/strong> It\u00a0doesn\u2019t just fail you; it\u00a0counts how many distinct lies it\u00a0caught and shows them by\u00a0name. That\u2019s the most useful debugging surface in\u00a0this entire field, and it\u2019s the reason\u00a0I recommend the page even to\u00a0people who never ship a\u00a0spoofed browser.<\/p>\n<p>The consequence is\u00a0brutal and clarifying: <strong>every technique based on\u00a0patching JavaScript from userland is\u00a0visible to\u00a0this page.<\/strong> Not \u00abmight be\u00a0visible with effort.\u00bb Visible by\u00a0default, by\u00a0design. If\u00a0your approach is\u00a0an\u00a0init script that redefines getters before page scripts run, CreepJS will list\u00a0it.<\/p>\n<h3>So\u00a0the work moved into the engine<\/h3>\n<p>That\u2019s why the version that passes doesn\u2019t inject anything. The values are produced by\u00a0the browser build itself, from a\u00a0per-profile config file the process reads at\u00a0startup. When a\u00a0page asks for the GPU renderer string, nothing intercepts the call and rewrites the answer\u00a0\u2014 the answer <em>is<\/em>\u00a0the configured value, at\u00a0the layer that would normally read the driver. There\u2019s no\u00a0patched function to\u00a0inspect, no\u00a0descriptor mismatch, and worker threads and iframes report the same thing as\u00a0the main thread because they\u2019re all asking the same underlying implementation.<\/p>\n<p>The things that made the biggest difference, roughly in\u00a0order:<\/p>\n<p><strong>1. Making the persona self-consistent before caring about any individual value.<\/strong> A\u00a0profile gets one generated identity\u00a0\u2014 OS, Chrome major version, GPU vendor\/renderer pair, screen geometry, device pixel ratio, language list, timezone\u00a0\u2014 and every surface derives from that one record. Values invented independently are how you get an\u00a0NVIDIA renderer on\u00a0a\u00a0persona with an\u00a0Intel-only screen resolution, or <code>Intl<\/code> reporting a\u00a0timezone that contradicts the UA\u2019s platform.<\/p>\n<p><strong>2. Deriving network-adjacent values from measurement, not from taste.<\/strong> <code>navigator. connection<\/code> exposes <code>rtt<\/code>, <code>downlink<\/code> and <code>effectiveType<\/code>. Real Chrome computes all three from one observed round-trip using published thresholds. So\u00a0I\u00a0measure the actual round trip through the profile\u2019s proxy, round it\u00a0to\u00a0a\u00a0grid, and run it\u00a0through the same threshold table Chromium uses. The measured value is\u00a0biased high\u00a0\u2014 it\u00a0includes DNS, connect and TLS\u00a0\u2014 and\u00a0I deliberately don\u2019t compensate, because a\u00a0slightly pessimistic <code>effectiveType<\/code> is\u00a0a\u00a0real value combination, while a\u00a0hand-tuned one is\u00a0not.<\/p>\n<p><strong>3. Deriving locale and timezone from the exit IP.<\/strong> Not from the host. Not from a\u00a0setting the user forgot to\u00a0change. The browser looks up\u00a0where its own traffic actually comes out and configures itself to\u00a0match.<\/p>\n<p><strong>4. Removing automation artifacts that aren\u2019t fingerprint surface at\u00a0all.<\/strong> The one I\u2019m most glad I\u00a0fixed: proxy authentication. The easy implementation is\u00a0a\u00a0small extension that answers the auth challenge. It\u00a0works perfectly, and it\u00a0leaves an\u00a0enumerable entry in\u00a0the browser\u2019s extension list\u00a0\u2014 a\u00a0\u00abProxy Auth\u00bb item in\u00a0a\u00a0browser that\u2019s supposed to\u00a0look like a\u00a0stock consumer install. Now the credentials are handled in\u00a0the network layer of\u00a0the engine itself: SOCKS5 sub-negotiation at\u00a0the socket level, and HTTP proxy auth answered inside the browser\u2019s own auth path. No\u00a0extension, no\u00a0JavaScript, and the password never reaches a\u00a0renderer process.<\/p>\n<h3>The part that still catches me<\/h3>\n<p>Here\u2019s the one\u00a0I haven\u2019t fixed, stated plainly because you\u2019ll hit it\u00a0too if\u00a0you go\u00a0down this road.<\/p>\n<p>On\u00a0a\u00a0Windows host with a\u00a0Chinese system locale, CreepJS can still surface a\u00a0CJK UI\u00a0font in\u00a0the font results\u00a0\u2014 while the persona claims to\u00a0be\u00a0a\u00a0US\u00a0English machine. Everything on\u00a0the fingerprint <em>enumeration<\/em> side is\u00a0clean: the font list is\u00a0what a\u00a0stock US\u00a0Windows install would report. The leak comes through CSS system font keywords (<code>menu<\/code>, <code>small-caption<\/code>, <code>status-bar<\/code>). Those resolve through a\u00a0different code path than the one I\u00a0replaced, so\u00a0the values reported are still the host\u2019s real\u00a0UI font, and the page measures the rendering rather than asking for a\u00a0name.<\/p>\n<p>Two lessons in\u00a0that, both generalizable:<\/p>\n<p>\u2014\u00a0<strong>The spoofing surface and the rendering surface are different surfaces.<\/strong> You can win the first one completely and lose on\u00a0the second, because detectors don\u2019t ask \u00abwhat fonts do\u00a0you have,\u00bb they measure how text renders. \u2014\u00a0<strong>The fix has to\u00a0be\u00a0in\u00a0the same code path the value comes from.<\/strong> I\u00a0patched something plausible-looking that turned out never to\u00a0be\u00a0called on\u00a0that platform, and\u00a0I believed it\u00a0was fixed for weeks because the enumeration output looked right.<\/p>\n<p>A\u00a0second one from a\u00a0different platform, in\u00a0the same spirit. On\u00a0macOS, the browser\u2019s internationalization layer takes the locale from the\u00a0OS, not from the command-line language flag\u00a0\u2014 so\u00a0a\u00a0persona that claims US\u00a0English while the host is\u00a0set to\u00a0something else gets a\u00a0contradictory <code>Intl<\/code> result. The workaround is\u00a0to\u00a0pass the locale in\u00a0the form macOS reads. It\u00a0works, and it\u00a0costs you: the argument format isn\u2019t recognized as\u00a0a\u00a0flag by\u00a0the command-line parser, so\u00a0it\u00a0gets treated as\u00a0a\u00a0URL and the browser opens a\u00a0junk tab on\u00a0every start. The launcher closes that tab after connecting. For a\u00a0while\u00a0I looked for a\u00a0cleaner trick that would avoid it\u00a0entirely; the one\u00a0I tried broke session restore in\u00a0a\u00a0way that was much worse than a\u00a0stray tab, and\u00a0I rolled it\u00a0back.<\/p>\n<p>That\u2019s the actual texture of\u00a0this work. Not clever bypasses\u00a0\u2014 a\u00a0long list of\u00a0platform-specific paths, each of\u00a0which will produce a\u00a0contradiction if\u00a0you handle it\u00a0approximately.<\/p>\n<h3>What passing does and doesn\u2019t buy you<\/h3>\n<p>The most honest independent test I\u2019ve seen scored several commercial anti-detect browsers on\u00a0consistency and found the marketing ranking roughly inverted: two products sold as\u00a0top-tier landed mid-pack, and a\u00a0cheaper one topped the table. The author\u2019s own caveat is\u00a0the right one to\u00a0end\u00a0on: <strong>CreepJS is\u00a0an\u00a0educational open-source project, and passing it\u00a0is\u00a0not the same as\u00a0not getting banned.<\/strong><\/p>\n<p>Real production defenses layer things CreepJS never touches\u00a0\u2014 TLS and HTTP\/2 fingerprints below the\u00a0JS layer, IP\u00a0reputation, account history, behavioral models built from the first request. A\u00a0profile can score beautifully and still get flagged for arriving from a\u00a0datacenter\u00a0IP with no\u00a0history and mouse-free navigation.<\/p>\n<p>What passing gets you is\u00a0the elimination of\u00a0an\u00a0entire class of\u00a0instant, cheap disqualification. It\u00a0means the contradictions are gone, so\u00a0when something still fails you can go\u00a0look at\u00a0the layers that actually decide. That\u2019s worth a\u00a0lot. It\u00a0just isn\u2019t a\u00a0finish line, and I\u2019d rather say so\u00a0than sell you the version where it\u00a0is.<\/p>\n<hr\/>\n<p>\u0412\u043f\u0435\u0440\u0432\u044b\u0435 \u043e\u043f\u0443\u0431\u043b\u0438\u043a\u043e\u0432\u0430\u043d\u043e \u0437\u0434\u0435\u0441\u044c: <a href=\"https:\/\/antibrow.com\/blog\/passing-creepjs-what-still-catches-me\" rel=\"noopener noreferrer nofollow\">https:\/\/antibrow.com\/blog\/passing-creepjs-what-still-catches-me<\/a><\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1074186\/\">https:\/\/habr.com\/ru\/articles\/1074186\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>CreepJS is\u00a0the test everyone in\u00a0this space quotes and almost nobody reads carefully. It\u2019s an\u00a0open-source page that fingerprints your browser and then\u00a0\u2014 this is\u00a0the part that matters\u00a0\u2014 checks whether your browser is\u00a0lying. Not whether your values are unusual. Whether they\u2019re internally possible.I\u00a0spent a\u00a0long stretch getting a\u00a0browser through\u00a0it. It\u00a0passes now, mostly. I\u00a0want to\u00a0write down what actually moved the needle, and then be\u00a0specific about the part that still catches\u00a0me, because a\u00a0post that ends with \u00aband then it\u00a0was perfect\u00bb is\u00a0a\u00a0post you shouldn\u2019t believe.What CreepJS is\u00a0really testingMost fingerprint pages collect values and show you a\u00a0hash. CreepJS collects values and then attacks them:\u2014\u00a0Native function integrity. If\u00a0you replace HTMLCanvasElement. prototype. toDataURL with your own function, the replacement\u2019s toString() no\u00a0longer produces the native code string, its name may be\u00a0wrong, its property descriptor differs, and the prototype chain can be\u00a0walked to\u00a0compare. There\u2019s an\u00a0entire family of\u00a0these checks. \u2014\u00a0Cross-realm consistency. It\u00a0asks the same questions from the main thread, from a\u00a0Web Worker, and from an\u00a0iframe. Each of\u00a0those is\u00a0a\u00a0fresh set of\u00a0native objects. An\u00a0override that lives in\u00a0one realm is\u00a0absent in\u00a0the others, and the disagreement is\u00a0the finding. \u2014\u00a0Derived-value coherence. Fonts, screen metrics, Intl output, math implementations, audio and canvas rendering are all products of\u00a0the machine underneath. It\u00a0cross-checks the story they collectively tell against the one your\u00a0UA claims. \u2014\u00a0Lie tallies. It\u00a0doesn\u2019t just fail you; it\u00a0counts how many distinct lies it\u00a0caught and shows them by\u00a0name. That\u2019s the most useful debugging surface in\u00a0this entire field, and it\u2019s the reason\u00a0I recommend the page even to\u00a0people who never ship a\u00a0spoofed browser.The consequence is\u00a0brutal and clarifying: every technique based on\u00a0patching JavaScript from userland is\u00a0visible to\u00a0this page. Not \u00abmight be\u00a0visible with effort.\u00bb Visible by\u00a0default, by\u00a0design. If\u00a0your approach is\u00a0an\u00a0init script that redefines getters before page scripts run, CreepJS will list\u00a0it.So\u00a0the work moved into the engineThat\u2019s why the version that passes doesn\u2019t inject anything. The values are produced by\u00a0the browser build itself, from a\u00a0per-profile config file the process reads at\u00a0startup. When a\u00a0page asks for the GPU renderer string, nothing intercepts the call and rewrites the answer\u00a0\u2014 the answer is\u00a0the configured value, at\u00a0the layer that would normally read the driver. There\u2019s no\u00a0patched function to\u00a0inspect, no\u00a0descriptor mismatch, and worker threads and iframes report the same thing as\u00a0the main thread because they\u2019re all asking the same underlying implementation.The things that made the biggest difference, roughly in\u00a0order:1. Making the persona self-consistent before caring about any individual value. A\u00a0profile gets one generated identity\u00a0\u2014 OS, Chrome major version, GPU vendor\/renderer pair, screen geometry, device pixel ratio, language list, timezone\u00a0\u2014 and every surface derives from that one record. Values invented independently are how you get an\u00a0NVIDIA renderer on\u00a0a\u00a0persona with an\u00a0Intel-only screen resolution, or Intl reporting a\u00a0timezone that contradicts the UA\u2019s platform.2. Deriving network-adjacent values from measurement, not from taste. navigator. connection exposes rtt, downlink and effectiveType. Real Chrome computes all three from one observed round-trip using published thresholds. So\u00a0I\u00a0measure the actual round trip through the profile\u2019s proxy, round it\u00a0to\u00a0a\u00a0grid, and run it\u00a0through the same threshold table Chromium uses. The measured value is\u00a0biased high\u00a0\u2014 it\u00a0includes DNS, connect and TLS\u00a0\u2014 and\u00a0I deliberately don\u2019t compensate, because a\u00a0slightly pessimistic effectiveType is\u00a0a\u00a0real value combination, while a\u00a0hand-tuned one is\u00a0not.3. Deriving locale and timezone from the exit IP. Not from the host. Not from a\u00a0setting the user forgot to\u00a0change. The browser looks up\u00a0where its own traffic actually comes out and configures itself to\u00a0match.4. Removing automation artifacts that aren\u2019t fingerprint surface at\u00a0all. The one I\u2019m most glad I\u00a0fixed: proxy authentication. The easy implementation is\u00a0a\u00a0small extension that answers the auth challenge. It\u00a0works perfectly, and it\u00a0leaves an\u00a0enumerable entry in\u00a0the browser\u2019s extension list\u00a0\u2014 a\u00a0\u00abProxy Auth\u00bb item in\u00a0a\u00a0browser that\u2019s supposed to\u00a0look like a\u00a0stock consumer install. Now the credentials are handled in\u00a0the network layer of\u00a0the engine itself: SOCKS5 sub-negotiation at\u00a0the socket level, and HTTP proxy auth answered inside the browser\u2019s own auth path. No\u00a0extension, no\u00a0JavaScript, and the password never reaches a\u00a0renderer process.The part that still catches meHere\u2019s the one\u00a0I haven\u2019t fixed, stated plainly because you\u2019ll hit it\u00a0too if\u00a0you go\u00a0down this road.On\u00a0a\u00a0Windows host with a\u00a0Chinese system locale, CreepJS can still surface a\u00a0CJK UI\u00a0font in\u00a0the font results\u00a0\u2014 while the persona claims to\u00a0be\u00a0a\u00a0US\u00a0English machine. Everything on\u00a0the fingerprint enumeration side is\u00a0clean: the font list is\u00a0what a\u00a0stock US\u00a0Windows install would report. The leak comes through CSS system font keywords (menu, small-caption, status-bar). Those resolve through a\u00a0different code path than the one I\u00a0replaced, so\u00a0the values reported are still the host\u2019s real\u00a0UI font, and the page measures the rendering rather than asking for a\u00a0name.Two lessons in\u00a0that, both generalizable:\u2014\u00a0The spoofing surface and the rendering surface are different surfaces. You can win the first one completely and lose on\u00a0the second, because detectors don\u2019t ask \u00abwhat fonts do\u00a0you have,\u00bb they measure how text renders. \u2014\u00a0The fix has to\u00a0be\u00a0in\u00a0the same code path the value comes from. I\u00a0patched something plausible-looking that turned out never to\u00a0be\u00a0called on\u00a0that platform, and\u00a0I believed it\u00a0was fixed for weeks because the enumeration output looked right.A\u00a0second one from a\u00a0different platform, in\u00a0the same spirit. On\u00a0macOS, the browser\u2019s internationalization layer takes the locale from the\u00a0OS, not from the command-line language flag\u00a0\u2014 so\u00a0a\u00a0persona that claims US\u00a0English while the host is\u00a0set to\u00a0something else gets a\u00a0contradictory Intl result. The workaround is\u00a0to\u00a0pass the locale in\u00a0the form macOS reads. It\u00a0works, and it\u00a0costs you: the argument format isn\u2019t recognized as\u00a0a\u00a0flag by\u00a0the command-line parser, so\u00a0it\u00a0gets treated as\u00a0a\u00a0URL and the browser opens a\u00a0junk tab on\u00a0every start. The launcher closes that tab after connecting. For a\u00a0while\u00a0I looked for a\u00a0cleaner trick that would avoid it\u00a0entirely; the one\u00a0I tried broke session restore in\u00a0a\u00a0way that was much worse than a\u00a0stray tab, and\u00a0I rolled it\u00a0back.That\u2019s the actual texture of\u00a0this work. Not clever bypasses\u00a0\u2014 a\u00a0long list of\u00a0platform-specific paths, each of\u00a0which will produce a\u00a0contradiction if\u00a0you handle it\u00a0approximately.What passing does and doesn\u2019t buy youThe most honest independent test I\u2019ve seen scored several commercial anti-detect browsers on\u00a0consistency and found the marketing ranking roughly inverted: two products sold as\u00a0top-tier landed mid-pack, and a\u00a0cheaper one topped the table. The author\u2019s own caveat is\u00a0the right one to\u00a0end\u00a0on: CreepJS is\u00a0an\u00a0educational open-source project, and passing it\u00a0is\u00a0not the same as\u00a0not getting banned.Real production defenses layer things CreepJS never touches\u00a0\u2014 TLS and HTTP\/2 fingerprints below the\u00a0JS layer, IP\u00a0reputation, account history, behavioral models built from the first request. A\u00a0profile can score beautifully and still get flagged for arriving from a\u00a0datacenter\u00a0IP with no\u00a0history and mouse-free navigation.What passing gets you is\u00a0the elimination of\u00a0an\u00a0entire class of\u00a0instant, cheap disqualification. It\u00a0means the contradictions are gone, so\u00a0when something still fails you can go\u00a0look at\u00a0the layers that actually decide. That\u2019s worth a\u00a0lot. It\u00a0just isn\u2019t a\u00a0finish line, and I\u2019d rather say so\u00a0than sell you the version where it\u00a0is.\u0412\u043f\u0435\u0440\u0432\u044b\u0435 \u043e\u043f\u0443\u0431\u043b\u0438\u043a\u043e\u0432\u0430\u043d\u043e \u0437\u0434\u0435\u0441\u044c: https:\/\/antibrow.com\/blog\/passing-creepjs-what-still-catches-me\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 https:\/\/habr.com\/ru\/articles\/1074186\/<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-492099","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/492099","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=492099"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/492099\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=492099"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=492099"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=492099"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}