APP 加壳技术的分类与识别,动态加载与双亲委派模型
加壳技术概述
在 Android 逆向工程领域,"加壳"是指对 APK 中的 DEX 文件(Dalvik Executable,Android 的可执行字节码文件)进行加密、混淆或隐藏处理的技术。加壳的目的是保护原始代码不被静态分析和反编译,从而增加逆向分析的难度。理解加壳技术的分类和工作原理,是脱壳工作的前提条件。
一个典型的加壳流程如下:开发者将原始 APK 的 DEX 文件加密后,替换为壳程序的 DEX 文件。当用户安装并运行 APP 时,壳程序 DEX 先被 Android 系统加载执行,壳程序在运行时将加密的原始 DEX 解密到内存中,然后通过自定义 ClassLoader 将解密后的 DEX 动态加载进虚拟机,最终完成原始业务逻辑的运行。
加壳技术的五大分类
DEX 整体加密壳
DEX 整体加密壳是最基础的加壳方式,其原理是将整个 classes.dex 文件进行加密(通常使用 AES、DES 等对称加密算法),将加密后的数据存储在 APK 的 assets 目录或附加到 classes.dex 的末尾。壳程序 DEX 启动后,读取加密数据并解密到内存,再通过 DexClassLoader 加载。
这种壳的优点是实现简单,保护效果对初级逆向人员有效;缺点是安全性较低,在内存中可以完整获取解密后的 DEX 文件。代表性产品有早期版本的 360 加固和腾讯乐固。
DEX 抽取壳
DEX 抽取壳是目前最主流的加壳方式。其核心思想不是加密整个 DEX 文件,而是将 DEX 中每个方法的字节码(Dalvik bytecode)抽取出来单独加密存储。抽取后,原始方法体的位置会被替换为一条返回指令(如 return void),使得直接反编译时只能看到空方法壳。
壳程序在运行时,需要逐个解密方法字节码并将其写回 DEX 内存中的对应位置,才能让应用正常运行。由于方法体是动态回填的,静态分析工具(如 jadx、jeb)无法获取真实代码。
抽取壳的代表产品包括:梆梆加固、爱加密、娜迦(Naga)加固。这类壳的防御效果显著,但也带来了性能损耗和兼容性问题。
DEX 混淆壳
DEX 混淆壳通过代码混淆技术来保护代码逻辑,而非对字节码进行加密。主要手段包括:
- 控制流混淆:将方法的控制流图打乱,插入大量无效分支和跳转指令
- 字符串加密:将代码中的常量字符串加密存储,运行时动态解密
- 变量名混淆:将有意义的变量名、方法名替换为无意义的字符序列
- 花指令插入:在代码中插入不影响执行结果但干扰分析的无用指令
代表产品有 ProGuard、DexGuard 以及阿里聚安全。混淆壳的特点是不需要修改 ClassLoader 的加载流程,兼容性较好,但对专业逆向人员而言,通过动态调试仍可还原代码逻辑。
VMP 壳
VMP(Virtual Machine Protection,虚拟机保护)壳是最复杂、安全性最高的一种加壳方式。VMP 并不直接保护原始字节码,而是将 DEX 中的方法体翻译成自定义虚拟机的指令集。运行时,壳程序内嵌的自定义虚拟机解释执行这些翻译后的指令。
VMP 壳的工作流程如下:
- 分析原始 DEX 中的每个方法
- 将 Dalvik 字节码翻译为自定义 VM 的操作码
- 将翻译后的指令和操作数重新编码存储
- 运行时由内嵌 VM 解释器逐条执行
由于 VMP 的指令集是自定义的,传统的反编译工具完全失效。分析者需要先逆向还原自定义 VM 的指令集定义,才能理解程序逻辑。代表性产品有某著名商业加固的高级保护方案。
内核级壳
内核级壳是最深度的保护方式,直接在 Android 内核层面进行保护。主要手段包括:
- 内核内存保护:通过修改内核,禁止用户态进程读取特定内存区域
- 反调试检测:在内核层面检测 ptrace 附加、frida-server 注入等调试行为
- 完整性校验:在内核中监控 DEX 内存区域的完整性,一旦发现被修改立即终止进程
这类壳的实现难度最大,通常只有少数大型安全厂商能够提供。其特点是防御效果最强,但兼容性风险也最高——不同 Android 版本的内核接口差异可能导致系统不稳定。
识别加壳的方法
特征文件检测
大多数加壳方案会在 APK 中留下特征文件。常见的检测点包括:
- assets 目录:检查是否存在加密的 DEX 文件(如
classes0.dex、classes_encrypted.dex、.dat文件等) - lib 目录:检查是否包含壳程序的 native 库(如
libjiagu.so、libnaga.so、libexec*.so、libshell*.so等) - META-INF:检查是否有壳厂商的签名信息
- Application 声明:在 AndroidManifest.xml 中检查
android:name是否指向壳程序的 Application 类
以下是一个常用的壳识别特征对应表:
| 特征文件/类名 | 可能的壳类型 |
|---|---|
libjiagu.so、libjiagu_art.so |
360 加固 |
libshell*.so、lib DexHelper.so |
梆梆加固 |
libexec*.so、libnaga.so |
娜迦加固 |
libmobisec.so |
爱加密 |
libBugly.so、libSGMain.so |
腾讯乐固 |
lib Apollo.so |
网易易盾 |
libchaosvmp.so |
某虚拟机保护壳 |
异常行为分析
加壳程序在运行时通常会有一些异常行为,可以通过动态分析来识别:
- 首次启动缓慢:壳程序需要解密和加载原始 DEX,导致冷启动时间显著延长
- 内存占用异常:内存中同时存在壳程序 DEX 和解密后的原始 DEX,内存占用偏高
- 文件 IO 异常:壳程序在启动时会从 assets 或自定义路径读取加密数据
- 多 ClassLoader 加载:使用 DexClassLoader 动态加载 DEX 是壳程序的典型行为
lib 目录分析
通过分析 APK 的 lib/ 目录结构,可以快速判断是否加壳及壳的类型:
# 解压 APK 查看 lib 目录
unzip -l target.apk | grep "lib/"
对于只有极少量 native 库的 Java 应用,如果突然出现大量 .so 文件,尤其是名称不常见的库文件,大概率是加了壳。同时,检查 lib/arm64-v8a/、lib/armeabi-v7a/ 等目录下是否有壳厂商特征库。
Java 双亲委派模型详解
什么是双亲委派模型
Java 的类加载机制采用双亲委派模型(Parents Delegation Model)。当一个类加载器收到类加载请求时,它不会自己先去加载这个类,而是将请求委派给父类加载器。只有当父类加载器无法完成加载时(在其搜索范围内找不到目标类),子类加载器才会尝试自己加载。
Android 中的类加载器层次结构为:
BootstrapClassLoader(引导类加载器)
↓
ExtClassLoader(扩展类加载器)
↓
URLClassLoader(系统类加载器)
↓
PathClassLoader / DexClassLoader(应用类加载器)
双亲委派的工作流程
以加载 android.app.Activity 为例,完整流程如下:
- 应用代码请求加载
android.app.Activity PathClassLoader收到请求,委派给父加载器- 逐级向上委派,直到
BootstrapClassLoader BootstrapClassLoader在其加载路径中查找(核心类库),找到则返回- 如果
BootstrapClassLoader未找到,向下传递给ExtClassLoader,依此类推 - 只有所有父加载器都未找到时,
PathClassLoader才会在应用自己的路径中加载
这种机制保证了 Java 核心类库(如 java.lang.*、android.*)的安全性和唯一性——无论哪个类加载器请求加载核心类,最终都由顶层的引导类加载器加载,防止核心类被恶意替换。
打破双亲委派
双亲委派模型虽然保证了安全性,但在某些场景下需要打破它:
- SPI 机制(Service Provider Interface):如 JDBC、JNDI 等需要由父加载器加载的接口,但其实现类在子加载器的路径中
- 热部署/热加载:运行时动态替换类的实现
- Android 加壳:壳程序需要在运行时加载解密后的 DEX,这必然涉及自定义类加载行为
PathClassLoader 与 DexClassLoader 的区别
Android 提供了两个主要的 DEX 类加载器:
PathClassLoader
// Android 8.1 源码中的定义
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
PathClassLoader 是 Android 应用默认的类加载器,用于加载 APK 安装目录中的 DEX 文件。它的特点是:
- 只能加载已安装的 APK 中的 DEX(路径在
/data/app/下) - Android 8.1(API 27)之后,
PathClassLoader也可以加载 DEX 文件路径,不再受限制 - 在 Android 5.0 之前,
PathClassLoader不支持动态加载额外的 DEX 文件
DexClassLoader
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, optimizedDirectory, librarySearchPath, parent);
}
}
DexClassLoader 设计用于从任意路径加载 DEX 文件,具有更大的灵活性:
dexPath:DEX 文件或包含 DEX 的 APK/JAR 的路径optimizedDirectory:ODEX 文件的输出目录(Android 8.0 后已废弃)librarySearchPath:native 库的搜索路径parent:父类加载器
两者都继承自 BaseDexClassLoader,核心加载逻辑在 BaseDexClassLoader 中实现。在 Android 8.0 之后,两者的功能差异已经很小,DexClassLoader 的 optimizedDirectory 参数被废弃,实际行为与 PathClassLoader 基本一致。
壳程序如何利用 ClassLoader 动态加载 DEX
壳程序利用 DexClassLoader 动态加载解密后的 DEX,是整个加壳方案的关键环节。以下是典型实现流程:
第一步:启动壳 Application
壳程序在 AndroidManifest.xml 中将 android:name 替换为自己的 Application 类。当 APP 启动时,系统首先执行壳的 Application.attachBaseContext() 方法。
// 壳程序的 Application 类
public class ShellApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
// 1. 从 assets 或附加数据区读取加密的原始 DEX
byte[] encryptedDex = readEncryptedDex(base);
// 2. 解密原始 DEX
byte[] originalDex = decrypt(encryptedDex);
// 3. 将解密后的 DEX 写入临时文件
File dexFile = new File(getCacheDir(), "original.dex");
writeToFile(dexFile, originalDex);
// 4. 使用 DexClassLoader 加载解密后的 DEX
DexClassLoader dexClassLoader = new DexClassLoader(
dexFile.getAbsolutePath(),
getCacheDir().getAbsolutePath(),
getLibraryPath(),
getClassLoader()
);
// 5. 替换当前 ClassLoader 的 DEX 列表
replaceDexElements(dexClassLoader);
}
}
第二步:替换 ClassLoader 的 DEX 列表
壳程序需要将新加载的 DEX 插入到当前 ClassLoader 的 DEX 搜索路径中,使得后续的类加载请求能够找到原始 DEX 中的类。这通常通过反射实现:
private void replaceDexElements(DexClassLoader newLoader) {
try {
// 获取 BaseDexClassLoader 的 pathList 字段
Field pathListField = BaseDexClassLoader.class.getDeclaredField("pathList");
pathListField.setAccessible(true);
Object newPathList = pathListField.get(newLoader);
Object oldPathList = pathListField.get(getClassLoader());
// 获取 DexPathList 的 dexElements 数组
Field dexElementsField = newPathList.getClass().getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
Object[] newElements = (Object[]) dexElementsField.get(newPathList);
Object[] oldElements = (Object[]) dexElementsField.get(oldPathList);
// 合并数组,新 DEX 放在前面
Object[] combinedElements = (Object[]) Array.newInstance(
newElements.getClass().getComponentType(),
newElements.length + oldElements.length
);
System.arraycopy(newElements, 0, combinedElements, 0, newElements.length);
System.arraycopy(oldElements, 0, combinedElements, newElements.length, oldElements.length);
// 将合并后的数组设置回旧的 ClassLoader
dexElementsField.set(oldPathList, combinedElements);
} catch (Exception e) {
e.printStackTrace();
}
}
第三步:加载原始 Application
最后,壳程序需要加载并实例化原始 APP 的 Application 类,并将所有生命周期回调转发给它:
private void loadOriginalApplication() {
// 从解密的 DEX 中加载原始 Application 类
Class<?> originalAppClass = Class.forName("com.example.original.MyApplication");
Application originalApp = (Application) originalAppClass.newInstance();
// 通过反射替换 ActivityThread 中的 mApplication
Field mApplicationField = ActivityThread.class.getDeclaredField("mInitialApplication");
mApplicationField.setAccessible(true);
mApplicationField.set(getActivityThread(), originalApp);
// 调用原始 Application 的 attach 和 onCreate
Method attachMethod = Application.class.getDeclaredMethod("attach", Context.class);
attachMethod.setAccessible(true);
attachMethod.invoke(originalApp, getBaseContext());
originalApp.onCreate();
}
总结
理解加壳技术的分类和 ClassLoader 的工作机制,是进行脱壳分析的基础。DEX 整体加密壳可以通过内存 dump 直接获取解密后的 DEX;DEX 抽取壳需要在方法体回填后才能获取完整代码;VMP 壳则需要更深入的分析手段。双亲委派模型和 PathClassLoader/DexClassLoader 的区别帮助我们理解壳程序如何劫持类加载流程,从而为后续的脱壳点选择和脱壳工具开发奠定理论基础。