LLM 분석으로부터 함수 이름 숨기기
문제
vmObfuscation: true를 켜고 validateLicense 같은 함수가 들어 있는 파일에 적용했는데, 난독화된 결과물에 여전히
validateLicense라는 문자열이 그대로 남아 있는 것을 발견했습니다. 본문은 사라지고 바이트코드로 대체되었지만, 이름 자체는
훤히 드러나 있습니다.
// Input
function validateLicense(token) {
const decoded = decodeBase64(token);
return verifySignature(decoded);
}
// Output - name is preserved, body is bytecode
function validateLicense(b) {
return vmq_1bac70(0x5, [], undefined, undefined, undefined, this);
}
실제 코드베이스 전체에 이런 일이 벌어지면 validateLicense, decryptPayload, processPayment, checkSubscription 같은
함수 이름 목록이 그대로 남습니다. LLM은 프로그램이 무슨 일을 하는지 파악하기 위해 바이트코드를 깨뜨릴 필요가 없습니다. 이름만
있어도 모듈의 동작을 정확하고 자신 있게 요약해 낼 수 있습니다. 바이트코드는 불투명하지만 목차는 그렇지 않습니다.
VM 난독화가 이 이름들을 남겨두는 이유
vmTargetFunctionsMode: 'root'(기본값)에서 난독화 도구는 모든 최상위 함수의 본문을 VM 바이트코드로 변환하지만
이름은 의도적으로 그대로 둡니다. 최상위 함수 선언은 의미상 주변 스코프에 대한 바인딩이며, 스크립트에서는 전역 객체를,
모듈에서는 모듈 네임스페이스를 뜻합니다. 난독화 도구는 그 이름을 또 누가 참조하는지 알 방법이 없기 때문에 안전하게 이름을 바꿀
수 없습니다. 다른 번들일 수도, 인라인 <script>일 수도, HTML의 onclick="validateLicense(...)" 속성일 수도, 동적
window['validateLicense'] 조회일 수도 있으니까요.
그래서 기본값이 택한 절충안은 이렇습니다. 구현은 보호하되 공개된 표면은 보존한다. 덕분에 연동이 깨지지 않지만, 동시에 LLM에게 모든 진입점의 색인을 공짜로 넘겨주는 셈이 됩니다.
LLM을 활용한 리버스 엔지니어링에서 이것이 중요한 이유
바이트코드 디스패치 코드 수백 줄을 마주한 사람 공격자는 대개 포기합니다. 하지만 같은 파일을 받은 LLM은 바이트코드를 공격할 생각조차 하지 않습니다. 이름을 읽고, 눈에 보이는 몇 개의 문자열 리터럴과 대조한 뒤 다음과 같은 결과를 내놓습니다.
이 정도 요약이면 공격자는 VM을 건드리지 않고도 표적화된 우회를 계획할 수 있습니다. 이름이 곧 유출입니다.
해결책: 코드를 IIFE로 감싸기
이 유출을 없애는 가장 단순하고 확실한 방법은 민감한 함수를 스코프 트리에서 한 단계 더 깊이 밀어 넣는 것입니다. 다른 함수 안에 선언된 함수는 최상위가 아니므로, 난독화 도구가 자유롭게 이름을 바꾸고 다른 문장과 마찬가지로 그 선언을 바이트코드에 흡수할 수 있습니다.
IIFE(즉시 실행 함수 표현식)는 이를 구현하는 가장 가벼운 방법입니다. 한 번만 실행되고 이름으로는 아무것도 노출하지 않는 감싸는 함수 하나를 추가할 뿐입니다.
적용 전 - 이름이 노출됨
function validateLicense(token) {
const decoded = decodeBase64(token);
return verifySignature(decoded);
}
function checkExpiry(license) {
return Date.now() < license.expiresAt;
}
document.querySelector('#activate').addEventListener('click', () => {
const token = document.querySelector('#token').value;
if (validateLicense(token)) {
unlockUI();
}
});
VM 난독화 후에도 validateLicense와 checkExpiry는 결과물에 이름 그대로 남습니다.
적용 후 - IIFE 뒤로 이름이 감춰짐
(function () {
function validateLicense(token) {
const decoded = decodeBase64(token);
return verifySignature(decoded);
}
function checkExpiry(license) {
return Date.now() < license.expiresAt;
}
document.querySelector('#activate').addEventListener('click', () => {
const token = document.querySelector('#token').value;
if (validateLicense(token)) {
unlockUI();
}
});
})();
이제 두 함수 선언 모두 IIFE의 본문 안에 있습니다. 최상위 구성 요소는 IIFE 하나뿐이고, 익명 IIFE에는 유출될 이름이 없습니다. VM 난독화 후에는 그 안의 모든 선언을 포함한 본문 전체가 바이트코드로 변환되어 인코딩되므로, IIFE 안의 어떤 것도 읽을 수 있는 텍스트로 남지 않습니다.
함수가 정말로 전역이어야 한다면?
어떤 함수는 실제로 공개 진입점이기도 합니다. 인라인 이벤트 핸들러, JSONP 콜백, 서드파티 SDK 훅 같은 경우입니다. 두 가지 선택지가 있습니다.
얇은 트램펄린만 노출하고 로직은 IIFE 안에 두기. IIFE 스코프의 구현을 호출하는 것만이 유일한 역할인 작은 전역 래퍼를 선언하세요. 트램펄린 이름은 여전히 유출되지만 아무 의미도 담고 있지 않으므로(
__entry1같은 이름을 쓰세요) 의미 있는 로직은 모두 감춰집니다.var __entry1; (function () { function validateLicense(token) { /* … */ } __entry1 = validateLicense; })(); // Outside code calls __entry1(token) instead of validateLicense(token).통제할 수 없는 호출부를 고쳐 쓰기. 인라인
onclick="validateLicense(...)"가 필요해서 전역이 존재하는 것뿐이라면, 그 인라인 핸들러를 IIFE 내부의addEventListener로 바꾸세요. HTML이 더 이상 함수 이름을 언급하지 않게 되고, 함수도 전역일 필요가 없어지며, 유출은 완전히 사라집니다.
최상위 변수 초기화식: vmWrapTopLevelInitializers
파일 최상위에 놓이는 것이 함수 선언만은 아닙니다. 문자열 상수, 설정 객체, 조회 테이블 같은 최상위 변수 초기화식도 기본
설정에서는 결과물에서 똑같이 읽힙니다. const API_BASE = '/api/v2/license' 같은 한 줄은 LLM에게 function validateLicense
못지않은 정보를 줍니다.
vmWrapTopLevelInitializers 옵션(불리언, 기본값 false)은 대상이 되는 최상위 초기화식을 IIFE로 감싸서, 값 자체가 소스에
리터럴로 남지 않고 런타임에 VM 바이트코드로 계산되게 합니다.
옵션을 쓰지 않은 경우
// Input
const MY_STRING = 'my-string';
// Output - string is visible
const MY_STRING = 'my-string';
vmWrapTopLevelInitializers: true를 쓴 경우
// Input
const MY_STRING = 'my-string';
// Output - initializer is now a VM call, the string lives inside bytecode
const MY_STRING = (() => {
return vmq_1bac70(0x5, [], undefined, undefined, undefined, this);
})();
바인딩 이름(MY_STRING)은 함수 이름과 같은 이유로 여전히 최상위에 남습니다. 파일 바깥의 무언가가 이를 참조할 수 있기
때문입니다. 하지만 그 이름이 담고 있는 값 은 이제 VM이 만들어 내므로 읽을 수 있는 텍스트로는 나타나지 않습니다.
이것만으로 충분하지 않은 경우
- 다른 모듈에서 가져오는 이름. 여러 파일을 번들링하면서 한 모듈이 다른 모듈이 가져다 쓸
validateLicense를 내보낸다면, 번들러는 최상위 함수와 마찬가지로 그 이름을 번들 결과물에 그대로 남깁니다. 번들 자체를 IIFE로 감싸거나(대부분의 번들러가 지원합니다), export를 IIFE 안으로 옮긴 뒤 의미 없는 트램펄린을 통해 다시 노출하세요.
