前段时间分析一个 Android ARM64 程序时,在 native 层遇到了一套自定义虚拟机。
程序中有一部分逻辑没有直接编译成普通 ARM64 指令,而是先转换成一套私有 bytecode,运行时由 native 层解释器负责执行。
为了避免文章和具体产品、厂商或商业项目产生关联,本文对样本名称、函数地址、内部协议名称、常量、字符串以及部分结构信息进行了匿名化处理。
文中使用:
分别代指 native runtime 和 VM program。
本文只讨论:
最初的目标只是看懂解释器。
最后实际做成了:
整个过程中最值得记录的并不是某一条特殊指令,而是从“一个看起来完全无法阅读的解释器”,逐步重新建立一套 machine model 的过程。
如果只看最终结果,这套 VM 好像就是:
实际分析时远没有这么线性。
真正困难的地方,是几层不同的问题同时叠在一起:
其中任意一层判断错了,错误都会向后传递。
例如:
所以这次逆向最麻烦的并不是“ARM64 汇编难看”,而是必须不断区分哪些东西是 VM 本身,哪些只是 VM 外面的实现噪声。
这是最早遇到,同时也是最容易把整个方向带偏的难点。
解释器本身已经是:
但 native 编译结果外面又套了 Control Flow Flattening:
于是实际看到的是两层“状态机”套在一起:
两层都有:
所以单纯看到:
几乎无法判断它究竟是哪一层 dispatcher。
这里真正需要判断的是:
而不是看某几条汇编“长得像不像 VM”。
第二个难点是:
handler 中看到的 native 操作,不一定就是 opcode 的真正语义。
很多 opcode 在真正计算之前,都要经过公共逻辑:
如果只截取其中一部分,很容易把:
误认为:
这次实际就出现过:
后来通过完整 handler、descriptor、真实 bytecode 和 emulator 对照,才发现真正语义其实是一个带宽度控制的有符号乘法。
所以这种 VM 很难靠:
恢复准确。
如果 VM 是最简单的:
分析会容易很多。
但这里 descriptor 同时影响:
于是同一个 arithmetic family 可能表现为:
如果 descriptor 少理解一个 bit,后面就可能出现:
所以 bytecode decoder 本身就是逆向里的核心部分,而不是辅助工作。
很多 opcode 在静态分析阶段可以达到:
但 VM 逆向的问题在于:
90% 对一个解释器是不够的。
因为一条基础指令的微小误差,会影响后面的所有代码。
一个典型例子是寄存器窄写入。
第一版解释器把:
理解成:
从 C 的角度看非常自然。
但原 VM 实际语义是:
于是两种实现只差高 32 位,却能让后续调用参数完全改变。
这种问题靠“看起来合理”很难发现。
真正暴露它的是 Clean VM 跑真实 bytecode 时结果开始异常。
因此这次分析很大一个难点其实是:
即使把:
全部恢复出来,也只解决了 VM 内部计算。
真正让 VM 和外部世界交互的是:
而 Host Interface 本身又是一套新的未知 ABI:
如果 HOSTCALL 不恢复,最终代码永远会停留在:
ISA 虽然已经理解,但程序本身依旧不好读。
所以 VM 逆向其实包含两套系统:
两个都要恢复。
普通 ELF 至少还有:
可以辅助识别函数。
bytecode 里可能只有一大块:
其中:
和:
看起来都只是跳到另一个 offset。
但前者代表:
后者代表:
如果把 CALL target 当成 CFG successor,多个函数就会被错误合成一个巨大函数。
反过来,如果把普通 branch 当 CALL,又会制造大量假函数。
所以 function discovery 自身也是一个独立问题。
直接调用:
很容易恢复。
但:
目标来自寄存器。
如果不做 symbolic/dataflow propagation,调用图到这里就断了。
而调用图一旦缺失,后面的:
都会受到影响。
因此间接调用恢复并不是“锦上添花”,而是静态反编译能不能继续往上走的基础。
ISA 恢复以后,可以很快得到:
这时候从 VM 逆向角度来说已经取得了很大进展。
但实际阅读一个几百甚至上千条 VM 指令的函数时,仍然非常困难。
因为里面充满:
真正的高级逻辑只占一部分。
所以要继续跨越:
每一层其实都是一个独立的编译器问题。
简单的:
并不算太难。
真正麻烦的是:
这种 CFG 如果强行结构化,很容易生成:
但其实改变了原始控制流。
所以后期一个重要原则是:
宁可保留局部 goto,也不能生成漂亮但错误的结构。
这也是为什么反编译器后期最重要的指标并不是“有没有 goto”,而是:
这是比较隐蔽的难点。
假设两个分支分别产生:
在 merge block 汇合。
正确应该是:
如果 cleanup 阶段错误删掉其中一个 definition,最终代码仍然可能语法完全正常:
但 else 分支的值已经丢了。
这种错误比:
名字不好看严重得多,因为它改变程序语义。
所以反编译器越往后,真正难的东西反而从 opcode 转向:
最后一个比较明显的难点是类型。
VM 中首先看到的是:
而不是:
同一个 register 在不同时间甚至可能分别装:
栈上的对象也可能同时存在:
而跨函数传播更危险。
例如:
不能单凭这一处就推断:
因为另一个 callsite 可能是:
所以这套反编译器后期采用的是保守策略:
宁可输出:
也不要输出一个错误但看起来很漂亮的结构体类型。
如果简单归纳,这次分析的难点大概可以分成四层:
所以这类 VM 真正难的不是:
而是:
分析者需要自己逐层建立:
这也是后面为什么 Clean VM 会成为整个分析里的关键节点。
它第一次让:
变成:
一开始只看 native 层,其实很难直接断定它就是一个 VM。
普通程序同样可能存在:
所以我的判断主要来自几个特征同时出现。
首先,一个很大的 native 函数被频繁调用,而且输入中明显包含一块外部数据。
函数内部持续出现:
抽象以后非常像:
第二个特征是出现了明显的“虚拟寄存器区”。
很多路径都在访问类似:
而 index 来自 bytecode 中的小整数。
这就很像:
第三个特征是不同 semantic block 的尾部会用几个固定长度更新 cursor。
例如:
不同 opcode 的长度不同,但最后都会把新的 PC 写回同一个 context。
这几个特征组合在一起以后,基本可以确定:
这不是普通状态机,而是一套变长指令的 register VM。
真正开始分析解释器后,第一个误判很快就出现了。
在函数中可以看到大量:
这种结构。
从表面看:
特别像经典的 VM opcode dispatch:
所以第一版分析直接把那张大表标成了:
后来证明是错的。
继续追 index 的来源后发现,它并不是:
或者任何明显的 bytecode 值。
它来自一串:
最终计算出的内部 state。
更关键的是,这些跳转目标执行完以后通常不会像真正 VM handler 一样:
而是继续计算新的 flattening state。
于是实际结构更接近:
这是 Control Flow Flattening,而不是 VM 自己的调度。
这是整个分析里第一个比较大的坑。
如果这里判断错了,后面会得到一整套完全错误的:
对应关系。
确认外围存在控制流平坦化后,有两个选择。
第一种:
第二种:
我后来选择第二种。
原因是分析 VM 时真正需要的东西没有那么多。
核心其实只有:
只要这些东西能从 flattening 里抽出来,原生函数是否恢复成漂亮的 CFG 反而不是重点。
于是分析方法变成:
例如某些 block 中可以反复看到:
把 native register 名字拿掉以后,就是:
这类 block 才是真正值得关心的 VM semantic block。
还有一个比较容易忽略的问题。
IDA 中查看 dispatcher table 时,会发现一些表项看起来是:
一开始可能会怀疑:
后来检查 ELF relocation 后发现,并不是。
部分地址是在 loader 阶段通过:
动态填入。
因此可以直接:
抽象成:
这样就可以静态得到:
这一阶段最大的意义,不是得到完整 native CFG,而是可以批量把 semantic block 收集出来。
找到 code buffer 以后,还需要确定 PC。
不能因为某个变量不断变化就直接叫它 PC。
我主要看三个证据。
反复出现:
说明这个 value 很可能是 instruction offset。
例如:
并且这个增量与当前指令格式对应。
普通算术指令表现为:
而 jump handler 则变成:
conditional branch 类似:
这三个条件同时成立以后,基本可以确定:
于是最初一团 native dataflow 可以简化成:
接下来是寄存器。
分析 operand 读取路径时,经常会出现:
这类访问说明:
是固定 stride。
于是可以先建立一个假设:
再检查不同 opcode 是否都通过同一规则访问。
如果:
都最终落到:
那么这个假设就比较稳了。
最终虚拟机状态可以先抽象成:
这里一开始不需要追求准确的 C struct layout。
只需要保证:
就够了。
自己实现 VM 以后,有一个 bug 花了一些时间。
例如一条 32 位 MOV:
第一版 emulator 很自然地写成:
但是执行真实 bytecode 后,某些后续结果始终对不上。
重新看 native handler 才发现,它做的实际上是:
高 32 bit 保留。
也就是:
8 位、16 位也是类似逻辑。
这个细节看起来很小,但如果 register slot 被后续不同宽度重复使用,错误会一直向后传播。
因此在实现 VM 时,我后来专门统一做了:
而不是让每个 handler 自己随便赋值。
恢复 VM 时,一开始很容易认为:
但实际上很多 VM 是:
共同决定语义。
这一套 VM 也是如此。
可以把一条指令抽象成:
descriptor 中又包含几个维度:
为了便于说明,假设 descriptor 被拆成:
这只是文章里的抽象表示,不对应原始编码。
decoder 可以先做:
然后 opcode handler 本身只负责:
例如同一个 ADD:
可以共用一个 family:
这比给每一种 descriptor 单独定义一个 opcode 更容易维护。
为了确认 decoder,必须真正拿 bytecode 对。
下面用一条经过重新构造的示意指令说明分析方法。
假设看到:
先不要直接解释。
根据当前假设:
然后去 native handler 看:
如果发现:
那么最终才能给它写成:
或者更高层:
这就是后来一直采用的验证方式:
有一条 opcode 很适合说明为什么不能过早命名。
早期在它的 native 路径中看到了:
于是最开始把它记成:
disassembler 也按这个逻辑写了。
问题是,后面真正执行 bytecode 时发现:
重新检查后才发现:
之前看到的 LOAD/STORE 只是 descriptor 解析和 operand 提取,并不是这个 opcode 的真正语义。
继续追 arithmetic block 后,最终恢复成:
抽象语义:
这个修正以后,同一批真实 bytecode 在 Clean VM 中才能继续正常执行。
从此以后,opcode 命名至少要求五个证据:
确定基础 decoder 以后,可以开始按 family 恢复 ISA。
不是按:
逐个硬啃,而是先聚类 semantic pattern。
例如算术类:
通常都有相似结构:
逻辑类:
也是一组。
比较类:
控制流:
调用约定:
后面还能看到浮点:
以及整数/浮点转换。
这样处理的好处是:
如果已经确认:
几个公共模块,其它 opcode 的恢复会越来越快。
VM 自己只有:
它要真正和系统交互,必须有 Host Interface。
因此恢复到后面以后,HOSTCALL 比很多普通 opcode 更重要。
最开始只能看到:
后面逐步判断 service 行为,例如:
于是低层:
才能变成:
或者:
在文章里不需要保留原始 service ID。
只需要说明恢复方法。
比如判断一个 service 是否近似 memset,可以看:
再结合 native implementation 交叉验证。
这种 semantic recovery 做完以后,VM asm 的可读性会发生质变。
到 ISA 基本完成时,静态分析容易产生一种错觉:
但“看起来对”并不够。
所以另外写了一套完全不调用原始解释器的 Clean VM。
其核心 state 包含:
执行循环:
之所以先设置:
再执行,是因为这样 branch instruction 可以直接覆盖:
普通指令则保持默认值。
给每条 opcode handler 都做统一 trace。
例如:
如果执行到某一点后结果开始偏离预期,就往前找最后一条可疑 opcode。
另外还记录:
因此一个 VM 函数可以得到:
当越来越多真实函数能够从入口运行到预期出口时,ISA 才算真正闭环。
测试过程中有一个函数一直跑不完。
第一反应是:
后来对该函数单独建立静态 CFG,发现:
它本身就是:
所以它是一个:
而不是失败的测试。
这件事让 emulator 验证标准从:
改成:
VM 能执行以后,下一个问题就是:
一整块 bytecode 怎么切成函数?
最开始一个很容易犯的错误是,把所有控制流 target 都当成当前函数内部 basic block。
但:
和:
不是同一类 edge。
对于:
target 仍属于当前 function。
而:
应该创建新的 function entry。
所以 function discovery 大致是:
initial_entries 可以来自:
这样逐渐扩大 function inventory。
得到 function entry 后,再切 basic block。
基本 block boundary 包括:
例如:
block 可以切成:
然后建立:
这时就已经从:
进入普通 compiler analysis 世界。
直接 CALL 很简单:
但间接调用:
会破坏调用图。
所以必须做 symbolic propagation。
例如:
传播以后:
因此:
可以静态改写成:
更复杂一点:
这里不能强行变成单一 target。
应该保留:
原则仍然是:
能证明才解析。
做到这里,已经可以生成很完整的 VM asm。
但几百条 VM 指令仍然不好读。
例如:
很多指令只是:
真正的高级逻辑可能只是:
因此下一阶段不是继续优化 disassembly,而是写 lifter。
lifter 不再接收文本汇编。
它直接接:
然后把 VM register operation 提升成 symbolic expression。
例如:
转换成:
如果后面:
就可以继续折叠。
最终 IR 可能是:
而不是:
这是反编译器里一个非常关键的点。
VM 的:
只是 physical register。
它可能在一个函数中先后保存:
所以直接输出:
虽然“能看”,但类型完全混乱。
SSA 后会变成:
之后 emitter 再决定是否给这些 value 起:
这样的名字。
CFG:
如果:
B3 使用 x,就必须有:
第一版 cleanup 很容易因为:
而过早删掉。
这样最后可能得到:
直接丢语义。
正确顺序是:
而不是在 CFG 结构还没确定前就删除。
为了从 goto CFG 恢复:
最基础的两个东西是:
假设:
A 分支到 B/C,而 D 是 B/C 的最近共同 post-dominator。
就可以恢复:
如果一条 edge:
并且 A dominates B,则这是一个典型 back edge。
于是 A 可能是 natural loop header。
继续分析 loop exits 后,可以恢复:
而不是:
真正麻烦的是:
这种循环对应 CFG 中多个 exit。
第一版 structurer 很容易找不到唯一 follow block。
后来改成:
如果不存在可靠的共同 follow,就宁可保留局部 goto。
这也是后期反编译器一直遵守的原则:
结构化失败时可以丑,但不能为了生成 while 强行改控制流。
控制流解决以后,伪代码里还会出现大量: