做游戏安全、爬虫逆向或者Web防护研究的朋友,一定遇到过这种“绝望时刻”:F12打开某个游戏平台的登录或支付接口,发现核心校验逻辑根本不在正常的JS函数里,而是被塞进了一个巨大的switch-case分发器中。变量名全是_0x4a2c,没有正常的函数调用栈,甚至连字符串都是运行时动态解码的。
恭喜你,你撞上了JSVM(JavaScript Virtual Machine)虚拟机保护。
这不是普通的代码混淆,而是一种指令级翻译。攻击者(或保护方案提供商)把原始的JS逻辑编译成了一套自定义的字节码指令集,再用一个JS写的“虚拟CPU”来解释执行。传统的de4js、JSNice在这种保护面前基本失效——因为它们还原的只是“虚拟机解释器”本身,而不是你真正关心的业务逻辑。
今天这篇不讲虚的,直接以某游戏平台签名校验为例,拆解如何从字节码层面还原JSVM保护,把加密逻辑从“黑盒”变成可读的原生代码。
一、先认清敌人:JSVM和普通混淆的本质区别
在动手之前,必须建立一个关键认知:JSVM不是混淆,是编译。
| 对比维度 | 普通混淆 (Obfuscation) | JSVM虚拟机保护 |
|---|---|---|
| 本质 | 语法等价变换,AST结构不变 | 指令级翻译,生成自定义字节码 |
| 执行方式 | 浏览器原生JS引擎直接执行 | 自研解释器循环分发字节码 |
| 还原目标 | 恢复原始源码结构 | 反编译字节码→重建原生逻辑 |
| 工具链 | de4js / JSNice / Prettier | 自定义反编译器 + 动态调试 |
| 难度等级 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
💡核心判断标准:如果你在源码中看到一个大数组存放着数字序列,配合一个
while(true) + switch(dispatcher)的结构,且所有业务运算都通过数组索引和位操作完成——这就是JSVM的典型特征。那个大数组就是字节码段,switch里的case就是虚拟指令实现。
二、逆向四步法:从字节码提取到逻辑重建
下面以某游戏平台请求签名生成为例,演示完整的JSVM逆向流程。
第一步:定位虚拟机三要素
任何JSVM都由三个核心组件构成,找到它们就找到了突破口:
- 字节码数组(Code Array):通常是一个Uint8Array或普通数组,存放编译后的指令序列
- 分发器(Dispatcher):
while(true) { switch(opcode) { ... } }结构,负责读取并执行指令 - 虚拟寄存器/栈(Virtual Stack):用于存储中间计算结果,通常是另一个数组
实战技巧:在Chrome DevTools的Sources面板,用正则搜索case\s+\d+:密度最高的函数,大概率就是分发器入口。字节码数组通常在分发器函数的闭包变量或模块顶层定义。
【JSVM架构示意图】 ┌─────────────────────────────────────┐ │ 原始JS业务逻辑 │ │ sign = md5(url + ts + secret) │ └──────────────┬──────────────────────┘ │ JSVM编译器 ▼ ┌─────────────────────────────────────┐ │ 字节码: [0x01, 0x03, 0x0A, ...] │ ← Code Array ├─────────────────────────────────────┤ │ while(true) { │ │ op = code[pc++]; │ ← Dispatcher │ switch(op) { │ │ case 0x01: stack.push(...) │ ← Virtual Stack │ case 0x03: a=stack.pop();... │ │ case 0x0A: call_native(...) │ │ } │ │ } │ └─────────────────────────────────────┘第二步:导出字节码 + 构建指令映射表
这是最关键的一步。你需要知道每个opcode对应什么操作。
方法A:静态分析分发器
逐个阅读switch-case,手动记录opcode → 语义的映射。比如:
0x01: PUSH_IMMEDIATE(压入立即数)0x03: ADD(栈顶两元素相加)0x0A: CALL_NATIVE(调用外部原生函数)0x1F: XOR(异或运算)
方法B:动态Trace(推荐)
在分发器的switch入口处插桩,hook住op变量和虚拟栈状态,触发一次签名请求,导出完整的执行trace。然后对trace做频次统计和模式匹配,自动推断指令语义。
🔍实战经验:很多JSVM会把字符串、常量单独存放在一个“常量池”数组中,字节码里的索引指向的是常量池而非直接值。务必同时导出常量池,否则反编译出来的代码全是数字,无法理解。
第三步:编写反编译器,字节码→伪代码
有了指令映射表和字节码序列,就可以写反编译器了。不需要做得很完美,目标是生成可读的伪代码,而非可执行的JS。
核心思路:模拟虚拟栈的行为,将字节码序列转换为等价的表达式树或三地址码。
# 伪代码示例:简单的栈式反编译器核心逻辑defdecompile(bytecode,const_pool):pc=0output=[]whilepc<len(bytecode):op=bytecode[pc]ifop==0x01:# PUSHval=const_pool[bytecode[pc+1]]output.append(f"push({val})")pc+=2elifop==0x03:# ADDoutput.append("add(pop(), pop())")pc+=1elifop==0x0A:# CALL_NATIVEfunc_idx=bytecode[pc+1]output.append(f"call(native_funcs[{func_idx}])")pc+=2# ... 其他指令returnoutput这一步的输出可能长这样:
push(const[12]) // "https://api.game.com/sign" push(reg[3]) // timestamp add() // url + ts push(const[45]) // secret_key xor() // (url+ts) ^ secret call(md5) // md5(...) store(reg[7]) // sign = result虽然粗糙,但加密逻辑已经清晰可见。
第四步:验证与重构
反编译出的伪代码需要验证正确性。
验证方法:在原始JSVM环境中,对相同输入执行签名,对比输出是否与你的伪代码推导一致。如果不一致,说明指令映射表有误或遗漏了某些隐式操作(如隐式类型转换、溢出处理)。
验证通过后,就可以将伪代码重写为干净的原生JS,替换掉原有的JSVM调用,完成最终的还原。
三、踩坑预警:JSVM逆向的三个深水区
- 指令编码动态化:高级JSVM每次加载时opcode含义会变(通过一个shuffle数组重映射)。解决方案:必须先还原shuffle算法,或在运行时动态捕获映射关系。
- 嵌套虚拟机:解释器本身也被另一层VM保护。这种情况需要先脱外层VM,再分析内层。通常需要结合AST去平坦化和动态dump。
- 环境绑定检测:字节码执行过程中会检查
window.navigator、canvas指纹等环境特征,不满足则返回错误签名。必须在Node.js或Puppeteer中补全环境,或在浏览器中绕过检测后再trace。
【JSVM逆向决策流程图】 发现疑似JSVM保护 ↓ 能否定位三要素(字节码/分发器/栈)? ──否──→ AST去平坦化 / 动态Dump ↓ 是 opcode是否动态编码? ──是──→ 还原Shuffle算法 / 运行时Hook ↓ 否 构建指令映射表(静态/动态Trace) ↓ 编写反编译器 → 生成伪代码 ↓ 输入输出验证 ──失败──→ 修正映射表 / 检查环境检测 ↓ 成功 ✅ 重建原生加密逻辑四、写在最后:逆向JSVM的真正价值
很多人觉得JSVM逆向耗时耗力,不如直接Hook拿结果。这话没错,但只适用于“一次性任务”。
如果你需要长期稳定地对接某个平台、需要理解其风控策略的变化、或者在做安全审计需要评估保护强度——那么指令级还原是唯一可靠的路径。Hook只能拿到“是什么”,反编译才能告诉你“为什么”和“怎么变的”。
给准备入手JSVM逆向的朋友三个建议:
- 先从开源JSVM练手:如JSMerger、Bytenode、obfuscator-io的VM选项,理解原理后再碰商业保护。
- 建立自己的指令识别模板库:不同厂商的JSVM指令集有共性,积累多了可以半自动化识别。
- 永远保留动态调试作为兜底:静态反编译不可能100%覆盖,遇到复杂分支时,回到DevTools里单步跟踪虚拟栈,是最可靠的验证手段。
JSVM保护看似铜墙铁壁,但它终究是在JS引擎上跑的“软件模拟”。只要是软件,就有迹可循。耐心拆解,你会发现那些神秘的字节码背后,不过是开发者精心包装过的、你早已熟悉的加密逻辑而已。
本文所述技术仅供安全研究、漏洞分析及合法授权测试使用。未经授权对第三方系统进行逆向工程可能违反相关法律法规及服务条款,请严格遵守法律底线,尊重知识产权。