一种 实验性 的动态方法,用于去虚拟化受 VMProtect 3.x 保护的纯函数
我分享一些关于以动态方法去虚拟化受 VMProtect 保护的纯函数的笔记。 如果被虚拟化的函数只包含一个基本块 (无论其大小如何),这种方法已显示出非常好的效果。当二进制文件保护算术运算时,这是一种常见场景。然而, 当目标函数包含多个基本块时,这种方法会更具实验性。 尽管如此,我们仍成功对包含 2 个基本块的样本进行了去虚拟化并重建了二进制代码,这可能表明 完全动态去虚拟化小型函数是可能的。
VMProtect 是一种软件保护方案,它通过让代码在一种非标准架构的 虚拟机中运行来保护代码。这种保护对汇编语言爱好者来说是一个绝佳的游乐场 [0, 1, 2, 3, 4, 5, 6, 11]。此外,已经有许多工具针对这种保护进行攻击 [7, 8, 9, 12, 13]。 2016 年,我们研究了 Tigress 软件 保护方案,并成功利用符号执行和 LLVM 破解了它的虚拟化。这一方法 已在 DIMVA 2018 [10] 上展示,而我想在 VMProtect 上测试它。请注意,不存在 能适用于所有二进制文件的万能解决方案,根据目标和你的需求,总是存在各种权衡。 这份微薄的贡献旨在提供一个针对 VMProtect 虚拟化的 纯函数 进行动态攻击的示例。 动态攻击的主要优势在于,它从设计上就能绕过 VMProtect 的一些静态保护, 例如自修改代码、密钥和操作数加密等。
我们所说的纯函数,是指路径数量有限且没有副作用的函数。 它可以有多个输入,但只有一个输出。下面是一个纯函数的示例:```cpp int secret(int x, int y) { int r = x ^ y; return r; }
# 方法
我们依赖一个关键直觉:混淆后的轨迹 T'(来自混淆代码 P')组合了原始代码 P 中的原始指令(原始代码中与 T' 对应的轨迹 T)以及虚拟机 VM 的指令,因此 T' = T + VM(T)。如果我们能够区分这两个指令子序列 T 和 VM(T),那么我们就能从轨迹 T' 重构原始程序 P 的一条路径。通过重复此操作以覆盖被虚拟化程序的所有路径,我们将能够重构原始程序 P。在我们的实际示例中,原始代码具有有限数量的可执行路径,这在许多涉及知识产权保护的情况下都是如此。为此,我们按以下步骤进行:
1. 识别被虚拟化的函数及其参数
2. 生成目标的 VMProtect 轨迹
3. 重放 VMP 轨迹并构建符号表达式,以获得输入与输出之间的关系
4. 对符号表达式应用优化,尽可能避免来自 VM 的指令
5. 将我们的符号表示提升到 LLVM-IR,以构建目标的一个新的未受保护版本
## 示例 1:一个简单的按位运算
让我们以以下函数作为第一个示例:它接受两个输入,并返回受 VMProtect 保护的 `x ^ y`。```cpp
int secret(int x, int y) {
VMProtectBegin("secret");
int r = x ^ y;
VMProtectEnd();
return r;
}
我们首先识别哪些函数使用了 VMProtect,以及它们有多少个参数。在我们的示例中,我们可能会得到如下内容:
仅通过阅读代码,我们就能知道该函数起始地址为 0x4011c0,有两个 32 位参数(edi 和 esi)
并在 0x4011ef 返回。这就是我们需要的全部逆向工程工作。接下来的部分将自动完成。现在我们需要
生成这个虚拟化函数的执行跟踪。为此,我们使用一个 Pintool。
它只需要一个 start 地址和一个 end 地址(在我们的示例中为 0x4011c0 和 0x4011ef),它们表示插桩的范围。
请注意,任何类型的 DBI 或模拟器都可以完成这项工作。```
$ ./pin/pin -t ./pin/source/tools/VMP_Trace/obj-intel64/VMP_Trace.so -start 4198848 -end 4198895 -- ./vmp_binaries/binaries/sample2.vmp.bin 1 2 &> ./vmp_traces/sample2.vmp.trace
你可以在这里查看结果:[here](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/HEAD/vmp_traces/sample2.vmp.trace)。该 trace 格式使用三种操作:`mr`、`r` 和 `i`。
`mr` 是指令 `i` 执行的内存读访问,`r` 是 CPU 寄存器。例如:```
mr:0x7ffda459d718:8:0x227db4f8
r:0x40200a:0x0:0x7ffda459f571:0x2:0x40200a:0x0:0x0:0x7ffda459d688:0x0:0x0:0x7feee9b80ac0:0x7feee9b8000f:0xad1c3e:0x0:0x0:0x0
i:0x89173e:8:488BB42490000000
我们有一个内存读取,从地址 0x7ffda459d718 加载了一个 8 字节常量 0x227db4f8。
该指令在地址 0x89173e 处执行,其 8 字节长操作码为 488BB42490000000,即
mov rsi, qword ptr [rsp + 0x90]。
执行前的寄存器状态如下:```python
(1) RAX = 0x40200a (9) R8 = 0
(2) RBX = 0 (10) R9 = 0
(3) RCX = 0x7ffda459f571 (11) R10 = 0x7feee9b80ac0
(4) RDX = 0x2 (12) R11 = 0x7feee9b8000f
(5) RDI = 0x40200a (13) R12 = 0xad1c3e
(6) RSI = 0 (14) R13 = 0
(7) RBP = 0 (15) R14 = 0
(8) RSP = 0x7ffda459d688 (16) R15 = 0
一旦生成了 VMP 跟踪,我们使用 [attack_vmp.py](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/HEAD/attack_vmp.py) 脚本重放它。该脚本使用 [Triton](https://github.com/jonathansalwan/Triton) 构建跟踪的路径谓词。请注意,所有涉及符号变量(函数的输入)的表达式都会保持符号化,而所有与输入无关的表达式都会被具体化。换句话说,我们的符号表达式不包含任何与虚拟机相关的操作(虚拟机机制本身不依赖于用户),而只包含与原始程序相关的操作。
例如,下面是一个具体化的示例。左侧是一个包含不涉及符号变量的子表达式(`1 + 2` 和 `6 ^ 3`)的 AST。因此,这些分支被具体化并替换为常量 `3` 和 `5`,从而得到右侧的 AST。**这就是我们对代码进行反虚拟化的方式。**
<p align="center">
<img src="https://assets.kitploit.com/production/public/readmes/8096/857ed50cfe9cb2347f816ece1d8dc4c13174c971dcb65ebc5621be14269a5ca8.png">
</p>
**关于公式级反向切片的一点说明**:与符号执行中常见的做法一样,首先沿路径以正向方式计算符号表示,然后从符号表达式中移除所有既不影响最终结果也不影响所跟随路径的逻辑操作和定义(公式切片,又称公式剪枝)。这相当于对公式执行从程序输出出发的反向切片代码分析。因此,在 `secret` 函数返回时,我们得到了一个不包含 VMProtect 指令的输入与输出之间关系的表达式。
`./attack_vmp.py` 脚本将跟踪文件和符号变量的大小作为参数。回想一下,符号变量是 `edi` 和 `esi`,因此它们都是 4 字节长。该脚本的运行结果如下:```
$ ./attack_vmp.py --trace1 ./vmp_traces/sample2.vmp.trace --symsize 4
[+] Replaying the VMP trace
[+] Symbolize inputs
[+] Instruction executed: 12462
[+] Emulation done
[+] Return value: 0x3
[+] Devirt expr: (bvor (bvnot (bvor (bvnot (bvnot x)) (bvnot y))) (bvnot (bvor (bvnot x) (bvnot (bvand (bvnot y) (bvnot y))))))
[+] Synth expr: (bvxor x y)
[+] LLVM IR ==============================
; ModuleID = 'tritonModule'
source_filename = "tritonModule"
define i32 @__triton(i32 %SymVar_0, i32 %SymVar_1) {
entry:
%0 = xor i32 %SymVar_0, %SymVar_1
ret i32 %0
}
[+] EOF LLVM IR ==============================
正如我们所见,secret 函数返回的去虚拟化表达式相当简洁,不包含来自
虚拟机的指令。```smt
(bvor
(bvnot (bvor
(bvnot (bvnot x))
(bvnot y)
)
)
(bvnot (bvor
(bvnot x)
(bvnot (bvand
(bvnot y)
(bvnot y)
)
)
)
)
)
但是,我们未能恢复原始表达式,它原本是一个简单的`XOR`操作。看起来`XOR`已被转换为位运算。幸运的是,我们最近在Triton项目中发布了新功能,包括一个[合成器](https://github.com/JonathanSalwan/Triton/issues/1074)和一个到[LLVM-IR](https://github.com/JonathanSalwan/Triton/issues/1078)的提升器。因此,我们可以综合该表达式,得到`(bvxor x y)`。这是一次不错的胜利,现在我们可以更进一步,将该表达式提升到LLVM-IR,然后编译出新的去虚拟化二进制代码。
## 示例 2:受MBA操作保护的情况
好的,现在我们来看另一个示例,它试图隐藏一个MBA操作。原始源代码如下:```cpp
// This function is an MBA that computes: (x ^ 92) + y
// We will protect this MBA with VMProtect and see if we can recover "(x ^ 92) + y"
char secret(char x, char y) {
VMProtectBegin("secret");
int a = 229 * x + 247;
int b = 237 * a + 214 + ((38 * a + 85) & 254);
int c = (b + ((-(2 * b) + 255) & 254)) * 3 + 77;
int d = ((86 * c + 36) & 70) * 75 + 231 * c + 118;
int e = ((58 * d + 175) & 244) + 99 * d + 46;
int f = (e & 148);
int g = (f - (e & 255) + f) * 103 + 13;
int r = (237 * (45 * g + (174 * g | 34) * 229 + 194 - 247) & 255) + y;
VMProtectEnd();
return r;
}
与第一个示例一样,我们需要确定该函数的起始位置和结束位置,并生成 VMP 跟踪。``` $ ./pin/pin -t ./pin/source/tools/VMP_Trace/obj-intel64/VMP_Trace.so -start 4198857 -end 4199140 -- ./vmp_binaries/binaries/sample3.vmp.bin 1 2 &> ./vmp_traces/sample3.vmp.trace
一旦 [VMP 跟踪](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/HEAD/vmp_traces/sample3.vmp.trace) 生成,让我们运行 `./attack_vmp.py` 脚本。```
$ ./attack_vmp.py --trace1 ./vmp_traces/sample3.vmp.trace --symsize 1
[+] Replaying the VMP trace
[+] Symbolize inputs
[+] A potential symbolic jump found on CF flag: 0x821dac: popfq - Model: {0: x:32 = 0xa3, 1: y:32 = 0xff}
[+] A potential symbolic jump found on CF flag: 0x87f437: popfq - Model: {0: x:32 = 0xa3, 1: y:32 = 0xff}
[+] Instruction executed: 25085
[+] Emulation done
[+] Return value: 0x5f
[+] Devirt expr: In: (bvadd (bvadd (bvshl (bvadd (_ bv1 32) (bvnot (bvlshr (concat (_ bv0 8) (_ bv0 8) ((_ extract 15 8) ...
[+] Synth expr: In: (bvadd (bvadd (bvshl (bvadd (_ bv1 32) (bvnot (bvlshr (concat (_ bv0 8) (_ bv0 8) ((_ extract 15 8) ...
[+] LLVM IR ==============================
; ModuleID = 'tritonModule'
source_filename = "tritonModule"
define i32 @__triton(i8 %SymVar_0, i8 %SymVar_1) {
entry:
%0 = xor i8 %SymVar_0, 92
%1 = and i8 %SymVar_0, 0
%2 = zext i8 %1 to i32
%3 = or i32 0, %2
%4 = shl i32 %3, 8
%5 = zext i8 %0 to i32
%6 = or i32 %4, %5
%7 = and i8 %SymVar_1, 0
%8 = zext i8 %7 to i32
%9 = or i32 0, %8
%10 = shl i32 %9, 8
%11 = zext i8 %SymVar_1 to i32
%12 = or i32 %10, %11
%13 = zext i8 %7 to i32
%14 = or i32 0, %13
%15 = shl i32 %14, 8
%16 = zext i8 %SymVar_1 to i32
%17 = or i32 %15, %16
%18 = lshr i32 %17, 7
%19 = xor i32 %18, -1
%20 = add i32 1, %19
%21 = shl i32 %20, 8
%22 = add i32 %21, %12
%23 = add i32 %22, %6
ret i32 %23
}
[+] EOF LLVM IR ==============================
这个结果相当有趣,原因有几个。首先,我们成功地在尽可能大的程度上避免了 来自虚拟机的指令,因为执行指令数从 25085 条下降到了 25 条 LLVM 指令。然而, 我们并没有得到一个良好的输出合成版本(是的,我知道,我们正在做的已经超出了单纯的 反虚拟化)。将我们的符号表达式提升到 LLVM-IR 的一个优势在于,我们可以充分利用 LLVM 的 优化管线。让我们开始吧:```llvm $ opt -S -O3 ./devirt/sample3.ll ; ModuleID = 'devirt/sample3.ll' source_filename = "tritonModule"
; Function Attrs: mustprogress nofree norecurse nosync nounwind readnone willreturn define i32 @__triton(i8 %SymVar_0, i8 %SymVar_1) local_unnamed_addr #0 { entry: %0 = xor i8 %SymVar_0, 92 %1 = zext i8 %0 to i32 %2 = zext i8 %SymVar_1 to i32 %3 = shl nuw nsw i32 %2, 1 %4 = and i32 %3, 256 %5 = add nuw nsw i32 %1, %2 %6 = sub nsw i32 %5, %4 ret i32 %6 }
利用 LLVM 优化,我们成功地从去虚拟化后的输出中清除了噪声,从而破解了 MBA。
我们可以看到带有常量的 `XOR` 操作(`%0 = xor i8 %SymVar_0, 92`)以及 `+ y`(`%6 = add nsw i32 %5, %1`)。
中间的指令仅用于处理符号。简而言之,在此示例中,我们使用 `attack_vmp.py` 脚本对 `secret` 函数进行了完全去虚拟化,
然后借助 LLVM 优化完全破解了 MBA。
## 示例 3:多个基本块
如果 `secret` 函数只包含一个基本块,那么无论其大小如何,我们都能获得非常好的结果。因此,目前
我们能够对单条路径进行去虚拟化。为了重建整个函数的行为,我们必须依次对可达路径进行去虚拟化。
为此,我们需要对用户相关的分支执行路径覆盖。最终,我们得到一棵路径树,它表示原始函数的不同路径。
路径树是通过引入 if-then-else 结构而获得的:两条迹线 T1 和 T2 具有相同的前缀,随后 T1 中出现条件 C,而 T2 中出现 not(C)。
一旦路径树构建完成,我们就可以让 LLVM 生成 CFG。
<p align="center">
<img src="https://assets.kitploit.com/production/public/readmes/8096/fc5d1bf9a1544b9b44ef164247f3fc38d04bf45d7c2d7a3c0b21ea3d6636cd39.png">
</p>
对于 Tigress 软件保护,虚拟跳转是通过真正的 `jcc` 指令实现的,这使我们能够快速识别跳转条件。
然而,当涉及 VMProtect 的虚拟跳转时,事情变得更加复杂,因为它不使用 `jcc` 指令来跳转到另一个虚拟块。
我们不得不在动态跟踪上定义标记,以定位用户相关分支所涉及的条件。这是本攻击的实验性部分,
因为标记并非十分精确,但对我们分析的样本有效。
好的,我们来看下面的示例:```cpp
int secret(int x, int y) {
VMProtectBegin("secret");
int r = 0;
if (x + y == 1001)
r = x + 1;
else
r = y - 1;
VMProtectEnd();
return r;
}
就像第一个示例一样,我们必须生成并分析跟踪信息。``` $./pin/pin -t ./pin/source/tools/VMP_Trace/obj-intel64/VMP_Trace.so -start 4198848 -end 4198928 -- ./vmp_binaries/binaries/sample5.vmp.bin 1 2 &> ./vmp_traces/sample5.vmp.trace.1
$ ./attack_vmp.py --trace1 ./vmp_traces/sample5.vmp.trace.1 --symsize 4 [+] Replaying the VMP trace [+] Symbolize inputs [+] A potential symbolic jump found of AF flag: 0x80d905: cmp r11b, dl - Model: {0: x:32 = 0x0, 1: y:32 = 0x3e9} [+] Instruction executed: 16164 [+] Emulation done [+] Return value: 0x4 [+] Devirt expr: (bvnot (bvadd (bvand (bvnot y) (bvnot y)) (_ bv1 32))) [+] Synth expr: (bvadd y (_ bv4294967295 32))
[+] LLVM IR ==============================
; ModuleID = 'tritonModule' source_filename = "tritonModule"
define i32 @__triton(i32 %SymVar_1) { entry: %0 = add i32 %SymVar_1, -1 ret i32 %0 }
[+] EOF LLVM IR ==============================
脚本告诉我们,在地址 `0x80d905` 处可能有一个基于 `AF` 标志的潜在符号跳转。
它还提供了一个新模型(使用符号执行),该模型应该走另一条路径。因此,让我们使用这个模型生成第二条跟踪记录(如果你看一下这个模型,它相对于我们的源代码是正确的)。```
$ ./pin/pin -t ./pin/source/tools/VMP_Trace/obj-intel64/VMP_Trace.so -start 4198848 -end 4198928 -- ./vmp_binaries/binaries/sample5.vmp.bin 0 1001 &> ./vmp_traces/sample5.vmp.trace.2
一旦生成了第二条跟踪记录,我们就必须将这两条跟踪记录提供给 attack_vmp.py 脚本,以便它能够合并它们并创建路径树。我们还有额外的选项来定义条件所在的位置以及基于哪个标志(位于 0x80d905 的 AF 标志)。```
$ ./attack_vmp.py --trace1 ./vmp_traces/sample5.vmp.trace.1 --symsize 4 --trace2 ././vmp_traces/sample5.vmp.trace.2 --vbraddr 0x80d905 --vbrflag af
[+] Replaying the VMP trace
[+] Symbolize inputs
[+] A potential symbolic jump found of AF flag: 0x80d905: cmp r11b, dl - Model: {0: x:32 = 0x0, 1: y:32 = 0x3e9}
[+] Instruction executed: 16164
[+] Emulation done
[+] A second trace has been provided
[+] Replaying the VMP trace
[+] Symbolize inputs
[+] Instruction executed: 15758
[+] Emulation done
[+] Merging expressions from trace1 and trace2
[+] Return value: 0x3e9
[+] Devirt expr: In: (ite (= (ite (= (_ bv16 8) (bvand (_ bv16 8) (bvxor (bvsub (_ bv80 8) ((_ extract 7 0) (bvadd (bvlsh ...
[+] Synth expr: In: (ite (= (ite (= (_ bv16 8) (bvand (_ bv16 8) (bvxor (bvsub (_ bv80 8) ((_ extract 7 0) (bvadd (bvlsh ...
[+] LLVM IR ==============================
; ModuleID = 'tritonModule' source_filename = "tritonModule"
define i32 @__triton(i32 %SymVar_0, i32 %SymVar_1) { entry: %0 = add i32 %SymVar_1, -1 %1 = add i32 %SymVar_0, 1 %2 = add i32 %SymVar_1, %SymVar_0 %3 = xor i32 %2, -1 %4 = xor i32 %2, -1 %5 = and i32 %4, %3 %6 = xor i32 %5, 1001 %7 = add i32 %5, 1001 %8 = xor i32 %5, 1001 %9 = xor i32 %8, %7 %10 = and i32 %9, %6 [... skip ...] %469 = add i64 %468, 140737488347280 %470 = trunc i64 %469 to i8 %471 = xor i8 80, %470 %472 = sub i8 80, %470 %473 = xor i8 %472, %471 %474 = and i8 16, %473 %475 = icmp eq i8 16, %474 %476 = select i1 %475, i1 true, i1 false %477 = icmp eq i1 %476, false %478 = select i1 %477, i32 %1, i32 %0 ret i32 %478 }
[+] EOF LLVM IR ==============================
在此步骤中,我们对两条迹线进行了去虚拟化,并将它们合并为 `if-then-else` 表达式。
将表达式提升到 LLVM-IR 后,我们得到了一个仅包含 480 条 LLVM 指令的 CFG,与虚拟机执行的数千条指令相比,这已经是一个不小的收获。
但如果使用 LLVM 优化,我们可以做得更好:```llvm
$ opt -S -O3 ./devirt/sample5.ll
; ModuleID = './devirt/sample5.ll'
source_filename = "tritonModule"
; Function Attrs: mustprogress nofree norecurse nosync nounwind readnone willreturn
define i32 @__triton(i32 %SymVar_0, i32 %SymVar_1) local_unnamed_addr #0 {
entry:
%0 = add i32 %SymVar_0, 1
%1 = add i32 %SymVar_1, -1
%2 = add i32 %SymVar_1, %SymVar_0
%.not = icmp eq i32 %2, 1001
%3 = select i1 %.not, i32 %0, i32 %1
ret i32 %3
}
attributes #0 = { mustprogress nofree norecurse nosync nounwind readnone willreturn }
太好了,我们恢复了 secret 函数的原始行为!
虽然该方法对包含单一路径的函数表现出了非常好的结果,但该方法的主要局限性在于,由于 VMProtect 执行虚拟跳转的方式,它主要适用于路径数量较少的程序。如果路径数量过多,部分原始代码可能会丢失,导致恢复不完整。请注意,我们考虑的是可执行路径,而不是 CFG 中的语法路径。哈希函数和其他密码函数通常只有很少的路径——在抗计时攻击的实现中甚至只有一条路径。
此外,我们当前的实现仅限于没有任何用户相关内存访问的程序。通过在 DSE 中对内存访问采用更符号化的处理方式,可以部分消除这一限制。
还要注意,虽然有界循环和非递归函数调用已被处理,但它们目前会被恢复为内联或展开的代码,可能导致反虚拟化代码的体积膨胀。如果能有一个后处理步骤来尝试重建这些高级抽象,将会很有趣。
最后,请注意,我并不打算提供任何神奇的方法,这些只是关于针对 VMProtect 保护的非常特定情况所进行的动态攻击的一些笔记 =)。
如果你想深入了解,可以查看以下资源:
最后但同样重要的是,特别感谢我的伙伴 @0vercl0k 的校对和编辑 🚀
[00] https://www.usenix.org/legacy/event/woot09/tech/full_papers/rolles.pdf [01] https://secret.club/2021/09/08/vmprotect-llvm-lifting-1.html [02] https://secret.club/2021/09/08/vmprotect-llvm-lifting-2.html [03] https://secret.club/2021/09/08/vmprotect-llvm-lifting-3.html [04] https://back.engineering/17/05/2021/ [05] https://back.engineering/21/06/2021/ [06] https://www.mitchellzakocs.com/blog/vmprotect3 [07] https://github.com/can1357/NoVmp [08] https://github.com/archercreat/vmpfix [09] https://github.com/void-stack/VMUnprotect [10] https://github.com/JonathanSalwan/Triton/blob/master/publications/DIMVA2018-slide-deobfuscation-salwan-bardin-potet.pdf [11] https://whereisr0da.github.io/blog/posts/2021-02-16-vmp-3/ [12] https://github.com/pgarba/UniTaint [13] https://github.com/mrexodia/VMProtectTest