Android APK 加固原理(三):方法级代码抽取——PVM1 虚拟化打包到底是什么?
Android APK 加固原理(三):方法级代码抽取——PVM1 虚拟化打包到底是什么?
系列文章
第一篇:《Android APK 加固原理(一):Native Shell 如何隐藏和恢复 DEX》
第二篇:《Android APK 加固原理(二):从 DEX 解密到 ART 加载,如何缩短代码明文暴露窗口》
第三篇:《Android APK 加固原理(三):方法级代码抽取——PVM1 虚拟化打包到底是什么》
第四篇:《Android APK 加固原理(四):真正的代码虚拟化——PVM2 Native Interpreter 技术解析》
第五篇:《Android APK 加固原理(五):SO.text段加密、ELF 加载与运行时动态解密》
第六篇:《Android APK 加固原理(六):RASP 运行时安全防护——如何检测 Frida、Hook 与运行时攻击》
第七篇:《从 APK 加密到代码虚拟化:XopProtector 多层 Android 应用保护体系解析》
项目地址:https://github.com/xopJack/XopProtector
一、前言:为什么有了 DEX 加密,还需要 PVM1?
在前两篇文章中,我们已经分析了 Android APK 加固最基础的一层防护:
第一层,是把原始 DEX 从 APK 中拿走。
第二层,是把 DEX 加密,让逆向工具无法直接从 APK 中得到完整的 classes.dex。
但是,仅仅做到 DEX 加密,并不能解决所有问题。
因为 Android 应用最终还是需要运行。
无论 DEX 在 APK 中如何加密,应用启动以后,代码最终还是需要进入 Android Runtime,也就是 ART 的执行体系。
因此,攻击者真正关心的问题会逐渐从:
“APK 里面有没有完整 DEX?”
转变为:
“运行时能不能把 DEX 恢复出来?”
进一步又会变成:
“能不能只针对关键方法进行分析?”
这也是方法级保护存在的意义。
XopProtector 在整体保护体系中增加了 PVM1:
原始 DEX ↓ 定位目标方法 ↓ 抽取方法 Dalvik 指令 ↓ PVM1 编码 ↓ AES-GCM 加密 ↓ 原方法体替换成安全占位 Stub ↓ 生成 code.bin ↓ 运行时 Native Shell 解密 ↓ 恢复 Dalvik 指令 ↓ 写回 DEX ↓ ART 执行因此,PVM1 的核心思想并不是“让代码永远不出现”。
而是:
把关键方法从正常 DEX 的 code_item 中抽离出来,让静态 DEX 分析工具看到的只是一个占位方法;真正的方法实现被移动到独立的加固数据区中,并在运行时恢复。
XopProtector 源码明确将--vmp-prefix定义为 PVM1,并特别说明它是 **“decode → write Dalvik”**,而不是 Interpreter。
二、先搞清楚:PVM1 到底是什么?
很多人看到“VMP”“Virtual Machine”“虚拟化”这些词,第一反应就是:
原始代码 ↓ 虚拟指令 ↓ Virtual Machine ↓ Interpreter这种理解对于 XopProtector 的PVM2才成立。
对于 PVM1,并不是这样。
XopProtector 的VmCodec.java对 PVM1 的注释非常直接:
Lightweight method-level VM packing (PVM1) Dalvik → non-Dalvik image Runtime unpacks inside .bitcode before writing DEX. This is virtualized packing, not a full bytecode interpreter.也就是说,PVM1 更准确的技术定义应该是:
Method-Level Virtualized Packing
方法级虚拟化打包
而不是:
True Code Virtualization
真正的代码虚拟化。
这两个概念一定要区分。
三、PVM1 和 PVM2 到底有什么区别?
这是整个系列最容易混淆的地方。
可以直接用下面这张表理解:
| 特性 | PVM1 | PVM2 |
|---|---|---|
| 保护粒度 | 方法级 | 方法级 |
| 是否抽取原始指令 | 是 | 是 |
| 是否生成虚拟化数据 | 是 | 是 |
| 是否存在 Interpreter | 否 | 是 |
| 是否恢复 Dalvik | 是 | 否 |
| 是否写回 DEX | 是 | 否 |
| 最终执行者 | ART | Native Interpreter |
| 核心目的 | 提高静态逆向成本 | 改变代码执行模型 |
| 对运行时 Hook 的抵抗能力 | 中等 | 更强 |
| 性能开销 | 相对较小 | 更高 |
| 实现复杂度 | 较低 | 很高 |
XopProtector README 对两者的定义非常明确:
--vmp-prefix PVM1 unpack → write Dalvik not an interpreter --true-vmp-prefix PVM2 JNI trampoline + native interpret因此:
PVM1 是“代码搬家 + 编码保护 + 运行时恢复”。
而:
PVM2 是“代码翻译 + 自定义指令集 + Native Interpreter”。
这也是为什么 XopProtector 把 PVM1 和 PVM2 设计成两个不同阶段。
四、PVM1 的核心:从“类级保护”下降到“方法级保护”
传统 DEX 加密往往是:
classes.dex ↓ 整体加密运行时:
整个 DEX ↓ 整体解密 ↓ 交给 ART这种方式的缺点很明显:
一旦整个 DEX 被恢复,攻击者就获得了大量可分析代码。
而 PVM1 改变了保护粒度。
它不再简单地把整个 DEX 看成一个整体,而是进一步进入:
DEX ├── Class A │ ├── method A() │ ├── method B() │ └── method C() │ ├── Class B │ ├── method D() │ └── method E() │ └── Class C └── method F()然后选择其中关键方法:
method B() method D() method F()进行抽取。
最终形成:
DEX ├── 普通方法 → 正常保留 │ ├── PVM1 方法 → 抽空 │ ↓ │ code.bin │ └── PVM2 方法 → 后续真正虚拟化因此,PVM1 的第一个重要思想就是:
保护从“DEX 级”进一步下降到了“Method 级”。
五、第一步:找到需要保护的方法
XopProtector 的 Packer 是在构建阶段工作的。
源码中的PackerMain会读取 DEX,并遍历其中的 ClassDef、ClassData 以及 DirectMethods / VirtualMethods。
核心流程可以抽象为:
APK ↓ 解包 ↓ classes.dex ↓ 解析 DEX ↓ 遍历 ClassDef ↓ 定位目标 Class ↓ 遍历 DirectMethod ↓ 遍历 VirtualMethod ↓ 提取 code_item源码中的walkDexMethods()就承担了这一职责。
它会遍历:
dex.classDefs()然后读取:
classData.getDirectMethods() classData.getVirtualMethods()再逐个调用:
extractOne(...)处理方法。
六、PVM1 并不是所有方法都保护
这是一个非常重要的设计。
XopProtector 并不是无脑把所有方法都转换成 PVM1。
源码中存在:
vmpPrefixes对应:
--vmp-prefix也就是说,可以按照类描述符前缀选择需要进行 PVM1 处理的代码。
例如:
--vmp-prefix Lcom/example/security/那么:
Lcom/example/security/Foo; Lcom/example/security/Pay; Lcom/example/security/License;等类中的方法,就可能进入 PVM1 流程。
源码中明确维护了:
vmpPrefixes trueVmpPrefixes hollowPrefixes三个不同维度。
这实际上构成了:
普通代码 ↓ Hollow PVM1 代码 ↓ PVM1 Packing PVM2 代码 ↓ True VMP因此可以针对不同代码价值选择不同保护等级。
七、第二步:读取方法真正的 Dalvik 指令
找到目标方法以后,PVM1 并不是简单地复制整个 MethodId。
它真正需要保护的是:
方法的 code_item 中的 Dalvik instruction stream。
源码中:
com.android.dex.Codecode=dex.readCode(method);然后:
short[]units=code.getInstructions();再根据:
units.length * 2计算实际指令区域大小。
之后通过:
method.getCodeOffset()+16定位到 code_item 中真正的 instructions 区域。
然后:
raf.seek(insnsOffset);raf.readFully(original);把原始 Dalvik 指令读取出来。
所以这里可以把 PVM1 的第一核心动作总结成:
Method ↓ CodeItem ↓ instructions ↓ byte[]也就是:
把原方法的 Dalvik 指令从 DEX 中物理抽取出来。
八、第三步:PVM1 编码到底做了什么?
现在进入 PVM1 最核心的VmCodec。
XopProtector 的 PVM1 数据拥有一个非常明显的 Magic:
PVM1源码:
privatestaticfinalbyte[]MAGIC={'P','V','M','1'};编码后的数据结构可以简单理解成:
+----------------+ | PVM1 | +----------------+ | encoded byte 0 | +----------------+ | encoded byte 1 | +----------------+ | encoded byte 2 | +----------------+ | ... | +----------------+值得注意的是:
PVM1 编码后的数据长度基本等于原始 Dalvik 指令长度 + 4 字节 Magic。
它并没有像真正的虚拟机那样把一条 Dalvik 指令重新编译成复杂的 VM 指令流。
这也是为什么称它为:
Virtualized Packing
而不是:
True Virtualization
源码明确说明 PVM1 编码结果是:
same length + 4即原始长度加上PVM1四字节头。
九、PVM1 的第一层编码:基于 Method Index 的 Key Stream
PVM1 的编码并不是简单:
byte ^ 0x55它会根据:
methodIdx以及:
byte offset生成一个简单的动态字节流。
源码:
keystream(methodIdx, i)其核心计算为:
(methodIdx * 131 + i * 17 + 0xA5) & 0xff因此:
Key = f(methodIndex, byteOffset)然后:
encodedByte = originalByte ^ key这样不同 Method Index 的编码结果就不会完全相同。
十、PVM1 的第二层:Nibble Swap
除了 XOR,PVM1 还做了一层非常轻量的字节变换。
源码中:
if((i&1)==0){b=((b<<4)&0xf0)|((b>>4)&0x0f);}也就是:
对偶数位置的字节进行高低 4 bit 交换。
例如:
原始: 1010 0011经过 Nibble Swap:
0011 1010所以 PVM1 的实际编码逻辑可以抽象为:
Dalvik Byte ↓ XOR KeyStream ↓ 偶数位置 Nibble Swap ↓ PVM1 Blob解码时反过来:
PVM1 Blob ↓ Nibble Swap ↓ XOR KeyStream ↓ 原始 Dalvik Byte因此这个过程本质上是:
为了破坏原始 Dalvik 指令的线性特征,让静态扫描器无法直接把这一段数据当作正常 DEX 指令流解析。
十一、但是 PVM1 真正的安全边界并不在这个 XOR
这一点非常重要。
如果只看:
methodIdx i 131 17 0xA5 XOR你会发现:
这并不是现代密码学意义上的强加密。
实际上 XopProtector 的真正安全边界来自后面那一层:
AES-GCMPVM1 编码只是:
Dalvik ↓ PVM1 Transform ↓ AES-GCM源码中extractOne()的流程非常清楚:
original Dalvik instructions ↓ VmCodec.encode() ↓ PVM1 blob ↓ CryptoUtils.aesGcmEncrypt() ↓ stored也就是说:
PVM1 负责改变数据形态,AES-GCM 负责真正的数据机密性。
十二、第四步:原始方法代码被真正“抹掉”
这是 PVM1 最关键的一步。
如果只是:
复制一份代码那么原 DEX 里面仍然存在原始代码。
保护就没有意义。
所以 XopProtector 在提取完方法指令以后,会直接修改原来的 DEX。
流程:
原始 Method Code ↓ 读取 ↓ 保存到 PVM1 ↓ 原位置写入 Stub源码中:
writeReturnStub(...)会把原始 instructions 替换成一个与返回类型匹配的占位代码。
例如:
void → return-void int → const/4 v0, 0 → return v0 object → const/4 v0, 0 → return-object v0剩余空间则用:
nop类指令填充。
十三、为什么不能直接把方法体全部清零?
这是 Android ART 加载过程中的一个关键问题。
DEX 并不是:
Class Method Code随便写什么都可以。
ART 在加载、验证以及后续执行过程中,会检查 Method 的结构和 code_item。
如果直接:
code_item = 0或者破坏整个 CodeItem,很容易造成:
DEX 验证失败 VerifyError Class loading failure Crash所以加固系统常见的思路是:
不破坏方法结构,只替换真正的业务指令。
XopProtector 也是这样做的。
例如原方法:
intadd(inta,intb){returna+b;}原来的 Dalvik 指令可能类似:
add-int returnPVM1 后变成:
const/4 v0, 0 return v0 nop nop ...真正的:
add-int已经被拿走。
这就是所谓:
Hollow / Method Hollowing
也就是:
方法空洞化。
十四、因此 PVM1 最重要的结构变化是这样的
加固之前:
classes.dex Method A ↓ CodeItem ↓ 真正业务 Dalvik 指令加固之后:
classes.dex Method A ↓ CodeItem ↓ 安全 Stub与此同时:
assets/protector/code.bin ↓ PVM1 ↓ AES-GCM ↓ 真正的 Dalvik 指令形成:
┌─────────────────────┐ │ classes.dex │ │ │ │ Method A │ │ ↓ │ │ Stub / Hollow │ └──────────┬──────────┘ │ │ runtime restore ↓ ┌─────────────────────┐ │ code.bin │ │ │ │ Method Index │ │ Plain Size │ │ Flags │ │ AES-GCM Blob │ └─────────────────────┘十五、PVM1 的第五步:生成 code.bin
方法被抽取以后,需要一个地方保存这些方法。
XopProtector 使用:
code.bin作为运行时方法代码仓库。
源码中的writeCodeBin()会将不同 DEX 中的保护方法进行组织。
当前代码使用的是code.bin v4。
其结构可以抽象为:
Header ↓ version ↓ dex count ↓ dex offsets ↓ Dex Blob每个方法记录大致包含:
methodIndex plainInsnsSize storedInsnsSize flags insns也就是:
Method Index ↓ 告诉 Runtime: “这个代码属于哪个方法” Plain Size ↓ 解密以后需要恢复多少字节 Stored Size ↓ 当前加密数据长度 Flags ↓ 告诉 Runtime: PVM1 / PVM2 / 其他类型 Insns ↓ AES-GCM 加密后的方法数据源码中writeCodeBin()明确写入了:
methodIndex plainInsnsSize insns.length flags insns并且支持多 DEX。
十六、为什么必须保存 Method Index?
这是整个方法级保护体系的“索引核心”。
DEX 中的方法是通过:
method_ids进行编号的。
例如:
method_id #100 method_id #101 method_id #102PVM1 不需要在code.bin中保存一套完整的 Java/Kotlin 方法名。
它可以直接利用:
methodIndex关联:
DEX Method ↕ code.bin Record因此 Runtime 可以实现:
methodIndex = 102 ↓ 找到 code.bin 中 #102 ↓ AES-GCM decrypt ↓ PVM1 decode ↓ 得到真实 Dalvik instructions ↓ 恢复 Method #102这就是 PVM1 的“方法级映射关系”。
十七、PVM1 运行时到底发生了什么?
这部分是整个机制最值得分析的地方。
很多人会误以为:
启动 APP ↓ 整个 code.bin 解密 ↓ 整个 DEX 恢复实际上从源码结构来看,XopProtector 的 Runtime 是围绕:
code.bin code_map Method ART Hook组织起来的。
Native Shell 启动以后,会先定位:
dexes.zip code.bin config.json然后加载相关 Key。
之后:
code.bin ↓ read_file() ↓ codeitem::parse() ↓ state.code_mapRuntime 将保护方法建立成内部映射。
源码:
protector::codeitem::parse(...)解析完成以后:
state.code_map就成为运行时的方法保护索引。
十八、Runtime 为什么要 Hook ART?
这里就涉及 Android 加固真正困难的地方。
如果:
Method A在 DEX 中已经被替换成:
return 0那么 ART 自己执行的时候,自然只会执行:
return 0它不知道真正代码在哪里。
所以必须在:
ART 加载 / 定义 Class / Method的关键路径上进行干预。
XopProtector 在初始化阶段会:
protector::hook::install_hooks();然后再解析和应用:
code.bin源码明确说明:
在解析 / 应用 code.bin 前安装 ART hooks,以便 DefineClass 时进行 patch。
因此整个体系实际上形成:
DEX ↓ ART ↓ Hook ↓ 识别被保护 Method ↓ 查 code_map ↓ 恢复真实 Dalvik ↓ 交给 ART十九、PVM1 的运行时恢复流程
可以把它完整画成:
App 启动 │ ▼ Native Shell 初始化 │ ▼ 读取 code.bin │ ▼ codeitem::parse │ ▼ 建立 code_map │ ▼ 安装 ART Hook │ ▼ ART 加载目标 Class │ ▼ 检查 Method 是否受保护 │ ┌──────┴──────┐ │ │ 否 是 │ │ ▼ ▼ 正常执行 查询 code_map │ ▼ AES-GCM 解密 │ ▼ PVM1 Decode │ ▼ 得到真实 Dalvik 指令 │ ▼ Patch CodeItem │ ▼ ART 执行这就是 PVM1 最核心的运行时机制。
二十、PVM1 的“虚拟化”到底体现在哪里?
现在回到最开始的问题:
PVM1 到底算不算虚拟化?
答案是:
算,但属于非常轻量级的“虚拟化打包”,而不是完整 VM 虚拟化。
因为它确实把原始代码:
Dalvik instructions转换成了:
PVM1 image原始代码不再直接位于原 Method CodeItem 中。
但是它没有:
Dalvik opcode ↓ PVM opcode ↓ VM Register ↓ VM Stack ↓ Dispatcher ↓ Handler这一整套机制。
因此:
PVM1 = Dalvik → protected image → Dalvik而:
PVM2 = Dalvik → custom VM bytecode → Native Interpreter这才是真正意义上的:
Code Virtualization
二十一、为什么 PVM1 比真正虚拟化简单很多?
假设原始代码:
intcalc(inta,intb){intx=a+b;returnx*10;}PVM1 的目标只是:
隐藏: add-int mul-int return然后运行时:
恢复: add-int mul-int returnART 继续执行。
所以 PVM1 并不需要理解:
add-int mul-int if goto invoke new-instance monitor try/catch的语义。
它只需要:
保存 ↓ 解码 ↓ 恢复因此实现成本相对较低。
二十二、真正的 PVM2 为什么会复杂得多?
假设 PVM2 也拿到:
add-int mul-int return它不会把这些指令恢复到 DEX。
而是转换成自己的:
VM_ADD VM_MUL VM_RETURN然后:
Native Interpreter switch(opcode) { case VM_ADD: ... case VM_MUL: ... case VM_RETURN: ... }于是:
ART ↓ JNI Trampoline ↓ Native Interpreter ↓ VM Opcode ↓ Handler整个执行模型都发生改变。
XopProtector 当前 PVM2 文档也明确说明:
PVM2 方法不会恢复到 Dalvik,而是通过 JNI trampoline 进入 Native Interpreter,读取
code.bin中的 PVM2 image。
这就是下一篇文章真正需要讨论的内容。
二十三、PVM1 对静态逆向的意义
假设没有 PVM1:
classes.dex ↓ jadx ↓ Java/Kotlin-like code攻击者可以直接看到:
calculateToken()validateLicense()checkSignature()generateKey()而加入 PVM1 后:
classes.dex ↓ 目标方法 ↓ Stub静态分析工具看到的可能只是:
return0;或者:
returnnull;真实逻辑已经不在 Method CodeItem 中。
所以:
静态反编译结果 ≠ 真实业务逻辑这就是 PVM1 最直接的价值。
二十四、但是 PVM1 并不是“不可逆”
这一点在技术文章中必须客观说明。
PVM1 的最终执行路径仍然是:
PVM1 ↓ Decode ↓ Dalvik ↓ ART所以从攻击者角度:
静态分析 ↓ 难度增加 运行时动态分析 ↓ 仍然可以观察 最终恢复后的 Dalvik ↓ 仍然存在因此:
PVM1 的目标不是让代码永远无法获得,而是增加静态分析和自动化脱壳的成本。
这也符合 XopProtector 项目本身的定位:加固的作用是提高逆向成本,而不是保证应用绝对不可破解。
二十五、PVM1 最大的价值其实是“方法级明文控制”
如果把保护过程画成时间轴:
APK │ │ 加密状态 ▼ App 启动 │ ▼ Runtime 解密 │ ▼ PVM1 Method Decode │ ▼ Dalvik Method 明文 │ ▼ ART 执行关键区别是:
以前:
整个 DEX ↓ 大量代码同时明文PVM1:
Method A ↓ 需要时恢复 Method B ↓ 需要时恢复 Method C ↓ 需要时恢复因此保护对象从:
“整个代码包”
变成:
“一个个关键方法”。
这就是方法级加固最重要的工程价值。
二十六、从源码看,PVM1 实际上是“三层保护叠加”
如果把 XopProtector 的 PVM1 单独拆开,可以得到:
第一层 Method Hollowing ↓ 从 DEX 中移除真实实现 第二层 PVM1 Transform ↓ XOR + Nibble Swap ↓ 破坏原始 Dalvik 数据特征 第三层 AES-GCM ↓ 真正的数据机密性所以它并不是:
PVM1 = XOR也不是:
PVM1 = AES而是:
Method │ ▼ ┌───────────────┐ │ Method Extract│ └───────┬───────┘ ▼ ┌───────────────┐ │ PVM1 Transform │ │ XOR + Nibble │ └───────┬───────┘ ▼ ┌───────────────┐ │ AES-GCM │ └───────┬───────┘ ▼ code.bin而 DEX 中只留下:
Stub二十七、PVM1 和传统“代码抽取”有什么区别?
如果只说:
“PVM1 就是把代码抽出来。”
其实不够准确。
因为普通代码抽取可能只是:
DEX ↓ 抽取 Method ↓ 保存到其他文件但是 PVM1 还增加了:
Method Index + PVM1 Encoding + AES-GCM + Method Hollowing + ART Runtime Restore所以完整体系是:
代码抽取 + 代码变形 + 加密存储 + 原位置空洞化 + 运行时恢复这才构成完整的 PVM1。
二十八、PVM1 的完整生命周期
把整个源码实现浓缩成一条链:
【构建阶段】 APK │ ▼ 解包 DEX │ ▼ 遍历 ClassDef │ ▼ 找到目标 Method │ ▼ 读取 CodeItem │ ▼ 提取 Dalvik Instructions │ ▼ VmCodec.encode │ ▼ PVM1 Blob 生成 │ ▼ AES-GCM 加密 │ ▼ code.bin │ ├───────────────┐ │ │ ▼ ▼ 原 Method 被抹掉 Stub │ ▼ DEX 重写 │ ▼ APK 重新打包 【运行阶段】 App 启动 │ ▼ Native Shell │ ▼ 读取 code.bin │ ▼ codeitem::parse │ ▼ 建立 code_map │ ▼ 安装 ART Hook │ ▼ ART 加载 Class │ ▼ 找到受保护 Method │ ▼ 查询 code_map │ ▼ AES-GCM 解密 │ ▼ PVM1 Decode │ ▼ 得到真实 Dalvik │ ▼ Patch Method │ ▼ ART │ ▼ 正常执行这条链基本就是 XopProtector PVM1 的核心原理。
二十九、PVM1 真正解决了什么问题?
可以总结成四个字:
静态不可见。
更准确地说:
1. 静态 DEX 中不再存在完整方法实现
攻击者拿到 DEX 后,目标方法已经被替换成 Stub。
2. 方法实现被移动到独立数据区
真实代码进入:
code.bin而不是继续留在原来的 CodeItem 中。
3. PVM1 破坏原始 Dalvik 数据形态
通过:
XOR KeyStream + Nibble Swap让数据不再表现为正常 Dalvik instruction stream。
4. AES-GCM 提供真正的机密性
即使攻击者找到:
code.bin也不能简单通过:
strings dexdump jadx直接获得原始代码。
三十、但 PVM1 还有一个天然弱点
这也是为什么 XopProtector 后面还需要 PVM2。
PVM1:
加密 ↓ 恢复 ↓ Dalvik ↓ ART所以最终:
真实 Dalvik还是要出现。
因此攻击者如果把分析重点从:
APK 静态分析转向:
Runtime ↓ ART Hook ↓ Method Restore ↓ Memory Dump就可能重新获取真实方法。
所以 PVM1 的安全模型是:
提高静态分析成本 + 增加脱壳复杂度 + 缩短攻击者直接获得代码的路径 但是: 最终仍然回到 ART Dalvik 执行体系这也是 PVM1 与 PVM2 最根本的安全边界。
三十一、PVM1 → PVM2,是 Android 加固的一次质变
可以把整个演进过程理解为:
第一阶段: DEX 加密 攻击者: APK ↓ 找到解密点 ↓ 得到 DEX 第二阶段: PVM1 攻击者: APK ↓ 找到 code.bin ↓ 找到 Runtime Restore ↓ 获取 Dalvik 第三阶段: PVM2 攻击者: APK ↓ 找到 JNI Trampoline ↓ 找到 Native Interpreter ↓ 理解自定义 VM ↓ 分析 VM Opcode ↓ 恢复原始语义所以难度是逐层提升的。
三十二、PVM1 最值得学习的工程设计
从工程实现角度看,PVM1 并没有试图一步做到极端复杂。
它采取的是一种非常实用的思路:
DEX 加密 ↓ 解决“APK 静态暴露” PVM1 ↓ 解决“关键方法暴露” PVM2 ↓ 解决“恢复后仍然是 Dalvik” SO 加密 ↓ 解决 Native 代码暴露 RASP ↓ 解决运行时攻击也就是说:
不同保护技术解决不同攻击面。
这比单纯依赖一种“超级加密算法”更加符合商业 APK 加固系统的工程思路。
三十三、源码层面的关键文件
如果你准备继续深入研究 XopProtector,那么 PVM1 最值得看的几个源码位置是:
packer/ └── src/main/java/com/yqsh/protector/packer/ │ ├── PackerMain.java │ ├── walkDexMethods() │ ├── extractOne() │ ├── writeReturnStub() │ └── writeCodeBin() │ └── VmCodec.java ├── encode() └── decode()其中:
PackerMain.java
负责:
扫描 DEX ↓ 定位 Method ↓ 抽取指令 ↓ 替换 Stub ↓ 生成 code.binVmCodec.java
负责:
PVM1 Encode PVM1 DecodeNative 侧则对应:
native/src/main/cpp/vm/ └── vm_codec.cpp负责运行时:
PVM1 DecodeNative Runtime:
native/src/main/cpp/runtime/ └── engine.cpp负责:
初始化 ↓ 加载 code.bin ↓ 建立 code_map ↓ 安装 Hook ↓ 进入运行时保护流程这些源码结构可以非常清晰地证明:
PVM1 并不是一个独立的 VM,而是 Packer + Native Runtime + ART Hook 三者协同完成的方法级保护机制。
三十四、最终总结:一句话理解 XopProtector PVM1
如果只用一句话解释:
PVM1 就是在构建阶段把关键方法的 Dalvik 指令从 DEX 中抽出来,经过 PVM1 变换和 AES-GCM 加密后保存到
code.bin,原 Method 只留下与返回类型匹配的安全 Stub;应用运行时由 Native Shell 解析code.bin,通过 ART Hook 找到目标 Method,解密并恢复真实 Dalvik 指令,再交给 ART 执行。
整个过程可以最终浓缩成:
PVM1 ┌───────────────────┐ │ 原始 Method │ └─────────┬─────────┘ │ ▼ 提取 Dalvik Code │ ▼ PVM1 Encode XOR + NibbleSwap │ ▼ AES-GCM │ ▼ code.bin │ │ ┌─────────▼─────────┐ │ 原 DEX Method │ │ │ │ 真实 Code 被移除 │ │ ↓ │ │ Stub │ └───────────────────┘ Runtime Native Shell │ ▼ code.bin │ ▼ code_map │ ▼ ART Hook │ ▼ 找到目标 Method │ ▼ AES-GCM Decode │ ▼ PVM1 Decode │ ▼ 恢复 Dalvik │ ▼ Patch Method │ ▼ ART │ ▼ 执行代码所以:
PVM1 不是“让代码不再执行”,而是让代码不再以正常 DEX Method 的形式存在。
这就是它和传统 DEX 加密最大的区别。
而下一阶段真正值得研究的问题就是:
如果连“恢复 Dalvik”这一步都不要了,能不能让被保护方法从始至终都不回到 DEX,而是直接由 Native 自己解释执行?
答案就是:
PVM2。
PVM2 不再是:
加密 → 解密 → 恢复 Dalvik → ART而会变成:
原始 Dalvik ↓ PVM2 Compiler ↓ 自定义 VM Image ↓ JNI Trampoline ↓ Native Interpreter ↓ VM Opcode ↓ 执行XopProtector 当前源码中的 PVM2 已经进一步加入了多 ISA、Opcode Morphing、RASP Gate、解释执行以及 PVM2 Image等机制。PVM2 v3 还会为每个 APK 生成 opcode 映射,并根据isa_id选择不同 Native dispatch 入口。
这才是真正意义上的 Android 代码虚拟化。
下一篇:
《Android APK 加固原理(四):真正的代码虚拟化——PVM2 Native Interpreter 技术解析》
将重点拆解:
Dalvik ↓ PVM2 Compiler ↓ VM Opcode ↓ PVM2 Image ↓ JNI Trampoline ↓ Native Interpreter ↓ Dispatcher ↓ Opcode Handler ↓ 寄存器 / 对象 / Field / Method ↓ 最终执行并重点解释:
为什么 PVM2 和 PVM1 已经不是同一个层级的加固技术。
参考源码
本文分析以 XopProtector 当前公开源码为基础,重点涉及:
packer/PackerMain.javapacker/VmCodec.javanative/vm/vm_codec.cppnative/runtime/engine.cpp- PVM2 设计文档
项目公开 README 明确将--vmp-prefix定义为 PVM1,将--true-vmp-prefix定义为 PVM2。
