一个 ARM64 自定义 VM 的分析与还原
0x00 前言
前段时间分析一个 Android ARM64 程序时,在 native 层遇到了一套自定义虚拟机。
程序中有一部分逻辑没有直接编译成普通 ARM64 指令,而是先转换成一套私有 bytecode,运行时由 native 层解释器负责执行。
为了避免文章和具体产品、厂商或商业项目产生关联,本文对样本名称、函数地址、内部协议名称、常量、字符串以及部分结构信息进行了匿名化处理。
文中使用:
libtarget.so
sample_vm.bin
分别代指 native runtime 和 VM program。
本文只讨论:
如何定位 VM
如何从控制流混淆中提取 VM 状态
如何恢复指令格式和 descriptor
如何恢复 ISA
如何验证 opcode 语义
如何实现一套独立的 Clean VM
如何做函数发现、CFG、SSA
如何最终生成 C-like 伪代码
最初的目标只是看懂解释器。
最后实际做成了:
VM Container
↓
Parser
↓
Decoder
↓
VM Emulator
↓
Function Discovery
↓
CFG / IR
↓
SSA / Dataflow
↓
Structurer
↓
Type Recovery
↓
C-like Decompiler
整个过程中最值得记录的并不是某一条特殊指令,而是从“一个看起来完全无法阅读的解释器”,逐步重新建立一套 machine model 的过程。
0x01 这个 VM 的逆向难在哪
如果只看最终结果,这套 VM 好像就是:
找 opcode
↓
给 opcode 命名
↓
写解释器
↓
写反编译器
实际分析时远没有这么线性。
真正困难的地方,是几层不同的问题同时叠在一起:
ARM64 native
+
Control Flow Flattening
+
未知 VM state
+
未知 bytecode format
+
未知 descriptor
+
未知 ISA
+
未知 Host ABI
+
未知函数边界
其中任意一层判断错了,错误都会向后传递。
例如:
dispatcher 判断错
↓
opcode / handler 对应错
↓
ISA 错
↓
emulator 错
↓
CFG 错
↓
最终伪代码虽然能生成
但语义已经不是原程序
所以这次逆向最麻烦的并不是“ARM64 汇编难看”,而是必须不断区分哪些东西是 VM 本身,哪些只是 VM 外面的实现噪声。
1. OLLVM Dispatcher 和 VM Dispatcher 叠在一起
这是最早遇到,同时也是最容易把整个方向带偏的难点。
解释器本身已经是:
bytecode
↓
opcode
↓
VM dispatcher
↓
handler
但 native 编译结果外面又套了 Control Flow Flattening:
native block
↓
flattening state
↓
CFF dispatcher
↓
native block
于是实际看到的是两层“状态机”套在一起:
┌─────────────────┐
│ CFF State │
└────────┬────────┘
│
▼
CFF Dispatcher
│
▼
Native Basic Block
│
▼
VM Logic
│
▼
VM State
两层都有:
table
index
indirect branch
state update
所以单纯看到:
LDR Xn, [table, index]
BR Xn
几乎无法判断它究竟是哪一层 dispatcher。
这里真正需要判断的是:
index 的来源是什么?
跳转目标有没有读 bytecode?
有没有访问 VM register?
有没有更新 VM PC?
而不是看某几条汇编“长得像不像 VM”。
2. 指令语义和 Operand Extraction 被拆开了
第二个难点是:
handler 中看到的 native 操作,不一定就是 opcode 的真正语义。
很多 opcode 在真正计算之前,都要经过公共逻辑:
解析 descriptor
↓
取得 register index
↓
读取 register
↓
符号扩展 / 零扩展
↓
得到 operand
↓
真正执行 opcode
如果只截取其中一部分,很容易把:
operand extraction
误认为:
opcode semantics
这次实际就出现过:
看到 LOAD / STORE
↓
认为是 indirect memory instruction
后来通过完整 handler、descriptor、真实 bytecode 和 emulator 对照,才发现真正语义其实是一个带宽度控制的有符号乘法。
所以这种 VM 很难靠:
“IDA 里看到几条指令 → 直接给 opcode 起名字”
恢复准确。
3. Descriptor 让“一条 Opcode”拥有多种形态
如果 VM 是最简单的:
1 byte opcode
+
固定 operand
分析会容易很多。
但这里 descriptor 同时影响:
operand mode
source width
destination width
immediate/register
instruction length
于是同一个 arithmetic family 可能表现为:
8-bit source → 32-bit destination
16-bit signed source → 64-bit destination
register source
immediate source
如果 descriptor 少理解一个 bit,后面就可能出现:
operand 解错
PC 长度错
下一条 instruction boundary 错
整个函数随后全部错位
所以 bytecode decoder 本身就是逆向里的核心部分,而不是辅助工作。
4. 静态分析很难证明语义真的正确
很多 opcode 在静态分析阶段可以达到:
“我有 90% 把握它是这个意思”
但 VM 逆向的问题在于:
90% 对一个解释器是不够的。
因为一条基础指令的微小误差,会影响后面的所有代码。
一个典型例子是寄存器窄写入。
第一版解释器把:
32-bit write
理解成:
reg = (uint32_t)value;
从 C 的角度看非常自然。
但原 VM 实际语义是:
只改低 32 位
高 32 位保持不变
于是两种实现只差高 32 位,却能让后续调用参数完全改变。
这种问题靠“看起来合理”很难发现。
真正暴露它的是 Clean VM 跑真实 bytecode 时结果开始异常。
因此这次分析很大一个难点其实是:
怎么证明自己恢复出来的 VM
真的和原 VM 是同一颗 CPU
5. HOSTCALL 是另一套未知 ABI
即使把:
MOV
ADD
CMP
JMP
CALL
RET
全部恢复出来,也只解决了 VM 内部计算。
真正让 VM 和外部世界交互的是:
HOSTCALL
而 Host Interface 本身又是一套新的未知 ABI:
service id 是什么
参数从哪里取
返回值放哪里
哪些参数是 pointer
哪些参数是长度
是否存在动态 provider
是否还能调用 native function pointer
如果 HOSTCALL 不恢复,最终代码永远会停留在:
hostcall_xxx(a, b, c);
ISA 虽然已经理解,但程序本身依旧不好读。
所以 VM 逆向其实包含两套系统:
Virtual ISA
+
Host ABI
两个都要恢复。
6. VM 中没有天然的函数边界
普通 ELF 至少还有:
symbol
prologue
epilogue
relocation
exception metadata
可以辅助识别函数。
bytecode 里可能只有一大块:
00 14 05 ...
17 28 ...
...
其中:
JMP target
和:
CALL target
看起来都只是跳到另一个 offset。
但前者代表:
同一函数里的 basic block
后者代表:
新的函数入口
如果把 CALL target 当成 CFG successor,多个函数就会被错误合成一个巨大函数。
反过来,如果把普通 branch 当 CALL,又会制造大量假函数。
所以 function discovery 自身也是一个独立问题。
7. CALL_REG 会让静态调用图断掉
直接调用:
CALL 0x1234
很容易恢复。
但:
CALL_REG r15
目标来自寄存器。
如果不做 symbolic/dataflow propagation,调用图到这里就断了。
而调用图一旦缺失,后面的:
函数签名恢复
参数类型传播
helper 识别
跨函数对象恢复
都会受到影响。
因此间接调用恢复并不是“锦上添花”,而是静态反编译能不能继续往上走的基础。
8. “能反汇编”和“能反编译”之间差得非常远
ISA 恢复以后,可以很快得到:
MOV
ADD
CMP
Jcc
CALL
RET
这时候从 VM 逆向角度来说已经取得了很大进展。
但实际阅读一个几百甚至上千条 VM 指令的函数时,仍然非常困难。
因为里面充满:
物理 register 搬运
frame 操作
PUSH_ARG
临时值
CALL ABI
HOSTCALL wrapper
真正的高级逻辑只占一部分。
所以要继续跨越:
VM asm
↓
IR
↓
SSA
↓
CFG structuring
↓
type recovery
↓
C-like
每一层其实都是一个独立的编译器问题。
9. 复杂 CFG 比 Opcode 更难收尾
简单的:
if
if/else
while
并不算太难。
真正麻烦的是:
nested loop
multi-exit loop
early return
break / continue
多个 merge point
间接调用混入控制流
这种 CFG 如果强行结构化,很容易生成:
while (...) {
...
}
但其实改变了原始控制流。
所以后期一个重要原则是:
宁可保留局部 goto,也不能生成漂亮但错误的结构。
这也是为什么反编译器后期最重要的指标并不是“有没有 goto”,而是:
CFG 是否正确
10. SSA 错一次,最终 C 可能看起来完全正常
这是比较隐蔽的难点。
假设两个分支分别产生:
value_A
value_B
在 merge block 汇合。
正确应该是:
phi(value_A, value_B)
如果 cleanup 阶段错误删掉其中一个 definition,最终代码仍然可能语法完全正常:
if (cond)
result = A();
use(result);
但 else 分支的值已经丢了。
这种错误比:
local_68
名字不好看严重得多,因为它改变程序语义。
所以反编译器越往后,真正难的东西反而从 opcode 转向:
SSA correctness
CFG correctness
definition/use correctness
11. 类型恢复不能为了“像 C”而过度推断
最后一个比较明显的难点是类型。
VM 中首先看到的是:
64-bit register slot
而不是:
char *
size_t
SomeStruct *
同一个 register 在不同时间甚至可能分别装:
pointer
integer
boolean
return value
栈上的对象也可能同时存在:
struct
union
overlay
array tail
mixed-width fields
而跨函数传播更危险。
例如:
foo(&obj);
不能单凭这一处就推断:
foo(SomeStruct *obj);
因为另一个 callsite 可能是:
foo(&obj.field_08);
所以这套反编译器后期采用的是保守策略:
所有 callsite
都提供一致的强证据
↓
才提升类型
宁可输出:
uint64_t *arg;
也不要输出一个错误但看起来很漂亮的结构体类型。
难点总结
如果简单归纳,这次分析的难点大概可以分成四层:
| 层次 | 主要难点 |
|---|---|
| Native 层 | CFF、间接跳转、relocation、真实 semantic block 定位 |
| VM 层 | PC、register、descriptor、instruction boundary、ISA |
| Runtime 层 | HOSTCALL ABI、CALL/RET、CALL_REG、执行语义验证 |
| Decompiler 层 | 函数发现、CFG、SSA、结构化、对象和类型恢复 |
所以这类 VM 真正难的不是:
opcode 数量很多
而是:
每一层都缺少 specification
分析者需要自己逐层建立:
假设
↓
证据
↓
实现
↓
真实 bytecode 验证
↓
发现反例
↓
修改模型
这也是后面为什么 Clean VM 会成为整个分析里的关键节点。
它第一次让:
“我认为这个 ISA 是这样”
变成:
“我按这个 ISA 重写了一颗 VM,
真实程序确实能按照它执行”
0x02 从哪里判断这里存在 VM
一开始只看 native 层,其实很难直接断定它就是一个 VM。
普通程序同样可能存在:
大 switch
状态机
函数指针表
间接调用
事件 dispatcher
所以我的判断主要来自几个特征同时出现。
首先,一个很大的 native 函数被频繁调用,而且输入中明显包含一块外部数据。
函数内部持续出现:
读取某个 cursor
从同一块 buffer 取数据
根据取出的值执行不同路径
更新 cursor
回到公共执行点
抽象以后非常像:
while (running)
{
opcode = code[pc];
execute(opcode);
pc = next_pc;
}
第二个特征是出现了明显的“虚拟寄存器区”。
很多路径都在访问类似:
base + index * 8
而 index 来自 bytecode 中的小整数。
这就很像:
vm->regs[index]
第三个特征是不同 semantic block 的尾部会用几个固定长度更新 cursor。
例如:
pc += 4
pc += 5
pc += 7
pc += 11
不同 opcode 的长度不同,但最后都会把新的 PC 写回同一个 context。
这几个特征组合在一起以后,基本可以确定:
这不是普通状态机,而是一套变长指令的 register VM。
0x03 第一个坑:把 CFF Dispatcher 当成 VM Dispatcher
真正开始分析解释器后,第一个误判很快就出现了。
在函数中可以看到大量:
LDR X9, [X21, W8, UXTW #3]
BR X9
这种结构。
从表面看:
index
↓
table[index]
↓
BR target
特别像经典的 VM opcode dispatch:
handler = handler_table[opcode];
goto *handler;
所以第一版分析直接把那张大表标成了:
VM_HANDLER_TABLE
后来证明是错的。
继续追 index 的来源后发现,它并不是:
code[pc]
或者任何明显的 bytecode 值。
它来自一串:
加减
异或
比较
条件选择
状态值转换
最终计算出的内部 state。
更关键的是,这些跳转目标执行完以后通常不会像真正 VM handler 一样:
更新 VM PC
回到 fetch
而是继续计算新的 flattening state。
于是实际结构更接近:
┌─────────────┐
│ opaque calc │
└──────┬──────┘
│
▼
flattening state
│
▼
dispatch table
│
┌──────────┼──────────┐
▼ ▼ ▼
block A block B block C
│ │ │
└──────┬───┴──────┬───┘
▼ ▼
next state calculation
这是 Control Flow Flattening,而不是 VM 自己的调度。
这是整个分析里第一个比较大的坑。
如果这里判断错了,后面会得到一整套完全错误的:
opcode → handler
对应关系。
0x04 不完整去平坦化,直接追 VM 数据流
确认外围存在控制流平坦化后,有两个选择。
第一种:
先把 native CFG 完整恢复
↓
再分析解释器
第二种:
只恢复和 VM 有关的数据流
我后来选择第二种。
原因是分析 VM 时真正需要的东西没有那么多。
核心其实只有:
code_base
PC
opcode
descriptor
register file
FLAGS
next_pc
只要这些东西能从 flattening 里抽出来,原生函数是否恢复成漂亮的 CFG 反而不是重点。
于是分析方法变成:
CFF dispatcher
↓
取出一个真实 block
↓
判断这个 block 是否访问 VM state
↓
标记它的输入和输出
↓
忽略纯 flattening block
例如某些 block 中可以反复看到:
LDR X8, [Xstate]
LDR X9, [X8, #code_field]
LDR W10, [X8, #pc_field]
ADD X11, X9, X10
LDRB W12, [X11]
LDRB W13, [X11, #1]
LDRB W14, [X11, #2]
把 native register 名字拿掉以后,就是:
insn = vm->code + vm->pc;
opcode = insn[0];
desc = insn[1];
arg0 = insn[2];
这类 block 才是真正值得关心的 VM semantic block。
0x05 利用 Relocation 恢复真实跳转
还有一个比较容易忽略的问题。
IDA 中查看 dispatcher table 时,会发现一些表项看起来是:
0
0
0
0
...
一开始可能会怀疑:
是不是运行时解密?
是不是需要动态 trace?
后来检查 ELF relocation 后发现,并不是。
部分地址是在 loader 阶段通过:
R_AARCH64_RELATIVE
动态填入。
因此可以直接:
解析 ELF
↓
枚举 relocation
↓
找到 table 对应 relocation
↓
恢复最终 target
抽象成:
for reloc in elf.relocations:
if table_begin <= reloc.offset < table_end:
index = (reloc.offset - table_begin) // 8
targets[index] = image_base + reloc.addend
这样就可以静态得到:
CFF state
↓
native basic block
这一阶段最大的意义,不是得到完整 native CFG,而是可以批量把 semantic block 收集出来。
0x06 怎么确认哪个字段是 VM PC
找到 code buffer 以后,还需要确定 PC。
不能因为某个变量不断变化就直接叫它 PC。
我主要看三个证据。
1. 它参与 code address 计算
反复出现:
insn = code_base + value;
说明这个 value 很可能是 instruction offset。
2. Handler 会稳定修改它
例如:
+4
+5
+7
+11
并且这个增量与当前指令格式对应。
3. Branch 会直接覆盖它
普通算术指令表现为:
next_pc = pc + insn_size;
而 jump handler 则变成:
next_pc = branch_target;
conditional branch 类似:
if (condition)
next_pc = target;
else
next_pc = pc + insn_size;
这三个条件同时成立以后,基本可以确定:
该字段 = VM PC
于是最初一团 native dataflow 可以简化成:
pc = vm->pc;
while (pc < vm->code_size)
{
ins = decode(vm->code, pc);
pc = execute(vm, &ins);
}
0x07 Register File 是怎么恢复的
接下来是寄存器。
分析 operand 读取路径时,经常会出现:
UBFX Wn, Wdesc, ...
...
ADD Xaddr, Xstate, Windex, UXTW #3
LDR Xvalue, [Xaddr, #reg_base]
这类访问说明:
index * 8
是固定 stride。
于是可以先建立一个假设:
uint64_t regs[N];
再检查不同 opcode 是否都通过同一规则访问。
如果:
MOV
ADD
CMP
CALL
都最终落到:
regs[index]
那么这个假设就比较稳了。
最终虚拟机状态可以先抽象成:
typedef struct
{
uint64_t regs[VM_REG_COUNT];
uint64_t pc;
uint64_t code_size;
uint8_t *code;
uint64_t sp;
uint64_t fp;
uint64_t flags;
} VMState;
这里一开始不需要追求准确的 C struct layout。
只需要保证:
语义字段关系正确
就够了。
0x08 一个后来才发现的细节:窄写入
自己实现 VM 以后,有一个 bug 花了一些时间。
例如一条 32 位 MOV:
MOV.d r3, value
第一版 emulator 很自然地写成:
vm->regs[3] = (uint32_t)value;
但是执行真实 bytecode 后,某些后续结果始终对不上。
重新看 native handler 才发现,它做的实际上是:
只修改寄存器 slot 的低 32 bit
高 32 bit 保留。
也就是:
uint64_t old = vm->regs[3];
vm->regs[3] =
(old & 0xffffffff00000000ULL) |
(uint32_t)value;
8 位、16 位也是类似逻辑。
这个细节看起来很小,但如果 register slot 被后续不同宽度重复使用,错误会一直向后传播。
因此在实现 VM 时,我后来专门统一做了:
write_reg8()
write_reg16()
write_reg32()
write_reg64()
而不是让每个 handler 自己随便赋值。
0x09 Descriptor 比 Opcode 更麻烦
恢复 VM 时,一开始很容易认为:
opcode 决定指令语义
但实际上很多 VM 是:
opcode + descriptor
共同决定语义。
这一套 VM 也是如此。
可以把一条指令抽象成:
+00 opcode
+01 descriptor
+02 operand
+03 operand
...
descriptor 中又包含几个维度:
source kind
source width
destination width
operand encoding
为了便于说明,假设 descriptor 被拆成:
bits 0..2 operand mode
bits 3..4 source width
bits 5..6 destination width
这只是文章里的抽象表示,不对应原始编码。
decoder 可以先做:
OperandMode decode_mode(uint8_t desc);
ValueWidth decode_src_width(uint8_t desc);
ValueWidth decode_dst_width(uint8_t desc);
然后 opcode handler 本身只负责:
ADD
SUB
MOV
CMP
...
例如同一个 ADD:
ADD.b
ADD.w
ADD.d
ADD.q
可以共用一个 family:
result = lhs + rhs;
write_reg(dst, result, dst_width);
这比给每一种 descriptor 单独定义一个 opcode 更容易维护。
0x0A 从 Bytecode 到一条 VM 指令
为了确认 decoder,必须真正拿 bytecode 对。
下面用一条经过重新构造的示意指令说明分析方法。
假设看到:
12 95 03 07 2A 00 00
先不要直接解释。
根据当前假设:
12 opcode
95 descriptor
03 dst
07 src
2A 00 00 ...
extra/immediate
然后去 native handler 看:
descriptor 是否进入 width decoder
operand index 是否乘 8
src 是否来自 register file
PC 最终增加多少
如果发现:
dst = reg[3]
src = sign_extend(reg[7], 16)
result width = 32 bit
next_pc = pc + 7
那么最终才能给它写成:
OP_X.d r3, r7
或者更高层:
r3.low32 =
r3.low32 * (int16_t)r7.low16;
这就是后来一直采用的验证方式:
原始 bytes
↓
descriptor decode
↓
native handler
↓
寄存器 effect
↓
PC effect
↓
最终 opcode semantics
0x0B 一个实际发生过的误判
有一条 opcode 很适合说明为什么不能过早命名。
早期在它的 native 路径中看到了:
地址计算
LOAD
STORE
operand fetch
于是最开始把它记成:
INDIRECT_LOAD_STORE
disassembler 也按这个逻辑写了。
问题是,后面真正执行 bytecode 时发现:
部分运算结果不合理
部分寄存器的符号扩展完全对不上
后续比较结果也开始错
重新检查后才发现:
之前看到的 LOAD/STORE 只是 descriptor 解析和 operand 提取,并不是这个 opcode 的真正语义。
继续追 arithmetic block 后,最终恢复成:
SMUL_EXT
抽象语义:
src_value =
sign_extend(
read_reg(src),
source_width
);
result =
read_reg(dst) * src_value;
write_reg(
dst,
result,
destination_width
);
这个修正以后,同一批真实 bytecode 在 Clean VM 中才能继续正常执行。
从此以后,opcode 命名至少要求五个证据:
native handler
descriptor
真实 bytecode
dataflow
Clean VM 执行结果
0x0C 恢复 ISA 时不要一条一条孤立看
确定基础 decoder 以后,可以开始按 family 恢复 ISA。
不是按:
OP00
OP01
OP02
...
逐个硬啃,而是先聚类 semantic pattern。
例如算术类:
ADD
SUB
MUL
DIV
REM
通常都有相似结构:
decode dst
decode src
read operand
perform arithmetic
write result
advance PC
逻辑类:
AND
OR
XOR
SHIFT
也是一组。
比较类:
CMP
SETcc
控制流:
JMP
Jcc
CALL
CALL_REG
RET
调用约定:
PUSH_ARG
ENTER_FRAME
LEAVE_FRAME
后面还能看到浮点:
FADD
FSUB
FMUL
FDIV
FCMP
FMIN
FMAX
以及整数/浮点转换。
这样处理的好处是:
如果已经确认:
width decode
operand decode
register read/write
几个公共模块,其它 opcode 的恢复会越来越快。
0x0D HOSTCALL:从 VM 世界进入 Native 世界
VM 自己只有:
寄存器
内存
算术
分支
它要真正和系统交互,必须有 Host Interface。
因此恢复到后面以后,HOSTCALL 比很多普通 opcode 更重要。
最开始只能看到:
HOSTCALL service_A
HOSTCALL service_B
后面逐步判断 service 行为,例如:
memory allocation
memory copy
string operation
file I/O
symbol lookup
serialization
native call bridge
于是低层:
PUSH r3
PUSH r7
HOSTCALL service_X
才能变成:
memset(buffer, 0, size);
或者:
handle = resolve_symbol(module, symbol);
在文章里不需要保留原始 service ID。
只需要说明恢复方法。
比如判断一个 service 是否近似 memset,可以看:
参数 0 总是地址
参数 1 经常为常量
参数 2 像长度
返回值的使用方式
再结合 native implementation 交叉验证。
这种 semantic recovery 做完以后,VM asm 的可读性会发生质变。
0x0E 为什么一定要自己实现一个 Clean VM
到 ISA 基本完成时,静态分析容易产生一种错觉:
看起来都对了
但“看起来对”并不够。
所以另外写了一套完全不调用原始解释器的 Clean VM。
其核心 state 包含:
struct VM
{
uint64_t regs[REG_COUNT];
uint64_t pc;
uint64_t sp;
uint64_t fp;
Flags flags;
Memory memory;
HostInterface host;
};
执行循环:
for (;;)
{
Instruction ins =
decode(vm.code, vm.pc);
uint64_t fallthrough =
vm.pc + ins.size;
vm.pc = fallthrough;
execute(&vm, &ins);
}
之所以先设置:
fallthrough PC
再执行,是因为这样 branch instruction 可以直接覆盖:
vm.pc = target;
普通指令则保持默认值。
0x0F Emulator 如何验证语义
给每条 opcode handler 都做统一 trace。
例如:
PC : 0x120
OP : ADD
DST : r3
SRC : r7
before:
r3 = 10
r7 = 5
after:
r3 = 15
next PC:
0x125
如果执行到某一点后结果开始偏离预期,就往前找最后一条可疑 opcode。
另外还记录:
CALL depth
SP
FP
HOSTCALL
branch target
因此一个 VM 函数可以得到:
enter func_A
CALL func_B
HOSTCALL strlen
RET
CALL func_C
...
RET
RET
当越来越多真实函数能够从入口运行到预期出口时,ISA 才算真正闭环。
0x10 “不返回”不一定是 Emulator 卡死
测试过程中有一个函数一直跑不完。
第一反应是:
是不是某条 branch 实现错了?
是不是 FLAGS 算错了?
是不是 CALL/RET 有问题?
后来对该函数单独建立静态 CFG,发现:
根本不存在可达 RET
它本身就是:
┌────────────┐
│ loop │
▼ │
check condition │
│ │
├── retry ───┘
│
└── continue work
所以它是一个:
persistent worker
而不是失败的测试。
这件事让 emulator 验证标准从:
所有入口最终都 RET
改成:
returning function
→ 正常 RET
non-returning function
→ 符合静态 CFG
unknown opcode
→ 0
unexpected semantic error
→ 0
0x11 Function Discovery
VM 能执行以后,下一个问题就是:
一整块 bytecode 怎么切成函数?
最开始一个很容易犯的错误是,把所有控制流 target 都当成当前函数内部 basic block。
但:
JMP / Jcc
和:
CALL
不是同一类 edge。
对于:
JMP target
target 仍属于当前 function。
而:
CALL target
应该创建新的 function entry。
所以 function discovery 大致是:
worklist = initial_entries
while worklist:
entry = worklist.pop()
if entry in known_functions:
continue
fn = decode_function(entry)
for call_target in fn.direct_calls:
worklist.add(call_target)
initial_entries 可以来自:
program entry
callback registration
metadata
已知 dispatch entry
这样逐渐扩大 function inventory。
0x12 Basic Block 是怎么切的
得到 function entry 后,再切 basic block。
基本 block boundary 包括:
function entry
branch target
conditional branch fallthrough
CALL 后继点
RET 前结束
例如:
0000 MOV
0005 CMP
000A JZ 0030
000F ADD
0014 JMP 0040
0030 SUB
0035 ...
0040 RET
block 可以切成:
B0: 0000 - 000A
B1: 000F - 0014
B2: 0030 - ...
B3: 0040
然后建立:
B0 → B1
B0 → B2
B1 → B3
B2 → B3
这时就已经从:
VM instruction stream
进入普通 compiler analysis 世界。
0x13 CALL_REG 怎么恢复
直接 CALL 很简单:
CALL address
但间接调用:
CALL_REG r15
会破坏调用图。
所以必须做 symbolic propagation。
例如:
r3 = function_A
r8 = r3
r15 = r8
CALL_REG r15
传播以后:
r15 = constant function_A
因此:
CALL_REG r15
可以静态改写成:
CALL function_A
更复杂一点:
if (...)
r10 = func_A
else
r10 = func_B
CALL_REG r10
这里不能强行变成单一 target。
应该保留:
indirect call {func_A, func_B}
原则仍然是:
能证明才解析。
0x14 为什么不能一直输出 VM 汇编
做到这里,已经可以生成很完整的 VM asm。
但几百条 VM 指令仍然不好读。
例如:
MOV r3, ...
MOV r4, r3
PUSH_ARG r4
CALL host_A
MOV r7, RET
CMP r7, 0
JZ ...
很多指令只是:
VM ABI noise
真正的高级逻辑可能只是:
ptr = allocate(size);
if (!ptr)
return -1;
因此下一阶段不是继续优化 disassembly,而是写 lifter。
0x15 IR Lifter
lifter 不再接收文本汇编。
它直接接:
decoded Instruction
然后把 VM register operation 提升成 symbolic expression。
例如:
MOV r3, r1
ADD r3, 8
转换成:
r3 := r1 + 8
如果后面:
PUSH_ARG r3
HOSTCALL memory_op
就可以继续折叠。
最终 IR 可能是:
v1 = arg0 + 8
call memory_op(v1, 0, 32)
而不是:
r3 = ...
r7 = ...
0x16 SSA:不要把 VM Register 当局部变量
这是反编译器里一个非常关键的点。
VM 的:
r7
只是 physical register。
它可能在一个函数中先后保存:
pointer
size
return value
boolean
所以直接输出:
r7 = open_object(...);
r7 = get_length(...);
r7 = r7 > 10;
虽然“能看”,但类型完全混乱。
SSA 后会变成:
v1 = open_object(...)
v2 = get_length(...)
v3 = v2 > 10
之后 emitter 再决定是否给这些 value 起:
handle
length
condition
这样的名字。
0x17 Phi 是不能乱删的
CFG:
B0
/ \
B1 B2
\ /
B3
如果:
B1:
x = 1
B2:
x = 2
B3 使用 x,就必须有:
x3 = phi(x1, x2)
第一版 cleanup 很容易因为:
“phi 看起来很丑”
而过早删掉。
这样最后可能得到:
if (cond)
x = 1;
use(x);
直接丢语义。
正确顺序是:
SSA
↓
CFG structuring
↓
识别 if/else
↓
把 phi 降成普通 assignment
而不是在 CFG 结构还没确定前就删除。
0x18 Dominator 和 Post-Dominator
为了从 goto CFG 恢复:
if
if/else
while
最基础的两个东西是:
dominator
post-dominator
假设:
A
/ \
B C
\ /
D
A 分支到 B/C,而 D 是 B/C 的最近共同 post-dominator。
就可以恢复:
if (condition) {
B;
} else {
C;
}
D;
如果一条 edge:
B → A
并且 A dominates B,则这是一个典型 back edge。
于是 A 可能是 natural loop header。
继续分析 loop exits 后,可以恢复:
while (cond) {
...
}
而不是:
loc_A:
...
if (...) goto loc_A;
0x19 Multi-Exit Loop
真正麻烦的是:
while (...)
{
if (...)
break;
if (...)
return;
...
if (...)
continue;
}
这种循环对应 CFG 中多个 exit。
第一版 structurer 很容易找不到唯一 follow block。
后来改成:
收集 loop exits
↓
计算 exits 的共同 post-dominator
↓
作为 loop follow candidate
如果不存在可靠的共同 follow,就宁可保留局部 goto。
这也是后期反编译器一直遵守的原则:
结构化失败时可以丑,但不能为了生成 while 强行改控制流。
0x1A Stack Object Recovery
控制流解决以后,伪代码里还会出现大量:
*(uint64_t *)(fp + 0x120)
*(uint32_t *)(fp + 0x128)
foo(fp + 0x120);
这时候需要识别:
stack object
基本做法是收集所有:
fp + offset
访问。
记录:
offset
access width
read/write
是否取地址
是否传给 CALL
是否进入 memset/memcpy
lifetime
例如发现:
fp+0x120 qword
fp+0x128 qword
fp+0x130 dword
且:
foo(fp+0x120)
那么很可能这是同一个 aggregate。
先不要猜业务名称。
可以先生成:
struct local_obj_120_t
{
uint64_t field_00;
uint64_t field_08;
uint32_t field_10;
};
于是:
local_obj_120.field_00 = a;
local_obj_120.field_08 = b;
foo(&local_obj_120);
0x1B 为什么还要支持 Union / Overlay
局部对象并不总是标准 C struct。
有时同一块 8 字节:
offset + 0
既会被:
uint64_t
整体写入,又会单独访问:
offset + 0
offset + 4
例如:
*(uint64_t *)(base) = value64;
*(uint32_t *)(base + 4) = value32;
如果直接生成:
struct {
uint64_t field_00;
uint32_t field_04;
};
显然是重叠的。
正确表示更接近:
union
{
uint64_t word;
struct
{
uint32_t low;
uint32_t high;
};
};
所以 local object inference 不能只解决:
struct
还要支持:
union
overlay
array tail
mixed-width record
0x1C 数组尾部
另一个常见模式是:
固定 header
+
动态索引区域
例如:
*(uint32_t *)(base + 0x00)
*(uint64_t *)(base + 0x08)
*(uint32_t *)(base + 0x10 + index * 4)
可以推成:
struct object_t
{
uint32_t flags;
uint32_t pad;
uint64_t count;
uint32_t items[];
};
判断 array 的主要证据是:
base
+
constant offset
+
index * fixed stride
如果 stride 稳定是:
1 / 2 / 4 / 8
数组证据通常比较强。
0x1D 跨函数类型传播
局部对象恢复后,自然会出现:
foo(&obj);
于是很想直接推:
void foo(Object *obj);
但是这样很危险。
因为另一个调用点可能是:
foo(&obj.field_08);
这时 foo 的真实参数可能只是:
uint64_t *
所以最后采用:
收集所有 callsite
↓
比较 argument evidence
↓
只有全部兼容
↓
提升 callee signature
否则保持保守类型。
这条原则在整个类型恢复过程中都适用:
错误的高层类型
往往比:
uint64_t *
更误导分析。
0x1E 从一个 Callback 到 C-like
最后把整个流程串起来。
假设有一段匿名化后的 bytecode。
decoder 得到:
MOV
CALL
CMP
JZ
ADD
CALL_REG
RET
经过 function discovery:
func_A
├─ call func_B
└─ indirect call func_C
CFG:
B0
|
▼
CALL B
|
▼
CMP
/ \
B1 B2
\ /
B3
lifter:
v1 = func_B(arg0)
if (v1 == 0)
goto B2
v2 = arg1 + 8
func_C(v2)
SSA/structurer:
result = func_B(arg0);
if (result != 0) {
func_C(arg1 + 8);
}
类型恢复后:
int process_object(Context *ctx, Object *obj)
{
int result;
result = validate_object(ctx);
if (result != 0) {
process_field(&obj->field_08);
}
return result;
}
这时候才算真正完成:
VM bytecode
↓
C-like pseudo code
0x1F Decompiler 最后的架构
做到后面以后,工具被拆成两层。
VM Backend
只负责 VM 特有内容:
container format
section layout
opcode map
descriptor
ISA
HOSTCALL ABI
callback ABI
Generic Decompiler
负责通用程序分析:
function discovery
basic block
CFG
dominator
post-dominator
SSA
dataflow
structurer
type propagation
object recovery
C-like emitter
完整架构:
VM Container
│
▼
Parser
│
┌─────────┴─────────┐
│ │
▼ ▼
Backend Metadata
│
▼
Decoder
│
▼
Disassembler
│
▼
Function Discovery
│
▼
CFG
│
▼
Lifter
│
▼
SSA / Dataflow
│
▼
Structurer
│
▼
Type / Object Recovery
│
▼
C-like Output
这样以后碰到同系列的新 VM:
opcode map 变化
descriptor 变化
HOSTCALL 变化
只需要换 backend。
而:
CFG
SSA
Structurer
Type System
仍然可以直接使用。
0x20 回头看几个最重要的经验
1. 不要先入为主地完整去 CFF
分析 VM 时,比漂亮 native CFG 更重要的是:
PC
code_base
opcode
descriptor
register
next_pc
只要这些数据流还能恢复,就可以先绕开外围混淆。
2. 间接跳转表不等于 Opcode Table
一定要确认:
index 是哪里来的?
如果来自:
flattening state
那它只是控制流 dispatcher。
只有 index 真正来自:
bytecode / opcode
才可能是 VM dispatch。
3. Opcode 不要太早命名
看到:
LOAD
STORE
不代表 opcode 就是 LOAD/STORE。
它可能只是:
operand extraction
必须结合:
handler
descriptor
bytecode
dataflow
emulator
一起判断。
4. Clean VM 是非常重要的验证手段
只有静态分析时,只能说:
“这个语义大概率是这样。”
自己实现 VM 后可以问:
“真实 bytecode 能不能按照这个语义运行?”
这是完全不同的证据强度。
5. CALL 和 Branch 必须严格分离
JMP target
属于 CFG。
CALL target
属于 callgraph。
如果这一步错了,后面:
函数数量
调用图
签名
类型
都会一起错。
6. 反编译器最重要的不是“看起来漂亮”
一个:
uint64_t arg1;
可能不好看。
但一个错误的:
SomeComplexStruct *ctx;
会严重误导分析。
所以类型恢复原则应该始终是:
Evidence first.
0x21 总结
回头看整个过程,其实可以分成三个阶段。
最初面对的问题是:
这个大型 ARM64 函数到底在干什么?
后来变成:
这颗 VM CPU 到底是怎么工作的?
最后则变成:
怎么为它写一套反编译器?
整个过程:
stripped ARM64 ELF
+
Control Flow Flattening
+
Unknown Bytecode
│
▼
发现 VM PC
│
▼
发现 Register File
│
▼
恢复 Descriptor
│
▼
恢复 ISA
│
▼
恢复 HOSTCALL
│
▼
Clean VM
│
▼
Function Discovery
│
▼
CFG / SSA
│
▼
Structuring
│
▼
Type Recovery
│
▼
C-like Decompiler
到最后以后,原本隐藏在:
ARM64 混淆
+
自定义 VM
后面的程序重新变成了普通的:
function
basic block
call
condition
loop
variable
struct
array
所以如果让我用一句话总结这种 VM 的分析方式:
不要一开始想着“破解一套 VM”,而是先把它当成一颗完全未知的 CPU。
先恢复:
machine state
instruction encoding
ISA
ABI
host interface
当 machine model 建立完成以后:
CFG
SSA
dataflow
type recovery
decompilation
这些已经成熟的程序分析方法就都可以重新使用。
VM 改变的是程序的执行机器。
并没有改变程序本身仍然需要:
计算
访存
分支
调用
返回
这个事实。