
Обратная разработка новой виртуальной машины Datadome 🔥
Этот репозиторий содержит первую публичную версию анализа встроенной в браузер JavaScript виртуальной машины (VM) DataDome, используемой в их CAPTCHA/промежуточном потоке. Анализ охватывает:
Примечание: Этот репозиторий охватывает только одну (статическую) версию ВМ и предназначен для целей исследования безопасности и анализа. Он не включает динамические решатели или реализации промышленных решателей.
14 января 2026 года DataDome начала внедрять новый компонент на основе ВМ в свой клиентский тег.
Код ВМ был извлечён из задачи капчи в vm.js (доступен в этом репозитории).
Первым шагом была деобфускация скрипта:
Обфускация проста: оценить каждую переменную и заменить её фактическим значением. Скрипт деобфускации доступен в deobf.js.
Запуск деобфусцированного кода (out.js) в DevTools показывает ожидаемый вывод ВМ:

Вывод — это JSON-объект, содержащий два числа и строку. Теперь давайте углубимся в реализацию самой ВМ.

В начале функции Q.exports видно, как декодируется байткод:
B(), генератора псевдослучайных чисел)
-> D содержит декодированный байткод с некоторым случайным «шумом»
Прокручивая вниз, можно найти точку входа ВМ: функция с двумя параметрами A (байткод) и Q (пустой словарь для обработки ошибок).
Наиболее интересный аспект этой ВМ — её архитектура: всё хранится в одном массиве (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
После нанесения этих смещений структура ВМ становится ясной.

ВМ начинается с набора вспомогательных функций, которые обрабатывают:

После вспомогательных функций ВМ инициализирует основные значения:
Ниже инициализации находятся все обработчики инструкций.

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Это повторяет логику диспетчера, создавая цикл выборки-декодирования-исполнения, характерный для архитектур ВМ.
Чтобы облегчить анализ, был разработан прототип дизассемблера (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
Каждая строка включает:
Одним из наиболее сложных аспектов является декодирование непосредственных значений, встроенных в байткод. ВМ использует маркеры типа, чтобы указать, как интерпретировать следующие байты:
Простые типы (без дополнительных данных):
0x28 → true0x7D → false0x4C → null0x3D → undefinedМаленькие целые числа (0-127): Кодируются с установленным старшим битом
0x85 → 5 (0x80 | 5)Строки: XOR-кодированные и завершающиеся нулём
0x67, начальный ключ XOR 1830x27, начальный ключ XOR 46Числовые типы:
0x6F + 1 байт0x61 + 2 байта (big-endian)0x65 + 3 байта (big-endian)0x54 + 4 байта (big-endian)0x05 + 8 байтXOR-кодирование для строк простое, но предотвращает поверхностный просмотр:
let str = '';
let xorKey = 183; // Начальный ключ для строк ASCII
let ch;
while ((ch = readByte() ^ (xorKey++ & 0xFF)) !== 0) {
str += String.fromCharCode(ch);
}
Некоторые опкоды требуют специальной обработки:
CLOSURE (опкод 136): Создаёт функции/замыкания с захваченными переменными
Формат: CLOSURE locals, capture_count, [capture_indices...], skip_offset
Смещение пропуска указывает на конец тела функции, позволяя ВМ пропустить определение функции при линейном выполнении.
PUSH_MULTI_IMM (опкод 96): Помещает несколько значений сразу
Формат: PUSH_MULTI_IMM count, val1, val2, ...
# Дизассемблировать из файла
node disasm.js bytecode.txt
; DataDome VM Disassembly
; Bytecode size: 5428 bytes
; VM Constants: 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
...
Такой формат вывода позволяет:
Я всё ещё довольно новичок в ВМ, так что относитесь ко всему с долей скептицизма. ИИ использовался для документирования кода и написания частей этого README (потому что писать документацию — это боль).
Это исключительно для образовательных целей / целей исследования безопасности. Никаких решателей или обходов — только документирование того, как работает ВМ, потому что это действительно интересно.
DataDome: если вы это читаете, привет!!! Это просто я пытаюсь получить стипендию 🙏. Пожалуйста, не судите меня, я на мели. Если у вас есть какие-либо проблемы с этим репозиторием, просто дайте мне знать, и мы сможем обсудить это :)
извините тех, кто думал, что я буду рассказывать о внутренностях ВМ... этого не будет
НЕ ПИШИТЕ МНЕ В ЛИЧКУ С ПРОСЬБОЙ ДАТЬ API DATADOME, Я НЕ БУДУ ПОМОГАТЬ