文章总结: 本文讲解Android一代与二代加壳技术原理。一代壳通过AES加密并落地加载DEX,存在文件被窃取风险;二代壳利用InMemoryDexClassLoader实现内存加载,避免文件落地。文章分析了ClassLoader替换机制及DEX结构,指出未来三代四代壳将解决内存完整DEX暴露问题,建议关注函数抽取与VMP技术以提升加固强度。 综合评分: 90 文章分类: 移动安全,逆向分析,二进制安全,安全开发,安全工具
拒绝“裸奔”!带你手搓一个 Android 加壳器(附源码解析)
原创
二进制磨剑 二进制磨剑
二进制磨剑
2026年2月2日 08:31 四川
拒绝“裸奔”!带你手搓一个 Android 加壳器(附源码解析)
本文从攻防视角出发,以通俗易懂的方式讲解 Android 一代壳(DEX 落地加载)和二代壳(内存加载)的实现原理。不堆砌概念,带你亲手拆解“壳”的秘密。
本文 AI 友好: 复制给 Vibe Coding Agent 你将得到一个 App 加固器!
引言:我们为什么要加固?
在开始研究技术细节之前,小编想请大家先思考一个问题:当我们费尽心血开发完一款 App,把它发布到应用市场后,它面临着什么样的风险?
在 Android 的世界里,获取一个 APK 文件简直太容易了。对于攻击者来说,拿到 APK 往往意味着“游戏开始”。他们通常会做三件事:
第一,解包。APK 说白了就是一个压缩包(Zip),用解压软件就能打开。里面的图片资源、配置文件一览无余。 第二,反编译。这是最要命的。利用 jadx 这样的工具,攻击者可以把编译好的机器码(DEX)几乎完美地还原成 Java 源代码。如果你的代码没有混淆,那简直就是在这个“裸奔”。 第三,分析逻辑。有了源码,你的加密密钥存放在哪、和服务器通信的协议是什么、VIP 校验逻辑在哪一行,都被看得清清楚楚。
所以,加固的核心目的非常纯粹:提高逆向成本。
我们要做的,就是把“拖进 jadx 就能看源码”这种点击鼠标就能完成的低成本操作,提升到“需要动态调试、需要懂汇编、需要对抗反调试”的高阶门槛。让攻击者觉得“太麻烦了,不搞了”,我们的目的就达到了。
接下来,小编就以一个自制的加壳器为例,带大家看看早期的加固技术是如何一步步实现这个目标的。
前置知识:知己知彼
在动手写壳之前,我们需要了解 Android 系统的一些底层机制。这就像做手术前必须了解人体构造一样重要。
1. App 是怎么启动的?
很多同学写 App 时习惯从 MainActivity 的 onCreate 写起,但从加固的视角看,那里已经太晚了。
当用户点击图标时,系统并在后台默默做了很多准备工作。简化来看,流程是这样的:
- 1. 系统进程(Zygote)孵化出 App 进程。
- 2. App 进程开始初始化,执行
ActivityThread.main(),这是 Java 世界的入口。 - 3. 系统读取
AndroidManifest.xml,找到大家配置的Application类。 - 4. 关键时刻来了:系统调用
Application.attachBaseContext()。这是 App 代码最早能被执行的时机! - 5. 最后才是大家熟悉的
Application.onCreate()。
为什么这点很重要?
因为加固壳的核心逻辑就是“偷天换日”。我们需要在真正的业务逻辑执行之前,先把环境布置好。attachBaseContext() 就是那个绝佳的“作案时间”。在这里,我们可以解密代码、加载类,神不知鬼不觉地把环境替换掉。
2. 类是谁加载的?
Java 代码要运行,必须先被加载到内存里。在 Android 中,这个工作由 ClassLoader(类加载器) 完成。
大家需要分清这三种加载器的角色:
- • PathClassLoader:这是系统的“亲儿子”。Android 应用启动时,默认就是用它来加载安装在手机里的 APK 代码。
- • DexClassLoader:这是我们的“外援”。它允许从手机存储的任意路径加载 DEX 或 APK 文件。一代壳就靠它来加载解密后的代码。
- • InMemoryDexClassLoader(Android 8.0+):这是更高级的“隐形战机”。它可以直接从内存(字节数组)中加载代码,完全不需要文件落地。这是二代壳的核心武器。
这里有个巨坑,小编必须重点提醒:
系统加载 Activity 等组件时,默认是用 PathClassLoader。如果我们自己 new 了一个 DexClassLoader 把代码加载进来了,但系统并不知道它的存在。等到系统要去加载原本的 MainActivity 时,它还是会去原来的 PathClassLoader 里找,结果肯定是找不到(ClassNotFoundException)。
所以,光加载没用,我们还得想办法把系统默认的加载器给“偷梁换柱”了,这在后面会详细讲。
3. DEX 文件头的那点事
DEX 文件是 Android 可执行代码的载体。它的头部(Header)记录了文件的“身份信息”,比如文件有多大、校验值(Checksum)是多少。
DEX 文件结构
通常的加壳手段,是把加密后的真实代码(Payload)直接拼接到壳 DEX 文件的末尾。这就像是在一本书的封底硬塞了几十页纸,这就导致了一个问题:书的实际厚度(文件大小)变了,和目录(Header)里记录的不一致了。
如果不对 Header 进行修复,Android 系统在安装或加载时一校验:“咦,文件大小对不上,文件损坏!”直接就报错退出了。所以,修复 Header 是加壳过程中必不可少的一步。
第一代壳:DEX 整体加密(落地加载)
好了,基础打牢了,我们正式进入实战。
一代壳的设计思路非常直接:把真实的 App 藏起来,外面套一层伪装的壳。
这个过程分为两个阶段:一个是我们在电脑上进行的“加壳(打包)”阶段,另一个是 App 在用户手机上运行时的“解壳(执行)”阶段。
第一步:加壳(打包阶段)
想象一下,我们要做一个“特洛伊木马”。
我们需要写一个小程序(加壳机),它做的事情主要有两步:
- 1. 加密:读取原本的 APK(或者编译好的 DEX),用 AES 之类的算法把它加密成一堆乱码。
- 2. 拼接:把这堆乱码硬拼接到我们的“壳程序”(Loader DEX)的末尾。
- 3. 修补:修改 AndroidManifest.xml,把入口
Application改成我们的壳ShellApplication。这样 App 一启动,先运行的就是我们的壳代码,而不是原程序。
来看看用 Rust 语言如何实现这个拼接过程:
// 假设 encrypted 是我们加密后的数据结构,包含了原始内容和一些元数据
let mut output = Vec::new();
// 1. 先写入一些头部信息,比如 nonce(随机数),防止相同的明文加密出相同的密文
output.extend_from_slice(&encrypted.nonce);
// 2. 标记一下是否进行了压缩,节省点空间
output.push(if encrypted.compressed { 0x01 } else { 0x00 });
// 3. 记录原始文件的大小,解密时用得着
output.extend_from_slice(&(encrypted.original_size as u32).to_le_bytes());
// 4. 最后写入真正的加密数据
output.extend_from_slice(&encrypted.data);
// 5. 把拼接好的数据写入文件
std::fs::write(&encrypted_dex_path, &output)?;
第二步:解壳(运行阶段)
当用户点开 App,我们的 ShellApplication 最先开始运行。它的任务很重:要从自己的身体里把藏着的代码挖出来,解密,然后让它跑起来。
引导代码通常是 Java 写的:
public class ShellApplication extends Application {
static {
// 加载我们用 C/C++ 写的底层库,处理加密更安全、更高效
System.loadLibrary("shell");
}
private native void nativeLoadDex(Context context);
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
// 关键时刻!在系统初始化上下文时,立即执行解密加载逻辑
nativeLoadDex(base);
}
}
进入 Native 层(C/C++ 或 Rust),真正的解密开始了。这里我们要做的是把之前拼接在文件末尾的数据读取出来,利用预埋的密钥进行 AES 解密。
pub fn decrypt_dex(encrypted: &EncryptedDex, key: Option<&str>) -> Result<Vec<u8>> {
// 1. 从各种藏匿处获取解密密钥(千万别明文写在代码里!)
let key = derive_key_from_password(key)?;
let cipher = Aes256Gcm::new_from_slice(&key)?;
// 2. 提取 nonce,准备解密
let nonce = Nonce::from_slice(&encrypted.nonce);
// 3. 执行解密操作
let decrypted = cipher.decrypt(nonce, encrypted.data.as_ref())?;
// 4. 如果之前压缩过,这里还得解压
let result = if encrypted.compressed {
let mut buf = Vec::new();
zstd::stream::read::Decoder::new(&decrypted[..])?.read_to_end(&mut buf)?;
buf
} else {
decrypted
};
Ok(result)
}
第三步:落地加载与“偷梁换柱”
解密得到了真实的 DEX 数据(字节流),接下来怎么让系统运行它呢?
在一代壳的时代(Android 8.0 之前),系统还没有提供直接从内存加载 DEX 的接口。我们别无选择,只能把解密后的数据写回到文件系统中(通常是 /data/data/包名/ 下的私有目录)。这就是所谓的“落地加载”。
fn load_dex_from_file(env: &mut JNIEnv, context: &JObject, dex_data: &[u8]) -> Result<()> {
let data_dir = get_app_data_dir(env, context)?;
// 1. 把解密后的数据写入文件 decrypted.dex
let dex_path = format!("{}/decrypted.dex", data_dir);
fs::write(&dex_path, dex_data)?;
// 2. 创建一个 DexClassLoader 来加载这个文件
// 参数说明:dex路径、优化缓存路径、库路径、父加载器
let loader = env.new_object(
"dalvik/system/DexClassLoader",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/ClassLoader;)V",
&[dex_path, opt_dir, null, parent_loader],
)?;
// 3. 修正系统 ClassLoader(重点!)
set_application_classloader(env, context, &loader)?;
Ok(())
}
关键的 ClassLoader 修正:
刚才我们创建的 loader 只是个光杆司令。系统里的 LoadedApk 对象里存的 mClassLoader 还是原来的那个。如果不把它换掉,等会儿系统加载 MainActivity 时还是会报错。
我们需要利用 Java 反射 机制,强行修改系统内部变量:
fn replace_loaded_apk_classloader(env: &mut JNIEnv, context: &JObject, loader: &JObject) -> Result<()> {
// 1. 先拿到 context 里的 mPackageInfo 对象(也就是 LoadedApk)
let loaded_apk = context.getField("mPackageInfo");
// 2. 找到 LoadedApk 类里的 mClassLoader 字段
let field = LoadedApk.class.getDeclaredField("mClassLoader");
// 3. 强行解除访问限制(private 变 public)
field.setAccessible(true);
// 4. 把我们自己的 loader 塞进去!
field.set(loaded_apk, loader);
Ok(())
}
痛点:为什么一代壳被淘汰了?
一代壳虽然原理清晰,但有一个致命弱点:文件落地。
我们辛苦加密的 DEX,在运行那一刻,不得不被解密并写入到手机存储里。这就好比你把保险箱锁得再好,但每次看文件时都得拿出来摊在桌子上。
攻击者只需要编写一个脚本,监控 App 的私有目录,一旦发现生成了 .dex 文件,直接拷贝出来,壳就废了。甚至对于 Root 过的手机,这简直如探囊取物。
于是,二代壳应运而生。
第二代壳:不落地加载(In-memory)
二代壳的出现,主要是为了解决“文件落地”这个大漏洞。它的核心理念是:DEX 文件解密后,只存在于内存中,坚决不写回磁盘。
这得益于 Android 8.0(API 26)引入的一个新特性:InMemoryDexClassLoader。
核心实现:内存加载
二代壳的整体流程和一代壳非常相似,唯独在“加载”这一步有了质的飞跃。
看看代码实现的区别:
fn load_multiple_dex_in_memory(env: &mut JNIEnv, context: &JObject, dex_data_list: &[Vec<u8>]) -> Result<()> {
let parent_loader = get_system_classloader(env)?;
let mut buffers = Vec::new();
for dex_data in dex_data_list {
// 1. 这里是关键!我们将解密后的数据转换成 Java 的 ByteBuffer 对象
// 这一步完全是在内存中操作,没有文件 IO
let byte_array = env.byte_array_from_slice(dex_data)?;
let buffer = ByteBuffer.wrap(byte_array);
buffers.push(buffer);
}
// 2. 直接使用 ByteBuffer 数组创建 ClassLoader
// 系统会直接从内存块里解析 DEX 格式,而不需要去读取文件
let loader = env.new_object(
"dalvik/system/InMemoryDexClassLoader",
"([Ljava/nio/ByteBuffer;Ljava/lang/ClassLoader;)V",
&[buffers.as_array(), parent_loader],
)?;
// 3. 依然需要做 ClassLoader 修正,这步不能省
set_application_classloader(env, context, &loader)?;
Ok(())
}
二代壳就完美了吗?
虽然二代壳防住了简单的文件监控,但并没有彻底解决问题。
为什么?因为无论你是在磁盘还是在内存,DEX 文件最终都要被还原成连续、完整的格式,虚拟机才能执行它。
攻击者可以利用这一特性,开发出更高级的工具(比如所谓的“脱壳机”)。原理很简单:既然虚拟机能读懂你的 DEX,那我在虚拟机内部“插个眼”(Hook 系统函数),等你把 DEX 解密好、准备交给虚拟机执行的那一瞬间,我直接从内存里把它 Dump(转储)出来。
这就好比,虽然你不再把文件摊在桌子上(文件系统),而是记在脑子里(内存),但只要你读出来(执行),我就能在旁边听到。
总结与展望
看完了通过一代和二代壳的演变,我们可以发现一个规律:加固技术的发展,就是不断推迟代码暴露时机、增加还原难度的过程。
- • 一代壳:解决了静态分析问题,但在运行时会有文件落地,容易被抓包。
- • 二代壳:解决了文件落地问题,但在内存中依然有完整的 DEX 映像,容易被内存 Dump。
那么,有没有一种方法,让代码在内存中也永远不以“完整 DEX”的形式存在呢?
当然有!这就是三代壳(函数抽取/指令抽取)和四代壳(VMP 虚拟机保护)要解决的问题。它们把原本连续的代码打散,甚至把指令翻译成只有自己能懂的“天书”,让 Android 虚拟机和攻击者都摸不着头脑。
相关文章推荐
- • 开源推荐|Launch:全网最全的 Android 环境检测工具
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:二进制磨剑 二进制磨剑 二进制磨剑《拒绝“裸奔”!带你手搓一个 Android 加壳器(附源码解析)》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








![在[1,n]中与n互质的数有k个,求k/n](/images/random/titlepic/5.jpg)

评论