第一代壳
第一代加固壳简介
DEX 加密(也称落地加载)
第一代壳将整个 apk 文件压缩加密到壳 dex 文件的后面,在壳 dex 文件上写上解压代码,动态加载执行,由于是加密整个 apk,在大型应用中很耗资源,因此这代壳很早就被放弃了但思路还是不变。其中这种加密还可以具体划分为几个方向,如下:
- Dex 字符串加密
- 静态 DEX 文件整体加密解密
- 资源加密(xml 与 arsc 文件加密及十六进制加密)
- 对抗反编译(针对反编译工具,如 apktool。利用反编译工具本身存在的缺陷,使得反编译失败,以此实现对反编译工具的抵抗)
- Ptrace 反调试、TracePid 值校验反调试
- 自定义 DexClassLoader(主要是针对 dex 文件加固、加壳等情况)
- 落地加载(dex 可以在 apk 目录下看到)
第一代加固壳原理
涉及到三个程序:
- 待加壳程序的 APK(源程序 DEX)
- (脱)壳程序 APK(负责解密源程序 APK 并加载运行它)
- 加壳程序(将源程序 APK 进行加密并与壳程序的 dex 合并成新的 dex)。
主要步骤如下:
首先利用加密算法对源程序 APK 进行加密,然后与脱壳程序 APK 合并得到新的 dex 文件,最后替换脱壳程序中原有的 dex 文件即可。之后运行合并后的程序时,会通过壳程序对源程序进行解密和运行时加载。
具体实现
源程序
MainActivity 中添加一行
1 | Log.i("demo", "app:"+getApplicationContext()); |
文件下添加一个 MyAppliaction
类,并重写一下 OnCreate
,输出一下 Log
。其实 Application 即使不创建,系统也会帮我们创建的。但是创建自定义 Application 类是为了方便反射获取 Application 类,进而创建源程序的 Application 实例来替换原有的壳程序的 Application 实例。
1 | public class MyApplication extends Application { |
加壳程序
主要功能是加密源程序 APK、合并成新的 dex 文件、修正三个字段。常用 python 实现
对于合并后的 dex 文件,需要修改其 dex_header 中的三个字段:
- checksum:dex 文件(除
magic
、checksum
)的校验和(使用 adler32 算法),通过它来判断 dex 文件是否被损坏或篡改。 - signature:dex 文件(除
magic
、checksum
和signature
之外的所有内容)的 SHA-1 签名(哈希),用于对文件进行唯一标识。 - file_size:整个 dex 文件的大小。
脱壳程序
由于我们最终需要运行的是我们的源程序,所以我们必须在启动流程调用 Application
的 OnCreate
之前释放出源程序,并替换 Application
为我们的源程序 Application
实例(原来是脱壳程序的 Application
实例)。
工作:
替换
LoadedApk
的mClassLoader
关键点:
mClassLoader
的作用:ClassLoader
是用来加载应用程序的类文件的核心组件,通过双亲委托机制寻找和加载类。如果壳程序的ClassLoader
不包含源程序的类,那么应用将无法正常运行。- **为什么需要替换
mClassLoader
**: 壳程序本身的类加载器是基于壳的 APK,而源程序的类已经被加密或独立存储(例如在Source.apk
中)。所以壳程序的ClassLoader
无法直接加载源程序的类。这就需要创建一个包含源程序类路径的DexClassLoader
替换壳的ClassLoader
。
替换方式:
三种替换
mClassLoader
的方法:- 替换类加载器:用源程序的
DexClassLoader
完全替换壳的ClassLoader
。 - 插入类加载器:在壳的
ClassLoader
和其父加载器之间插入源程序的DexClassLoader
,实现动态加载源程序的类。 - 合并 dexElements:合并壳和源程序的
dexElements
,复用壳的ClassLoader
,既不改变原有加载逻辑,又能访问源程序的类。
分析:
- 直接替换和插入的方式较简单,但可能引入与系统组件兼容性的问题。
- 合并
dexElements
更加优雅,也是在插件化、热修复等场景中常用的方法。
替换
Application
关键点:
Application
的作用:Application
是整个安卓应用程序的上下文和入口点,负责初始化全局状态,并与Activity
、Service
等组件交互。壳程序和源程序可能对应不同的Application
实例,为了正确运行源程序,必须替换掉壳的Application
。- 替换方法:
- **删除壳的
Application
**:从ActivityThread
的mAllApplications
中移除壳的Application
。 - **设置
LoadedApk.mApplication
为null
**:清空mApplication
,让系统重新调用makeApplication()
方法创建源程序的Application
。 - **动态创建源程序的
Application
**:通过meta-data
提供的源程序Application
类名反射生成对应实例。 - **替换
ContentProvider
的Application
**:避免因不同Application
导致组件交互问题。 - 运行源程序的
onCreate()
方法:完成初始化,开始应用生命周期。
- **删除壳的
分析:
- 替换
Application
是必要的,因为壳的Application
只是个临时过渡,无法维护源程序的全局状态。 - 关键是要保证在替换前后应用的生命周期保持完整和一致,特别是对于一些较早初始化的组件(如
ContentProvider
)。
关键点:
Application
的特殊性:- 它是整个应用程序的入口点,生命周期贯穿应用始终。
- 在系统加载
Activity
或Service
之前,Application
就已被创建,可以在此时完成解密、加载等操作。
- 脱壳流程设计:
- 在
attachBaseContext()
中替换壳的ClassLoader
,保证后续加载类时能找到源程序的类。 - 在
onCreate()
中替换壳的Application
,完成全局状态切换。
- 在
分析:
- 脱壳逻辑可以全部在 attachBaseContext() 或 onCreate()中完成,但分步处理更清晰:
- 在
attachBaseContext()
中修改ClassLoader
,确保后续逻辑能正确加载源程序的类。 - 在
onCreate()
中完成Application
替换,既符合逻辑顺序,也避免早期替换影响到ActivityThread.mInitialApplication
。
- 在
第二代壳
第二代加固壳简介
第一代壳与第二代壳对比
特性 | 第一代壳 | 第二代壳 |
---|---|---|
加密对象 | 整个APK文件 | .dex 文件 |
解密位置 | 文件系统 | 内存中 |
加载方式 | ClassLoader 加载文件系统中的 APK |
动态加载器直接加载内存中的 .dex 数据 |
保护层级 | 纯 Java 层 | Java + Native 层,可能配合虚拟化保护 |
安全性 | 易逆向,落地解密后容易被提取 | 难逆向,加载逻辑在 Native 层,避免落地解密 |
性能 | 低,涉及文件系统读写和两次加载 | 高,直接从内存加载,减少不必要的 IO 消耗 |
适用场景 | 理论验证或简单防护 | 生产环境,适用于对安全性要求高的商业应用 |
在第一代加固壳中,会将源程序APK存储到文件系统中,然后再通过DexClassLoader动态加载。这种落地加载方式存在很大问题,一是容易在文件系统中获取到源程序APK,二是两次加载源程序APK到内存中,效率低。
第二代壳就是为了解决上述问题,做到不落地加载,即不使用文件系统作为中转站,而是直接将内存中的源APK字节码进行加载。首先加密对象就不是整个apk而是变成了apk内的代码文件dex,这个时候第二代壳就体现出了其强大的实用性,如果说第一代壳只是一个理论基础而第二代壳就是可以量产的初号机型了。
第一代壳是将其加密的dex文件存放在dex文件里的,由于java可读性较高,这为逆向人员分析脱壳代码降低了难度,因此第二代壳是将dex代码加密到native层,即so文件。大大增加了逆向难度。
根据各大厂商以这个思路加密的手段,又可以分为:
- DEX动态加载(分为利用jni和自定义jni,即自定义底层函数)
- Dex Method代码抽取到外部(类抽取加密按需解密和动态方法修改替换)
- So加密
- 反调试,防HOOK
- 不落地加载 (apk目录下不能看到原始dex,把加密的dex文件加载到内存中,然后解密,从始至终不会以文件形式存在)
第二代加固壳的原理
仅加密 .dex
文件而非整个 APK,以减少解密开销。加密后的 .dex
数据通常存储在壳的资源文件或 Native 层的 so
文件中。
不再将解密后的 .dex
数据存储到文件系统。直接在内存中解密,并通过自定义加载器(如 InMemoryDexClassLoader
)加载到虚拟机运行。
解密逻辑隐藏在 Native 层,通过 JNI 与 Java 层交互。加密算法和解密流程使用 Native 实现,避免被 Java 层反编译分析。
按模块对 .dex
文件进行解密和加载,减少内存占用。设置触发条件,在运行时动态加载目标模块。
具体实现
加载内存dex
Android 4.x 及以前的 BaseDexClassLoader
最终调用了带有三个参数的 native
方法 openDexFile()
。
openDexFileNative
方法
在 Android 5.x ~ 7.x,.dex
文件的加载路径发生了变化。最终调用的 openDexFileNative
方法位于 libart.so
中。
在 libart.so
中引入了 OpenMemory
函数,用于直接从内存加载 .dex
数据。OpenMemory
接收内存映射的 .dex
数据 dex_mem_map
,而不是文件路径。
解密后的 .dex
数据可以通过 MemMap
(内存映射)包装,传递给该函数直接加载。
InMemoryDexClassLoader
方法
Android 8.x 引入了 InMemoryDexClassLoader
,支持从内存直接加载 .dex
字节码。接收一个或多个 ByteBuffer
对象,表示内存中的 .dex
数据。
通过 BaseDexClassLoader
的逻辑,将这些字节码注册到虚拟机中。
不管哪种方法,它们都会返回一个cookie值,它是对dex文件进行操作的唯一凭据,可以简单理解成dex文件的“身份证”。
内存dex中类的加载
在 Android 中,类加载器 ClassLoader
的职责是动态加载类,加载过程可以分为以下步骤:
ClassLoader
调用 **loadClass()
**。BaseDexClassLoader
调用 **findClass()
**。DexPathList
调用 **findClass()
**。DexPathList.Element
调用 **findClass()
**。DexFile
调用 **loadClassBinaryName()
**。- 最终调用
defineClassNative()
完成类的加载。
其中:
3~6 的方法主要起中转作用,不包含实际的类加载逻辑。**defineClassNative()
** 是最终完成类加载的本地方法。
自定义类加载器的原理
核心思路
- 重写
BaseDexClassLoader
的findClass()
方法,直接调用底层的 **defineClassNative()
**。 - 使用
cookie
提供.dex
文件的引用,通过获取.dex
中的类名来决定加载逻辑。
核心问题
- 如何获取
.dex
中的类名?- 利用
DexFile.getClassNameList(Object cookie)
本地方法,可以获取.dex
文件中所有的类名。
- 利用
- 如何调用
defineClassNative()
?- 需要通过反射调用该方法,同时传递合适的参数(包括类名、类加载器、cookie 等)。
- 如何处理
cookie
?cookie
是.dex
文件的首地址,DexFile
可以通过cookie
直接映射和操作.dex
文件。
defineClassNative
方法的分析
- Android 7 之前的实现
1 | static jobject DexFile_defineClassNative(JNIEnv* env, |
作用:根据类名 name
、类加载器 loader
和 .dex
文件的引用 cookie
定义类。
参数说明:
name
:要加载的类名。loader
:当前类加载器。cookie
:.dex
文件的首地址,用于访问.dex
数据。
主要功能:
- 使用
cookie
解析.dex
文件。 - 调用虚拟机的类加载逻辑完成类定义。
Android 7+ 的变化
1 | static jobject DexFile_defineClassNative(JNIEnv* env, |
**新增参数 dex_file
**:
DexFile
参数主要用于class_linker->InsertDexFileInToClassLoader()
,将DexFile
对象关联到类加载器中。
解决方案:
- 如果不需要
InsertDexFileInToClassLoader
的功能,可以直接传入null
,绕过新增的逻辑。 - 手动实现类加载逻辑,加载过程不会受影响。
获取 .dex
中类名的实现
DexFile
提供了一个本地方法 **getClassNameList(Object cookie)
**,可以通过 cookie
获取 .dex
文件中所有的类名:
1 | private static native String[] getClassNameList(Object cookie); |
关键点:
cookie
是.dex
文件的首地址(可从DexFile
获取)。- 返回值是一个包含类名的字符串数组。
实现示例:
1
2
3DexFile dexFile = ... // 获取 DexFile 对象
Object cookie = getCookie(dexFile); // 通过反射获取 cookie
String[] classNames = DexFile.getClassNameList(cookie);
自定义类加载器的实现
继承 BaseDexClassLoader
,重写 findClass()
方法:
1 | public class CustomClassLoader extends BaseDexClassLoader { |
使用方法
- 准备
.dex
文件的字节码。 - 创建
CustomClassLoader
,传入解密后的.dex
字节码:
1 | ByteBuffer dexBuffer = ByteBuffer.wrap(decryptedDexData); |
3.加载类:
1 | Class<?> clazz = classLoader.loadClass("com.example.MyClass"); |
Native 层实现细节
在 Native 层,cookie
是 .dex
文件的首地址,通过它可以操作 .dex
文件。
1. cookie
的由来
在 Android 5.1 的 DexFile_defineClassNative:
1
const DexFile* dex_file = toDexFiles(cookie, env);
cookie
被解析为DexFile
对象。
在 Android 6+:
1
std::unique_ptr<std::vector<const DexFile*>> dex_files = ConvertJavaArrayToNative(env, cookie);
cookie
是DexFile
数组的首地址。
2. 直接获取 DexFile
对象
- 通过反射或其他方式将
cookie
转换为DexFile
对象,方便调用相关方法。
一些总结
脱壳的关键点
确定内存中 .dex
的起始地址和大小
.dex
文件的加载流程中,有多个函数会直接或间接提供.dex
的内存地址和大小。通过跟踪这些函数,可以准确获取到解密后的.dex
的内存范围。- 常见函数和位置:
DexFile::Open(const uint8_t* base, size_t size, ...)
OpenMemory(const uint8_t* base, size_t size, ...)
DexFileVerifier::Verify(const DexFile* dex_file, const uint8_t* begin, size_t size, ...)
- 返回
DexFile
对象的函数。
- 获取方式:
- Hook 关键函数,拦截参数或返回值。
- 直接修改源码,打印出对应的地址和大小。
脱壳时机
- 脱壳时机决定了 dump 的
.dex
文件是否是解密状态。 - 关键点:
- 壳程序会先加载加密的
.dex
,然后通过解密后再将其交给虚拟机加载。因此,解密后的.dex
只会存在于内存中,dump 必须在解密后进行。 - 适合的时机通常是壳完成 .dex的解密并交给系统加载时,例如:
DexFile::Open()
函数。defineClassNative()
调用前后。- 类加载完成后,通过调用相关函数遍历
DexFile
对象。
- 壳程序会先加载加密的
Dump 数据
- 通过拦截上述脱壳点,将
.dex
数据从内存中拷贝出来,并写入文件。 - 注意事项:
- 确保获取到
.dex
数据的完整性。 .dex
数据的起始地址应对齐,避免损坏文件结构。
- 确保获取到
脱壳点的选择
在 Android 的加载流程中,以下位置可以作为脱壳点:
1**DexFile::Open()
系列函数**
DexFile::Open()和 OpenMemory()是加载 .dex文件的核心函数,会接收 .dex 的起始地址和大小参数:
1
DexFile* DexFile::Open(const uint8_t* base, size_t size, ...)
Hook 或修改方法:
- 在
base
和size
被传递时,将这段内存直接 dump 出来。 - 插入代码将内存内容保存到文件中。
- 在
2DexFileVerifier::Verify()
DexFileVerifier::Verify()验证 .dex 文件是否合法,也会用到 .dex的起始地址和大小:
1
bool DexFileVerifier::Verify(const DexFile* dex_file, const uint8_t* begin, size_t size, ...)
作用:
- 可以在验证阶段拦截到
.dex
数据。 - 解密后的
.dex
文件通常会经过验证。
- 可以在验证阶段拦截到
3defineClassNative()
defineClassNative()
是最终的类定义入口,会加载具体的类。- 参数中包含
.dex
文件的cookie
,可以通过cookie
获取.dex
数据。
4类加载完成后
- 可以在加载完成后,通过
DexFile
提供的方法获取所有加载的类和.dex
数据。 - 例如,通过反射获取
cookie
,再利用getClassNameList()
遍历类名。
实现脱壳的具体方法
相关脱壳方法
- 内存 Dump 法
- 缓存脱壳法
- 文件监视法
- Hook 法
- 定制系统法
- 动态调试法
1. 通过 Hook 实现
- 使用工具:
- Xposed(适合 Java 层 Hook)
- Frida(适合 Native 层 Hook)
- Inline Hook 框架(适合底层 Hook,比如
libart.so
,使用 Inline Hook 替换DexFile::Open
。)
2.修改源码
在加载流程中直接插入代码,输出 .dex
文件。
源码修改示例:DexFile::Open
找到 DexFile::Open
函数,在解密后插入代码:
1 | // Insert this before returning DexFile |
3. 利用调试器
- 使用调试工具(如 GDB)设置断点,手动 Dump 数据:
- 找到
DexFile::Open
或其他脱壳点。 - 设置断点,获取
base
和size
。 - 使用调试器保存内存到文件。
- 找到