
새로운 Datadome VM 리버스 엔지니어링 🔥
이 저장소는 DataDome이 CAPTCHA/인터스티셜 흐름에서 사용하는 브라우저 내 JavaScript VM(가상 머신)의 첫 번째 공개 버전을 문서화합니다. 이 분석은 다음을 다룹니다:
참고: 이 저장소는 (정적) VM 한 버전만 다루며 보안 연구 및 분석 목적으로 작성되었습니다. 동적 솔버나 프로덕션 솔버 구현은 포함되지 않습니다.
2026년 1월 14일, DataDome은 클라이언트 태그에 새로운 VM 기반 구성 요소를 배포하기 시작했습니다.
VM 코드는 캡차 챌린지에서 추출되어 vm.js (이 저장소에 있음)에 저장되었습니다.
첫 번째 단계는 스크립트의 난독화를 해제하는 것이었습니다:
난독화는 간단합니다: 각 변수를 평가하고 실제 값으로 대체합니다. 난독화 해제 스크립트는 deobf.js에서 확인할 수 있습니다.
난독화가 해제된 코드 (out.js)를 DevTools에서 실행하면 VM의 예상 출력을 볼 수 있습니다:
출력은 두 개의 숫자와 하나의 문자열을 포함하는 JSON 객체입니다. 이제 실제 VM 구현을 살펴보겠습니다.
Q.exports 함수 시작 부분에서 바이트코드가 어떻게 디코딩되는지 확인할 수 있습니다:
B() 의사 난수 생성기 사용)를 반환합니다.
-> D는 약간의 무작위 "노이즈"가 포함된 디코딩된 바이트코드를 보관합니다.
아래로 스크롤하면 VM 진입점이 나타납니다: 두 개의 매개변수 A (바이트코드)와 Q (오류 처리를 위한 빈 딕셔너리)를 받는 함수입니다.
이 VM에서 가장 흥미로운 점은 아키텍처입니다: 모든 것이 단일 배열(A)에 저장됩니다. 이 배열에는 다음이 포함됩니다:
이 설계는 서로 다른 메모리 영역을 가진 실제 컴퓨터 아키텍처를 반영합니다. 다음 단계는 각 오프셋을 매핑하여 어떤 데이터가 어디에 저장되는지 이해하는 것입니다:
var stack_pointer = 4593
var instruction_pointer = 4635
var frame_base_pointer = 4674
var last_result = 4633
var exit_flag = 4656
var current_opcode_handler = 4685
var current_opcode_id = 4675
var stack_offset = 124482
var vm_start = 5258
이러한 오프셋이 매핑되면 VM 구조가 명확해집니다.
VM은 다음을 처리하는 헬퍼 함수 모음으로 시작합니다:
헬퍼 함수 다음으로 VM은 핵심 값을 초기화합니다:
초기화 아래에는 모든 명령어 핸들러가 있습니다.
디스패처는 `exit_flag`가 설정될 때까지 실행되는 메인 VM 루프입니다:
I는 현재 명령어를 나타냄P는 난독화를 고려한 실제 배열 오프셋current_opcode_handler로 설정하고 current_opcode_id를 업데이트
다음은 오퍼코드 핸들러의 기본 예제입니다:
%= 또는 ^=)fetch() 함수 호출가장 복잡한 오퍼코드 중 하나로 클로저/함수를 생성합니다:
A[4919] = function () {
var Q = readUint8(); // 예상 인자 수
var B = [];
for (var E = readUint8(), D = 0; D < E; D++) {
var g = readUint8();
var a = A[A[frame_base_pointer] + g];
B.push(a); // 현재 스코프에서 변수 캡처
}
var h = A[instruction_pointer] + 3; // 함수 본문의 주소 저장
A[A[stack_pointer]++] = function (E) {
// 호출 시 새 스택 프레임 설정
var e = A[stack_pointer] - E;
while (E < Q) {
A[e + E++] = undefined; // 누락된 인자를 undefined로 채움
}
A[stack_pointer] = e + Q;
for (var D = 0; D < B.length; D++) {
var g = B[D];
A[A[stack_pointer]++] = g; // 캡처한 변수 푸시
}
A[e - 2] = A[frame_base_pointer]; // 이전 프레임 포인터 저장
A[e - 1] = A[instruction_pointer]; // 반환 주소 저장
A[frame_base_pointer] = e;
A[instruction_pointer] = h; // 함수 본문으로 점프
};
fetch();
};
이 오퍼코드:
undefined로 처리이 오퍼코드는 일반 호출과 생성자 호출을 모두 처리하는 함수 호출 래퍼를 생성합니다:
A[5003] = function () {
var Q = A[--A[stack_pointer]]; // POP 함수
var B = A[--A[stack_pointer]]; // POP 'this' 컨텍스트
function E(e) { // e = 인자 수
var D = A[stack_pointer];
var g = A.slice(D - e, D); // 스택에서 인자 가져오기
if (this instanceof E) {
// 생성자 호출 (new E(...))
g.unshift(null);
var h = Function.prototype.bind.apply(Q, g);
A[stack_pointer] -= e;
try {
a = new h();
} catch (A) {
a = A.message;
}
A[A[stack_pointer]++] = a;
} else {
// 일반 함수 호출
var t;
try {
t = Q.apply(B, g);
} catch (A) {
t = A.message;
}
A[stack_pointer] -= e + 2;
A[A[stack_pointer]++] = t;
}
}
A[A[stack_pointer]++] = E;
fetch();
};
이 오퍼코드:
new)인지 일반 호출인지 감지A[4961] = function () {
var Q = {};
for (var E = readUint16(), e = 0; e < E; e++) {
var D = A[--A[stack_pointer]]; // 첫 번째 POP
var g = A[--A[stack_pointer]]; // 두 번째 POP
Q[D] = g;
}
A[A[stack_pointer]++] = Q;
fetch();
};
이 오퍼코드는 객체 리터럴을 다음과 같이 생성합니다:
object[firstPop] = secondPop각 명령어는 fetch()를 호출하여 종료되며, 이 함수는 다음 명령어를 준비합니다:
function fetch() {
var Q = A[instruction_pointer];
var B = A[vm_start + Q];
A[instruction_pointer] = Q + 1;
var E = A[4783 + B];
A[current_opcode_handler] = E;
A[current_opcode_id] = B;
}
이 함수:
current_opcode_handler와 current_opcode_id를 업데이트이는 디스패처 로직을 반영하며, VM 아키텍처의 전형적인 fetch-decode-execute 사이클을 만듭니다.
분석을 돕기 위해 VM의 바이트코드를 사람이 읽을 수 있는 어셈블리로 변환하는 개념 증명 디스어셈블러(disasm.js)가 개발되었습니다.
디스어셈블러는 두 패스로 작동합니다:
첫 번째 패스는 바이트코드를 스캔하여 모든 점프 대상을 식별합니다. 여기에는 다음이 포함됩니다:
JMP_FWD, JMP_BACK)JZ, JNZ_KEEP, JZ_KEEP)각 대상 주소는 레이블(예: L_0042)로 표시되어 제어 흐름을 더 쉽게 따라갈 수 있습니다.
두 번째 패스는 각 명령어를 어셈블리 형식의 출력으로 변환합니다:
000042: fa 00 0a PUSH_IMM 10
000045: 19 00 19 PUSH_REG 25
000048: eb ADD
각 줄에는 다음이 포함됩니다:
가장 복잡한 부분 중 하나는 바이트코드에 포함된 즉시 값을 디코딩하는 것입니다. VM은 타입 마커를 사용하여 다음 바이트를 해석하는 방법을 나타냅니다:
단순 타입 (추가 데이터 없음):
0x28 → true0x7D → false0x4C → null0x3D → undefined작은 정수 (0-127): 최상위 비트가 설정되어 인코딩됨
0x85 → 5 (0x80 | 5)문자열: XOR 인코딩되고 null로 종료됨
0x67, XOR 키는 183부터 시작0x27, XOR 키는 46부터 시작숫자 타입:
0x6F + 1바이트0x61 + 2바이트 (빅 엔디안)0x65 + 3바이트 (빅 엔디안)0x54 + 4바이트 (빅 엔디안)0x05 + 8바이트XOR 인코딩은 단순하지만 우연한 검사를 방지합니다:
let str = '';
let xorKey = 183; // ASCII 문자열의 초기 키
let ch;
while ((ch = readByte() ^ (xorKey++ & 0xFF)) !== 0) {
str += String.fromCharCode(ch);
}