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 应用保护体系解析》 项目地址:github.com/xopJack/Xop…
一、前言:为什么有了 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.Code code = 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
源码:
private static final byte[] 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-GCM
PVM1 编码只是:
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 也是这样做的。
例如原方法:
int add(int a, int b) {
return a + b;
}
原来的 Dalvik 指令可能类似:
add-int
return
PVM1 后变成:
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 #102
PVM1 不需要在 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_map
Runtime 将保护方法建立成内部映射。
源码:
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 比真正虚拟化简单很多?
假设原始代码:
int calc(int a, int b) {
int x = a + b;
return x * 10;
}
PVM1 的目标只是:
隐藏:
add-int
mul-int
return
然后运行时:
恢复:
add-int
mul-int
return
ART 继续执行。
所以 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
静态分析工具看到的可能只是:
return 0;
或者:
return null;
真实逻辑已经不在 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.bin
VmCodec.java
负责:
PVM1 Encode
PVM1 Decode
Native 侧则对应:
native/src/main/cpp/vm/
└── vm_codec.cpp
负责运行时:
PVM1 Decode
Native 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。