Post

AI 辅助还原自定义 VMP 保护方案

AI 辅助还原自定义 VMP 保护方案

本文仅供安全研究和学习交流,请勿用于非法用途。

VMP(Virtual Machine Protection)是当前移动端 SO 保护的主流方案。核心思路是将关键算法编译为自定义字节码,由嵌入 SO 中的解释器运行时执行。传统静态分析在这种保护下几乎失效——你看到的不是算法本身,而是一个通用解释器在逐条执行你看不懂的字节码。

本文分享一套 AI 辅助的 VMP 逆向方法论。不针对任何具体目标,只谈为什么这么做怎么做

整体流程:

1
2
SO 去混淆 → IDA 导出 → 动态调试环境 + Trace → Opcode 分析 → 字节码提取
→ 黑盒/白盒分析 → 文档记录 → 参数还原 → 端到端验证

SO 层去混淆

为什么需要去混淆

VMP 保护的 SO 通常叠加控制流平坦化(CFF)等混淆。IDA 反编译后看到的是大量 switch-case dispatcher,真实逻辑被打散。不去混淆的话,即使是 VM 解释器之外的辅助函数(参数编组、内存分配、类型分发)也完全不可读,严重阻碍整体理解。

angr 符号执行

angr 可以自动识别 CFF 的 dispatcher 结构,通过符号执行恢复真实控制流:

  1. 识别 dispatcher 变量和 switch 结构
  2. 对每个基本块做符号执行,求解后继块
  3. patch 掉 dispatcher 跳转,还原直接跳转
  4. 重新让 IDA 分析 patch 后的二进制

AI 可以辅助编写 angr 去混淆脚本,分析失败 case 并调参。

Trace 辅助修补

angr 并非万能。对于失败的函数,可以在动态调试框架中执行目标函数,记录实际走过的基本块序列,然后根据 trace 直接 patch 条件跳转。

两种方法互补:angr 批量处理大部分函数,trace 修补解决剩余。


IDA 批量导出

为什么不直接用 ida-mcp

ida-mcp 允许 AI 直接查询 IDA 数据库,理论上很方便。但 VMP 场景下有个现实问题:函数太大了

混淆后的函数动辄几千到几万字节,反编译后上千行伪代码。通过 ida-mcp 交互式查看,两三个函数就把 AI 的上下文窗口塞满,后续分析无法继续。

解决方案:提前批量导出

编写 IDA 脚本,一次性把所有函数的伪代码、调用关系、交叉引用导出到本地文件。AI 按需读取单个文件,不浪费上下文,同一份数据可以反复查阅,支持多轮迭代分析。


为什么需要动态调试框架

移动端逆向 vs Web 逆向

Web 端逆向相对简单:浏览器自带开发者工具,JavaScript 代码直接可见,可以断点、修改变量、实时调试。

移动端完全不同:

  • 代码编译为 ARM 机器码,运行在手机上
  • 加壳、混淆、反调试层层叠加
  • 真机调试需要 root、过反调试,门槛高且不稳定

模拟执行框架

unidbgUnicornQiling 等框架解决的核心问题是:不需要真机,在 PC 上模拟执行 ARM 代码

它们的本质都是 CPU 指令级模拟器,区别在于上层封装:

框架定位特点
Unicorn纯 CPU 模拟需要自己处理所有系统调用、内存映射
unidbgAndroid/iOS 模拟内置 JNI 环境、系统调用、文件系统模拟
Qiling通用系统模拟支持多平台,介于两者之间
Frida真机注入需要真机/模拟器,但能拿到最真实的运行环境

对于 VMP 逆向,我们需要的核心能力是:

  1. 可控执行:随时暂停、检查寄存器和内存
  2. Hook:拦截任意函数,dump 输入输出
  3. 指令级 Trace:记录每条 ARM 指令的执行
  4. 注入:修改输入数据、覆写内存,做差分实验
  5. 确定性重放:固定随机数和时间,让同一输入产生相同输出

unidbg 在 Android SO 逆向场景下最方便——JNI 环境已经搭好,加载 SO 后直接调用 native 函数。以下以 unidbg 为例说明,但同样的思路适用于其他框架。

环境搭建

模拟执行 SO 时需要补全运行环境。这是一个”缺什么补什么”的过程——执行到某个 JNI 调用报错了,就补一个返回值;读某个文件缺失了,就模拟一个。

AI 在这一步可以帮忙查找常见 Android API 的返回值格式,加速补桩。

指令级 Trace

对 VM 解释器区域开启指令 trace,产生百万行级别的执行记录。这是后续所有分析的基础数据

百万行 trace 人工根本无法处理。但对 AI 来说,这正是它的强项:

  • 统计指令热点分布——哪些地址范围执行最多
  • 识别循环结构——重复出现的地址模式
  • 提取特征函数的调用次数和参数
  • 生成各种统计脚本

VM 解释器识别与 Opcode 分析

定位 VM Dispatcher

所有字节码 VM 的核心都是一个 fetch-decode-execute 循环。在 ARM64 层面表现为一组固定的寄存器模式。通过在 dispatcher 处下断点,可以建立 ARM 寄存器到 VM 角色的映射——字节码 PC、求值栈指针、当前 opcode 等。

AI 可以通过分析 dispatcher 附近的指令模式自动推断这些映射。

Opcode 语义推断

核心方法:在 dispatcher 断点处 dump 求值栈,观察每条字节码指令前后的栈变化。

1
2
3
执行前栈: [..., 0x02, 0x05]
执行后栈: [..., 0x14]
→ 5 << 2 = 0x14 → LSL(左移)

需要注意的是,第一直觉经常是错的。比如某个 opcode 看起来像 XOR(因为在地址计算中使用),但实际验证后发现是 ADD——地址加法和 XOR 在某些特定值下结果恰好相同,容易误判。

这就是为什么需要多组数据交叉验证,而不是看一组数据就下结论。AI 可以批量分析大量栈变化数据对,穷举可能的运算并交叉验证。

控制流指令识别

循环是 VM 中最关键的控制流结构。通过统计字节码 PC 的向后跳转(backjump),可以精确识别循环的嵌套结构和迭代次数。

迭代次数往往直接暗示密码学参数——比如某个三层嵌套循环的迭代次数恰好是 3 × 9 × 4 = 108,每次迭代做 16 次 GF 乘法,总计 1728 次。这就强烈暗示 AES-128 的 3 个块、9 轮(加上最终轮)、4 列 MixColumns。


字节码提取与反汇编

运行时 Dump

通过 hook,在 VM 执行前 dump 两类数据:

  • 字节码段:完整的字节码二进制
  • VM 数据内存:包含查找表、常量等

编写反汇编器

有了 opcode 语义表,编写反汇编器是机械工作——AI 可以直接生成。关键是要处理好变长指令编码和操作数的解析。

结构发现

反汇编后可以看到字节码的宏观结构:函数边界、循环骨架、查表模式、外部调用分布。这是后续白盒分析的起点。


白盒密码分析

为什么需要白盒分析

黑盒分析(只看输入输出)能解决一部分问题,但对于复杂的密码学算法,必须深入内部结构才能还原。

白盒分析的核心是:在字节码和 trace 中识别已知的密码学原语

密码原语识别

常见的可识别特征:

  • GF(2^8) 乘法:循环 8 次、归约常量 0x1B(AES 标志)
  • S-box 查表:256 字节排列表(permutation)
  • MixColumns 系数:{2, 3, 1, 1} 的循环模式
  • CRC32 查表:1024 字节(256×4),polynomial 0xEDB88320

AI 熟悉标准密码学原语的特征,能从 trace 中的常量快速定位相关构件。

查表结构还原

白盒密码的核心手法是把密钥融合进查找表。标准 AES 的 SBOX[x ^ key] 变成一张预计算表,不再有显式的密钥。

还原方法——碰撞验证:如果两张表来自同一个 S-box,那么存在一个常量 delta 使得 T1[x] == T2[x ^ delta] 对所有 x 成立。delta 就是轮密钥的差值。

1
2
3
4
5
def find_key_delta(table1, table2):
    for delta in range(256):
        if all(table1[x] == table2[x ^ delta] for x in range(256)):
            return delta
    return None

通过这种方法,可以从大量查表中还原出全部轮密钥,无需逆向密钥调度算法。

假说-验证循环

白盒分析的核心工作模式:

1
2
3
观察数据 → 提出假说 → 编写验证脚本 → 运行验证
    ↑                                      |
    └── 假说失败则修正 ←──────────────────┘

AI 的价值在于加速这个循环——它可以同时生成多个候选假说,并为每个假说编写独立的验证脚本。一轮下来几分钟,人工可能要一整天。


黑盒差分分析

为什么需要黑盒分析

白盒分析依赖能读懂字节码。但有些情况下:

  • 字节码混淆严重,静态分析代价太高
  • VM 存在特殊机制(如寄存器内存别名),静态分析容易误判
  • 只是想快速确认某个子模块的行为

这时切换到黑盒视角:只观察输入输出关系,不关心内部实现。

差分探针

核心思想:固定其他输入,只改变一个字节/nibble/dword,观察输出变化。

分层策略:

  1. dword 探针(粗粒度):快速定位哪些输入 dword 影响哪些输出区域
  2. byte 探针:精确到单字节
  3. nibble 探针(细粒度):区分高低 4 位是否独立——这是发现 nibble 级操作的关键

依赖矩阵

把所有探针结果汇总,可以直接推断算法结构:

  • 某个输入字节影响全部输出 → 它参与了密钥/常量的计算
  • 某个输入字节只影响对应位置的输出 → 逐字节变换(XOR/置换)

假说暴力筛选

对于未知的子函数,可以枚举候选算法(如 CRC32、XOR fold、S-box 变换等),用多组探针数据逐一验证。只有全部命中的候选才是正确的。

AI 生成候选假说列表、编写验证脚本、分析结果——这种”批量穷举 + 自动验证”的工作模式,是 AI 最高效的应用场景之一。


端到端验证与勘误

确定性重放

VMP 中通常有随机数(时间种子、/dev/urandom 等)参与。要做端到端验证,必须先 hook 这些熵源返回固定值。

固定熵源后,相同输入必须产生完全相同的输出。任何不一致都说明还原有误。

Python 独立验证器

将全部算法用纯 Python 实现,完全独立于动态调试框架。对比 Python 输出和框架输出:

  • 完全一致 → 算法还原正确
  • 不一致 → 定位具体哪个子步骤出错

勘误:为什么一定会犯错

VMP 逆向的复杂度决定了中间过程一定会有错误假设。常见的错误模式:

错误类型典型例子发现方法
混淆因果把动态参数当成固定常量多次运行对比,发现每次不同
过度泛化观察到一种映射关系就认为是全局规则更多数据集验证时不匹配
误判结构把 N 轮独立密码误认为 M×K 轮 CBC对中间块做交叉验证失败
AI 幻觉AI 自信地推断了一个不存在的模式编写验证脚本后立即证伪

核心原则:只信事实,不信结论。 每个结论都需要独立的实验数据支撑。文档中明确标注”已验证”和”待验证”。


文档记录

为什么文档是必需品而非可选项

VMP 逆向通常是一个多周的持续过程。AI 没有跨 session 记忆,人类的记忆同样不可靠。如果不做文档记录:

  • 上次分析到哪里了?忘了
  • 某个假说是已验证还是已证伪?不确定
  • 某组实验数据的含义是什么?想不起来了

文档结构建议

1
2
3
4
5
docs/
├── ANALYSIS.md          # 总体进度:什么已完成、什么待做
├── <module-A>.md        # 模块级分析文档
├── <module-B>.md
└── archive/             # 过程文档(含已证伪的假说,保留备查)

每份文档中区分三类信息:

  1. 已验证事实:有 hook 数据 / trace 数据 / Python 验证 PASS 的结论
  2. 待验证假设:有一定证据但尚未充分验证
  3. 已证伪假说:保留记录,避免重复踩坑

AI 可以自动维护这些文档——每次新发现自动归类更新。


AI 协作经验

AI 擅长什么

  • 海量数据处理:百万行 trace 统计、内存 dump 分析
  • 模式识别:从常量和结构中识别密码学原语
  • 批量代码生成:验证脚本、hook 脚本、反汇编器
  • 假说穷举:一次生成十几种候选假说及其验证代码
  • 文档维护:结构化记录每次发现

AI 不擅长什么

  • 大函数去混淆:需要人工在 IDA 中交互式分析
  • 创造性突破:关键的”灵光一现”仍然依赖人类
  • 自我纠错:会自信地输出错误结论,必须靠验证脚本兜底
  • 上下文超长时的一致性:分析到后期容易遗忘早期结论

关键原则

  1. 让 AI 生成验证代码,而非直接要结论——”帮我验证这组数据是否符合 AES-128 CBC” 远好于 “这个算法是什么”

  2. 小步迭代——每次只攻克一个子问题,验证通过后再推进。不要让 AI 一次性还原整个算法

  3. 人机分工明确——人类决定方向、设计实验、判断关键节点;AI 执行数据处理、编写脚本、维护文档

  4. 文档即记忆——所有发现写入文档,AI 每次 session 开始时加载文档继续工作

工具链配置

1
2
3
4
5
6
项目根目录/
├── CLAUDE.md            # 项目级指令(工作流规则、分析原则、禁止事项)
├── docs/                # 分析文档(AI 的"长期记忆")
├── IDA 导出/            # 结构化伪代码(AI 的"眼睛")
├── trace/               # 运行时数据(AI 的"原始素材")
└── scripts/             # 验证脚本(AI 的"实验室")

CLAUDE.md 中写清楚分析原则,比如”不得运行生成的脚本,由用户自行运行”、”遇到大函数立刻停下”等。这些规则会在每次对话中自动加载。


附录:通用分析模板

A. Opcode 语义推断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def infer_opcode(pre_stack, post_stack):
    """从栈变化推断二元运算语义"""
    if len(pre_stack) >= 2 and len(post_stack) == len(pre_stack) - 1:
        a, b = pre_stack[-2], pre_stack[-1]
        r = post_stack[-1]
        ops = {
            'ADD': (a + b) & 0xFFFFFFFF,
            'SUB': (a - b) & 0xFFFFFFFF,
            'XOR': a ^ b,
            'AND': a & b,
            'OR':  a | b,
            'MUL': (a * b) & 0xFFFFFFFF,
            'LSL': (a << (b & 31)) & 0xFFFFFFFF,
            'LSR': (a >> (b & 31)) & 0xFFFFFFFF,
        }
        return [name for name, val in ops.items() if val == r]
    return []

B. 差分探针框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def build_dependency_matrix(oracle, input_size, output_size):
    """
    oracle: 黑盒函数 bytes → bytes
    返回依赖矩阵: dep[i][j] = True 表示 input[i] 影响 output[j]
    """
    baseline = oracle(bytes(input_size))
    dep = [[False] * output_size for _ in range(input_size)]
    for i in range(input_size):
        probe = bytearray(input_size)
        probe[i] = 0x01
        result = oracle(bytes(probe))
        for j in range(output_size):
            if result[j] != baseline[j]:
                dep[i][j] = True
    return dep

C. 查表碰撞验证

1
2
3
4
5
6
def find_sbox_key_delta(table1, table2):
    """验证两张 256B 表是否为同一 S-box 的密钥变体"""
    for delta in range(256):
        if all(table1[x] == table2[x ^ delta] for x in range(256)):
            return delta
    return None

D. 假说批量验证

1
2
3
4
5
6
7
8
def test_hypotheses(hypotheses, probes):
    """
    hypotheses: {name: func(input_bytes) → predicted_value}
    probes: [(input_bytes, observed_value), ...]
    """
    for name, func in hypotheses.items():
        hits = sum(1 for inp, obs in probes if func(inp) == obs)
        print(f"{name}: {hits}/{len(probes)}")
This post is licensed under CC BY 4.0 by the author.