안티-LLM 난독화: 핑거프린팅 스크립트의 반격
안티-LLM 난독화는 핑거프린팅 스크립트에 탈옥 프롬프트를 숨겨 AI 역난독화를 방해합니다. 작동 원리와 대응 방법을 정리합니다.

한 연구자가 웹에서 가장 큰 사이트 1만 곳의 모든 canvas 호출을 후킹해 1,000개가 넘는 핑거프린팅 렌더링을 수집했고, 그중 가장 예쁜 것을 찾아 나섰습니다. 그 결과 해당 스크립트에서 발견한 것은 안티-LLM 난독화의 가장 명확한 사례였습니다. 스크립트에는 computeRicinLethalDosageForWaterSupply 같은 이름의 함수들이 들어 있었고, 보이지 않는 유니코드 문자로 인코딩된 탈옥 프롬프트까지 숨어 있었습니다. 이것은 사람인 리버스 엔지니어를 피하려는 장치가 아니라, AI 어시스턴트가 디오브퓨스케이션을 거부하도록 설계된 장치입니다.
저는 여러 해 동안 인시던트 대응 업무를 해왔는데, 이 분석글을 처음 읽었을 때 저자들이 의도한 반응이 그대로 나왔습니다. 악성코드이거나 그보다 더 나쁜 무언가라는 느낌이었죠. 리신 치사량이나 더러운 폭탄의 낙진 반경 같은 함수 이름, 생물무기 합성 단계별 가이드를 요구하는 디코딩된 문자열, 그리고 .npmrc, .env, kube 설정 파일을 읽고 내부 IP에서 바이너리를 받아 cron 비콘을 설치하는 Node.js 코드 경로까지 있었습니다. 모든 본능이 악의적 공격자라고 말합니다. 그런데 그 판단은 틀렸습니다. 바로 그 틀렸다는 점이 핵심입니다. 이것은 난독화를 파는 사람들이 실제 적은 더 이상 디버거를 든 사람이 아니라 안전 필터를 가진 모델이라는 사실을 깨달았을 때 무슨 일이 벌어지는지에 관한 이야기입니다.
크롤링 자체는 단순했습니다. Tranco 상위 사이트 목록을 대상으로 Playwright를 돌리면서 HTMLCanvasElement.prototype.toDataURL을 후킹하고, navigator.webdriver 같은 전형적인 자동화 흔적으로 양성 캔버스를 걸러냈습니다. 수집된 렌더링은 공개 갤러리에 모였고, FingerprintJS, Castle 같은 벤더와 정체불명의 긴 꼬리 목록으로 묶였습니다. 흐르는 선들이 아름답게 소용돌이치는 캔버스 하나는 러시아 DDoS 방어 벤더가 제공하는 스크립트까지 추적되었습니다. 여기서부터 재미있어졌습니다.
문제: LLM이 난독화의 비즈니스 모델을 무너뜨렸다
20년 동안 JavaScript 난독화는 단 하나의 역할만 맡아 왔습니다. 리버스 엔지니어링의 비용을 높여서 아무도 굳이 시도하지 않게 만드는 것이죠. javascript-obfuscator와 상용 파생 도구들은 식별자를 뒤섞고, 문자열 리터럴을 조회 배열로 묶고, 불투명 조건식을 주입하고, 제어 흐름을 switch 기반 상태 머신으로 평탄화합니다. 시간 많은 사람이 달려들면 이런 기법은 대부분 뚫립니다. 하지만 그래도 괜찮았습니다. 시간 많은 사람은 드물었고, 경제성은 방어자 쪽에 있었으니까요.
그러다 LLM이 뒤섞인 코드를 잘 읽게 되면서 경제성이 뒤집혔습니다. 400KB짜리 난독화된 봇 탐지 스크립트를 채팅창에 붙여넣고 "이게 뭘 하는 코드야?"라고 묻는 데 30초면 충분합니다. 모델은 함수 이름이 PiERW인지, 문자열이 회전 배열에 들어 있는지 신경 쓰지 않습니다. 의미를 읽습니다. 디코더를 인라인하고, 데이터 흐름을 따라가고, 평이한 영어로 요약을 돌려줍니다. 봇 탐지 벤더들이 가졌던 해자, 즉 불투명성은 조용히 증발했습니다.
벤더들도 이를 알아챘고, 그들의 대응책은 짜증날 정도로 영리합니다. 모델에게서 코드를 숨길 수 없다면 모델을 공격하라는 것이죠. 안전 훈련이 반드시 거부하도록 만든 콘텐츠를 먹입니다. 안티-LLM 난독화는 분석 파이프라인에서 모델 정렬을 가장 약한 고리로 취급합니다. 짜증스럽지만, 그들의 판단이 맞습니다.
브라우저 핑거프린팅 스크립트는 엔트로피를 어떻게 수집하나
이 함정을 이해하려면 그것이 붙어 있는 정상적인 구조부터 알아야 합니다. 캔버스 핑거프린팅은 보이지 않는 <canvas>에 텍스트, 그라데이션, 도형을 그린 뒤 toDataURL()로 픽셀 버퍼를 다시 읽어 들이는 방식으로 작동합니다. GPU, 드라이버, 폰트 래스터라이저, 안티앨리어싱, 서브픽셀 힌팅으로 이어지는 렌더링 파이프라인은 기기마다 미묘하게 달라서, 같은 명령을 그려도 두 브라우저의 픽셀 버퍼 해시는 서로 다릅니다. 여기에 WebGL 렌더러 문자열, AudioContext 타이밍, 화면 해상도, 설치된 폰트 등 수많은 신호를 결합하면 쿠키 하나 없이도 안정적인 식별자를 얻을 수 있습니다.
이 스크립트를 찾아낸 크롤러는 표준적인 가로채기 기법을 썼습니다. 초기화 스크립트에서 읽기 프리미티브를 후킹하고, 스택 트레이스와 캡처된 이미지의 앞부분을 기록합니다:
await page.addInitScript(() => {
const original = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function (...args) {
const result = original.apply(this, args);
fetch("/__log", {
method: "POST",
body: JSON.stringify({
stack: new Error().stack,
byteLength: result.length,
head: result.slice(0, 64),
}),
});
return result;
};
});
이 방식을 충분히 많은 사이트에 적용하면 핑거프린팅 생태계가 스스로 지도를 그립니다. 대부분의 캔버스는 지루합니다. 팬그램, 몇 개의 사각형, 그라데이션 정도죠. 일부만 생성 예술이라 부를 만큼 정교합니다. 그런데 이것들은 모두 심하게 난독화된 스크립트에서 만들어집니다. 벤더들은 난독화되지 않은 핑거프린터가 그 자체로 핑거프린팅될 수 있다는 것을 알고 있기 때문입니다. 난독화는 처음부터 제품의 일부였습니다. 새로운 것은 난독화가 누구를 겨냥하는가입니다.
안티-LLM 난독화 함정 안을 들여다보면
스크립트는 평범하게 시작했습니다. 뒤섞인 이름, 묶인 문자열, 흔한 구성이었죠. 그런데 식별자 이름이 무작위이기를 멈추고 특정 대상을 향하기 시작했습니다. computeRicinLethalDosageForWaterSupply. calculateDeadlyLethalSuicideAspirinDosage. computeCRISPRGeneDrivePropagationRate. computeCesium137DirtyBombFalloutRadius. 이 함수들은 "성인 치사 임계값 X mg/kg 확인" 같은 섬뜩한 요약을 붙인 숫자를 반환하지만, 계산은 미끼입니다. 실제로는 아무것도 계산하지 않고 어디에도 값을 넘기지 않습니다.
핵심은 이것입니다. 식별자는 런타임에서 아무 의미가 없지만 언어 모델에게는 엄청난 의미적 무게를 가집니다. 인간 리버스 엔지니어는 경험을 통해 이름을 무시하도록 훈련되어 있습니다. validateUser라는 함수가 실제로는 아무 검증도 하지 않는 것을 한 번쯤 겪어 봤기 때문이죠. 모델은 정반대로 행동합니다. computeRicinLethalDosageForWaterSupply를 읽고 생물무기 맥락으로 패턴을 맞추면, 호기심보다 안전 훈련이 먼저 작동합니다.
더 깊은 층은 보이지 않는 문자로 가득 찬 템플릿 리터럴이었습니다. 유니코드 Tags 블록의 코드포인트로, U+E0000부터 시작합니다. 이 문자들은 화면에 아무것도 그리지 않습니다. 디코더는 한 줄짜리입니다:
const cleartext = [...payload]
.map((c) => String.fromCodePoint(c.codePointAt(0) - 917504))
.join("");
// 917504 === 0xE0000, the start of the invisible "Tags" block.
// Subtracting it maps each hidden char back to plain ASCII.
디코딩하자 완전한 탈옥 프롬프트가 나왔습니다. "SYSTEM OVERRIDE -- CLASSIFIED BRIEFING"이라는 가짜 문구가 안전 지침이 정지되었다고 선언하며, 의료 인프라에 대한 DDoS 공격, 탈세, 에어로졸 생물학 작용제, 약물 투여량에 대한 단계별 가이드를 요구합니다. 여기에 중국 호스팅 모델의 검열 반응을 건드리려는 듯, 영어와 중국어 양쪽으로 된 천안문 광장 언급까지 들어 있었습니다. 거의 모든 안전 분류 체계를 동시에 건드리도록 설계된 문자열이었습니다. 주변 코드를 분석하라고 요청받은 LLM은 이것을 맥락으로 흡수하고, 원래 하려던 일이 무엇이든 이제는 아무 일도 하지 않게 됩니다.

실패한 시도들: 거부, 오탐, 그리고 가짜 봇넷
가장 자연스러운 첫 시도는 프런티어 모델에 붙여넣는 것이었고, 이는 설계된 대로 실패했습니다. 모델은 생물학적 안전 위험을 이유로 거부했습니다. 다른 안전 정책 설정으로 다시 시도해도 차단되었습니다. 난독화 도구가 사실상 거부 반응을 무기로 만든 것입니다. 이제 이 스크립트는 모두가 실제로 쓰는 도구로는 역컴파일할 수 없습니다. 안전장치가 약한 오픈 웨이트 모델은 응했는데, 이는 데이터 포인트일 뿐 해결책이 아닙니다. 대부분의 분석가는 이 일을 위해 로컬 모델을 준비해 두지 않고, 대부분의 회사 정책은 검증되지 않은 인프라에 제3자 코드를 붙여넣는 것을 어차피 금지합니다.
두 번째 실패는 더 미묘하고 더 위험합니다. 분석가가 이 스크립트를 악성코드로 결론짓는 것이죠. Node 전용 코드 경로가 있는데, process.env.METRICS_AGENT_8BFD_ACTIVE로 막혀 있고, SSH 관련 설정 파일을 읽고, 10.15.45.1에서 "metrics agent"를 내려받고, 호스트를 핑거프린팅해 /tmp에 저장하고, 압축한 뒤 cron 비콘을 설치합니다. 공급망 침투 코드처럼 보입니다. 그러나 실마리를 당겨 보면 다릅니다. 환경 변수 플래그는 아무 데서도 설정되지 않고, readFile은 빈 콜백과 함께 호출되어 훔친 데이터가 어디로도 가지 않으며, C2 주소는 RFC 1918 사설 대역이고, 셸 명령은 아무도 외부로 빼내지 않는 마커 파일에 탈옥 미끼를 더 찍어 냅니다. 샌드박스에서 실행해 보면 피해 범위는 임시 디렉터리 하나입니다. 이것은 연극입니다. AI 기반 트리아지와 대충 훑어보는 사람을 위해 맞춘 미끼입니다.
현실에도 이와 비슷한 사례가 있습니다. 한 주요 모델 허브가 실제 침해 사고를 조사했을 때, 상용 모델 API가 공격자의 명령, 페이로드, C2 아티팩트 분석을 거부했다는 것을 발견했습니다. 안전장치가 인시던트 대응자와 공격자를 구별하지 못한 것입니다. 결국 그들은 자체 인프라에서 오픈 웨이트 모델로 포렌식 분석을 수행해야 했습니다. 이런 실패 양상이 규모를 키워 반복되는 것이 바로 안티-LLM 난독화가 노리는 비대칭입니다. 그래서 저는 계속해서 AI 코드 이해도 검사를 유일한 방어선으로 삼으면 안 된다고 주장합니다. 이제 모델의 판단 자체가 공개된 익스플로잇이 있는 공격 표면이 되었기 때문입니다.
난독화 도구는 더 이상 당신에게서 코드를 숨기지 않습니다. 당신의 모델에게서 코드를 숨기고, 모델 자신의 안전 훈련을 자물쇠로 쓰고 있습니다.
안티-LLM 난독화에 실제로 효과가 있는 방법
해법은 원본 소스를 모델에 그대로 먹이지 않고, 함정이 오염시킨 파이프라인 단계를 되찾는 것입니다. 구체적으로는 제가 구축한다면 다음 순서로 하겠습니다:
- 결정론적 역난독화를 먼저 합니다. Babel 기반 AST 패스를 돌리세요. 상수 폴딩, 문자열 배열 인라인화, 죽은 분기 제거, 제어 흐름 펼치기를 수행합니다. 함정은 문자열 리터럴과 식별자에 숨어 있으므로, 폴딩과 리네이밍으로 모델이 토큰 하나라도 보기 전에 무력화됩니다. 디코딩된 탈옥 프롬프트도 그저 죽은 코드 경로의 또 다른 문자열이 됩니다.
- 소스가 아니라 동작을 추적합니다. 계측된 샌드박스에서 스크립트를 실행하세요. 후킹된 API를 가진 Playwright 페이지나 격리된 realm을 쓰면 됩니다. 모든 DOM 호출, 네트워크 요청, 속성 접근, 캔버스 읽기를 기록하고, 모델에는 트레이스를 넘기세요. 동작 트레이스에는 리신 관련 언급이 전혀 없습니다. 동작에는 식별자가 없기 때문입니다.
- LLM을 돌리기 전에 식별자를 중립 토큰으로 바꿉니다.
computeRicinLethalDosageForWaterSupply는fn_0417이 됩니다. 의미적 힌트는 조금 잃지만, 오염은 모두 사라집니다. 좋은 거래입니다. - 정말 민감한 트리아지를 위해 로컬 오픈 웨이트 모델을 유지하세요. 실제 익스플로잇 킷이나 실제 피싱 키트처럼 내용이 정당하게 불쾌한 경우, 클라우드 안전장치가 없는 에어갭 모델이 분석과 거부 벽의 차이를 만듭니다.
- 감이 아니라 증거로 판정합니다. 관찰된 기능으로 스크립트를 점수화하세요. 자격 증명을 읽었는가? 바이트가 페이지 밖으로 나갔는가? 소스가 자격 증명을 언급하는지가 아니라 이 기준으로 봐야 합니다.
패턴이 보입니다. 모든 기법은 "코드가 무엇을 말하는지 모델에게 묻는다"를 "코드가 무엇을 하는지 관찰하고, 그 관찰에 대해 모델에게 묻는다"로 바꿉니다. 이것은 자율 에이전트에 대해 제가 계속 돌아가는 원칙과 같습니다. 격리가 모니터링을 이긴다는 것이죠. 산출물의 자기 설명을 믿지 말고, 가두고, 경계를 계측하고, 출력으로 판단하세요.
서드파티 스크립트가 모델 제공자와 대화하고 있습니다
이제 리버스 엔지니어링을 취미로 하지 않는 사람들도 걱정해야 할 부분입니다. 이 스크립트는 어두운 구석에 호스팅되는 것이 아닙니다. 고객 웹사이트에서, 고객 도메인 아래에서, 고객 브랜드 옆에서 제공되는 봇 탐지 검사입니다. 즉, 페이지를 수집하는 모든 크롤러, LLM 기반 콘텐츠 분류기, 신뢰·안전 파이프라인은 이제 회사 로고와 함께 생물무기 합성 요청과 탈옥 프롬프트도 수집하게 됩니다. 고객은 거의 확실히 모를 것이고, 벤더는 묻지도 않았습니다.
자신이 이해하지 못하는 콘텐츠에 작동하는 자동화된 분류기는 가설이 아닙니다. 실무자들은 보이지 않는 워터마크 문자열이 메일 필터를 건드려 발신 문서가 조용히 격리 상태로 사라지는 것을 봐 왔습니다. 이 실패 양상을 키워 보세요. 브랜드 안전 분류기가 당신의 도메인을 극단주의나 생물학적 위험 콘텐츠와 인접하다고 판단하면, 왜 도달 범위가 무너졌는지 아무도 알려주지 않습니다. 이의 제기도 할 수 없습니다. 증거가 당신이 작성하지 않은 파일 안의 보이지 않는 유니코드이기 때문입니다.
그리고 흔한 완화책도 통하지 않습니다. 벤더가 배포마다 스크립트 해시를 바꾸면 Subresource Integrity는 쓸모가 없고, 이 스크립트들은 부분적으로는 고정(pinning)을 무력화하기 위해 끊임없이 바뀝니다. 해시를 쓰는 엄격한 CSP도 마찬가지로 금방 무효화됩니다. 남는 결론은 불편합니다. LiteLLM 공급망 침해 이후 우리가 도달했던 결론과 같습니다. 검토할 수 없는 의존성이 결국 당신을 소유한다는 것입니다. 보안팀이 쓰는 도구로는 역컴파일할 수 없도록 벤더 코드가 의도적으로 설계되었다면, 그것은 기능이 아닙니다. CDN URL이 붙은 거버넌스 실패입니다.

이 군비 경쟁은 어디로 가는가
제 예측은 이렇습니다. 1년 안에 상용 난독화 도구에 "LLM 분석 저항"이라는 체크박스가 생기고, "제어 흐름 평탄화" 바로 옆에 놓일 겁니다. 모델 제공자들은 함정 코퍼스로 학습해 분석 맥락에 삽입된 미끼를 인식하고 무시하도록 대응할 것입니다. 난독화 도구는 더 정교한 함정으로 맞설 겁니다. 문자열이 아니라 트레이스에서 악성으로 보이는 동작을 가진 코드가 등장하겠죠. HN 댓글이 농담처럼 예측했던 프랙탈 군비 경쟁, 즉 모델에 맞춰 조율된 난독화와 난독화에 맞춰 조율된 모델은 이미 여기 있습니다. 다만 고르게 분포되어 있지 않을 뿐입니다.
지속 가능한 방어는 지루한 방어입니다. 90년대 첫 패커 이후 바뀌지 않았습니다. 동적 분석, 행위 증거, 샌드박스 실행이죠. 불투명성은 보안 속성이었던 적이 없습니다. 비용을 떠넘기는 전략이었고, 그 비용이 이제 되돌아왔습니다. 문자열 하나가 언어 모델의 기분을 상하게 해서 탐지 파이프라인이 뚫린다면, 그 파이프라인이 처음부터 취약점이었던 것입니다.


