<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Masque 개발기 on monkshark.dev</title><link>https://monkshark.github.io/categories/masque-%EA%B0%9C%EB%B0%9C%EA%B8%B0/</link><description>Recent content in Masque 개발기 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/categories/masque-%EA%B0%9C%EB%B0%9C%EA%B8%B0/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>#2 - 페이지보다 먼저, 그런데 설정을 실어서 주입하기</title><link>https://monkshark.github.io/p/masque-injection/</link><pubDate>Sun, 14 Jun 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/masque-injection/</guid><description>&lt;p&gt;위장의 첫 번째 조건은 단순하다. 가짜 값이 페이지 자신의 스크립트보다 먼저 자리잡아야 한다. 페이지가 &lt;code&gt;navigator.userAgent&lt;/code&gt;를 읽고 나서 바꿔봤자 이미 늦었다. 그런데 &amp;ldquo;먼저 박는다&amp;quot;와 &amp;ldquo;페르소나별 설정을 실어 보낸다&amp;quot;를 동시에 만족시키는 게 생각보다 까다로웠다.&lt;/p&gt;
&lt;h2 id="가짜-값은-페이지보다-먼저-자리잡아야-한다"&gt;&lt;a href="#%ea%b0%80%ec%a7%9c-%ea%b0%92%ec%9d%80-%ed%8e%98%ec%9d%b4%ec%a7%80%eb%b3%b4%eb%8b%a4-%eb%a8%bc%ec%a0%80-%ec%9e%90%eb%a6%ac%ec%9e%a1%ec%95%84%ec%95%bc-%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;가짜 값은 페이지보다 먼저 자리잡아야 한다
&lt;/h2&gt;&lt;p&gt;조건은 두 개였다. 첫째, &lt;code&gt;document_start&lt;/code&gt;에 돌아야 한다. 페이지의 첫 인라인 스크립트보다 앞서야 하니까. 둘째, 페이지와 같은 MAIN world에서 돌아야 한다. 확장의 격리된 world에서 navigator를 바꿔봐야 페이지엔 안 보인다.&lt;/p&gt;
&lt;p&gt;매니페스트에 정적 content script를 MAIN world·document_start로 박으면 타이밍은 해결된다. 그런데 여기서 막혔다. 정적 content script는 동적인 설정(어떤 페르소나를 쓸지)을 실어 보낼 수가 없고, MAIN world에선 &lt;code&gt;chrome.storage&lt;/code&gt;도 못 읽는다. 사용자가 고른 페르소나를 어떻게 그 코드 안으로 넣지?&lt;/p&gt;
&lt;h2 id="oncommitted--executescript는-너무-느렸다"&gt;&lt;a href="#oncommitted--executescript%eb%8a%94-%eb%84%88%eb%ac%b4-%eb%8a%90%eb%a0%b8%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;onCommitted + executeScript는 너무 느렸다
&lt;/h2&gt;&lt;p&gt;첫 시도는 서비스워커에서 &lt;code&gt;chrome.webNavigation.onCommitted&lt;/code&gt;를 듣고, 설정을 읽어 &lt;code&gt;chrome.scripting.executeScript&lt;/code&gt;로 주입하는 거였다. 이론상 내비게이션이 커밋되는 순간 쏘면 되니까.&lt;/p&gt;
&lt;p&gt;실제로는 처참하게 늦었다. 핸들러가 &lt;code&gt;getSettings()&lt;/code&gt;로 storage를 비동기로 읽고, executeScript 메시지가 렌더러까지 왕복하는 사이에, 로컬 페이지의 head 인라인 스크립트는 이미 다 돌아버렸다. 테스트 페이지에서 주입 표시가 &amp;ldquo;(없음)&amp;ldquo;으로 떴다. 동기 표면(navigator·screen)을 잡기엔 이 경로는 구조적으로 너무 느렸다.&lt;/p&gt;
&lt;h2 id="chromeuserscripts로-코드를-실어-보냈다"&gt;&lt;a href="#chromeuserscripts%eb%a1%9c-%ec%bd%94%eb%93%9c%eb%a5%bc-%ec%8b%a4%ec%96%b4-%eb%b3%b4%eb%83%88%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;chrome.userScripts로 코드를 실어 보냈다
&lt;/h2&gt;&lt;p&gt;답은 &lt;code&gt;chrome.userScripts&lt;/code&gt; API였다. 정확히 이 용도 — 동적 설정을 코드 문자열에 담아 MAIN world·document_start에 등록 — 를 위한 물건이다. 위장 함수를 통째로 직렬화하고, 그 뒤에 페르소나와 옵션을 JSON으로 붙여서 즉시 실행되는 한 덩어리로 만들었다.&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;span class="lnt"&gt;7
&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="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userScripts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;register&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="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;masque&amp;#39;&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="nx"&gt;matches&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;lt;all_urls&amp;gt;&amp;#39;&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="nx"&gt;world&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;MAIN&amp;#39;&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="nx"&gt;runAt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;document_start&amp;#39;&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="nx"&gt;js&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`(&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;applyInPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;)(&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;, &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&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="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;applyInPage&lt;/code&gt;가 완전히 self-contained여야 한다는 것이다. &lt;code&gt;.toString()&lt;/code&gt;으로 떼어내 다른 곳에서 다시 실행되니, 바깥 모듈의 어떤 심볼도 참조하면 안 된다. 그래서 헬퍼를 전부 함수 안에 인라인으로 넣고, 빌드 번들에서 외부 참조가 0인지 확인했다. 설정이 바뀌면 &lt;code&gt;update&lt;/code&gt;로 다시 등록만 하면 된다.&lt;/p&gt;
&lt;h2 id="그런데-chromeuserscripts가-undefined였다"&gt;&lt;a href="#%ea%b7%b8%eb%9f%b0%eb%8d%b0-chromeuserscripts%ea%b0%80-undefined%ec%98%80%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;그런데 chrome.userScripts가 undefined였다
&lt;/h2&gt;&lt;p&gt;빌드해서 로드했는데 여전히 &amp;ldquo;(없음)&amp;ldquo;이었다. 로그를 박아 보니 &lt;code&gt;chrome.userScripts&lt;/code&gt; 자체가 undefined였다.&lt;/p&gt;
&lt;p&gt;이게 함정이다. &lt;code&gt;userScripts&lt;/code&gt; 권한을 매니페스트에 선언해도, 사용자가 확장 세부정보에서 &amp;ldquo;사용자 스크립트 허용&amp;rdquo; 토글을 직접 켜기 전까지는 그 API 네임스페이스가 아예 노출되지 않는다. Chrome이 일부러 둔 보안 장치다. 코드로 켤 방법은 없다.&lt;/p&gt;
&lt;p&gt;그래서 감지해서 안내하는 쪽으로 갔다. &lt;code&gt;typeof chrome.userScripts&lt;/code&gt;로 켜졌는지 확인하고, 꺼져 있으면 팝업·옵션에 경고 배너와 설정 페이지 바로가기를 띄운다. 우리가 할 수 있는 건 감지·안내·딥링크까지고, 마지막 한 번은 사용자가 켜야 한다.&lt;/p&gt;
&lt;h2 id="js와-헤더가-어긋나면-안-된다"&gt;&lt;a href="#js%ec%99%80-%ed%97%a4%eb%8d%94%ea%b0%80-%ec%96%b4%ea%b8%8b%eb%82%98%eb%a9%b4-%ec%95%88-%eb%90%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;JS와 헤더가 어긋나면 안 된다
&lt;/h2&gt;&lt;p&gt;JS 표면을 다 바꿔도, HTTP 헤더가 진짜 User-Agent를 흘리면 앞서 경계한 그 모순이 그대로 생긴다. 헤더는 userScripts로 못 만지니 별도 경로가 필요했다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;chrome.declarativeNetRequest&lt;/code&gt; 동적 규칙으로 User-Agent·Accept-Language·sec-ch-ua 계열 헤더를 같은 페르소나 값으로 덮었다. 주입은 userScripts, 헤더는 DNR — 경로는 둘이지만 출처는 하나의 페르소나다. 그래서 JS에서 읽는 값과 서버가 받는 헤더가 항상 같은 사람을 가리킨다. 일관성이라는 원칙이 여기서 실제 구현으로 떨어졌다.&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;&amp;ldquo;페이지보다 먼저&amp;quot;와 &amp;ldquo;동적 설정을 실어서&amp;quot;는 얼핏 양립하기 어려운 요구였다. 정적 content script는 앞은 빠르지만 설정을 못 싣고, executeScript는 설정은 싣지만 너무 느렸다. 그 사이를 정확히 메우는 게 userScripts였고, 대신 사용자가 토글을 켜야 한다는 비용이 따라왔다. 결국 주입은 타이밍·동적성·탐지 가능성의 삼각형이었고, 어느 꼭짓점도 공짜가 아니었다.&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>