
새로운 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);
}
일부 오퍼코드는 사용자 정의 처리가 필요합니다:
CLOSURE (오퍼코드 136): 캡처된 변수로 함수/클로저 생성
형식: CLOSURE 지역변수수, 캡처수, [캡처인덱스...], 건너뛸오프셋
건너뛸 오프셋은 함수 본문을 지나쳐 VM이 선형 실행 중 함수 정의를 건너뛸 수 있도록 합니다.
PUSH_MULTI_IMM (오퍼코드 96): 여러 값을 한 번에 푸시
형식: PUSH_MULTI_IMM 개수, 값1, 값2, ...
# 파일에서 디스어셈블리
node disasm.js bytecode.txt
; DataDome VM 디스어셈블리
; 바이트코드 크기: 5428 바이트
; VM 상수: VM_START=5258, OPCODE_BASE=4783
000000: 88 01 00 04 CLOSURE locals=1, captures=[0, 4], body=L_0006, end=L_0a3f
L_0006:
000006: fa 67 ... PUSH_IMM "window"
00001f: 2b PUSH_WINDOW
000020: 02 SET
000021: fa 67 ... PUSH_IMM "navigator"
00003a: 19 00 00 PUSH_REG 0
00003d: fa 67 ... PUSH_IMM "navigator"
000056: ee GET
000057: 02 SET
...
이 출력 형식을 사용하면 다음을 수행할 수 있습니다:
저는 VM에 대해 아직 초보이므로 모든 내용을 가볍게 받아들이시기 바랍니다. 코드 문서화와 이 readme의 일부를 작성하는 데 AI를 사용했습니다 (문서 작성은 고통스럽기 때문입니다).
이것은 순전히 교육/보안 연구 목적입니다. 솔버나 우회 방법은 포함되어 있지 않습니다. VM이 어떻게 작동하는지 문서화하는 것뿐입니다. 정말 흥미롭기 때문입니다.
DataDome: 이 글을 읽고 계신다면 안녕하세요!!! 저는 그냥 장학금을 받으려고 하는 것뿐입니다 🙏. 고소하지 말아 주세요. 저는 완전 무일푼입니다. 이 저장소에 문제가 있으시면 알려주세요. 이야기할 수 있습니다 :)
VM 내부에 대해 이야기할 거라고 생각한 분들께 죄송합니다... 그런 일은 없습니다.
DM 보내서 DataDome API를 달라고 하지 마세요. 도와드리지 않겠습니다.