16、VMP核心原理:指令集转换、解释器实现、控制流平坦化
VMP,全称是Virtual Machine Protection。说白了,就是给你的代码造一个“虚拟机”。
我最早接触VMP是在2016年,当时一个客户的应用被脱壳机扒得干干净净。DEX加壳、函数抽取,全都没扛住。最后我上了VMP,才算是真正把核心算法保住了。嗯,今天我们就来聊聊VMP的三个核心:指令集转换、解释器实现、控制流平坦化。
16.1 指令集转换:把你的代码翻译成“外星语”
VMP的第一步,是把原始的Dalvik字节码或ARM指令,转换成自定义的虚拟指令集。这个过程,我习惯叫它“指令集转换”。
你想想看,原来的指令是公开的、标准的,比如invoke-virtual、iget这些,反编译工具一认一个准。但VMP会把这些指令映射成另一套完全不同的编码。比如:
invoke-virtual→0xAB 0x12 0x34iget→0xCD 0x56return-void→0xEF
这套映射表是开发者自己定义的。我在项目中遇到过一种做法:用随机数生成指令编码,每次加固都不一样。这样一来,就算攻击者拿到了你的VMP解释器,他也得先猜指令编码。
核心要点:指令集转换的本质是“编码替换”。但要注意,不是简单的一对一替换。有些复杂指令(比如invoke-virtual-range)需要拆成多条虚拟指令,或者合并成一条复合指令。这取决于你的VMP设计。
举个例子,原始代码:
const/4 v0, 0x1
const/4 v1, 0x2
add-int v2, v0, v1
return v2
转换后可能变成:
PUSH 0x01 ; 虚拟指令:压入常量1
PUSH 0x02 ; 虚拟指令:压入常量2
ADD ; 虚拟指令:弹出两个值相加,结果压栈
POP v2 ; 虚拟指令:弹出栈顶到v2
RET v2 ; 虚拟指令:返回v2
你看,原来的寄存器操作变成了栈操作。这就是VMP的“变形”能力。
16.2 解释器实现:你的代码在“虚拟机”里跑
指令集转换完了,谁来执行这些虚拟指令?答案就是解释器。
解释器本质上是一个无限循环,不断读取虚拟指令、解码、执行。我见过最简单的解释器长这样:
while (true) {
opcode = fetch_byte(); // 取指令
switch (opcode) {
case OP_PUSH:
value = fetch_byte();
stack.push(value);
break;
case OP_ADD:
a = stack.pop();
b = stack.pop();
stack.push(a + b);
break;
case OP_RET:
return stack.pop();
// ... 其他指令
}
}
但实际项目中,没人会用这么“裸”的解释器。为什么?因为太容易被识别了。攻击者看到while(true) + switch,基本就能断定这是VMP解释器。
我的经验:解释器一定要做混淆。我常用的手法是:
- 把
switch改成if-else链,然后打乱顺序 - 用间接跳转(
goto *table)代替switch - 在解释器循环里插入大量垃圾指令
- 把解释器拆成多个函数,用函数指针跳转
我曾经见过一个商业VMP,它的解释器用了2000多行代码,里面掺杂了各种数学运算、内存操作,真正的指令分发逻辑只占其中一小部分。攻击者想逆向这个解释器,得先花两周时间理清哪些代码是“真的”。
16.3 控制流平坦化:让代码变成“意大利面条”
控制流平坦化,是VMP里我最喜欢的技术。它能把一个正常的控制流图,压成一张“平铺”的图。
正常的代码,有if-else、for循环、switch,控制流图是有层次、有结构的。但平坦化之后,所有基本块都变成了“平级”的,通过一个分发器来切换执行顺序。
我画了一张图,帮你理解这个过程:
你看,平坦化之后,所有基本块都变成了“平级”的。每个基本块执行完后,都会跳回分发器,由分发器决定下一个执行哪个基本块。攻击者看到的,就是一个巨大的while(true) + switch,完全看不出原来的if-else结构。
注意:控制流平坦化不是万能的。如果攻击者使用动态分析工具(比如Unicorn、Frida),跟踪状态变量的变化,还是能还原出原始控制流的。所以,我建议把平坦化和指令集转换结合起来用——让攻击者即使知道了控制流,也看不懂指令的含义。
16.4 三者的协同工作
这三个技术不是孤立的。在实际的VMP实现中,它们是协同工作的:
| 技术 | 作用 | 对抗目标 |
|---|---|---|
| 指令集转换 | 把原始指令变成自定义编码 | 静态反编译工具(Jadx、GDA) |
| 解释器实现 | 执行虚拟指令的运行时环境 | 动态调试器(IDA Pro、GDB) |
| 控制流平坦化 | 打乱代码执行顺序 | 控制流分析工具(angr、BinDiff) |
我个人的习惯是:先用指令集转换把核心算法“加密”,然后用控制流平坦化把解释器本身“打乱”,最后再给解释器加上反调试和代码自修改。这样一套组合拳下来,大部分攻击者都会选择放弃。
避坑指南:我曾经在某个项目里,只做了指令集转换,没做平坦化。结果攻击者用IDA Pro的脚本,直接把解释器的switch结构还原了,然后根据指令编码反推出了原始逻辑。从那以后,我再也不敢省掉平坦化这一步了。
16.5 性能与安全的权衡
VMP不是免费的午餐。它带来的安全提升,是以性能损失为代价的。
我实测过一组数据:
- 纯DEX代码:执行时间 1x
- 加ProGuard:执行时间 1.05x(几乎无影响)
- 加DexGuard:执行时间 1.2x - 1.5x
- 加VMP(指令集转换+解释器):执行时间 3x - 10x
- 加VMP(全套:指令集转换+解释器+平坦化):执行时间 5x - 20x
所以,我建议只对核心代码使用VMP。比如登录验证、支付逻辑、License校验这些。UI代码、网络请求这些,用ProGuard就够了。
嗯,VMP的核心原理就这些。指令集转换是“变脸”,解释器是“舞台”,控制流平坦化是“剧本”。三者配合,才能演好这场“代码保护”的大戏。
公众号:蓝海资料掘金营,微信deep3321