偶然看到两篇有意思的文章[12],说利用了 LLVM 编译器协助破解号称最强大的软件保护工具之一 VMProtect,本文简单介绍下,不过涉及 LLVM 编译器的篇幅很少。

1、VMProtect

这里先跑个题,介绍一下 VMProtect,看了一下也挺有趣的。

VMProtect 是强大的软件保护工具,广泛应用于游戏反外挂、商业软件防破解等领域。

VMProtect 支持 x86/x86_64/ARM64 二进制文件以及使用 C/C++、C#/VB .NET、Rust 和 Golang 编译的 .NET 程序集,适用于所有主流操作系统:Windows、Linux、macOS 和 Android。

1.1、原理

VMProtect 的核心原理是代码虚拟化,执行流程如下:

  1. 编译转换:VMProtect 会把原本的指令转换成一套只有它自己懂的私有指令集(Bytecode/字节码)。
  2. 嵌入引擎:在程序里塞入一个解释器(Virtual Machine Interpreter)。
  3. 运行时:当程序运行到被保护的代码时,物理 CPU 不再直接执行原来的逻辑,而是跳转到执行这个“解释器”。解释器读取那些私有的字节码,模拟出原本的指令操作。

除了虚拟化,VMProtect 还组合了多种技术来恶心破解者:

  • 指令变形(Mutation): 把简单的指令变得极其复杂。
  • 控制流平坦化(Control Flow Flattening): 打乱代码的执行顺序。
  • 多态性(Polymorphism): 这是 VMP 最强的地方。每次编译保护同一个程序,生成的虚拟 CPU 架构、指令编码、解释器逻辑全都不一样。

1.2、对比

和传统加壳方式对比如下:

  • 普通压缩壳/加密壳
    • 程序开始运行时,壳的代码先运行,把原本的代码在内存中解压/解密还原来运行。
    • 破解者可以在运行时直接分析软件。
  • VMProtect
    • 编译时把指令集机器码换成全新的、私有的中间语言,运行时由自定义虚拟机执行。
    • 破解者需要花功夫逆向虚拟机中的解释逻辑。

1.3、使用方式

可以在源码开发时进行标记。

1
2
3
4
5
6
7
8
// 核心算法函数
void CheckLicenseKey() {
	// 告诉 VMP 开始保护
	VMProtectBegin("MySuperSecretKeyCheck");
	// ... 这里是复杂的验证逻辑 ...
	// ... 原本的代码会被 VMP 抽走,变成字节码 ...
	VMProtectEnd(); // 保护结束
}

如果不想改代码,或者没有源码。开发者可以在 VMProtect 的软件界面里打开编译好的 EXE 文件。 VMP 会分析出所有的函数列表。开发者像点菜一样,手动勾选。

1.4、优缺点

优势:

  • 极高的逆向难度。虚拟化的保护效果很强。
  • 防篡改能力强。VMP 具有完整性校验功能,如果检测到内存校验失败,就会崩溃。
  • 跨平台兼容性。 虽然生成的字节码是私有的,但解释器是跨平台的。

劣势:

  • 性能损耗。解释器指令影响执行效率。
  • 文件体积膨胀。引入了额外指令和虚拟机引擎。
  • 杀毒软件误报。病毒和木马也喜欢用 VMProtect 来隐藏自己的恶意代码。
  • 调试困难。如果程序在被保护的代码段里崩溃了,开发者自己都没法很好地调试。

2、破解 VMProtect

好了,终于到正题了。破解的思路可以总结为:既然 VMP 把代码变成了复杂的“垃圾指令”,那就想办法把这些指令翻译成 LLVM 能看懂的 IR 语言,然后让 LLVM 的优化器帮忙把垃圾清理掉!

该作者的研究目标是纯函数 。纯函数定义为:对于相同的输入,始终产生相同的输出,且不依赖于随机性、全局变量或 I/O 操作。由于这些特性,纯函数特别适合去虚拟化。

具体步骤如下:

  1. Trace。用工具(Intel Pin)全程录下程序运行时的每一个动作。
  2. Symbolic Execution。分析找出输入和输出的关系。
  3. Lifting。把分析出来的逻辑写成 LLVM IR。
  4. Optimization。交给 LLVM 优化器优化,只留下最核心的逻辑。
  5. Recompile。最后把清理干净的代码重新编译成普通的机器码。

2.1、Trace

Pin 是英特尔提供的动态二进制插桩 (DBI) 框架,支持 Windows 和 Linux 环境下的 x86 和 x64 二进制文件。Pin 不直接修改目标程序,而是通过基于 JIT(即时编译)的代码转换来干预执行流程。

但是记录全部会比较多,所以首先要找到跳转 VMP 入口的地方,通常是跳转到奇怪的地方的指令。

2.2、Symbolic Execution

通过记录程序运行轨迹,作者抓到了大量的指令流。但不需要看懂每一行,只需要关注状态的变化

Triton(一个动态二进制分析框架) 能在程序运行时,“看穿”每一条 CPU 指令背后的逻辑,并用数学公式来表达这些逻辑。作者利用 Triton 来模拟执行这些指令,把复杂的数学运算(比如 VMP 喜欢用的 MBA 表达式)简化成原本的样子。

跑题一下,Triton 的工作流程可以想象成一个 “翻译 + 求解” 的过程。核心机制由以下三个引擎驱动:

  • 指令语义提升(Lifting to AST)
    • 当 CPU 执行一条机器码(例如 add eax, ebx)时,Triton 不仅仅是执行它,还会把它翻译成一种抽象语法树(AST)。这样就把冰冷的机器码变成了可以被程序理解和操作的数学结构。
  • 符号执行引擎(Dynamic Symbolic Execution, DSE)
    • Triton 内置了 SMT 求解器接口(通常连接 Z3 求解器),并且维护着一个“符号状态”。假设程序要求输入一个数字 x,然后执行 if (x + 5 == 10)。它把 x 标记为一个符号变量(SymVar),把这个公式扔给 Z3,Z3 会瞬间算出 SymVar_0 = 5,从而告诉你:“只要输入 5,程序就会走进 True 分支”。
  • 污点分析引擎(Taint Analysis)
    • 它可以给特定的数据(比如从网络接收的数据或用户输入)打上“标签”(Taint)。这能让你看到攻击者控制的数据在程序里传播了多远,是否流向了 system()strcpy() 等敏感函数。

2.3、Lifting & Optimization & Recompile

看透了本质后(即 Triton 简化后),作者把这些逻辑翻译成 LLVM IR(中间表示)。因为 LLVM 自带极强的优化器,然后就把那些翻译好的、看起来还是很冗长的 LLVM IR 扔给优化器。

于是,LLVM 大手一挥,把 VMP 精心设计的混淆代码全部删掉,只留下了最核心的那几行逻辑。

最后转成普通的机器码,就可以愉快地分析软件了。

参考