<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>안티 핑거프린팅 on monkshark.dev</title><link>https://monkshark.github.io/tags/%EC%95%88%ED%8B%B0-%ED%95%91%EA%B1%B0%ED%94%84%EB%A6%B0%ED%8C%85/</link><description>Recent content in 안티 핑거프린팅 on monkshark.dev</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Mon, 15 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://monkshark.github.io/tags/%EC%95%88%ED%8B%B0-%ED%95%91%EA%B1%B0%ED%94%84%EB%A6%B0%ED%8C%85/index.xml" rel="self" type="application/rss+xml"/><item><title>#3 - 위장을 들키지 않기, 그리고 확장의 천장</title><link>https://monkshark.github.io/p/masque-hardening/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/masque-hardening/</guid><description>&lt;p&gt;값을 바꾸는 것보다 어려운 건, 바꿨다는 사실 자체를 숨기는 것이었다. 그리고 표면을 하나 막을 때마다 새로운 우회로가 보였다. 그 군비경쟁, 그리고 결국 확장으로는 못 넘는 천장에 대한 이야기다.&lt;/p&gt;
&lt;h2 id="위장은-흔적을-남긴다"&gt;&lt;a href="#%ec%9c%84%ec%9e%a5%ec%9d%80-%ed%9d%94%ec%a0%81%ec%9d%84-%eb%82%a8%ea%b8%b4%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;위장은 흔적을 남긴다
&lt;/h2&gt;&lt;p&gt;값만 덮으면 끝이 아니다. 수집기는 &amp;ldquo;이 함수가 네이티브인가&amp;quot;를 물어볼 수 있다. 우리가 &lt;code&gt;getParameter&lt;/code&gt;를 갈아끼우면 &lt;code&gt;WebGLRenderingContext.prototype.getParameter.toString()&lt;/code&gt;이 &lt;code&gt;[native code]&lt;/code&gt;가 아니라 우리 소스를 뱉는다. 그 순간 위장이 들킨다.&lt;/p&gt;
&lt;p&gt;그래서 &lt;code&gt;Function.prototype.toString&lt;/code&gt; 자체를 패치해, 우리가 손댄 함수는 네이티브처럼 보고하게 했다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;WeakMap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nativeToString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toString&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;nativeToString&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;손댄 함수마다 &lt;code&gt;map.set(fn, 'function getParameter() { [native code] }')&lt;/code&gt;로 등록해 두는 식이다. 흔적은 더 있었다. 초기엔 &lt;code&gt;window.__masque&lt;/code&gt;라는 전역 마커를 심어 뒀는데, 이건 말 그대로 &amp;ldquo;나 Masque 켜져 있어요&amp;quot;라고 광고하는 셈이라 지우고 클로저 WeakSet으로 옮겼다. 속성도 인스턴스가 아니라 원래 네이티브 getter가 사는 프로토타입에 정의해, descriptor 위치까지 진짜와 맞췄다.&lt;/p&gt;
&lt;h2 id="iframe과-worker로-새어나간다"&gt;&lt;a href="#iframe%ea%b3%bc-worker%eb%a1%9c-%ec%83%88%ec%96%b4%eb%82%98%ea%b0%84%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;iframe과 worker로 새어나간다
&lt;/h2&gt;&lt;p&gt;페이지가 직접 &lt;code&gt;navigator&lt;/code&gt;를 안 읽고, 동적으로 만든 &lt;code&gt;about:blank&lt;/code&gt; iframe의 &lt;code&gt;contentWindow.navigator&lt;/code&gt;를 읽으면 그 프레임은 우리 손이 안 닿은 진짜 값을 준다. 그래서 &lt;code&gt;contentWindow&lt;/code&gt;·&lt;code&gt;contentDocument&lt;/code&gt; 접근자를 후킹해, 자식 프레임에 접근하는 순간 거기에도 위장을 다시 입혔다.&lt;/p&gt;
&lt;p&gt;Web Worker는 더 까다로웠다. 워커는 자기만의 realm이라 MAIN world 주입이 안 닿는다. &lt;code&gt;new Worker(url)&lt;/code&gt;을 가로채, 위장 프리루드를 앞에 붙인 블롭으로 감싸 원본을 &lt;code&gt;importScripts&lt;/code&gt;하게 했다. navigator·타임존·WebGL·OffscreenCanvas까지 워커 안에서도 같은 페르소나로 맞췄다. 단 이건 동일 출처·CORS 워커에서만 된다 — 이 한계는 뒤에서 다시 나온다.&lt;/p&gt;
&lt;h2 id="표면은-끝이-없다"&gt;&lt;a href="#%ed%91%9c%eb%a9%b4%ec%9d%80-%eb%81%9d%ec%9d%b4-%ec%97%86%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;표면은 끝이 없다
&lt;/h2&gt;&lt;p&gt;하나를 막으면 다음이 보였다. 그리고 앞서 본 대로, 막다 만 표면은 새로운 모순을 만든다.&lt;/p&gt;
&lt;p&gt;타임존이 그랬다. &lt;code&gt;getTimezoneOffset&lt;/code&gt;과 &lt;code&gt;Intl&lt;/code&gt;은 뉴욕으로 바꿔놨는데 &lt;code&gt;new Date().toString()&lt;/code&gt;은 여전히 &amp;ldquo;Korean Standard Time&amp;quot;을 흘리고 있었다. 우리가 직접 모순을 만든 것이다. 그래서 &lt;code&gt;Date.prototype&lt;/code&gt;의 &lt;code&gt;toString&lt;/code&gt;·&lt;code&gt;toLocaleString&lt;/code&gt; 계열까지 페르소나 타임존 기준으로 다시 짰다(서머타임은 &lt;code&gt;Intl&lt;/code&gt;로 계산). 그 외에도 canvas·audio·OffscreenCanvas·AnalyserNode 파블링, plugins·mimeTypes, mediaDevices, connection, speechSynthesis 음성 목록, storage quota, keyboard 레이아웃, WebGPU 어댑터, 배터리, WebGL 확장 목록까지 — 표면별 토글로 하나씩 덮어 나갔다.&lt;/p&gt;
&lt;h2 id="폰트는-목록을-못-바꾼다-측정값만-흔든다"&gt;&lt;a href="#%ed%8f%b0%ed%8a%b8%eb%8a%94-%eb%aa%a9%eb%a1%9d%ec%9d%84-%eb%aa%bb-%eb%b0%94%ea%be%bc%eb%8b%a4-%ec%b8%a1%ec%a0%95%ea%b0%92%eb%a7%8c-%ed%9d%94%eb%93%a0%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;폰트는 목록을 못 바꾼다, 측정값만 흔든다
&lt;/h2&gt;&lt;p&gt;폰트는 엔트로피가 큰데 위험했다. 설치된 폰트 목록을 위조하면 그 폰트에 의존하는 사이트가 깨진다. 그래서 Brave식으로, 목록과 렌더링은 그대로 두고 폰트 탐지에 쓰이는 측정 API만 흔들었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;getBoundingClientRect&lt;/code&gt;와 &lt;code&gt;measureText&lt;/code&gt;의 반환값에, 측정값과 출처를 키로 한 sub-pixel 노이즈를 더했다. 같은 페이지에선 같은 값이 나와 레이아웃이 흔들리거나 재측정으로 들키지 않고, 사이트가 바뀌면 노이즈가 달라져 정밀 측정 벡터로 추적당하는 걸 끊는다. 정수인 &lt;code&gt;offsetWidth&lt;/code&gt;는 일부러 안 건드렸다 — 레이아웃을 깨니까. 대신 솔직히 적었다. 이 방식은 정밀 벡터는 깨지만 &amp;ldquo;그 폰트가 설치돼 있나&amp;quot;라는 불리언까지 완전히 가리진 못한다.&lt;/p&gt;
&lt;h2 id="확장의-천장"&gt;&lt;a href="#%ed%99%95%ec%9e%a5%ec%9d%98-%ec%b2%9c%ec%9e%a5" class="header-anchor"&gt;&lt;/a&gt;확장의 천장
&lt;/h2&gt;&lt;p&gt;밀고 나가다 보니 확장으로는 절대 못 넘는 벽이 분명해졌다.&lt;/p&gt;
&lt;p&gt;교차 출처 Worker는 못 막는다. 깔끔히 하려면 워커 스크립트의 응답 본문을 고쳐 프리루드를 끼워야 하는데, MV3의 declarativeNetRequest는 응답 본문 수정을 지원하지 않는다. 완전한 탐지 회피도 원리상 불가능하다 — 같은 realm에서 JS로 JS를 속이는 한 잔흔이 남는다. IP·네트워크는 아예 손이 안 닿고, &amp;ldquo;거대한 동일 사용자 군중&amp;quot;도 못 만든다.&lt;/p&gt;
&lt;p&gt;이건 전부 엔진 레벨의 영역이다. Brave는 Chromium을 포크해 C++에서 farbling을 구현하고, Tor는 엔진에 탐지 회피를 넣고 그 위에 Tor 네트워크와 수백만 동일 사용자를 얹는다. 확장은 같은 realm 안의 차선책일 수밖에 없다. 그래서 README에도 &amp;ldquo;더 강한 게 필요하면 Mullvad나 Tor를 써라&amp;quot;라고 적어 뒀다.&lt;/p&gt;
&lt;h2 id="깨지면-끌-수-있게"&gt;&lt;a href="#%ea%b9%a8%ec%a7%80%eb%a9%b4-%eb%81%8c-%ec%88%98-%ec%9e%88%ea%b2%8c" class="header-anchor"&gt;&lt;/a&gt;깨지면 끌 수 있게
&lt;/h2&gt;&lt;p&gt;장치 정보로 거짓말하는 이상, 그 정보가 진짜 필요한 사이트는 깨진다. 이건 해결이 아니라 트레이드오프라, 사용자가 조절할 수 있게 만드는 쪽으로 갔다.&lt;/p&gt;
&lt;p&gt;표면별 토글, 도메인별 예외, 그리고 타임존·언어·코어·메모리·DPR을 직접 고르는 override(유효한 값만 드롭다운으로). Worker 하드닝처럼 사이트를 깨뜨릴 수 있는 건 끄기 쉽게 뒀고, &amp;ldquo;사용자 스크립트 허용&amp;quot;이 꺼져 있으면 배너로 안내한다. 완벽히 숨기는 것보다, 어디까지 숨길지를 사용자가 정하게 하는 게 현실적이었다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;3부 내내 한 일은 결국 두 가지였다. 표면을 하나씩 더 덮고, 덮었다는 흔적을 지우는 것. 그러다 만난 천장은 코드를 더 잘 짜서 넘는 게 아니라, 레이어 자체를 바꿔야(엔진을 포크하거나 Tor를 쓰거나) 넘는 거였다. 확장으로 할 수 있는 건 거의 다 했다는 결론과, 그게 어디까지인지를 정직하게 적어두는 것 — 처음에 정한 best-effort라는 약속을 마지막까지 지킨 셈이다.&lt;/p&gt;</description></item><item><title>#1 - 어설픈 위장은 오히려 더 튄다</title><link>https://monkshark.github.io/p/masque-problem/</link><pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/masque-problem/</guid><description>&lt;p&gt;Masque는 브라우저가 내보내는 지문(fingerprint) 표면을 하나의 일관된 가짜 정체성으로 갈아끼우는 Chrome 확장이다. navigator·screen·WebGL·canvas·타임존 같은 JavaScript 값부터 User-Agent 같은 HTTP 헤더까지, 한 페르소나에서 함께 바꿔서 &amp;ldquo;JS에서 읽히는 값&amp;quot;과 &amp;ldquo;실제로 나가는 헤더&amp;quot;가 서로 맞물리게 한다. 한 줄로 줄이면, 어설프게 한 군데만 가리는 게 아니라 전부 같이 가리는 도구다.&lt;/p&gt;
&lt;h2 id="지문-수집은-한-군데만-보지-않는다"&gt;&lt;a href="#%ec%a7%80%eb%ac%b8-%ec%88%98%ec%a7%91%ec%9d%80-%ed%95%9c-%ea%b5%b0%eb%8d%b0%eb%a7%8c-%eb%b3%b4%ec%a7%80-%ec%95%8a%eb%8a%94%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;지문 수집은 한 군데만 보지 않는다
&lt;/h2&gt;&lt;p&gt;브라우저 정보나 화면 크기를 가린다고 하면 흔히 User-Agent 한 줄 바꾸는 확장을 떠올린다. 그런데 지문 수집은 그렇게 한 군데만 보지 않는다. 화면 해상도, CPU 코어 수, 메모리, 그래픽카드 이름, 타임존, 폰트, 캔버스 렌더링 결과까지 수십 개 신호를 모아 한 사람을 특정한다. 그래서 목표는 &amp;ldquo;정보를 지운다&amp;quot;가 아니라 &amp;ldquo;일관된 다른 사람으로 보이게 한다&amp;quot;가 됐다.&lt;/p&gt;
&lt;h2 id="어설픈-위장은-오히려-더-튄다"&gt;&lt;a href="#%ec%96%b4%ec%84%a4%ed%94%88-%ec%9c%84%ec%9e%a5%ec%9d%80-%ec%98%a4%ed%9e%88%eb%a0%a4-%eb%8d%94-%ed%8a%84%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;어설픈 위장은 오히려 더 튄다
&lt;/h2&gt;&lt;p&gt;여기서 가장 중요한 깨달음이 나왔다. 한 군데만 바꾸면 더 특이해진다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;navigator.userAgent&lt;/code&gt;는 Windows라고 말하는데, HTTP &lt;code&gt;User-Agent&lt;/code&gt; 헤더는 진짜 OS를 흘리고, 타임존은 서울인데 언어는 미국이고, WebGL 렌더러는 실제 노트북 GPU를 그대로 노출한다면 — 그 모순들의 조합 자체가 세상에 거의 없는 희귀한 신호가 된다. 가리려다 오히려 도장을 찍는 꼴이다.&lt;/p&gt;
&lt;p&gt;그래서 원칙을 박았다. 관련된 모든 표면을 하나의 페르소나 프로필에서 함께 바꾼다. 교차 검증을 당해도 정체성이 무너지지 않게.&lt;/p&gt;
&lt;h2 id="숨기는-게-아니라-갈아끼운다"&gt;&lt;a href="#%ec%88%a8%ea%b8%b0%eb%8a%94-%ea%b2%8c-%ec%95%84%eb%8b%88%eb%9d%bc-%ea%b0%88%ec%95%84%eb%81%bc%ec%9a%b4%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;숨기는 게 아니라 갈아끼운다
&lt;/h2&gt;&lt;p&gt;방향은 &amp;ldquo;노이즈로 흩뿌리기&amp;quot;가 아니라 &amp;ldquo;그럴듯한 실재 기기로 위장하기&amp;quot;였다. 값을 무작위로 흔들면 그 무작위 조합이 또 나만의 지문이 된다. 같은 모습을 쓰는 군중이 있어야 숨는데, 혼자 무작위면 군중이 없다.&lt;/p&gt;
&lt;p&gt;그래서 Windows·Chrome, macOS·Chrome, Android·Chrome 같은 흔하고 내부적으로 앞뒤가 맞는 프로필을 미리 만들어 두고, 그중 하나를 통째로 입는 방식으로 갔다. UA·플랫폼·화면·GPU·타임존·코어 수가 한 벌로 일관되게 묶인 &amp;ldquo;사람&amp;quot;을 제시하는 것이다.&lt;/p&gt;
&lt;h2 id="best-effort라고-먼저-말한다"&gt;&lt;a href="#best-effort%eb%9d%bc%ea%b3%a0-%eb%a8%bc%ec%a0%80-%eb%a7%90%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;best-effort라고 먼저 말한다
&lt;/h2&gt;&lt;p&gt;확장은 페이지와 같은 JavaScript 세계 안에서 돌면서 그 세계를 속인다. 그래서 아무리 잘해도 작정한 수집기는 위장이 켜져 있다는 사실 자체를 눈치챌 수 있다. 이건 한계가 아니라 구조다.&lt;/p&gt;
&lt;p&gt;그걸 숨기는 안티-핑거프린팅 도구가 제일 나쁘다고 봤다. 그래서 처음부터 best-effort, 즉 &amp;ldquo;지문 수집의 비용을 높이고 흔한 누수를 막지만 완전한 은폐는 아니다&amp;quot;라고 못 박고 시작했다. 정직하게 한계를 적는 걸 설계 원칙으로 삼았다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;결국 첫날 정한 세 가지가 이후 모든 선택을 지배했다. 한 군데가 아니라 전부 일관되게 바꾼다, 무작위가 아니라 실재하는 페르소나를 입는다, 그리고 완전하지 않다는 걸 먼저 인정한다. 이 세 줄이 이후의 주입 방식과 표면 하나하나의 선택을 전부 미리 정해 둔 셈이었다.&lt;/p&gt;</description></item></channel></rss>