Android APK 加固原理(四):真正的代码虚拟化——PVM2 Native Interpreter 技术解析

223 阅读26分钟

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_IGETOP_IPUTOP_SGETOP_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-intifreturn 恢复到 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 保护体系 的关键一篇。