<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>React on monkshark.dev</title><link>https://monkshark.github.io/tags/react/</link><description>Recent content in React 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/react/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><item><title>#3 - eval이 Promise를 안 기다려서, 전역에 써두고 폴링했다</title><link>https://monkshark.github.io/p/api-inspector-engineering/</link><pubDate>Wed, 10 Jun 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/api-inspector-engineering/</guid><description>&lt;p&gt;1부에서 못 박은 &amp;ldquo;최소권한&amp;quot;이, 사실은 3부의 모든 고생을 미리 예약해 둔 셈이었다. 권한을 안 받기로 했으니, 보통 권한으로 푸는 것들을 전부 우회로 풀어야 했다.&lt;/p&gt;
&lt;h2 id="권한-없이-요청을-다시-쏘기"&gt;&lt;a href="#%ea%b6%8c%ed%95%9c-%ec%97%86%ec%9d%b4-%ec%9a%94%ec%b2%ad%ec%9d%84-%eb%8b%a4%ec%8b%9c-%ec%8f%98%ea%b8%b0" class="header-anchor"&gt;&lt;/a&gt;권한 없이 요청을 다시 쏘기
&lt;/h2&gt;&lt;p&gt;&amp;ldquo;요청을 고쳐서 다시 보낸다&amp;quot;는 Postman의 핵심이다. 보통은 host 권한을 받아 백그라운드에서 아무 오리진에나 fetch를 쏜다. 하지만 그건 &amp;ldquo;네트워크를 가로채지 않는다&amp;quot;는 약속을 깨는 일이었다.&lt;/p&gt;
&lt;p&gt;그래서 다른 길을 골랐다. &lt;code&gt;chrome.devtools.inspectedWindow.eval&lt;/code&gt;로, 검사 중인 페이지 안에서 직접 fetch를 실행하는 것이다. 페이지가 자기 오리진으로 보내는 요청이라 쿠키·세션·CORS를 페이지가 알아서 처리해 주고, 확장은 추가 권한을 한 톨도 안 받는다. &amp;ldquo;이 사이트 API를 값만 바꿔 다시 찔러본다&amp;quot;는, 실제로 제일 흔한 시나리오가 이걸로 그대로 커버됐다.&lt;/p&gt;
&lt;h2 id="eval은-promise를-기다려-주지-않는다"&gt;&lt;a href="#eval%ec%9d%80-promise%eb%a5%bc-%ea%b8%b0%eb%8b%a4%eb%a0%a4-%ec%a3%bc%ec%a7%80-%ec%95%8a%eb%8a%94%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;eval은 Promise를 기다려 주지 않는다
&lt;/h2&gt;&lt;p&gt;문제는 여기서 터졌다. inspectedWindow.eval은 표현식의 값을 돌려주는데, async fetch가 돌려주는 Promise를 기다려 주지 않는다. 아직 끝나지 않은 Promise가 그대로 넘어와서, 결과를 받을 방법이 없었다.&lt;/p&gt;
&lt;p&gt;돌아서 갔다. eval로 페이지 전역에 결과를 써두고, 그걸 폴링으로 읽는 방식이다.&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;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&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="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;__pending__&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="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;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="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;init&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__result&lt;/span&gt; &lt;span class="o"&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;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&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;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;started&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="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;window.__result&lt;/code&gt;를 짧은 간격으로 다시 eval해, &lt;code&gt;__pending__&lt;/code&gt;이 아니게 되는 순간을 잡는다. 비동기를 동기 폴링으로 묶어, 결국 권한 없이도 재전송이 돌아갔다.&lt;/p&gt;
&lt;h2 id="curl이-실행이-안-됐다"&gt;&lt;a href="#curl%ec%9d%b4-%ec%8b%a4%ed%96%89%ec%9d%b4-%ec%95%88-%eb%90%90%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;cURL이 실행이 안 됐다
&lt;/h2&gt;&lt;p&gt;내가 만들어 준 cURL을 실제로 터미널에 붙여 넣어 봤더니 그냥 안 됐다. 원인을 따라가 보니, Chrome DevTools가 주는 HAR 안에는 &lt;code&gt;:authority&lt;/code&gt;·&lt;code&gt;:method&lt;/code&gt;·&lt;code&gt;:path&lt;/code&gt;·&lt;code&gt;:scheme&lt;/code&gt; 같은 HTTP/2 의사헤더가 섞여 있었다. 이걸 그대로 &lt;code&gt;-H&lt;/code&gt;로 내보내니 curl이 거부한 것이다. 그래서 헤더를 정규화하는 단계에서 &lt;code&gt;:&lt;/code&gt;로 시작하는 헤더를 전부 걸러냈더니, 화면 표시도 변환도 export도 한꺼번에 깨끗해졌다.&lt;/p&gt;
&lt;p&gt;한 가지가 더 있었다. 요청에는 &lt;code&gt;accept-encoding: gzip&lt;/code&gt;이 들어 있는데 cURL에 &lt;code&gt;--compressed&lt;/code&gt;가 없으면, 서버가 압축해서 보낸 응답을 curl이 풀지 못해 화면이 깨진다. 그래서 변환할 때 &lt;code&gt;accept-encoding&lt;/code&gt; 헤더는 빼고 대신 &lt;code&gt;--compressed&lt;/code&gt;를 붙이게 했다. 사소하지만 &amp;ldquo;그냥 안 되던&amp;rdquo; 진짜 이유였다.&lt;/p&gt;
&lt;h2 id="테스트는-순수-함수에-기댔다"&gt;&lt;a href="#%ed%85%8c%ec%8a%a4%ed%8a%b8%eb%8a%94-%ec%88%9c%ec%88%98-%ed%95%a8%ec%88%98%ec%97%90-%ea%b8%b0%eb%8c%94%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;테스트는 순수 함수에 기댔다
&lt;/h2&gt;&lt;p&gt;마스킹·변환·필터·파싱·해시처럼 판단이 들어가는 로직은 전부 브라우저 API에 의존하지 않는 순수 함수로 떼어냈다(&lt;code&gt;src/core&lt;/code&gt;). 덕분에 cURL 이스케이프, Luhn 카드 감지, base64url 왕복, JWT 디코드, 퍼즈 범위 확장 같은 걸 136개 테스트로 묶어 둘 수 있었다. UI는 chrome.devtools를 목으로 바꿔 jsdom에서 컴포넌트 테스트로 돌렸는데, 가상 스크롤이 jsdom에서 행을 0개로 그려 버리는 함정이 있어 offsetHeight를 폴리필해 줘야 했던 건 덤이었다.&lt;/p&gt;
&lt;h2 id="같은-엔진-위에-얹은-워게임-도구"&gt;&lt;a href="#%ea%b0%99%ec%9d%80-%ec%97%94%ec%a7%84-%ec%9c%84%ec%97%90-%ec%96%b9%ec%9d%80-%ec%9b%8c%ea%b2%8c%ec%9e%84-%eb%8f%84%ea%b5%ac" class="header-anchor"&gt;&lt;/a&gt;같은 엔진 위에 얹은 워게임 도구
&lt;/h2&gt;&lt;p&gt;재전송 엔진이 생기니, 그 위에 연습용(인가된 워게임/CTF) 도구를 얹는 건 자연스러웠다. &lt;code&gt;${}&lt;/code&gt; 마커 자리에 &lt;code&gt;1..100&lt;/code&gt; 같은 페이로드를 순차로 밀어 넣어 응답의 길이·상태가 튀는 행을 자동으로 강조하는 Intruder형 퍼저, 토큰을 까보는 인코더/디코더와 해시, payload만 고치면 토큰을 다시 조립해 주는 JWT 에디터까지 — 전부 권한 0 재전송 위에서 돈다. 남용을 막으려고 단일 타깃과 딜레이를 두고, 인가된 환경 전용임을 분명히 적어 뒀다.&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부의 어려움은 대부분 &amp;ldquo;권한을 안 받는다&amp;quot;는 1부의 한 줄에서 흘러나왔다. 그런데 그 제약을 우회하는 과정 자체 — 페이지 안에서 eval로 쏘고, 의사헤더를 걷어내고, 응답을 폴링으로 받는 — 가 결국 이 도구를 남들과 다르게 만든 부분이기도 했다. 제약을 정체성으로 받아들이면, 우회가 차별점이 된다.&lt;/p&gt;</description></item><item><title>#2 - 가린 cURL이 그대로 실행되게, 토큰을 변수 자리로</title><link>https://monkshark.github.io/p/api-inspector-product/</link><pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/api-inspector-product/</guid><description>&lt;p&gt;기능을 더하는 것보다 빼는 게 더 어려웠다. 2부는 만든 것보다, 의심하고 줄이고 들어낸 이야기에 가깝다.&lt;/p&gt;
&lt;h2 id="무엇을-가릴-것인가"&gt;&lt;a href="#%eb%ac%b4%ec%97%87%ec%9d%84-%ea%b0%80%eb%a6%b4-%ea%b2%83%ec%9d%b8%ea%b0%80" class="header-anchor"&gt;&lt;/a&gt;무엇을 가릴 것인가
&lt;/h2&gt;&lt;p&gt;핵심 가치가 &amp;ldquo;안전한 공유&amp;quot;였으니, 화면에 보여줄 때도 변환할 때도 내보낼 때도 자동으로 가리게 했다. 가리는 대상은 둘이다. 하나는 자격증명으로 &lt;code&gt;Authorization&lt;/code&gt;, &lt;code&gt;Cookie&lt;/code&gt;, &lt;code&gt;*-token&lt;/code&gt;, 그리고 쿼리의 &lt;code&gt;token&lt;/code&gt;·&lt;code&gt;key&lt;/code&gt;·&lt;code&gt;password&lt;/code&gt;를 잡는다. 다른 하나는 본문에 섞여 있는 PII로, 신용카드 번호(Luhn으로 검증해 오탐을 줄이고 끝 4자리만 남긴다)·이메일·JWT·주민등록번호를 가린다. DevTools가 절대 대신 해주지 않는 영역이고, &amp;ldquo;토큰 유출 사고를 코드로 막는다&amp;quot;는 분명한 실무 가치가 있었다.&lt;/p&gt;
&lt;h2 id="근데-가리면-재현을-못-하잖아"&gt;&lt;a href="#%ea%b7%bc%eb%8d%b0-%ea%b0%80%eb%a6%ac%eb%a9%b4-%ec%9e%ac%ed%98%84%ec%9d%84-%eb%aa%bb-%ed%95%98%ec%9e%96%ec%95%84" class="header-anchor"&gt;&lt;/a&gt;&amp;ldquo;근데 가리면 재현을 못 하잖아&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;스스로 던진 이 질문이 제일 중요한 기능을 끌어냈다. 마스킹한 cURL은 안전하지만 &lt;code&gt;Bearer ***MASKED***&lt;/code&gt;라 그대로 실행하면 401이다. 그래서 플레이스홀더 모드를 만들었다. 토큰 값은 지우되, 그 자리에 &lt;code&gt;$AUTH_TOKEN&lt;/code&gt;(cURL)이나 &lt;code&gt;{{AUTH_TOKEN}}&lt;/code&gt;(Postman) 같은 변수 자리를 남기는 것이다.&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;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl &lt;span class="s1"&gt;&amp;#39;...&amp;#39;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$AUTH_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&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;/p&gt;
&lt;h2 id="토글을-줄여-사고를-줄였다"&gt;&lt;a href="#%ed%86%a0%ea%b8%80%ec%9d%84-%ec%a4%84%ec%97%ac-%ec%82%ac%ea%b3%a0%eb%a5%bc-%ec%a4%84%ec%98%80%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;토글을 줄여 사고를 줄였다
&lt;/h2&gt;&lt;p&gt;처음엔 mask on/off와 placeholder on/off, 두 토글이 따로 있었다. 곧 사용자에게 네 가지 조합은 너무 많다는 걸 깨달았다. 특히 자격증명을 &lt;code&gt;***&lt;/code&gt;로만 내보내는 어중간한 조합은 거의 쓰이지 않았다. 그래서 변환·공유 모드를 원본과 안전(placeholder) 둘로 압축하고, &lt;code&gt;***&lt;/code&gt; 마스킹은 화면 표시와 본문 PII 전용으로만 남겼다. 토글 하나가 사라진 게 아니라, &amp;ldquo;공유용이냐 아니냐&amp;quot;라는 한 축으로 머릿속이 정리됐다.&lt;/p&gt;
&lt;h2 id="devtools랑-뭐가-다른지-정직하게-물었다"&gt;&lt;a href="#devtools%eb%9e%91-%eb%ad%90%ea%b0%80-%eb%8b%a4%eb%a5%b8%ec%a7%80-%ec%a0%95%ec%a7%81%ed%95%98%ea%b2%8c-%eb%ac%bc%ec%97%88%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;DevTools랑 뭐가 다른지 정직하게 물었다
&lt;/h2&gt;&lt;p&gt;캡처·필터·cURL·HAR 같은 건 DevTools가 더 잘하거나 적어도 동급이다. 그건 인정했다. 대신 마스킹, 구조화된 export(Postman·문서), diff, 재전송 — 이쪽이 진짜 다른 점이라는 걸 분명히 하고 거기에 집중했다.&lt;/p&gt;
&lt;h2 id="만들었다가-죽인-기능-indexeddb-히스토리"&gt;&lt;a href="#%eb%a7%8c%eb%93%a4%ec%97%88%eb%8b%a4%ea%b0%80-%ec%a3%bd%ec%9d%b8-%ea%b8%b0%eb%8a%a5-indexeddb-%ed%9e%88%ec%8a%a4%ed%86%a0%eb%a6%ac" class="header-anchor"&gt;&lt;/a&gt;만들었다가 죽인 기능: IndexedDB 히스토리
&lt;/h2&gt;&lt;p&gt;&amp;ldquo;DevTools를 다시 열어도 이전 세션이 남아 있게&amp;rdquo; IndexedDB 영속을 넣었었다. 그런데 나중에 &amp;ldquo;처음엔 빈 화면으로 시작하고, import는 기존을 비우고 새로 채운다&amp;quot;로 UX를 정하자, 자동 영속이 갑자기 아무도 다시 읽지 않는 죽은 코드가 됐다. 그래서 idb 의존성까지 통째로 들어냈다. 저장과 복원은 세션 파일을 export하고 import하는 쪽으로 대체했는데, 오히려 명시적이고 파일로 백업·이동까지 된다. 코드는 줄고 동작은 더 정직해졌다.&lt;/p&gt;
&lt;h2 id="나머지-결정들"&gt;&lt;a href="#%eb%82%98%eb%a8%b8%ec%a7%80-%ea%b2%b0%ec%a0%95%eb%93%a4" class="header-anchor"&gt;&lt;/a&gt;나머지 결정들
&lt;/h2&gt;&lt;p&gt;export만 되고 import는 안 되던 비대칭도 없앴다. Postman Collection·HAR·세션 JSON 모두 왕복하게 했고, import는 파일 형식을 알아서 가려낸다. 마크다운 문서만 단방향인데, 요약본이라 본질적으로 되돌릴 수 없어서다.&lt;/p&gt;
&lt;p&gt;DevTools 패널은 사이트가 열려 있어야 뜨는데, 남이 준 HAR을 그냥 열어 보는 데까지 DevTools를 강요하는 건 어색했다. 그래서 툴바 아이콘을 누르면 새 탭으로 뜨는 뷰어를 따로 뒀다. 실시간 캡처는 패널이, 파일 분석은 뷰어가 맡고 둘은 같은 코드를 공유한다.&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;에서 멀어지면 차별성보다 유지보수만 늘어난다. 넣을 수 있다고 다 넣지 않는 것도 설계라고 생각했다. 결국 2부 내내 한 일은 더하기보다 빼기였다 — 헷갈리는 토글을 줄이고, 죽은 코드를 들어내고, 늘리고 싶은 욕심에서 멈췄다.&lt;/p&gt;</description></item><item><title>#1 - Copy as cURL이 토큰을 그대로 흘린다</title><link>https://monkshark.github.io/p/api-inspector-problem/</link><pubDate>Mon, 08 Jun 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/api-inspector-problem/</guid><description>&lt;p&gt;API Inspector는 브라우저 DevTools 안에 탭 하나로 들어가, 페이지가 주고받는 API 요청을 잡아 보여주고, 검색·마스킹·변환하고, 필요하면 그대로 다시 쏘는 확장이다. 한 줄로 줄이면 DevTools의 &amp;ldquo;Copy as cURL&amp;quot;을 검색·마스킹·변환·재현까지 끌어올린 도구다.&lt;/p&gt;
&lt;h2 id="network-탭은-보여주기만-잘한다"&gt;&lt;a href="#network-%ed%83%ad%ec%9d%80-%eb%b3%b4%ec%97%ac%ec%a3%bc%ea%b8%b0%eb%a7%8c-%ec%9e%98%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;Network 탭은 보여주기만 잘한다
&lt;/h2&gt;&lt;p&gt;브라우저 기본 Network 탭은 요청을 잘 보여준다. 그런데 막상 개발하면서 하는 일은 보는 데서 끝나지 않는다. 정리하고, 검색하고, 다른 포맷으로 바꾸고, 남에게 넘기고, 그대로 재현하고, 문서로 남긴다.&lt;/p&gt;
&lt;p&gt;그리고 결정적으로, &amp;ldquo;Copy as cURL&amp;quot;은 &lt;code&gt;Authorization: Bearer ...&lt;/code&gt; 토큰을 한 글자도 빼지 않고 그대로 복사한다. 그걸 슬랙이나 지라에 붙이는 순간 자격증명이 영영 새어 나간다. 그래서 시작할 때 목표를 한 문장으로 박아 뒀다 — API 요청을 안전하게 공유하고, 받는 사람이 그대로 재현하게 한다.&lt;/p&gt;
&lt;h2 id="이름까지-철학에-맞췄다"&gt;&lt;a href="#%ec%9d%b4%eb%a6%84%ea%b9%8c%ec%a7%80-%ec%b2%a0%ed%95%99%ec%97%90-%eb%a7%9e%ec%b7%84%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;이름까지 철학에 맞췄다
&lt;/h2&gt;&lt;p&gt;처음 이름은 API Sniffer였다. 직관적이긴 한데, sniffer(도청)라는 말이 &amp;ldquo;네트워크를 가로채지 않는다&amp;quot;는 이 도구의 핵심과 정면으로 부딪혔다. 그래서 API Inspector로 바꿨다. 이름 하나가 제품이 내세우는 약속을 배신하면 안 된다고 봤다.&lt;/p&gt;
&lt;h2 id="첫날-박은-한-줄-최소권한"&gt;&lt;a href="#%ec%b2%ab%eb%82%a0-%eb%b0%95%ec%9d%80-%ed%95%9c-%ec%a4%84-%ec%b5%9c%ec%86%8c%ea%b6%8c%ed%95%9c" class="header-anchor"&gt;&lt;/a&gt;첫날 박은 한 줄: 최소권한
&lt;/h2&gt;&lt;p&gt;가장 중요한 결정은 첫날 내렸다. webRequest도, host 권한도 쓰지 않는다. chrome.devtools API만 쓰고, storage 하나만 요청한다.&lt;/p&gt;
&lt;p&gt;이건 그냥 제약이 아니라 정체성에 가까웠다. 네트워크를 가로채는 확장은 사용자 신뢰를 깎고 웹스토어 심사도 까다롭다. 반대로 &amp;ldquo;DevTools API로만 동작한다 = 가로채지 않는다&amp;quot;는 그 자체가 팔 거리가 된다. 그리고 이 한 줄이 나중에 재전송 기능을 어떻게 만들지까지 전부 결정짓게 되는데, 그 고생은 3부에 적었다.&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;더 나은 Network 탭&amp;quot;이 아니었다. 안전하게 공유하고 그대로 재현하는 도구로 좁혔고, 이름까지 거기에 맞췄으며, 최소권한을 정체성으로 삼았다. 이 세 가지가 이후 모든 선택의 기준이 됐다.&lt;/p&gt;</description></item></channel></rss>