本文根据 tmp.0ut 第五期中 patate 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:Overview of code virtualization
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:7123
0. 作者的话
这是作者向 tmpout(以及同类 zine)的第一次投稿。文章是虚拟化代码混淆的综合性概览,大部分内容并非全新,而是作者大量阅读后的整理。作者坦言英语不是母语,文章面向对 C 与汇编已有深入理解的读者;若发现技术错误欢迎邮件指正。
1. 引言
从第一行商业代码诞生起,盗版就像“服务器机房里的那头大象”。对 warez 圈子来说,破解不只是免费午餐,而是拿到最新 AAA 大作与高价开发工具的唯一途径。大公司开始想方设法防止二进制发布几小时就被 crack;病毒作者也需要让逆向更难,以便更久地不被发现。答案始终是:二进制混淆。
2. 现状
2.1 软件保护技术
2.1.1 代码混淆
混淆的目标是让试图逆向或破解的人更难读懂代码。本节演示代码变异(code mutation)、常量扩展与控制流平坦化。
代码变异:把一条简单指令换成多条语义相同但人为增加复杂度的指令。例如:
add rcx, rax
变成:
push rax
not rax
sub rcx, rax
pop rax
sub rcx, 1
行为不变,但更难读懂。常量扩展同理,把立即数拆成若干运算:
mov eax, 0xdeadbeef
变成:
pushfd
mov eax, 0x1cbd0f9
add eax, 0x12345678
shl eax, 0x00000001
xor eax, 0xf6adf00d
popfd
对特定值/地址做模式匹配就难多了。控制流平坦化则更进一步,用 C 更容易理解:
int main(int argc, char** argv) {
int a = atoi(argv[1]);
if(a == 0)
return 1;
else
return 10;
return 0;
}
平坦化后所有基本块被拆散放进一个无限循环,用一个 switch 和状态变量 b 控制流程(引述自 obfuscator-llvm 的 CFF wiki):
int main(int argc, char** argv) {
int a = atoi(argv[1]);
int b = 0;
while(1) {
switch(b) {
case 0:
if(a == 0)
b = 1;
else
b = 2;
break;
case 1:
return 1;
case 2:
return 10;
default:
break;
}
}
return 0;
}
这是 CFF 的常见实现方式,实践证明相当有效。
2.1.2 传统混淆的局限
上述方法使用最广、可靠且被广泛理解,能拖慢逆向、劝退新手,但(一定程度上)很容易被击破。例如代码变异遵循非常严格的规则,启发式算法可以借此恢复原指令——简单到模式匹配加字节补丁;有经验的分析者能识别模式并写脚本撤销。常量扩展也可用类似方法可靠击破,但许多引擎会引入随机性,更全面的做法是用符号执行(超出本文范围)确定指令块的结果。
控制流平坦化更难对付,在合适条件下“能让精确控制流的判定变成 NP-hard”(引述 T. Laszlo 与 A. Kiss 的论文)。它仍可用启发式撤销。更一般地说,这些技术可以用优化算法击破,IDA Pro 等反编译器已内置相关功能,你甚至可能注意不到保护存在(作者承认这略有夸张,但它确实大幅降低破解或还原被保护程序的难度)。
2.2 基于虚拟化的混淆
作者认为虚拟化混淆是解决上述问题的一种优雅而有效的方式。
2.2.1 概念与架构
核心思想:把函数的操作码从原始指令集(这里指 Intel x64)翻译成一套新的自定义指令集。新代码与 CPU 不兼容,怎么执行?答案是创建一台虚拟机——最简形式就是自定义操作码的解释器。
高层示例:函数原来是 add rcx, rax,虚拟化后变成一段字节码(load r1、load r2、add、store r1)加一个解释器(VM Magic)。原代码不再存在于函数中。“VM Magic”比看上去复杂,基本实现是:
push offset VMBytecode
jmp VMEntry
Pre-Initialization
↓
Initialization
↓
Fetch ←──────────────┐
↓ │
Decode │
↓ │
Dispatcher │
┌─────┬─────┬─────┬───┴────┐
↓ ↓ ↓ ↓ ↓
Handler#1..N VM Exit │
│ │ │
└───────> Next ────────────┘
预初始化和初始化阶段负责分配 VM 上下文,通常是一个结构体,包含:VM 栈、各 VM 寄存器、指令指针、VM 内部标志。随后保存程序原有寄存器与标志,VM 运行结束后再恢复,避免 VM 逻辑带来非预期的新行为。作者以 guardian-rs 项目为例展示寄存器保存(把 rax/rcx/rdx/rbx/rsp/rbp/rsi/rdi/r8-r15 依次存入 VM ctx 结构体、用 sub rsp,10h; pop rcx 把标志存进结构体、再 call fxsave 保存 xmm 寄存器)。
然后是取指与译码。Intel x64 指令结构复杂(前缀 0-4 字节、REX 0-1、opcode 1-3、ModR/M 0-1、SIB 0-1、位移 0/1/2/4、立即数 0/1/2/4/8),VM 字节码可能更简单,但这份表说明了为什么需要译码阶段。译码后,简单实现会进入一个巨大的 switch(Dispatcher)决定由哪个 handler 处理当前指令。作者给出自己 pasm 解释器里的 dispatcher 与 cmp_handler 示例(按命令名匹配返回命令表项;cmp_handler 检查参数后设置相等/大于/小于的状态码)。这些都是标准内容,真实世界的 VM 会复杂得多。一条指令在 VM 上下文中执行完后,取下一条指令再重复;函数执行完毕时,把之前保存的寄存器与标志复制回去,继续执行原程序。
2.2.2 虚拟化是关键
如果对二进制里每个函数都做虚拟化、每次都换一套自定义操作码,保护会非常强。VM 混淆常与 2.1.1 的标准混淆叠加使用(对 VM 内部函数和字节码都做),让分析变得冗长耗时。
2.3 现有方案
虚拟化成为 DRM 软件的常态已很久,主要有:著名的 Denuvo、VMProtect、Themida。它们价格不菲,且综合使用前文所有方法(反调试、反虚拟机、JIT 解释器、自修改代码)。妥善实现后对公司保护软件很有吸引力——例如 Denuvo 自 2023 年以来没有被真正破解过(写作时间是 2026 年)。
3. 逆向虚拟化保护的常规路线
作者用 Linux 上的 IDA Pro 9.3 演示,示例程序源码在附录。
3.1 理解 VM 结构
把可执行文件载入 IDA,start 调用 sub_401014(参数 edi=0xA),随后把返回值作为退出码退出。跟进 sub_401014,先看到一串 push(pushfq 后依次 push rax、rcx、rdx、rbx、rbp、rsi、rdi、r8-r15)——这是保存寄存器与 EFLAGS 以便稍后恢复,正是 2.2.1 说的“VM initialization”。
继续往下:
mov ds:qword_402018, rdi ; 保存函数参数 → func_arg
mov rax, offset unk_402000
mov ds:qword_402008, rax ; 字节码地址 → vm_ip
unk_402000 处是数据块 db 1, 2, 5, 3, 3, 0FFh——即 vm_bytecode。后面一段是 Fetch:
mov rsi, ds:vm_ip
xor rax, rax
mov al, [rsi]
inc rsi
mov ds:vm_ip, rsi
取 1 字节并推进 IP。再往下是一串比较 cmp al, 1/2/3/0FFh 后跳到对应位置,都不匹配则 ud2 崩溃——这就是 Dispatcher。可以断定各 loc_* 是 VM Handler。至此已识别出 VM 的全部关键要素。
3.2 逆向 handlers
因为不知道 handler 访问顺序,作者在每个 handler 起点下断点调试,并建立操作码-处理器-行为表:
| Opcode | Handler | 行为 |
|---|---|---|
| 0x1 | LOAD_ARG | 载入函数参数 |
| 0x2 | ADD_IMM | 给 vm_arg 加一个立即数 |
| 0x3 | MUL_IMM | 用立即数乘 vm_arg |
| 0xFF | VM_EXIT | 以 vm_arg 作为返回值退出函数 |
逐个分析:LOAD_ARG 把 func_arg 拷到 vm_arg;ADD_IMM 从字节码再取一个立即数字节、加入 vm_arg;MUL_IMM 几乎相同只是做乘法;VM_EXIT 把 vm_arg 写回栈上保存 rax 的位置(rsp+0x70 = 14 个寄存器 × 8 字节),随后 pop 全部寄存器与 EFLAGS 并 retn——即恢复现场并把 vm_arg 当返回值。四个 handler 全部逆向完成。
3.3 写一个字节码反汇编器
用 Python 定义操作码:
OPCODES = {
0x01: ("LOAD_ARG", 0),
0x02: ("ADD_IMM", 1),
0x03: ("MUL_IMM", 1),
0xFF: ("VM_EXIT", 0),
}
元组最后一个数是“额外大小”(ADD_IMM/MUL_IMM 后面还有一个字节的立即数)。粘贴提取出的字节码:
bytecode = [0x01, 0x02, 0x05, 0x03, 0x03, 0xFF]
i = 0
while i < len(bytecode):
ins = bytecode[i]
if ins not in OPCODES:
print(f"0x{ins:02x} is not a valid opcode.")
break
name, extra_size = OPCODES[ins]
if extra_size != 0:
if len(bytecode) <= (i + extra_size):
print(f"{i:04x}: {name} <missing imm>")
break
print(f"{i:04x}: {name} {bytecode[i + extra_size]:01x}")
i += 1 + extra_size
else:
print(f"{i:04x}: {name}")
i += 1
输出:
0000: LOAD_ARG
0001: ADD_IMM 5
0003: MUL_IMM 3
0005: VM_EXIT
3.4 读懂字节码
字节码现在可读了:给参数加 5,再乘 3,返回结果。x64 汇编等价于:
mov rax, rdi ; load argument x
add rax, 5 ; x + 5
imul rax, 3 ; (x + 5) * 3
ret
C 等价于:
long vm_func(long x) {
return (x + 5) * 3;
}
在未知程序里用未知 VM 恢复了虚拟化函数的原始代码——作者称之为成功。
4. 其他技术
常规分析可以反虚拟化,但非常耗时。本节更偏理论,主要基于论文《Symbolic deobfuscation: from virtualized code back to the original》(Jonathan Salwan、Sebastien Bardin、Marie-Laure Potet)。
4.1 用污点分析击破混淆
污点分析:把用户输入标记为“污点”,跟踪哪些指令受它影响,从而隔离出对核心逻辑真正重要的指令。实践中通常先生成执行轨迹再分析数据流,好用的工具如 AntoineBlaud 的 TheCodexRebirth。识别出污点路径后处理未受污染指令:它们不依赖用户输入,输出本质是静态的,可以用“常量折叠”求出结果并替换成具体值,再用死代码消除(DCE)丢掉无用指令——因为剥离的是 VM 添加的人为复杂度,只剩骨架逻辑(若程序与 OS 等外部不可控对象交互、输出不静态,则不成立,这是局限)。
示例:表达式树里只有与用户输入直接相关的顶部乘法和右侧加法被保留,其余全是常量,可化简。对 VM 应用此方法时不再需要逆向 handlers,只需要知道什么直接接触用户输入,其余永远相同、直接替换结果。
4.2 LLVM IR
用上一步(以及作者省略的许多复杂细节)可以把恢复的代码重建成 LLVM IR。LLVM IR 是语言解析与汇编生成之间的中间表示;拿到 IR 就能向任意架构重新编译,并享受 LLVM 的全部优化。通常用 McSema、Remill 这类 binary lifter 完成。到这一步,整个程序基本就被反虚拟化了。
5. 结论
读完本文应对基于虚拟化的混淆及其分析方法有了基本认识。这类保护使用得越来越多,是任何分析者都应熟悉的内容。作者的目的是给出自己动手实验所需的钥匙;内容当然有缺失、有可以做得更好的地方,但至少读者比开始时懂得更多。
6. 致谢
作者感谢导师 “uwu” 长期指导并激发他对混淆软件的兴趣;感谢 xss.is(Windows 漏洞利用与恶意软件工程);vx-underground 与 Phrack 提供的论文;以及 tmpout(特别感谢 netspooky)。
7. 参考
- Valdemar Caroe,Attacking virtualization-based obfuscation(论文)
- patate,Reverse engineering Guardian-rs’s virtualization(文章)
- Jonathan Salwan、Sebastien Bardin、Marie-Laure Potet,Symbolic deobfuscation: from virtualized code back to the original(论文)
- Sebastien Bardin、Robin David、Jean-Yves Marion,Backward-Bounded DSE(论文)
- weak1337,Alcatraz(GitHub)
- obfuscator-llvm wiki(Control-Flow Flattening)
- T. Laszlo、A. Kiss,Obfuscating C++ programs via control flow flattening(论文)
- Zerotistic,Breaking Control Flow Flattening(文章)
- Andreas Klopsch,Attacking Emotet’s Control Flow Flattening(文章)
- Geri Revay,Don’t flatten yourself(摘要)
- Branko Spasojevic,Using optimization algorithms for malware deobfuscation(论文)
- meowette,guardian-rs(GitHub)
- patate,pasm(Git 仓库)
- AntoineBlaud,TheCodexRebirth(GitHub)
8. 附录:示例程序源码
第 3 节示例的源码,编译命令:
nasm -f elf64 vm_demo.asm -o vm_demo.o
ld vm_demo.o -o vm_demo
section .data
; 虚拟化函数的字节码
vm_bytecode:
db 0x01
db 0x02, 0x05
db 0x03, 0x03
db 0xFF
section .bss
; VM 上下文结构
vip resq 1 ; 虚拟指令指针
vr0 resq 1 ; 虚拟寄存器 0
saved_rdi resq 1 ; 函数参数
section .text
global _start
_start:
mov rdi, 10 ; x = 10
call vm_entry ; vm_entry(x)
mov rdi, rax ; 保存虚拟化函数返回值
mov rax, 60 ; exit
syscall
vm_entry:
; 保存原始寄存器与 EFLAGS
pushfq
push rax
push rcx
push rdx
push rbx
push rbp
push rsi
push rdi
push r8
push r9
push r10
push r11
push r12
push r13
push r14
push r15
mov [saved_rdi], rdi ; 保存函数参数
mov rax, vm_bytecode
mov [vip], rax ; 把字节码地址复制到 VIP
vm_loop:
mov rsi, [vip] ; 把 VIP 载入 rsi
xor rax, rax
mov al, byte [rsi] ; 取 1 字节操作码
inc rsi ; vip++(刚读了一个操作码)
mov [vip], rsi ; 保存 vip
; 比较操作码以决定调用哪个 handler
cmp al, 0x01 ; LOAD_ARG
je handler_load_arg
cmp al, 0x02 ; ADD_IMM8
je handler_add_imm
cmp al, 0x03 ; MUL_IMM8
je handler_mul_imm
cmp al, 0xFF ; VM_EXIT
je handler_vm_exit
ud2 ; 未知操作码则崩溃
handler_load_arg:
mov rax, [saved_rdi] ; 载入函数参数
mov [vr0], rax ; 复制到 vr0
jmp vm_loop ; 回到 fetch
handler_add_imm:
mov rsi, [vip] ; 载入 vip
xor rbx, rbx
mov bl, byte [rsi] ; 取 1 字节立即数
inc rsi ; 递增 vip(刚读了 1 字节)
mov [vip], rsi ; 更新 vip
mov rax, [vr0] ; 载入函数参数
add rax, rbx ; 参数加立即数
mov [vr0], rax ; 更新函数参数
jmp vm_loop ; 回到 fetch
handler_mul_imm:
mov rsi, [vip] ; 载入 vip
xor rbx, rbx
mov bl, byte [rsi] ; 取 1 字节立即数
inc rsi ; 递增 vip(刚读了 1 字节)
mov [vip], rsi ; 更新 vip
mov rax, [vr0] ; 载入函数参数
imul rax, rbx ; 参数乘立即数
mov [vr0], rax ; 更新函数参数
jmp vm_loop ; 回到 fetch
handler_vm_exit:
mov rax, [vr0] ; 保存 VM 函数寄存器(函数返回值)
mov [rsp + 112], rax ; 所有寄存器都压栈了,rsp+112 就是栈上的
; rax 位置,随后可把它 pop 回 rax
pop r15
pop r14
pop r13
pop r12
pop r11
pop r10
pop r9
pop r8
pop rdi
pop rsi
pop rbp
pop rbx
pop rdx
pop rcx
pop rax ; 把 vr0 pop 进 rax
popfq
ret
上一页:用侧信道检测系统调用挂钩