加壳 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()
关键时序
整个流程中有几个对我们至关重要的时间节点:
- attachBaseContext():Application 绑定上下文时调用,这是最早的回调点,壳程序几乎都在这里执行解密和加载逻辑。
- Application.onCreate():Application 初始化完成时调用。
- 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 与加壳之间存在微妙的关系:
-
壳可以利用 MultiDex 机制:第一代壳的核心原理之一就是利用
MultiDex.install()中的DexClassLoader加载额外 DEX 的能力。壳程序在attachBaseContext()中先解密真实 DEX,然后通过类似 MultiDex 的方式将其加载进来。 -
MultiDex 增加了脱壳复杂度:多 DEX 环境下,脱壳工具需要处理多个 DEX 的合并问题,还要注意
classes.dex(壳代码)和classes2.dex(可能是加密的真实代码)之间的顺序关系。 -
Android 5.0 之后的 ART 虚拟机原生支持 MultiDex,不再需要
MultiDex.install()库。壳程序必须适配 ART 的原生 MultiDex 加载流程,这也催生了第二代壳(运行时解密)的发展。
小结
本文梳理了加壳 APP 从启动到运行的完整流程,核心要点可以概括为:
- 壳的本质:替换 Application 入口,在
attachBaseContext()中完成「解密 → 加载 → 替换」三步操作。 - DEX 加载关键:通过反射替换
DexPathList.dexElements,将真实 DEX 插入到 ClassLoader 的查找链最前端。 - 识别壳的入口:通过分析 AndroidManifest.xml 中的 Application 类名,可以快速判断 APP 是否加壳以及使用了哪种壳。
- 资源保护:加密 DEX 通常存放在
assets/目录,运行时解密到应用私有目录后加载。
理解这些原理后,后续学习脱壳技术(如内存 dump、Hook 系统加载函数等)就有了坚实的理论基础。