比 dex 加密更狠的防护
普通加固(360/梆梆/乐固)保护的是 dex 文件——运行时解密加载,dump 内存就能拿到明文。但 dex2c 和 VMP 加固玩的是另一个维度:直接把 Java 方法翻译成 native 代码,dex 里只剩一个空壳方法(native 声明或 throw 占位),真实逻辑编译进了 SO。
dump 出 dex 也没用——方法体是空的。这就是为什么这类加固被称为"脱壳的终点"。
识别:怎么判断中招了
jadx 打开脱壳后的 dex,看到这些特征就是 dex2c/VMP:
// 特征1:方法体是 native 声明,但 Java 层从没这样写过
public static native String getSign(String s);
// 特征2:方法体只有一行 throw(占位)
public String getSign(String s) {
throw new RuntimeException("stub");
}
// 特征3:伴随加载奇怪的 SO
static { System.loadLibrary("protect"); }再结合 SO 文件大小异常(几 MB 甚至十几 MB 的 libprotect.so),基本实锤。
dex2c 的对抗思路
dex2c(如某加固的"Java2C"功能)把 Java 方法逐条翻译成等价的 C 代码编译成 SO。对抗分三层:
第一层:动态执行拿结果(首选)
不试图理解 native 代码,直接让 App 自己执行:
// Frida 直接调这个"native 化"的方法,和普通方法调用没区别
Java.perform(function() {
var SignUtils = Java.use('com.target.app.SignUtils');
var result = SignUtils.getSign("test_input");
console.log(result);
});dex2c 翻译后的方法对调用者完全透明——Java 层该怎么调还怎么调。Frida RPC 方案不受任何影响。
第二层:unidbg 模拟执行
需要脱离设备时,unidbg 加载那个巨大的 libprotect.so。注意 dex2c 的 SO 重度依赖 JNI 函数表(它要模拟 Java 语义:创建对象、调方法、访问字段),补环境工作量大,但 unidbg 的 AbstractJni 覆盖了大部分。
第三层:静态还原(最后手段)
真要读懂算法,面对 dex2c 的 SO 有技巧:它生成的代码是模式化的——每个 Java 字节码对应固定的 native 代码模板。认识这些模板(比如 aload 对应局部变量读取的固定指令序列),就能反向映射回 Java 逻辑。社区有 dpt-shell 等工具针对特定 dex2c 方案做自动化还原。
VMP 加固:自定义指令集的终极形态
VMP(虚拟机保护)比 dex2c 再进一步:Java 方法被编译成自定义虚拟机的字节码,SO 里内置一个解释器来执行。你看到的 native 代码只是解释器,真实逻辑是数据段里的一堆自定义字节码。
对抗思路:
- 别碰解释器,碰输入输出:Frida 调方法或 hook 解释器的"取指-译码-执行"循环的边界,记录每条虚拟指令的操作数——这是"指令 trace"路线
- 还原虚拟机架构:分析解释器的译码逻辑,搞清自定义指令集(有多少种操作码、操作数格式),然后写反汇编器把字节码翻译成可读伪码——工作量以周计,只有高价值目标值得
- 执行流录制:用 Stalker 记录方法执行的所有 native 指令轨迹,从轨迹里提取数据流(输入怎么变成输出),跳过理解指令集
精髓:成本决策树
面对 dex2c/VMP,按这个顺序决策:
- Frida RPC 直调(成本:小时)→ 90% 的场景到这里就够了
- unidbg 模拟(成本:天)→ 需要规模化、离线化时
- 指令 trace + 数据流分析(成本:周)→ 需要理解算法原理时
- 完整还原虚拟机(成本:周~月)→ 学术研究或极高价值目标
逆向的终极目标不是"读懂每一行代码",而是"让目标能力为我所用"。能调用就别还原,这是面对强防护的第一原则。
总结
- dex2c/VMP 让 dex 方法变空壳,dump dex 路线终结
- 但调用接口不变——Frida 直调完全不受影响,这是首选
- unidbg 能跑 dex2c 的 SO,补环境是主要成本
- 静态还原 VMP 是周级工程,非必要不启动
交流微信:run1255