首页
社区
课程
招聘
[原创]一个 ARM64 自定义 VM 的分析与还原
发表于: 2026-10-1 00:33 1031

[原创]一个 ARM64 自定义 VM 的分析与还原

2026-10-1 00:33
1031

前段时间分析一个 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 强行改控制流。

控制流解决以后,伪代码里还会出现大量:


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
点赞 ・29
打赏
分享
最新回复 (8)
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2 楼
感谢分享
2026-10-1 05:34
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3 楼
感谢分享
2026-10-4 20:50
0
雪    币: 158
活跃值: (5926)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4 楼
感谢分享 
2026-10-8 14:25
0
雪    币: 18
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5 楼
感谢分享 
2026-10-14 14:34
0
雪    币: 178
活跃值: (3986)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
6 楼
666
2026-10-14 15:01
0
雪    币: 411
活跃值: (1106)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7 楼
6666
2026-10-19 07:00
0
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
8 楼
感谢分享
2026-10-20 17:37
0
雪    币: 321
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
9 楼
666
2026-10-20 17:50
0
游客
登录 | 注册 方可回帖
返回