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 结构,通过符号执行恢复真实控制流:
- 识别 dispatcher 变量和 switch 结构
- 对每个基本块做符号执行,求解后继块
- patch 掉 dispatcher 跳转,还原直接跳转
- 重新让 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、过反调试,门槛高且不稳定
模拟执行框架
unidbg、Unicorn、Qiling 等框架解决的核心问题是:不需要真机,在 PC 上模拟执行 ARM 代码。
它们的本质都是 CPU 指令级模拟器,区别在于上层封装:
| 框架 | 定位 | 特点 |
|---|---|---|
| Unicorn | 纯 CPU 模拟 | 需要自己处理所有系统调用、内存映射 |
| unidbg | Android/iOS 模拟 | 内置 JNI 环境、系统调用、文件系统模拟 |
| Qiling | 通用系统模拟 | 支持多平台,介于两者之间 |
| Frida | 真机注入 | 需要真机/模拟器,但能拿到最真实的运行环境 |
对于 VMP 逆向,我们需要的核心能力是:
- 可控执行:随时暂停、检查寄存器和内存
- Hook:拦截任意函数,dump 输入输出
- 指令级 Trace:记录每条 ARM 指令的执行
- 注入:修改输入数据、覆写内存,做差分实验
- 确定性重放:固定随机数和时间,让同一输入产生相同输出
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,观察输出变化。
分层策略:
- dword 探针(粗粒度):快速定位哪些输入 dword 影响哪些输出区域
- byte 探针:精确到单字节
- 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/ # 过程文档(含已证伪的假说,保留备查)
每份文档中区分三类信息:
- 已验证事实:有 hook 数据 / trace 数据 / Python 验证 PASS 的结论
- 待验证假设:有一定证据但尚未充分验证
- 已证伪假说:保留记录,避免重复踩坑
AI 可以自动维护这些文档——每次新发现自动归类更新。
AI 协作经验
AI 擅长什么
- 海量数据处理:百万行 trace 统计、内存 dump 分析
- 模式识别:从常量和结构中识别密码学原语
- 批量代码生成:验证脚本、hook 脚本、反汇编器
- 假说穷举:一次生成十几种候选假说及其验证代码
- 文档维护:结构化记录每次发现
AI 不擅长什么
- 大函数去混淆:需要人工在 IDA 中交互式分析
- 创造性突破:关键的”灵光一现”仍然依赖人类
- 自我纠错:会自信地输出错误结论,必须靠验证脚本兜底
- 上下文超长时的一致性:分析到后期容易遗忘早期结论
关键原则
让 AI 生成验证代码,而非直接要结论——”帮我验证这组数据是否符合 AES-128 CBC” 远好于 “这个算法是什么”
小步迭代——每次只攻克一个子问题,验证通过后再推进。不要让 AI 一次性还原整个算法
人机分工明确——人类决定方向、设计实验、判断关键节点;AI 执行数据处理、编写脚本、维护文档
文档即记忆——所有发现写入文档,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)}")