加壳 APP 运行流程和原理

前言

在 Android 逆向工程中,「加壳」是一种常见的 APP 保护手段。加壳的核心思想是:将原始的 DEX 文件加密后藏起来,用一段壳程序替代它运行,在运行时再将真实 DEX 解密并加载到内存中。理解加壳 APP 的运行流程,是后续学习脱壳技术的基础前提。

本文将从 APK 文件结构出发,对比正常 APP 与加壳 APP 的启动差异,逐步拆解壳程序的运行机制。

APK 文件结构回顾

一个标准的 APK 文件本质上是一个 ZIP 压缩包,解压后可以看到以下关键目录和文件:

├── AndroidManifest.xml    # 应用清单(编译后的二进制 XML)
├── classes.dex            # Dalvik 字节码(应用的核心代码)
├── classes2.dex           # MultiDex 场景下的第二个 DEX
├── lib/
│   ├── armeabi-v7a/
│   │   └── libnative.so   # ARMv7 架构的 Native 库
│   └── arm64-v8a/
│       └── libnative.so   # ARM64 架构的 Native 库
├── res/                   # 编译后的资源文件
├── resources.arsc         # 资源索引表(字符串、样式等)
├── assets/                # 原始资源文件(不编译,直接打包)
├── META-INF/              # 签名信息(CERT.RSA、CERT.SF、MANIFEST.MF)
└── www.xxx.com.apk.idsig  # APK Signature Scheme v3 签名块

其中几个关键组件需要特别注意:

  • AndroidManifest.xml:声明了四大组件(Activity、Service、BroadcastReceiver、ContentProvider)、权限、Application 入口类等信息。这是系统启动 APP 时首先读取的配置文件。
  • classes.dex:包含了应用所有的 Java/Kotlin 代码编译后的 Dalvik 字节码,是逆向分析的主要目标。
  • lib/:存放 Native 库(.so 文件),很多壳的核心解密逻辑就藏在这里。
  • assets/:壳程序经常把加密后的真实 DEX 文件存放在此目录。
  • META-INF/:包含 APK 的签名验证信息,签名机制与壳的保护策略密切相关。

正常 APP 的启动流程

在分析加壳 APP 之前,先梳理正常 APP 的启动流程。理解这个流程,才能明白壳程序在哪些环节做了手脚。

从点击图标到 Application 创建

用户点击图标
    ↓
Launcher → ActivityManagerService(AMS)
    ↓
AMS 通过 Socket 请求 Zygote fork 新进程
    ↓
Zygote fork 出 AppProcess(已预加载 Java 运行时和公共库)
    ↓
AppProcess → ActivityThread.main()(主线程入口)
    ↓
ActivityThread 调用 ActivityThread.attach(false)
    ↓
ActivityManagerNative.getDefault().attachApplication(mAppThread)
    ↓
AMS 回调 ApplicationThread.bindApplication()
    ↓
创建 Application 对象 → 调用 Application.attachBaseContext()
    ↓
创建并启动目标 Activity → 调用 Activity.onCreate()

关键时序

整个流程中有几个对我们至关重要的时间节点:

  1. attachBaseContext():Application 绑定上下文时调用,这是最早的回调点,壳程序几乎都在这里执行解密和加载逻辑。
  2. Application.onCreate():Application 初始化完成时调用。
  3. Activity.onCreate():首个页面创建时调用。

这三个回调按照严格顺序依次执行,壳程序必须在 attachBaseContext()onCreate() 之间完成真实 DEX 的解密和加载,否则后续的 Activity 调用会找不到类而崩溃。

加壳 APP 的启动流程

加壳 APP 的核心思路是在正常启动流程中「插入」一个解密加载环节。壳程序通过替换 Application 入口类,在 attachBaseContext() 中完成所有隐秘操作。

整体流程变化

用户点击图标
    ↓
Zygote fork → AppProcess → ActivityThread.main()
    ↓
创建壳 Application 对象(AndroidManifest.xml 中声明的是壳的 Application)
    ↓
壳 Application.attachBaseContext()
    ├── 1. 读取 assets/ 中的加密 DEX 文件
    ├── 2. 使用密钥解密,还原出真实 classes.dex
    ├── 3. 通过 DexClassLoader 或反射加载真实 DEX
    └── 4. 替换当前 ClassLoader 的 DexPathList,将真实 DEX 合并进去
    ↓
AMS 回调 → 创建 Activity(此时 Activity 类已从真实 DEX 中找到)
    ↓
Activity.onCreate() → 运行的是真实业务代码

从用户视角看,加壳 APP 和正常 APP 的运行效果完全一致。但从系统底层看,DEX 文件的加载路径已经发生了根本变化。

第一步:替换 Application 入口

壳程序通过修改 AndroidManifest.xml 中的 android:name 属性,将自己的 Application 类设置为入口:

<!-- 正常 APP 的 AndroidManifest.xml -->
<application android:name="com.example.app.MyApplication" ... />

<!-- 加壳 APP 的 AndroidManifest.xml(Application 被替换为壳程序) -->
<application android:name="com.shell.ProtectorApplication" ... />

系统在启动时读取 AndroidManifest.xml,创建的不再是应用自己的 Application,而是壳程序的 Application。这就是壳能「先于真实代码执行」的根本原因。

第二步:在 attachBaseContext 中解密真实 DEX

壳 Application 的 attachBaseContext() 是整个加壳流程的核心。伪代码如下:

public class ProtectorApplication extends Application {
    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);

        // 1. 从 assets 目录读取加密的 DEX 文件
        byte[] encryptedDex = readAssetsFile(base, "protected.dex");

        // 2. 使用密钥解密(解密算法可以是 AES、RC4 或自定义算法)
        byte[] realDexBytes = decrypt(encryptedDex, getKey());

        // 3. 将解密后的 DEX 写入应用私有目录
        File dexFile = new File(getDir("dex", MODE_PRIVATE), "real.dex");
        writeToFile(dexFile, realDexBytes);

        // 4. 加载真实 DEX
        loadDex(base, dexFile);
    }
}

第三步:加载真实 DEX

加载真实 DEX 有两种主要方式:

方式一:DexClassLoader

private void loadDex(Context context, File dexFile) {
    // 创建 DexClassLoader 加载解密后的 DEX
    DexClassLoader dexClassLoader = new DexClassLoader(
        dexFile.getAbsolutePath(),           // dexPath:DEX 文件路径
        context.getCacheDir().getAbsolutePath(), // optimizedDirectory:优化输出目录
        null,                                // librarySearchPath:Native 库搜索路径
        getClassLoader()                     // parent:父 ClassLoader
    );

    // 通过反射替换当前线程的 ClassLoader
    try {
        Thread.currentThread().setContextClassLoader(dexClassLoader);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

方式二:反射替换 DexPathList(更常见的做法)

private void loadDex(Context context, File dexFile) {
    try {
        // 获取当前 ClassLoader 的 pathList 字段
        Class<?> baseDexClassLoaderClass = Class.forName("dalvik.system.BaseDexClassLoader");
        Field pathListField = baseDexClassLoaderClass.getDeclaredField("pathList");
        pathListField.setAccessible(true);
        Object pathList = pathListField.get(getClassLoader());

        // 获取 DexPathList 的 dexElements 数组
        Field dexElementsField = pathList.getClass().getDeclaredField("dexElements");
        dexElementsField.setAccessible(true);
        Object[] oldElements = (Object[]) dexElementsField.get(pathList);

        // 使用 DexPathList 加载新的 DEX
        Class<?> dexPathListClass = pathList.getClass();
        Method makeDexElements = dexPathListClass.getDeclaredMethod(
            "makeDexElements", List.class, File.class, List.class);
        makeDexElements.setAccessible(true);

        ArrayList<IOException> suppressedExceptions = new ArrayList<>();
        Object[] newElements = (Object[]) makeDexElements.invoke(
            pathList,
            Collections.singletonList(dexFile),
            context.getCacheDir(),
            suppressedExceptions
        );

        // 合并新旧 dexElements(新 DEX 放在前面,优先加载)
        Object[] combinedElements = new Object[oldElements.length + newElements.length];
        System.arraycopy(newElements, 0, combinedElements, 0, newElements.length);
        System.arraycopy(oldElements, 0, combinedElements, newElements.length, oldElements.length);

        // 替换原始 dexElements
        dexElementsField.set(pathList, combinedElements);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

反射替换 DexPathList 的方式更为底层,通过直接操作 BaseDexClassLoader.pathList.dexElements 数组,将真实 DEX 的 Element 插入到最前面。这样当系统查找类时,会优先从真实 DEX 中查找,从而实现对原始类的无缝替换。

第四步:组件调用转发

当真实 DEX 成功加载后,系统在启动 Activity、Service 等组件时,就能从合并后的 ClassLoader 中找到对应的类了。整个转发过程对上层透明,开发者无需额外处理。

APK 签名机制与壳的关系

APK 签名机制对壳程序既有保护也有约束:

  • V1 签名(JAR 签名):对 META-INF/ 中的每个文件计算哈希并签名。壳程序修改 APK 内容后需要重新签名。
  • V2/V3 签名(APK Signature Scheme v2/v3):对整个 APK 的原始字节计算哈希并写入 APK 签名块。这种方式更安全,但也意味着壳程序需要在签名之前完成所有修改。

壳厂商通常会提供签名工具或在打包流程中集成签名步骤。对于逆向分析者而言,了解签名机制有助于判断 APK 是否被二次打包或篡改。

资源文件的保护

除了 DEX 文件,壳程序还会对资源进行保护:

保护对象 常见手段
classes.dex AES/RC4 加密后存入 assets/,运行时解密到私有目录
lib/*.so 加壳时将核心 .so 加密,壳 Application 启动时解密加载
assets/ 资源 对关键资源文件整体加密,需要时实时解密读取
字符串常量 将关键字符串加密,在 Native 层解密使用

加密的 DEX 通常存放在 assets/ 目录中,因为该目录下的文件不会经过编译处理,适合存放任意格式的二进制数据。在运行时,壳程序解密后将其写入应用的私有目录(如 /data/data/包名/app_dex/),再通过 DexClassLoader 加载。

实战案例:用 jadx 分析加壳 APP 的 AndroidManifest.xml

以一个简单的加壳 APP 为例,使用 jadx 分析其 AndroidManifest.xml 来识别壳程序入口:

分析步骤

1. 将 APK 拖入 jadx-gui

jadx 会自动反编译,打开 resources/AndroidManifest.xml

2. 定位 Application 入口

正常 APP 的 Application 类名通常包含应用包名,如:

<application android:name="com.example.myapp.MyApp">

而加壳 APP 的 Application 类名往往属于第三方壳 SDK:

<application android:name="com.stub.StubApp">

3. 查看壳 Application 的代码

在 jadx 中打开 com.stub.StubApp,通常会看到:

package com.stub;

import android.app.Application;

public class StubApp extends Application {
    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        // 这里通常调用 Native 方法进行解密
        // System.loadLibrary("stub");
        // nativeInit(base);
    }
}

4. 检查 assets 目录

查看 APK 中 assets/ 目录,如果发现大文件且扩展名为 .dex.bin 或没有扩展名,那大概率就是加密后的真实 DEX 文件。

常见壳的特征 Application 类名

壳厂商/类型 典型 Application 类名
360 加固 com.stub.StubApp
梆梆加固 com.secneo.apkwrapper.HookApplication
爱加密 com.ijiami.shell.MainActivity
通达信 com.wrapper.proxyapplication.WrapperProxyApplication
腾讯乐固 com.tencent.StubShell.TxAppEntry

MultiDex 与加壳的关系

当应用的代码量超过单个 DEX 文件的 65536 方法数限制时,就需要使用 MultiDex 机制,将代码拆分到多个 DEX 文件中。

MultiDex 与加壳之间存在微妙的关系:

  1. 壳可以利用 MultiDex 机制:第一代壳的核心原理之一就是利用 MultiDex.install() 中的 DexClassLoader 加载额外 DEX 的能力。壳程序在 attachBaseContext() 中先解密真实 DEX,然后通过类似 MultiDex 的方式将其加载进来。

  2. MultiDex 增加了脱壳复杂度:多 DEX 环境下,脱壳工具需要处理多个 DEX 的合并问题,还要注意 classes.dex(壳代码)和 classes2.dex(可能是加密的真实代码)之间的顺序关系。

  3. Android 5.0 之后的 ART 虚拟机原生支持 MultiDex,不再需要 MultiDex.install() 库。壳程序必须适配 ART 的原生 MultiDex 加载流程,这也催生了第二代壳(运行时解密)的发展。

小结

本文梳理了加壳 APP 从启动到运行的完整流程,核心要点可以概括为:

  1. 壳的本质:替换 Application 入口,在 attachBaseContext() 中完成「解密 → 加载 → 替换」三步操作。
  2. DEX 加载关键:通过反射替换 DexPathList.dexElements,将真实 DEX 插入到 ClassLoader 的查找链最前端。
  3. 识别壳的入口:通过分析 AndroidManifest.xml 中的 Application 类名,可以快速判断 APP 是否加壳以及使用了哪种壳。
  4. 资源保护:加密 DEX 通常存放在 assets/ 目录,运行时解密到应用私有目录后加载。

理解这些原理后,后续学习脱壳技术(如内存 dump、Hook 系统加载函数等)就有了坚实的理论基础。