VMP 逆向一直被视为一项“重工程”工作。传统做法通常要求分析者先理解虚拟机整体架构,再逐个拆解 handler 的语义,随后人工拼回控制流,最后编写复杂的匹配规则。这个流程并非不可行,但成本高、周期长,而且对分析经验依赖极强。

我更倾向于换一个角度看这个问题:既然今天的逆向流程里,trace 几乎已经成为标准产物,那么是否可以把 VMP 还原的问题,转化为对 trace 的结构化解释?换句话说,不再强求一开始就彻底理解每个 handler 的内部实现,而是优先识别“当前执行到的是哪个 handler”,先把执行链条还原出来,再逐步补全语义。

这套思路的核心并不复杂,但非常有效:把 VMP 逆向从“虚拟机架构分析”降维为“基于文本匹配的执行识别问题”

为什么 Trace 适合做 VMP 还原

无论是用 unidbg 模拟执行,还是在真机环境中记录运行过程,最终得到的通常都是体量巨大的执行日志,少则几十万条,多则上千万条指令。表面上看,这种数据量似乎增加了分析负担;但从另一个角度看,trace 已经把动态执行过程完整铺平,变成了一份可以逐行处理的静态文本。

而 VMP 的 handler 往往具备两个很重要的特点:

  • 入口形式稳定
  • 调度方式高度固定

这意味着,我们未必需要先理解 handler 内部每一条指令在做什么,才有能力推进分析。很多时候,只要能可靠识别 handler 的边界,并确认其名称,整条虚拟执行路径就已经清晰了大半。

从工程角度看,这种方法的优势很明显:

  • 不依赖一开始就完整吃透 VM 架构
  • 不需要先为所有 handler 编写复杂特征规则
  • 可以边运行、边识别、边补全配置
  • 更适合处理超大规模 trace

核心突破:固定的 Dispatch 模式

最初比较自然的想法,是对 handler 开头若干条指令做特征匹配,例如用正则或模板识别常见序列。但在实际实现中,一个更直接、也更稳健的切入点出现了:dispatch 本身就是固定模式

典型形式如下:

  • 从 dispatch table 中取出下一个 handler 地址
  • 通过 BR 做间接跳转
  • 跳转目标即下一个 handler 入口

这件事非常关键,因为它意味着识别逻辑可以从“猜测 handler 内部模式”转变为“精确追踪 dispatch 跳转目标”。

这带来了什么简化

如果 dispatch 结构稳定,那么自动化识别只需要完成三件事:

  • 识别固定的 LDR [X24, ...] + BR 调度模式
  • 提取 BR 的跳转目标
  • 将该目标地址与已知 handler 列表做匹配

一旦目标地址命中已知 handler,就可以准确确认下一段执行属于哪个 handler;如果没有命中,就停止处理,等待人工分析。

这一步本质上把 handler 识别从模糊匹配变成了确定性地址追踪。

Handler 结构为何天然适合自动识别

从实际样本来看,VMP handler 的内部虽然语义各异,但外形往往相当规整:

  • 开头读取操作数
  • 中间执行寄存器、内存或控制流相关逻辑
  • 结尾更新 PC 并跳回 dispatch

例如某些 handler 会先从 X0 指向的字节码流中取出操作数,再根据编码拆解出源寄存器、目标寄存器和立即数,最后通过 BR 跳转到下一个 handler。虽然中间的具体计算过程不同,但整体框架高度一致。

这意味着自动化工具不需要一开始理解“它为什么这么算”,只要知道“它从这里开始,到这里结束,它叫什么名字”,就可以先完成执行序列层面的还原。

系统设计:按解释器方式推进 Trace

在实现层面,我不建议一次性扫描并试图解析整份 trace。更稳妥的方式,是把工具本身设计成一个“解释器”:像 VM 一样,沿着执行流逐步推进。

基本工作逻辑

处理过程可以概括为:

  • 当前 offset 是否命中已知 handler 入口
  • 如果命中,记录 handler 名称,跳过其主体,继续前进
  • 如果不是 handler 入口,检查当前位置是否属于 dispatch 模式
  • 如果是,则解析 BR 目标
  • 若目标是已知 handler,继续处理
  • 若目标未知,则停止并提示人工介入
  • 其他情况视为普通代码,继续向后扫描

这种设计有一个很实用的特点:工具只会在真正遇到“未知的、影响还原继续推进的关键点”时停下。

也就是说,它不是盲目报错,而是精准地把问题收敛到“这个新 offset 对应的 handler 还没有命名”。这让整个分析流程变成一种可迭代、可积累的工作方式。

迭代式补全的优势

当工具发现未知 handler 时,分析者只需要回到 IDA 中查看对应函数,判断其用途和命名,再把结果加入配置文件。重新运行后,工具即可从原有断点继续推进。

这个过程非常像给解释器持续补充词典:

  • 先识别一批 handler
  • 运行到未知位置
  • 人工补一个新 handler
  • 再向前覆盖更长路径

相比一次性全量建模,这种方法的投入更可控,反馈也更快。

地址匹配的关键细节:使用 Offset 而非绝对地址

在 trace 处理中,有一个细节非常重要:匹配时应当使用 offset 地址,而不是运行时绝对地址。

原因很简单。不同运行环境下,模块基址可能变化,但相对于模块本身的 offset 通常保持稳定。如果配置文件记录的是绝对地址,那么换一次执行环境就可能全部失效;而使用 offset,可以显著提高方案的可复用性与稳定性。

因此,trace 中只要能提取出形如 0xoffset 的字段,就足以作为 handler 识别的统一键值。

面对千万级 Trace 的内存优化策略

大体量 trace 的处理,瓶颈往往不在算法,而在工程实现。比如一份超过千万条指令、接近 1GB 的日志,如果每条记录都完整保留原始文本,内存占用会迅速膨胀。

在实践中,可以采用两项非常有效的优化:

1. 只保留必要字段

对于 handler 识别而言,真正关键的信息只有:

  • offset

n- mnemonic

  • operands
  • 少量必要上下文

像整行原始文本这类冗余字段,如果后续不参与判定,就不必长期驻留内存。

2. 采用流式解析

使用逐行读取的方式处理 trace,而不是一次性整体载入。这种基于 std::getline 的流式方案,对大文件尤其友好,可以显著降低峰值内存占用。

经过类似优化后,处理接近 1GB 级别日志时,内存消耗可以从难以接受的水平压缩到更可控的范围。

Handler 配置设计:边界信息必须精确

为了实现稳定识别,handler 配置不应只包含入口地址。更可靠的做法是同时维护:

  • addr:handler 入口 offset
  • end_addr:handler 结束指令 offset
  • name:handler 名称

为什么 end_addr 很重要

部分 handler 内部并不是完全线性的,它们可能包含条件分支、条件选择等结构。如果只依赖入口识别,而没有明确边界,工具在跳过 handler 主体时可能发生误判。

加入 end_addr 后,handler 的范围可以被精确限定,尤其适合处理那些内部含有 beqcsel 等控制逻辑的复杂样本。

从工程实践看,完整导出 dispatch table 中的 handler 列表,是后续自动化的基础。只要这份映射足够准确,执行序列识别就会非常稳定。

输出形式:从“冗余记录”到“可读序列”

识别出 handler 之后,输出格式直接影响分析效率。

如果为每个 handler 输出完整 JSON、原始指令片段、索引范围和调用上下文,虽然信息齐全,但阅读成本很高。对于人类分析者而言,很多时候真正想看的只是“执行顺序”。

因此,更适合的输出形式往往是简洁的序列表示,例如:

  • COMPOUND_STORE_INIT // 69357
  • STORE_REG // 69393
  • LOAD_INDIRECT // 69523

这样的格式接近代码注释风格,阅读体验非常自然。它能够快速回答一个核心问题:这段虚拟执行路径到底由哪些 handler 组成。

为什么还需要 IR 解释层

仅有 handler 序列,仍然只能回答“执行了什么类型的操作”,还无法精确说明“具体操作了哪些寄存器、哪些偏移、哪些立即数”。

例如看到如下序列:

  • STORE_REG
  • MOV_REG
  • add_imm
  • call
  • load_mem
  • beq

我们大致知道它涉及存储、搬运、加立即数、调用、加载和条件跳转,但不知道目标寄存器是谁、内存偏移是多少、调用目标在哪里。要真正接近可读语义,就必须引入 IR(中间表示)层。

IR 的目标

IR 的任务是把 handler 对应的编码操作数解码出来,转换成明确的语义表达,例如:

  • VM_REG[24] = VM_REG[10]
  • store_mem(VM_REG[1] + 0x1b8, VM_REG[27])
  • VM_REG[5] = VM_REG[60] + 0x80
  • call 0x29bd94
  • beq VR[8], +0x1c

做到这一步后,trace 还原就不再只是“标签序列”,而开始具备类似反编译中间层的表达能力。

如何从 Trace 中提取操作数语义

VMP 中很多 handler 的操作数都来自对 X0 指向字节码流的读取,例如:

  • ldr [X0]
  • ldr [X0, #offset]

trace 天然记录了这些加载行为及其结果,因此只要能在 handler 内定位相关读取指令,就可以把编码值提取出来。

常见的三类操作数模式

1. 简单单操作数模式

这类 handler 通常只通过一次 ldr [X0] 取出一个编码值,再从中拆解出寄存器编号、立即数等字段。

典型解码思路是:

  • BYTE0 表示源寄存器
  • BYTE1 表示目标寄存器
  • 高 16 位表示有符号立即数

最终得到类似:

VM_REG[dst] = VM_REG[src] + imm

2. 槽位式复合模式

某些复合 handler 并不只读一个操作数,而是从 X0 的多个固定偏移位置取值,例如:

  • [X0, #0x10]
  • [X0, #0x08]
  • [X0, #0x00]

这意味着一个 handler 可能在一次执行中对应多条 IR,例如先做一次加法初始化,再连续执行两次存储操作。

3. 顺序读取模式

还有一类复合 handler 会在执行过程中多次从 [X0] 读取,并随着执行自动推进位置。这样,第 1 次读取对应第 1 个子操作,第 2 次读取对应第 2 个子操作,以此类推。

这类模式非常适合通过“第 N 次 X0 读取”来提取编码值,并逐个映射为多条 IR。

编码模式的抽象分类

在对一批 handler 进行系统整理后,可以发现其编码规则具有较强的规律性,大致可归纳为以下几类:

寄存器间运算

常见于 add64sub64and64EORUMOD 等类型。其核心模式通常是:

VM_REG[dst] = VM_REG[src1] OP VM_REG[src2]

立即数运算

这类指令会从编码中拆出寄存器编号和 16 位立即数,例如 add_immsltiORR_IMM 等。

移位与旋转

lsl32lsr32_immROR32ror64 等,编码中通常包含源寄存器、目标寄存器和移位位数。

内存加载与存储

对应 load_memldrswLDRBstr32strbstrh 等。它们一般使用:

  • 基址寄存器
  • 目标或源寄存器
  • 有符号偏移

分支跳转

bbeqbnebltbbit 等,编码中通常带条件寄存器和跳转偏移。

浮点相关操作

例如 fnegfcmpeqdceilfloorf 等,这类 handler 可能使用独立的浮点寄存器文件,编码规则与整数运算略有区别。

从自动化实现角度看,这种分类非常重要。因为一旦抽象出统一的解码模板,新增 handler 往往只需要映射到既有模式,而不必每次从零开始设计解释逻辑。

CALL 类 Handler 的特殊性

在所有 handler 中,CALL 相关类型通常最麻烦。原因在于它们表面上只是调用 VM 内部某个分发器,但真正的目标函数地址可能藏在更深一层甚至两层的间接调用中。

一个常见链路是:

  • handler 内部先 BL 到 VM 的 call dispatch
  • dispatch 内再通过 BLR 调用 wrapper
  • wrapper 再 BLR 到实际目标函数

如果只看第一层调用,得到的只是调度器地址,缺乏真实语义。因此在 IR 解释阶段,需要进一步追踪 BLR 路径,直到解析出最终落点。只有这样,IR 才能输出真正有价值的结果,例如:

call 0x29bd94

解释器架构:把语义提取收敛到统一入口

在实现上,比较稳妥的方式是为不同 handler 建立统一解释框架。通常可以拆成三层:

1. 操作数提取函数

负责从 handler 对应的 trace 片段中抓取编码值,例如:

  • 提取单一操作数
  • 读取固定 offset 槽位
  • 获取第 N 次 X0 访问
  • 追踪 BLR 的最终目标

2. 编码解码函数

负责把编码值拆解成结构化字段,例如:

  • decode_store
  • decode_load
  • decode_mov
  • decode_add_imm

3. Handler 级解释函数

每个 handler 对应一个 interpret_xxx(),将提取与解码组合起来,最终生成 IR。

这种组织方式的优点是边界清晰:当新增 handler 时,通常只需要复用已有提取或解码逻辑,再补一个针对性的解释函数即可。

大规模覆盖的关键:让所有 Handler 最终可解释

一套成熟的 VMP 还原工具,目标不应停留在“能识别一部分 handler”,而应尽量覆盖完整样本集。

从工程实践来看,handler 大致会分布在以下几类中:

  • 简单算术与逻辑
  • 移位与循环移位
  • 内存加载与写回
  • 比较与条件分支
  • 条件选择
  • 浮点运算
  • 特殊运算
  • CALL
  • 复合槽位式 handler
  • 复合顺序式 handler
  • 委托与存根类 handler

一旦这些类别都建立起稳定的解释模板,整个系统的覆盖率就会迅速提高,最终实现对全量 handler 的系统性还原。

工程难点:如何折叠 BL 展开的 Native Trace

在真实 trace 中,还有一个必须解决的问题:handler 内部经常会通过 BL 调用 native 函数,而 trace 会把这些 native 函数内部的执行过程完整展开。

如果不做折叠,会出现几个直接问题:

  • handler 的指令数量被 native 调用严重放大
  • 边界识别变得不清晰
  • 统计和 IR 提取都会受到干扰

基本折叠思路

一种有效办法是维护一个基于 offset 的调用栈:

  • 遇到 BL 时,把返回地址 current_offset + 4 入栈
  • 扫描过程中,如果当前 offset 命中栈顶返回地址,则出栈
  • 当栈非空时,说明当前位于某个被调用的 native 过程内部

在这种状态下,多数 native 指令都可以直接跳过,从而把 trace 压缩回 handler 的主干逻辑。

初版方案的缺陷

如果简单地在“栈非空”时无条件跳过所有指令,会漏掉一种重要情况:native 代码内部可能再次进入 VMP handler。

这意味着并不是所有处于调用栈内部的内容都应该被忽略。否则,嵌套出现的 handler 会被整体吞掉,破坏执行链条。

更稳妥的选择性折叠

更完善的策略是:当调用栈非空时,只折叠真正的 native 普通代码,但保留两类关键点:

  • 已知 handler 入口
  • BR 指令

这样做的效果是:

  • 纯 native 函数体被折叠
  • native 中嵌套的 VMP 执行仍可正常识别
  • handler 的结束跳转不会被误丢弃

从结果上看,这套机制可以稳定支持以下场景:

  • VMP 调用 native 函数
  • native 函数内部再次触发 VMP
  • VMP 嵌套 VMP
  • 多层 native/VMP 交错调用

这种处理方式的本质,是用动态调用栈为 trace 建立“折叠视图”,把分析关注点重新拉回虚拟执行主线。

用 IDA 做边界校验,保证配置可信

自动化工具是否可靠,很大程度上取决于 handler 边界信息是否准确。因此,配置文件中的 addrend_addr 不能仅凭经验填写,最好通过 IDA 做批量校验。

一个完整的验证流程通常应检查三件事:

  • addr 是否确实是 IDA 识别出的函数起始位置
  • end_addr 是否对应函数最后一条有效指令
  • 结束指令是否属于合法终止类型,例如 BRRET

如果所有 handler 都能通过上述检查,并且不存在重叠或断层,那么基于边界的后续识别、跳过和 IR 解释就具备了可靠基础。

推荐的项目工作流

从工具落地角度看,一套高效的工作流应当是迭代式的,而不是一次性求全。

建议流程

  • 先运行工具,输出基础 handler 序列
  • 遇到未知 handler 时停止
  • 在 IDA 中分析对应 offset 的函数语义
  • 将名称与边界补入配置文件
  • 在解释器中补充该 handler 的 IR 解码逻辑
  • 重新编译并继续运行
  • 重复这一过程,直到覆盖完整样本

这种方法最大的好处是:人只负责做最擅长的判断,工具负责重复性的遍历与还原。

为什么这套方案有效

把整套方法抽象来看,它之所以有效,不在于某个单点技巧,而在于每一层都进行了恰当的降维处理。

传统 VMP 逆向的难点

传统路径通常需要:

  • 先理解虚拟机架构
  • 再分析每个 handler 的语义
  • 手动恢复控制流
  • 编写复杂模式匹配规则
  • 最后才能逐渐得到可读结果

基于文本匹配的 VMP 还原优势

而基于 trace 的方案则做了不同的拆解:

  • 架构理解被推迟:trace 已经把执行过程平铺为静态文本
  • 控制流恢复被简化:dispatch 模式固定,可自动追踪
  • handler 识别更精确:入口由 BR 目标决定,而不是模糊规则猜测
  • 语义恢复按需推进:先拿到 handler 序列,再逐步补 IR
  • 人工成本显著下降:分析者只需对未知 handler 做命名和语义归类

这也是我认为它最有价值的地方:它并没有试图一步到位解决所有问题,而是把原本沉重的 VMP 逆向拆解成一系列可迭代、可验证、可积累的小问题。

结语:从 Handler 序列走向更高层反编译

基于文本匹配的 VMP 还原,并不意味着忽略虚拟机语义;相反,它是在更现实的工程前提下,先建立一条稳定、可扩展的还原链路:

  • 先从 trace 中识别 handler
  • 再把 handler 序列解释成 IR
  • 最终再考虑向更高层表达转换,例如平台汇编、伪代码甚至进一步的反编译表示

如果说传统方法强调“先理解,再自动化”,那么这条路线更像是“先跑通,再逼近语义”。对于大规模样本、复杂保护和长 trace 场景来说,我认为这是更务实、也更容易持续推进的 VMP 逆向方案。

从结果导向看,基于文本匹配的 VMP 还原,不只是一个技巧,而是一种适合现代 trace 驱动分析的工程方法论。

最后修改:2026 年 07 月 04 日
如果觉得我的文章对你有用,请随意赞赏