本仓库记录了 DataDome 在其 CAPTCHA/插页流程中使用的浏览器内 JavaScript 虚拟机(VM)的第一个公开版本。本分析涵盖:
注意: 本仓库仅覆盖一个(静态)VM 版本,仅供安全研究与分析目的。它不包含动态求解器或生产求解器实现。
2026 年 1 月 14 日,DataDome 开始在其客户端标签中推送一个新的基于 VM 的组件。
VM 代码已从验证码挑战中提取并保存到 vm.js(可在此仓库中找到)。
第一步是反混淆脚本:
混淆方式很直接:评估每个变量并将其替换为实际值。反混淆脚本可在 deobf.js 中找到。
在 DevTools 中运行反混淆后的代码(out.js)会显示 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 初始化核心值:
初始化下方是所有指令处理函数。
调度器是主 VM 循环,一直运行直到 `exit_flag` 被设置:
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]]; // 弹出函数
var B = A[--A[stack_pointer]]; // 弹出 '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]]; // 第一次弹出
var g = A[--A[stack_pointer]]; // 第二次弹出
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 架构中典型的取指-解码-执行循环。
为辅助分析,开发了一个概念验证的反汇编器(disasm.js),用于将 VM 的字节码转换为人类可读的汇编代码。
反汇编器分两遍运行:
第一遍扫描字节码以识别所有跳转目标。包括:
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):高位设置为1编码
0x85 → 5 (0x80 | 5)字符串:XOR 编码且以 null 结尾
0x67,XOR 密钥起始为 1830x27,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 locals, capture_count, [capture_indices...], skip_offset
跳转偏移指向函数体之后,允许 VM 在线性执行期间跳过函数定义。
PUSH_MULTI_IMM(操作码 96):一次性推入多个值
格式: PUSH_MULTI_IMM count, val1, val2, ...
# 从文件反汇编
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 还不太熟悉,所以请对一切持保留态度。AI 被用于帮助编写代码文档和本 README 的部分内容(因为写文档很痛苦)。
这纯粹出于教育/安全研究目的。不包含任何求解器或绕过方法——仅仅是为了记录 VM 的工作原理,因为确实很有趣。
DataDome:如果你们看到了这个,你们好!!!我只是想争取一份奖学金🙏。请不要起诉我,我穷得叮当响。如果你们对这个仓库有任何异议,请告诉我,我们可以谈谈 :)
抱歉让那些以为我会讨论 VM 内部的人失望了……那是不可能的。
不要私信我索要 DataDome API,我不会帮你的。