Android APK 加固原理(四):真正的代码虚拟化——PVM2 Native Interpreter 技术解析
系列文章
第一篇:《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 应用保护体系解析》
一、前言:PVM1 和 PVM2,到底差在哪里?
在上一篇文章中,我们分析了 XopProtector 的 PVM1。
PVM1 的核心逻辑可以概括为:
原始 Dalvik 指令
↓
方法级抽取
↓
PVM1 编码
↓
AES-GCM 加密
↓
保存到 code.bin
↓
原 DEX 方法变成 Stub
↓
运行时解密
↓
恢复 Dalvik
↓
交给 ART 执行
所以 PVM1 虽然被称为 Virtualized Packing,但它最终还是:
恢复 Dalvik → ART 执行。
这意味着一个非常重要的问题:
只要攻击者能够在运行时抓住“恢复 Dalvik”这个瞬间,就仍然有机会得到真实代码。
PVM2 则完全改变了这个执行模型。
XopProtector 的设计文档直接定义:
被选中的 PVM2 方法不会再恢复成 DEX 中的 Dalvik 指令,而是由 JNI trampoline 进入 Native Interpreter,直接解释执行
code.bin中的 PVM2 Image。
因此:
PVM1:
Protected Method
↓
Decode
↓
Dalvik
↓
ART
PVM2:
Protected Method
↓
JNI Trampoline
↓
Native Interpreter
↓
PVM2 Opcode
↓
执行
这就是两者最根本的区别。
二、什么叫“真正的代码虚拟化”?
要理解 PVM2,首先需要理解传统代码执行模型。
正常 Android Java/Kotlin 方法:
Java / Kotlin
↓
Java Bytecode
↓
D8 / R8
↓
DEX
↓
Dalvik Bytecode
↓
ART
↓
CPU
最终真正执行的是 Android Runtime 所理解的 Dalvik/DEX 指令。
例如一个简单方法:
int add(int a, int b) {
return a + b;
}
编译到 DEX 后,会变成类似:
add-int
return
也就是说:
ART 本身就是 Android 的“字节码执行环境”。
而 PVM2 做的事情,是把这个关系彻底改变:
原始 Dalvik
↓
PVM2 Compiler
↓
PVM2 Bytecode
↓
Native Interpreter
↓
CPU
也就是说:
PVM2 不再让 ART 直接执行原始方法体,而是让 Native 代码自己理解一套新的指令集。
这才是代码虚拟化。
三、PVM2 的核心思想:自己造一台“虚拟 CPU”
可以把 PVM2 想象成在 Android Native 层重新制造了一台非常小的 CPU。
正常 CPU:
指令
↓
CPU Decoder
↓
ALU / Register / Memory
↓
执行
PVM2:
PVM2 Opcode
↓
Native Dispatcher
↓
PVM Register
↓
Handler
↓
执行
于是原来的:
Dalvik Opcode
变成:
PVM2 Opcode
例如:
Dalvik:
const
move
add-int
if
invoke
return
PVM2 会建立自己的:
OP_CONST
OP_MOVE
OP_BINOP
OP_IF_Z
OP_INVOKE_STATIC
OP_RETURN
当前 XopProtector 的 PVM2 Opcode 表已经覆盖移动、常量、分支、返回、算术运算、方法调用、字段访问、对象创建、数组、类型检查、异常以及宽整数、浮点、double 等操作。源码中的 Opcode 定义目前扩展到 50 个虚拟操作。
四、PVM2 的整体架构
把 XopProtector 当前实现抽象出来,可以得到:
┌─────────────────────┐
│ Java / Kotlin │
└──────────┬──────────┘
│
▼
原始 DEX
│
▼
┌──────────────────┐
│ PVM2 Compiler │
└────────┬─────────┘
│
▼
PVM2 Virtual Code
│
▼
┌──────────────────┐
│ Opcode Morphing │
└────────┬─────────┘
│
▼
PVM2 Image
│
▼
code.bin
│
┌────────────┴────────────┐
│ │
▼ ▼
DEX Trampoline Native Runtime
│ │
└────────────┬────────────┘
▼
JNI interpret()
│
▼
Native Interpreter
│
▼
PVM2 Dispatcher
│
▼
VM Handlers
│
▼
CPU
这就是 PVM2 的核心架构。
五、第一步:PVM2 从哪里开始?
PVM2 仍然是在构建阶段完成代码转换。
XopProtector 提供:
--true-vmp-prefix
例如:
--true-vmp-prefix Lcom/foo/
只有匹配指定规则并且通过支持性检查的方法才会进入 PVM2。
如果一个方法使用了当前 PVM2 Compiler 不支持的 Dalvik Opcode 或不满足方法原型要求,则会回退到普通 hollowing 或 PVM1。官方 PVM2 设计文档明确说明了这一点。
因此 PVM2 并不是:
所有代码
↓
全部虚拟化
而是:
DEX
│
├── 普通方法
│ ↓
│ 正常执行
│
├── PVM1 方法
│ ↓
│ PVM1
│
└── PVM2 方法
↓
True VMP
这种设计非常符合商业加固系统的实际工程需求。
因为真正的代码虚拟化存在明显性能成本。
通常只应该保护:
支付
授权
License
密钥
核心算法
风控
安全校验
协议计算
等高价值代码。
六、第二步:PVM2 Compiler 并不是简单复制 Dalvik
这是 PVM2 和 PVM1 最大的区别之一。
PVM1:
Dalvik bytes
↓
编码
↓
保存
PVM2:
Dalvik Instruction
↓
解析 Opcode
↓
理解操作数
↓
理解寄存器
↓
理解常量
↓
理解跳转
↓
理解方法引用
↓
理解字段引用
↓
生成 PVM2 Instruction
也就是说:
PVM2 Compiler 必须理解 Dalvik 指令的语义。
XopProtector 的 Pvm2Compiler.java 就承担了这一工作。
源码中可以看到大量针对 Dalvik Opcode 的转换逻辑,例如整数、long、float、double 的二元运算都会被转换成 PVM2 的对应虚拟操作。
七、一个简单例子:add-int 是怎么变成 PVM2 指令的?
假设原始 Dalvik:
add-int v0, v1, v2
普通 ART 的理解是:
v0 = v1 + v2
PVM2 Compiler 不再保存:
add-int
而是生成:
OP_BINOP
并附带:
operation = ADD
dst = v0
src1 = v1
src2 = v2
于是:
Dalvik:
add-int v0, v1, v2
↓
PVM2:
OP_BINOP
ADD
v0
v1
v2
运行时 Native Interpreter 再理解:
OP_BINOP
然后:
regs[v0] = regs[v1] + regs[v2]
注意:
这里已经没有 ART 的 add-int 了。
ART 根本不需要理解这条原始指令。
八、PVM2 实际上建立了一套自己的寄存器机器
PVM2 Image Header 中存在:
reg_count
这个字段非常重要。
官方 PVM2 Image 格式定义:
magic[4] = PVM2
version
reg_count
ins_size
code_size
ret_kind
str_count
string pool
instructions
也就是说:
PVM2 自己拥有一套虚拟寄存器模型。
可以抽象成:
PVM Register File
R0
R1
R2
R3
...
Rn
例如:
R0 = 参数 a
R1 = 参数 b
R2 = 临时变量
R3 = 返回值
执行:
OP_BINOP
时,Native Interpreter 操作的是:
regs[]
而不是 ART 的 Dalvik register。
这就是“虚拟 CPU”的核心之一。
九、PVM2 的寄存器为什么重要?
因为 Dalvik 本身也是一种寄存器式虚拟机。
例如:
add-int v0, v1, v2
本质就是:
v0 = v1 + v2
所以 PVM2 没必要把 Dalvik 转成类似 x86 的栈式机器。
它可以直接设计成:
Dalvik Register
↓
PVM Register
从而降低解释器实现复杂度。
因此 PVM2 可以理解为:
把 Android 的 Dalvik Register Machine 再映射成一套由 Native 实现的第二层 Register Machine。
十、第三步:为什么要做 Opcode Morphing?
如果 PVM2 永远使用:
0x01 = OP_MOVE
0x02 = OP_CONST
0x03 = OP_BINOP
0x04 = OP_RETURN
那么攻击者很容易建立:
Opcode → Semantic
映射。
例如:
0x01 → MOVE
0x02 → CONST
0x03 → ADD
0x04 → RETURN
然后就可以写一个 PVM2 反编译器。
因此 XopProtector 又加入:
Opcode Morphing
也就是:
Canonical Opcode
↓
Random Wire Opcode
十一、Opcode Morphing 是怎么工作的?
源码中的:
Pvm2Morph.java
定义了:
forward[canonical] = wire
inverse[wire] = canonical
也就是:
标准 Opcode
↓
随机 Opcode
运行时再:
随机 Opcode
↓
inverse
↓
标准 Opcode
当前实现使用:
OP_COUNT = 50
ISA_COUNT = 3
并使用随机化的 Fisher-Yates shuffle 生成 Opcode 映射。源码还会根据 ISA Profile 做预旋转,使不同 ISA Profile 即使使用相同随机源,也不会得到完全相同的 Opcode 空间。
因此:
Build A:
OP_ADD → 0x37
Build B:
OP_ADD → 0x09
Build C:
OP_ADD → 0xD2
攻击者不能简单依靠:
0x37 = ADD
来分析所有 APK。
十二、这就是“每 APK 一套 VM”的雏形
如果每次构建:
Opcode Map
都是随机的,那么:
APK A
↓
VM A
APK B
↓
VM B
APK C
↓
VM C
虽然三者的核心语义可能类似:
ADD
SUB
MOVE
RETURN
INVOKE
但是:
Opcode
↓
Handler
↓
Dispatch
的映射可以不同。
这就是商业代码虚拟化非常重要的一个概念:
Polymorphic VM / Per-Build VM
XopProtector 当前 PVM2 Morph 实现已经具备这一方向的基础,并进一步区分多个 ISA Profile。
十三、第四步:为什么需要 PVM2 Image?
PVM2 不再把原始 Dalvik CodeItem 放进 DEX。
所以必须有一个新的容器。
这个容器就是:
PVM2 Image
当前设计中的结构为:
+--------------------+
| magic = PVM2 |
+--------------------+
| version |
+--------------------+
| reg_count |
+--------------------+
| ins_size |
+--------------------+
| reserved |
+--------------------+
| code_size |
+--------------------+
| ret_kind |
+--------------------+
| str_count |
+--------------------+
| String Pool |
+--------------------+
| PVM2 Instructions |
+--------------------+
官方设计文档明确给出了这一结构。
十四、为什么 PVM2 Image 还需要 String Pool?
因为 Java/Kotlin 方法中经常存在:
String
例如:
if ("premium".equals(type)) {
...
}
如果直接把字符串放到 PVM Opcode 中:
CONST_STRING "premium"
那么攻击者仍然可以通过:
strings
快速找到大量业务信息。
所以 PVM2 Image 本身定义了:
str_count
以及:
String Pool
PVM2 指令通过索引访问字符串,而不是每条指令都直接保存完整字符串。
可以理解为:
String Pool
0 → "premium"
1 → "license"
2 → "invalid"
3 → "token"
指令:
OP_CONST_STRING 2
运行时:
2
↓
String Pool
↓
"invalid"
这样可以统一管理虚拟机中的字符串资源。
十五、第五步:原方法为什么不再恢复 Dalvik?
这是 PVM2 最核心的一步。
PVM1:
Method
↓
Stub
↓
Runtime Restore
↓
Dalvik
PVM2:
Method
↓
Stub
↓
JNI Trampoline
↓
PVM2 Image
官方文档直接写明:
Selected methods never restore Dalvik into DEX。
所以攻击者再去寻找:
Method CodeItem
已经没有意义。
因为真正的方法逻辑已经变成:
PVM2 Image
而不是:
Dalvik CodeItem
十六、第六步:PVM2 为什么需要 JNI Trampoline?
这是 Android 集成中非常关键的一环。
Java/Kotlin 代码仍然需要:
调用方法
但是原始方法体已经被删除。
所以必须留下一个“入口”。
这个入口就是:
JNI Trampoline
可以把它理解成:
Java Method
↓
Trampoline
↓
Native VM
它并不执行真正业务逻辑。
它只负责:
找到 PVM2 方法
↓
进入 Native
↓
启动 Interpreter
XopProtector 专门提供了:
TrueVmpTrampoline.java
用于把目标方法改写成 True-VMP 入口。源码中会扫描 DEX 方法并按照方法签名定位目标,然后重新生成方法实现。
十七、PVM2 的执行入口
官方设计文档给出的入口是:
Lcom/yqsh/protector/shell/VmBridge;
->interpret(II[Ljava/lang/Object;)
->Ljava/lang/Object;
也就是说:
Java / DEX
↓
VmBridge.interpret(...)
↓
Native
↓
PVM2 Interpreter
这就是整个 PVM2 的“桥”。
因此:
DEX
只负责保留:
调用入口
而:
真正业务逻辑
已经移动到了:
Native Interpreter + PVM2 Image
十八、PVM2 Interpreter 到底是什么?
到了 Native 侧:
pvm2_interp.cpp
真正的虚拟机开始工作。
它可以抽象成:
while (pc < code_size) {
opcode = code[pc++];
switch (opcode) {
case OP_MOVE:
...
case OP_CONST:
...
case OP_BINOP:
...
case OP_IF:
...
case OP_INVOKE:
...
case OP_RETURN:
...
}
}
这就是经典的:
Bytecode Interpreter
也就是:
取指
↓
解码
↓
执行
↓
更新 PC
↓
继续取指
十九、PVM2 的 Dispatcher 是整个 VM 的核心
可以把 Dispatcher 理解成:
虚拟 CPU 的 Instruction Decoder。
执行过程:
PC
↓
读取 code[PC]
↓
得到 Wire Opcode
↓
Opcode Morph Decode
↓
Canonical Opcode
↓
找到 Handler
↓
执行
↓
PC += instruction_size
例如:
code[PC] = 0x73
但是:
0x73
并不一定代表:
ADD
必须经过:
inverse[0x73]
转换成:
OP_BINOP
然后 Interpreter 才知道应该执行什么。
二十、为什么 Opcode Morphing 对逆向分析影响很大?
没有 Morph:
0x01 → MOVE
0x02 → CONST
0x03 → ADD
0x04 → RETURN
逆向人员只需要:
识别 Dispatcher
↓
建立 Opcode Table
↓
反编译
有 Morph:
0xA7 → ?
0x31 → ?
0x82 → ?
0x05 → ?
每个 APK 都可能不同。
因此攻击者需要首先恢复:
Opcode Map
再理解:
Handler Semantics
攻击成本明显提高。
但需要注意:
Opcode Morphing 本身不是密码学意义上的加密。
它解决的是:
增加 VM 指令集的静态识别成本。
而不是:
提供绝对的机密性。
二十一、PVM2 并不是只支持简单整数运算
如果 PVM2 只支持:
ADD
SUB
MUL
RETURN
那么只能运行非常简单的代码。
真正有价值的 Android 方法通常还会涉及:
invoke
field
object
array
exception
string
monitor
float
double
long
所以 PVM2 Compiler 必须不断扩大 Opcode 覆盖范围。
当前源码中的 Opcode 已经包括:
MOVE
CONST
GOTO
IF
RETURN
BINOP
INVOKE_STATIC
INVOKE_VIRTUAL
INVOKE_DIRECT
INVOKE_INTERFACE
INVOKE_SUPER
MOVE_RESULT
SGET
SPUT
IGET
IPUT
NEW_INSTANCE
NEW_ARRAY
ARRAY_LENGTH
AGET
APUT
CHECK_CAST
INSTANCE_OF
THROW
MOVE_EXCEPTION
CONST_CLASS
NEG
FILLED_NEW_ARRAY
并进一步扩展:
BINOP_WIDE
BINOP_FLOAT
BINOP_DOUBLE
UNOP
等类型。
这说明:
PVM2 已经不是一个简单的“Opcode Demo”,而是在逐步构建一套可以承载真实 Java/Kotlin 方法语义的虚拟指令集。
二十二、方法调用是 PVM2 最复杂的部分之一
假设原始代码:
int result = calculate(a, b);
Dalvik:
invoke-static
move-result
PVM2 必须理解:
目标 Class
方法名
方法签名
参数
返回值
然后 Native Interpreter 再通过 JNI 找到:
jclass
jmethodID
最后调用:
CallStaticIntMethodA()
XopProtector 的 pvm2_interp.cpp 中确实实现了这一机制。
源码中的 invoke_method() 会解析:
owner
name
signature
然后通过:
GetStaticMethodID
GetMethodID
获得对应 JNI 方法,并按照返回类型分别处理:
boolean
byte
short
char
int
float
long
double
object
等返回值。
这意味着 PVM2 并不是把整个 Android Runtime 重写一遍。
它采取的是一种更加现实的方案:
核心计算由自己的 VM 执行;需要进入 Android / Java 世界的地方,通过 JNI 回调原有运行环境。
二十三、这就是 PVM2 的“混合执行模型”
因此 PVM2 的真实执行路径不是:
100% Native
也不是:
100% ART
而是:
PVM2 Interpreter
│
┌─────────────┼──────────────┐
│ │ │
▼ ▼ ▼
算术运算 分支控制 寄存器操作
│ │ │
└─────────────┴──────────────┘
│
▼
Native 执行
│
遇到 Java API / Method
│
▼
JNI
│
▼
Android Runtime
这种架构既保持了虚拟机的控制权,又可以利用 Android 自身的:
Class
Method
Object
String
Array
JNI
等能力。
二十四、PVM2 如何处理对象?
这是 Native Interpreter 最难实现的部分之一。
整数非常简单:
int
可以直接保存。
但 Java 对象:
Object
String
Activity
Context
Array
不能简单当作:
int
处理。
因此 Interpreter 中的寄存器实际上需要能够保存不同类型的值。
可以抽象成:
VM Register
┌─────────────┐
│ int │
│ long │
│ float │
│ double │
│ jobject │
└─────────────┘
当 PVM2 执行:
IGET
IPUT
INVOKE
NEW_INSTANCE
CHECK_CAST
时,就需要处理真实 Java 对象。
这也是为什么 PVM2 Native Interpreter 的代码量会迅速增加。
二十五、PVM2 如何实现 Field 操作?
假设:
int value = obj.count;
Dalvik:
iget
PVM2:
OP_IGET
Opcode 里保存:
dst
obj
fieldId
kind
当前 Opcode 定义中,OP_IGET、OP_IPUT、OP_SGET、OP_SPUT 都有对应的字段索引和类型信息。
运行时:
OP_IGET
↓
找到 Object
↓
找到 Field
↓
读取 Field
↓
写入 VM Register
所以:
Dalvik Field Access
被重新解释成:
PVM2 Field Access
二十六、PVM2 如何处理数组?
同样:
int x = array[index];
对应:
OP_AGET
写入:
OP_APUT
创建:
OP_NEW_ARRAY
获取长度:
OP_ARRAY_LENGTH
这说明 PVM2 并不是只实现数学计算,而是在逐渐覆盖 Java/Kotlin 的对象模型。
源码 Opcode 表中已经包含完整的数组操作集合。
二十七、PVM2 如何处理异常?
Java/Kotlin:
try {
...
} catch (Exception e) {
...
}
这是 VM 保护非常棘手的一部分。
因为异常涉及:
控制流
异常对象
异常表
catch block
栈状态
PVM2 至少需要理解:
THROW
MOVE_EXCEPTION
等操作。
当前 PVM2 Opcode 定义已经包含:
OP_THROW
OP_MOVE_EXCEPTION
这说明其虚拟机设计已经开始覆盖异常执行语义。
二十八、PVM2 的分支为什么重要?
假设:
if (a > 10) {
foo();
} else {
bar();
}
原始 Dalvik 有:
if
goto
PVM2 不能简单把它转换成:
if true
而必须建立:
Virtual PC
也就是:
PC = 0
OP_CONST
OP_IF
↓
PC = 37
所以 PVM2 实际上拥有自己的:
Program Counter
可以理解为:
Virtual CPU State
PC
Registers
Pending Result
Object References
Return State
这已经非常接近真正的虚拟 CPU。
二十九、PVM2 为什么需要 PC?
因为原始代码:
A
↓
B
↓
C
是线性执行。
但是:
if
while
switch
goto
try/catch
会改变控制流。
因此 PVM2 必须自己维护:
pc
例如:
PC = 0
0: OP_CONST
5: OP_IF_Z
↓
PC = 20
20: OP_INVOKE
...
此时:
ART 已经不知道原始代码的控制流是什么。
控制流由:
PVM2 Interpreter
自己管理。
这就是 PVM2 和 PVM1 的质变。
三十、PVM1 与 PVM2 的执行控制权发生了变化
这是整个系列最值得记住的一张图。
PVM1
Native Shell
│
▼
恢复 Dalvik
│
▼
ART
│
▼
执行
控制权:
Native → ART
PVM2
Native Shell
│
▼
PVM2 Image
│
▼
Native Interpreter
│
▼
PVM2 Dispatcher
│
▼
VM Handler
│
▼
JNI / Native
控制权:
Native VM
因此:
PVM2 真正改变的是“谁负责解释代码”。
三十一、PVM2 为什么比 PVM1 更难逆向?
PVM1:
恢复 Dalvik
↓
jadx / baksmali
↓
正常分析
PVM2:
找到 VM
↓
找到 Dispatcher
↓
识别 Opcode
↓
恢复 Opcode Map
↓
分析每个 Handler
↓
理解寄存器模型
↓
理解对象模型
↓
理解 JNI Bridge
↓
恢复控制流
↓
重新构造高级语义
攻击者面对的已经不是:
“如何脱 DEX?”
而是:
“如何逆向一台自定义虚拟机?”
这就是代码虚拟化真正提高逆向成本的原因。
三十二、PVM2 的攻击面也发生了变化
这点非常重要。
PVM1 的核心攻击面:
Method Restore
PVM2 的核心攻击面:
Interpreter
攻击者不再优先寻找:
真实 Dalvik
而会寻找:
Dispatcher
Opcode Table
Handler
VM State
JNI Bridge
PVM2 Image
因此加固系统的防守重点也必须变化。
三十三、PVM2 为什么还需要 Morph?
因为:
Interpreter
本身如果固定不变:
Dispatcher
↓
0x01 = MOVE
0x02 = CONST
0x03 = ADD
那么攻击者可以:
一次分析
↓
建立 VM Signature
↓
批量分析所有 APK
而 Opcode Morphing 的目标就是:
APK A
↓
VM A
APK B
↓
VM B
APK C
↓
VM C
让:
同一业务逻辑
在不同 APK 中呈现不同的:
Wire Opcode
当前源码使用 3 个 ISA Profile,并为每个构建生成随机 Opcode 映射。
三十四、PVM2 的 ISA 又是什么?
源码中:
ISA_COUNT = 3
可以把它理解成:
ISA 0
ISA 1
ISA 2
不同 ISA Profile 可以影响:
Opcode Layout
Instruction Encoding
Dispatch
这样即使:
APK A
APK B
使用相同 PVM2 Compiler,也可以得到不同的虚拟指令空间。
因此:
Canonical VM
↓
ISA Profile
↓
Opcode Morph
↓
Wire Bytecode
进一步提高了静态识别成本。
三十五、PVM2 其实是一套“编译器 + 虚拟机”
如果从软件工程角度重新看 PVM2:
它不是一个单独的 Interpreter。
它实际上由三个核心组件组成:
① Compiler
Dalvik
↓
PVM2 Bytecode
② Image Format
PVM2 Bytecode
↓
PVM2 Image
↓
code.bin
③ Interpreter
PVM2 Image
↓
Decode
↓
Execute
所以完整体系:
PVM2
┌──────────────┐
│ Compiler │
└──────┬───────┘
↓
┌──────────────┐
│ VM Bytecode │
└──────┬───────┘
↓
┌──────────────┐
│ PVM2 Image │
└──────┬───────┘
↓
┌──────────────┐
│ Interpreter │
└──────┬───────┘
↓
CPU
这已经是一套完整的软件虚拟机体系。
三十六、为什么 PVM2 的实现代码量这么大?
因为你实际上是在重新实现一部分:
Android Runtime
当然,它不需要实现整个 ART。
但是至少需要理解:
整数
long
float
double
对象
数组
Field
Method
String
Return
Branch
Exception
JNI
因此:
PVM2 Compiler
需要理解 Dalvik。
而:
PVM2 Interpreter
又必须重新实现这些语义。
这也是当前源码中:
Pvm2Compiler.java
已经达到千行级别,而:
pvm2_interp.cpp
达到数千行规模的原因之一。源码目录目前也明确将 PVM2 格式、Interpreter 与 Codec 独立拆分。
三十七、PVM2 为什么性能一定比 ART 慢?
这是代码虚拟化必须面对的问题。
正常 ART:
Dalvik
↓
ART
↓
JIT / AOT
↓
Native
ART 可以进行:
优化
内联
常量传播
寄存器优化
热点编译
而 PVM2:
PVM2 Opcode
↓
Native Interpreter
↓
switch / dispatch
↓
Handler
↓
执行
每一条虚拟指令都会产生额外的:
Fetch
Decode
Dispatch
开销。
例如原来:
a + b
可能直接进入 ART 优化后的机器指令。
PVM2 则可能:
读取 Opcode
↓
解析 Opcode
↓
读取寄存器
↓
执行 Handler
↓
写回寄存器
↓
PC 更新
因此:
代码虚拟化通常不是性能优化技术,而是安全增强技术。
三十八、所以商业加固为什么不会把所有代码都 PVM2?
原因非常简单:
PVM2 强度 ↑
↓
性能成本 ↑
↓
兼容性成本 ↑
↓
开发维护成本 ↑
所以最合理的策略是:
普通业务代码
↓
DEX 加密 / 常规保护
重要业务代码
↓
PVM1
核心安全代码
↓
PVM2
例如:
UI
网络请求
RecyclerView
普通业务逻辑
没有必要全部虚拟化。
而:
支付签名
授权验证
License
风控
核心算法
密钥派生
协议核心
才适合进入 PVM2。
三十九、PVM2 的一个典型执行例子
假设原始代码:
int check(int a, int b) {
int x = a + b;
if (x > 100) {
return 1;
}
return 0;
}
正常 Dalvik:
CONST / ADD / IF / RETURN
经过 PVM2:
OP_CONST
OP_BINOP
OP_IF
OP_RETURN
然后 Opcode Morph:
0x71
0xA4
0x19
0xE2
最终保存到:
PVM2 Image
运行时:
Java
↓
Trampoline
↓
VmBridge.interpret()
↓
Native Interpreter
↓
0x71
↓
Decode
↓
CONST
↓
0xA4
↓
Decode
↓
BINOP
↓
0x19
↓
Decode
↓
IF
↓
0xE2
↓
Decode
↓
RETURN
注意:
整个过程中没有必要把原来的
add-int、if、return恢复到 DEX。
这就是 PVM2 最核心的价值。
四十、PVM1 与 PVM2 的本质区别
现在可以用一句非常准确的话来区分:
PVM1:
保护代码的数据形态。
Dalvik
↓
Protected Blob
↓
Dalvik
PVM2:
改变代码的执行模型。
Dalvik
↓
PVM2 Bytecode
↓
Native Interpreter
所以:
PVM1 = Virtualized Packing
PVM2 = True Code Virtualization
这也是 XopProtector README 中明确区分:
--vmp-prefix
→ PVM1
→ decode → write Dalvik
--true-vmp-prefix
→ PVM2
→ JNI trampoline + native interpreter
的原因。
四十一、PVM2 的完整构建流程
现在把源码中的整个离线流程串起来:
原始 APK
│
▼
解包 DEX
│
▼
定位目标 Method
│
▼
检查 PVM2 支持情况
│
┌──────┴──────┐
│ │
支持 不支持
│ │
▼ ▼
PVM2 Compiler 回退
│ │
▼ ▼
Dalvik → PVM2 PVM1 / Hollow
│
▼
PVM2 Bytecode
│
▼
Opcode Morph
│
▼
PVM2 Image
│
▼
code.bin
│
▼
DEX Method Hollow
│
▼
写入 JNI Trampoline
│
▼
重新生成 DEX
│
▼
重新打包 APK
这就是 PVM2 的离线加固阶段。
四十二、PVM2 的完整运行流程
运行时则变成:
App 启动
│
▼
Native Shell
│
▼
初始化 Runtime
│
▼
加载 code.bin
│
▼
建立 PVM2 数据
│
▼
Java 调用方法
│
▼
JNI Trampoline
│
▼
VmBridge.interpret
│
▼
PVM2 Interpreter
│
▼
读取 PVM2 Opcode
│
▼
Opcode Morph Decode
│
▼
Dispatcher
│
▼
Handler
│
▼
修改 VM Registers
│
▼
更新 Virtual PC
│
▼
下一条指令
│
├──────────────┐
│ │
▼ ▼
Native JNI / Java
│ │
└──────┬───────┘
▼
Return
这就是完整的 True VMP 执行模型。
四十三、PVM2 最重要的三个“彻底改变”
如果只记住三点,可以记住:
第一:代码不再恢复成 Dalvik
PVM1:
Decode
↓
Dalvik
PVM2:
Decode
↓
PVM2 Image
不会再把真实方法体写回 DEX。
第二:执行权从 ART 转移到了 Native Interpreter
PVM1:
ART 执行
PVM2:
Native VM 执行
ART 不再直接理解被保护方法的原始指令。
第三:攻击者需要逆向的是一台虚拟机
PVM1:
脱壳
↓
DEX
↓
分析
PVM2:
分析 VM
↓
分析 Dispatcher
↓
分析 Opcode
↓
分析 Handler
↓
恢复 VM State
↓
恢复控制流
↓
恢复业务语义
攻击模型已经发生变化。
四十四、但 PVM2 也不是“绝对安全”
这一点必须客观说明。
XopProtector 官方 README 自己就明确强调:
加固的目标是提高逆向成本,而不是保证应用绝对不可破解。
PVM2 依然存在攻击面:
PVM2 Image
↓
Interpreter
↓
Opcode Mapping
↓
Handler
↓
JNI
攻击者仍然可以尝试:
动态调试
Native Hook
Interpreter Trace
Opcode Trace
JNI Trace
Memory Analysis
因此:
PVM2 不是让攻击者无法分析,而是让攻击者必须从“分析 DEX”升级到“分析一套自定义虚拟机”。
这才是它真正的安全价值。
四十五、PVM2 真正增加的是“语义恢复成本”
这是理解代码虚拟化最重要的一点。
普通 DEX:
Opcode
↓
Dalvik Semantic
↓
Java/Kotlin
分析工具已经非常成熟。
而 PVM2:
Wire Opcode
↓
Morph
↓
Canonical Opcode
↓
Handler
↓
VM Semantic
↓
Dalvik Semantic
↓
Java/Kotlin
攻击者需要多走至少几层。
因此:
静态逆向成本
↑
动态分析成本
↑
语义恢复成本
↑
自动化分析难度
都会增加。
四十六、从 XopProtector 源码看,PVM2 已经形成完整闭环
当前项目的源码结构已经非常清楚:
Packer
Pvm2Compiler.java
负责:
Dalvik
↓
PVM2
Opcode
Pvm2Opcodes.java
负责:
VM Instruction Set
Morph
Pvm2Morph.java
负责:
Opcode Randomization
Trampoline
TrueVmpTrampoline.java
负责:
DEX Method
↓
JNI Entry
Image Format
pvm2_format.h
pvm2_format.cpp
负责:
PVM2 Image
Interpreter
pvm2_interp.h
pvm2_interp.cpp
负责:
PVM2 Execution
源码目录也明确将这些组件拆分在 native/.../vm 下。
所以从工程结构上看:
PVM2 已经形成了 Compiler → Image → Trampoline → Interpreter 的完整闭环。
四十七、把 PVM1 和 PVM2 放在一起看
最终可以得到这样一张对比图:
PVM1
原始 Dalvik
│
▼
方法抽取
│
▼
PVM1 Encode
│
▼
AES-GCM
│
▼
code.bin
│
▼
Runtime Decode
│
▼
恢复 Dalvik
│
▼
ART
而:
PVM2
原始 Dalvik
│
▼
PVM2 Compiler
│
▼
VM Opcode
│
▼
Opcode Morph
│
▼
PVM2 Image
│
▼
code.bin
│
▼
JNI Trampoline
│
▼
Native Interpreter
│
▼
PVM2 Dispatcher
│
▼
VM Handler
│
▼
执行
所以:
PVM1 隐藏的是“代码数据”。
PVM2 隐藏的是“代码语义 + 执行模型”。
四十八、为什么说 PVM2 才是真正的 VMP?
因为它满足代码虚拟化最核心的条件:
原始代码
↓
转换成另一套指令系统
↓
原始执行环境无法直接执行
↓
由自定义 Interpreter 执行
PVM2 正是:
Dalvik
↓
PVM2 Opcode
↓
PVM2 Interpreter
而不是:
Dalvik
↓
加密
↓
恢复 Dalvik
因此从技术分类上:
PVM1
= Method-Level Virtualized Packing
PVM2
= True Code Virtualization
这个定义非常适合用于理解整个 XopProtector 的保护体系。
四十九、PVM2 之后,XopProtector 的安全体系就形成了四个层次
到目前为止,我们已经分析了:
第一层:
DEX 加密
解决:
APK 静态暴露
第二层:
PVM1
解决:
关键 Method 静态暴露
第三层:
PVM2
解决:
恢复 Dalvik 后直接分析
第四层:
RASP
解决:
Runtime Hook / Frida / 调试 / 篡改
最终:
XopProtector
APK
│
┌──────────┴──────────┐
│ │
DEX Native
│ │
▼ ▼
加密保护 SO 保护
│ │
▼ ▼
PVM1 RASP
│
▼
PVM2
这已经不是单纯的:
“APK 加密工具”。
而是一套:
DEX + Virtualization + Native + Runtime Security 的多层保护体系。
官方 README 当前也将项目能力归纳为 DEX 加密、双 VMP、SO 保护与 RASP,并明确把 PVM2 定义为 True VMP。
五十、最终总结:一句话理解 PVM2
如果 PVM1 用一句话总结:
把关键方法从 DEX 中抽出来,运行时恢复 Dalvik 再交给 ART。
那么 PVM2 可以用一句话总结:
把关键方法从 Dalvik 指令转换成自定义 PVM2 字节码,运行时通过 JNI Trampoline 进入 Native Interpreter,由自定义虚拟机直接解释执行,从根本上避免真实方法体重新回到 DEX。
整个机制最终可以浓缩成:
PVM2 TRUE VMP
原始 Java / Kotlin
│
▼
原始 DEX
│
▼
PVM2 Compiler
│
▼
PVM2 Bytecode
│
▼
Opcode Morph
│
▼
PVM2 Image
│
▼
code.bin
│
┌────────┴────────┐
│ │
▼ ▼
DEX Trampoline Native VM
│ │
└────────┬────────┘
▼
VmBridge.interpret
│
▼
PVM2 Interpreter
│
▼
Opcode Decode
│
▼
Handler
│
▼
VM Registers
│
▼
Virtual PC
│
▼
JNI / Native
│
▼
Return
所以真正的变化不是:
“代码加密得更厉害了。”
而是:
代码已经不再按照原来的执行方式运行。
这才是 PVM2 与 PVM1 的本质区别。
五十一、从整个系列来看,XopProtector 的技术路线已经非常清晰
到第四篇为止,可以把它总结成:
第一层
DEX 加密
↓
解决 APK 静态暴露
第二层
Method Hollow + PVM1
↓
解决关键方法暴露
第三层
PVM2 Native Interpreter
↓
解决原始 Dalvik 直接执行问题
第四层
SO `.text` Protection
↓
解决 Native 代码暴露
第五层
RASP
↓
解决运行时攻击
因此,后面两篇实际上会进入另外两个非常重要的攻击面:
第五篇:SO .text 段保护
如果 Java/Kotlin 层已经通过:
DEX 加密
PVM1
PVM2
进行保护,那么 Native 层仍然可能存在:
libxxx.so
↓
.text
↓
机器码
攻击者可以使用:
IDA
Ghidra
Binary Ninja
radare2
直接分析 Native Machine Code。
因此下一篇将进入:
SO
.text段加密、ELF 加载、内存映射、运行时动态解密,以及为什么 Native 代码保护必须解决“文件态”和“内存态”两个问题。
第六篇:RASP
即使:
DEX
PVM1
PVM2
SO
都保护起来,攻击者还可以从运行时入手:
Frida
Inline Hook
PLT Hook
GOT Hook
ptrace
TracerPid
maps
mem
调试器
注入
因此最终需要:
Runtime Application Self-Protection
也就是:
RASP。
这也是整个 XopProtector 最后一层主动防御。
五十二、最终形成 XopProtector 的完整保护闭环
最终整个系列可以浓缩为:
Android APK
│
┌──────────────┴──────────────┐
│ │
DEX SO
│ │
▼ ▼
DEX Encryption ELF / .text Protection
│ │
▼ ▼
PVM1 Runtime
│ │
▼ ▼
PVM2 RASP
│ │
└──────────────┬──────────────┘
▼
Native Shell
│
▼
Runtime Security
最终形成:
静态保护 + 方法级保护 + 代码虚拟化 + Native 保护 + Runtime 防护
五个维度共同组成的 Android APK 多层防护体系。
而这也是理解 XopProtector 最重要的一点:
它真正的价值并不在某一个单独的“加密算法”,而在于把攻击面拆开,再针对不同攻击面建立不同的保护层。
源码参考
本文重点分析了 XopProtector 当前公开源码中的:
packer/
├── Pvm2Compiler.java
├── Pvm2Opcodes.java
├── Pvm2Morph.java
└── TrueVmpTrampoline.java
native/
└── src/main/cpp/vm/
├── pvm2_format.cpp
├── pvm2_format.h
├── pvm2_interp.cpp
├── pvm2_interp.h
├── vm_codec.cpp
└── vm_codec.h
项目当前 README 明确将 --true-vmp-prefix 定义为 PVM2 True VMP,并将其描述为 JNI trampoline + native interpreter + 不写回 DEX;PVM2 设计文档则明确规定了 PVM2 Image 格式以及 VmBridge.interpret(...) 入口。
源码目录也可以直接看到 PVM2 Compiler、Opcode、Morph、Trampoline 与 Native Interpreter 的完整模块划分。
下一篇预告
《Android APK 加固原理(五):SO .text 段加密、ELF 加载与运行时动态解密》
下一篇不再讨论 DEX,而是转向:
libxxx.so
│
▼
ELF Parser
│
▼
.text Section
│
▼
加密 / 保护
│
▼
APK 中不保留完整明文
│
▼
Native Shell
│
▼
ELF 加载
│
▼
运行时动态解密
│
▼
Memory Mapping
│
▼
Native Code 执行
重点会分析一个 Native 加固最核心的问题:
如果把
.text加密了,Android linker 又必须执行机器码,那么加固系统到底应该在什么时候解密、解密到哪里、如何避免完整.text长时间以明文形式存在于磁盘和内存中?
这将是 XopProtector 从 DEX 保护体系进入 Native 保护体系 的关键一篇。