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 壳的工作流程如下:

  1. 分析原始 DEX 中的每个方法
  2. 将 Dalvik 字节码翻译为自定义 VM 的操作码
  3. 将翻译后的指令和操作数重新编码存储
  4. 运行时由内嵌 VM 解释器逐条执行

由于 VMP 的指令集是自定义的,传统的反编译工具完全失效。分析者需要先逆向还原自定义 VM 的指令集定义,才能理解程序逻辑。代表性产品有某著名商业加固的高级保护方案。

内核级壳

内核级壳是最深度的保护方式,直接在 Android 内核层面进行保护。主要手段包括:

  • 内核内存保护:通过修改内核,禁止用户态进程读取特定内存区域
  • 反调试检测:在内核层面检测 ptrace 附加、frida-server 注入等调试行为
  • 完整性校验:在内核中监控 DEX 内存区域的完整性,一旦发现被修改立即终止进程

这类壳的实现难度最大,通常只有少数大型安全厂商能够提供。其特点是防御效果最强,但兼容性风险也最高——不同 Android 版本的内核接口差异可能导致系统不稳定。

识别加壳的方法

特征文件检测

大多数加壳方案会在 APK 中留下特征文件。常见的检测点包括:

  • assets 目录:检查是否存在加密的 DEX 文件(如 classes0.dexclasses_encrypted.dex.dat 文件等)
  • lib 目录:检查是否包含壳程序的 native 库(如 libjiagu.solibnaga.solibexec*.solibshell*.so 等)
  • META-INF:检查是否有壳厂商的签名信息
  • Application 声明:在 AndroidManifest.xml 中检查 android:name 是否指向壳程序的 Application 类

以下是一个常用的壳识别特征对应表:

特征文件/类名 可能的壳类型
libjiagu.solibjiagu_art.so 360 加固
libshell*.solib DexHelper.so 梆梆加固
libexec*.solibnaga.so 娜迦加固
libmobisec.so 爱加密
libBugly.solibSGMain.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 为例,完整流程如下:

  1. 应用代码请求加载 android.app.Activity
  2. PathClassLoader 收到请求,委派给父加载器
  3. 逐级向上委派,直到 BootstrapClassLoader
  4. BootstrapClassLoader 在其加载路径中查找(核心类库),找到则返回
  5. 如果 BootstrapClassLoader 未找到,向下传递给 ExtClassLoader,依此类推
  6. 只有所有父加载器都未找到时,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 之后,两者的功能差异已经很小,DexClassLoaderoptimizedDirectory 参数被废弃,实际行为与 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 的区别帮助我们理解壳程序如何劫持类加载流程,从而为后续的脱壳点选择和脱壳工具开发奠定理论基础。