Android APK 加固原理(三):方法级代码抽取——PVM1 虚拟化打包到底是什么?

140 阅读20分钟

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 到底有什么区别?

这是整个系列最容易混淆的地方。

可以直接用下面这张表理解:

特性PVM1PVM2
保护粒度方法级方法级
是否抽取原始指令
是否生成虚拟化数据
是否存在 Interpreter
是否恢复 Dalvik
是否写回 DEX
最终执行者ARTNative 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.java
  • packer/VmCodec.java
  • native/vm/vm_codec.cpp
  • native/runtime/engine.cpp
  • PVM2 设计文档

项目公开 README 明确将 --vmp-prefix 定义为 PVM1,将 --true-vmp-prefix 定义为 PVM2。