拒绝“裸奔”!带你手搓一个Android加壳器(附源码解析)

admin 2026-02-03 01:11:05 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文讲解Android一代与二代加壳技术原理。一代壳通过AES加密并落地加载DEX,存在文件被窃取风险;二代壳利用InMemoryDexClassLoader实现内存加载,避免文件落地。文章分析了ClassLoader替换机制及DEX结构,指出未来三代四代壳将解决内存完整DEX暴露问题,建议关注函数抽取与VMP技术以提升加固强度。 综合评分: 90 文章分类: 移动安全,逆向分析,二进制安全,安全开发,安全工具


cover_image

拒绝“裸奔”!带你手搓一个 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. 1. 系统进程(Zygote)孵化出 App 进程。
  2. 2. App 进程开始初始化,执行 ActivityThread.main(),这是 Java 世界的入口。
  3. 3. 系统读取 AndroidManifest.xml,找到大家配置的 Application 类。
  4. 4. 关键时刻来了:系统调用 Application.attachBaseContext()。这是 App 代码最早能被执行的时机!
  5. 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. 1. 加密:读取原本的 APK(或者编译好的 DEX),用 AES 之类的算法把它加密成一堆乱码。
  2. 2. 拼接:把这堆乱码硬拼接到我们的“壳程序”(Loader DEX)的末尾。
  3. 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&nbsp;fn&nbsp;decrypt_dex(encrypted: &EncryptedDex, key:&nbsp;Option<&str>)&nbsp;->&nbsp;Result<Vec<u8>> {
&nbsp; &nbsp; // 1. 从各种藏匿处获取解密密钥(千万别明文写在代码里!)
&nbsp; &nbsp; let&nbsp;key&nbsp;=&nbsp;derive_key_from_password(key)?;
&nbsp; &nbsp; let&nbsp;cipher&nbsp;= Aes256Gcm::new_from_slice(&key)?;

&nbsp; &nbsp; // 2. 提取 nonce,准备解密
&nbsp; &nbsp; let&nbsp;nonce&nbsp;= Nonce::from_slice(&encrypted.nonce);

&nbsp; &nbsp; // 3. 执行解密操作
&nbsp; &nbsp; let&nbsp;decrypted&nbsp;= cipher.decrypt(nonce, encrypted.data.as_ref())?;

&nbsp; &nbsp; // 4. 如果之前压缩过,这里还得解压
&nbsp; &nbsp; let&nbsp;result&nbsp;=&nbsp;if&nbsp;encrypted.compressed {
&nbsp; &nbsp; &nbsp; &nbsp; let&nbsp;mut&nbsp;buf&nbsp;=&nbsp;Vec::new();
&nbsp; &nbsp; &nbsp; &nbsp; zstd::stream::read::Decoder::new(&decrypted[..])?.read_to_end(&mut&nbsp;buf)?;
&nbsp; &nbsp; &nbsp; &nbsp; buf
&nbsp; &nbsp; }&nbsp;else&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; decrypted
&nbsp; &nbsp; };

&nbsp; &nbsp; Ok(result)
}

第三步:落地加载与“偷梁换柱”

解密得到了真实的 DEX 数据(字节流),接下来怎么让系统运行它呢?

在一代壳的时代(Android 8.0 之前),系统还没有提供直接从内存加载 DEX 的接口。我们别无选择,只能把解密后的数据写回到文件系统中(通常是 /data/data/包名/ 下的私有目录)。这就是所谓的“落地加载”。

fn&nbsp;load_dex_from_file(env: &mut&nbsp;JNIEnv, context: &JObject, dex_data: &[u8])&nbsp;->&nbsp;Result<()> {
&nbsp; &nbsp; let&nbsp;data_dir&nbsp;=&nbsp;get_app_data_dir(env, context)?;
&nbsp; &nbsp; // 1. 把解密后的数据写入文件 decrypted.dex
&nbsp; &nbsp; let&nbsp;dex_path&nbsp;=&nbsp;format!("{}/decrypted.dex", data_dir);
&nbsp; &nbsp; fs::write(&dex_path, dex_data)?;

&nbsp; &nbsp; // 2. 创建一个 DexClassLoader 来加载这个文件
&nbsp; &nbsp; // 参数说明:dex路径、优化缓存路径、库路径、父加载器
&nbsp; &nbsp; let&nbsp;loader&nbsp;= env.new_object(
&nbsp; &nbsp; &nbsp; &nbsp; "dalvik/system/DexClassLoader",
&nbsp; &nbsp; &nbsp; &nbsp; "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/ClassLoader;)V",
&nbsp; &nbsp; &nbsp; &nbsp; &[dex_path, opt_dir, null, parent_loader],
&nbsp; &nbsp; )?;

&nbsp; &nbsp; // 3. 修正系统 ClassLoader(重点!)
&nbsp; &nbsp; set_application_classloader(env, context, &loader)?;
&nbsp; &nbsp; Ok(())
}

关键的 ClassLoader 修正:

刚才我们创建的 loader 只是个光杆司令。系统里的 LoadedApk 对象里存的 mClassLoader 还是原来的那个。如果不把它换掉,等会儿系统加载 MainActivity 时还是会报错。

我们需要利用 Java 反射 机制,强行修改系统内部变量:

fn&nbsp;replace_loaded_apk_classloader(env: &mut&nbsp;JNIEnv, context: &JObject, loader: &JObject)&nbsp;->&nbsp;Result<()> {
&nbsp; &nbsp; // 1. 先拿到 context 里的 mPackageInfo 对象(也就是 LoadedApk)
&nbsp; &nbsp; let&nbsp;loaded_apk&nbsp;= context.getField("mPackageInfo");

&nbsp; &nbsp; // 2. 找到 LoadedApk 类里的 mClassLoader 字段
&nbsp; &nbsp; let&nbsp;field&nbsp;= LoadedApk.class.getDeclaredField("mClassLoader");

&nbsp; &nbsp; // 3. 强行解除访问限制(private 变 public)
&nbsp; &nbsp; field.setAccessible(true);

&nbsp; &nbsp; // 4. 把我们自己的 loader 塞进去!
&nbsp; &nbsp; field.set(loaded_apk, loader);
&nbsp; &nbsp; Ok(())
}

痛点:为什么一代壳被淘汰了?

一代壳虽然原理清晰,但有一个致命弱点:文件落地

我们辛苦加密的 DEX,在运行那一刻,不得不被解密并写入到手机存储里。这就好比你把保险箱锁得再好,但每次看文件时都得拿出来摊在桌子上。

攻击者只需要编写一个脚本,监控 App 的私有目录,一旦发现生成了 .dex 文件,直接拷贝出来,壳就废了。甚至对于 Root 过的手机,这简直如探囊取物。

于是,二代壳应运而生。


第二代壳:不落地加载(In-memory)

二代壳的出现,主要是为了解决“文件落地”这个大漏洞。它的核心理念是:DEX 文件解密后,只存在于内存中,坚决不写回磁盘。

这得益于 Android 8.0(API 26)引入的一个新特性:InMemoryDexClassLoader

核心实现:内存加载

二代壳的整体流程和一代壳非常相似,唯独在“加载”这一步有了质的飞跃。

看看代码实现的区别:

fn&nbsp;load_multiple_dex_in_memory(env: &mut&nbsp;JNIEnv, context: &JObject, dex_data_list: &[Vec<u8>])&nbsp;->&nbsp;Result<()> {
&nbsp; &nbsp; let&nbsp;parent_loader&nbsp;=&nbsp;get_system_classloader(env)?;

&nbsp; &nbsp; let&nbsp;mut&nbsp;buffers&nbsp;=&nbsp;Vec::new();
&nbsp; &nbsp; for&nbsp;dex_data&nbsp;in&nbsp;dex_data_list {
&nbsp; &nbsp; &nbsp; &nbsp; // 1. 这里是关键!我们将解密后的数据转换成 Java 的 ByteBuffer 对象
&nbsp; &nbsp; &nbsp; &nbsp; // 这一步完全是在内存中操作,没有文件 IO
&nbsp; &nbsp; &nbsp; &nbsp; let&nbsp;byte_array&nbsp;= env.byte_array_from_slice(dex_data)?;
&nbsp; &nbsp; &nbsp; &nbsp; let&nbsp;buffer&nbsp;= ByteBuffer.wrap(byte_array);
&nbsp; &nbsp; &nbsp; &nbsp; buffers.push(buffer);
&nbsp; &nbsp; }

&nbsp; &nbsp; // 2. 直接使用 ByteBuffer 数组创建 ClassLoader
&nbsp; &nbsp; // 系统会直接从内存块里解析 DEX 格式,而不需要去读取文件
&nbsp; &nbsp; let&nbsp;loader&nbsp;= env.new_object(
&nbsp; &nbsp; &nbsp; &nbsp; "dalvik/system/InMemoryDexClassLoader",
&nbsp; &nbsp; &nbsp; &nbsp; "([Ljava/nio/ByteBuffer;Ljava/lang/ClassLoader;)V",
&nbsp; &nbsp; &nbsp; &nbsp; &[buffers.as_array(), parent_loader],
&nbsp; &nbsp; )?;

&nbsp; &nbsp; // 3. 依然需要做 ClassLoader 修正,这步不能省
&nbsp; &nbsp; set_application_classloader(env, context, &loader)?;
&nbsp; &nbsp; Ok(())
}

二代壳就完美了吗?

虽然二代壳防住了简单的文件监控,但并没有彻底解决问题。

为什么?因为无论你是在磁盘还是在内存,DEX 文件最终都要被还原成连续、完整的格式,虚拟机才能执行它。

攻击者可以利用这一特性,开发出更高级的工具(比如所谓的“脱壳机”)。原理很简单:既然虚拟机能读懂你的 DEX,那我在虚拟机内部“插个眼”(Hook 系统函数),等你把 DEX 解密好、准备交给虚拟机执行的那一瞬间,我直接从内存里把它 Dump(转储)出来。

这就好比,虽然你不再把文件摊在桌子上(文件系统),而是记在脑子里(内存),但只要你读出来(执行),我就能在旁边听到。


总结与展望

看完了通过一代和二代壳的演变,我们可以发现一个规律:加固技术的发展,就是不断推迟代码暴露时机、增加还原难度的过程。

  • • 一代壳:解决了静态分析问题,但在运行时会有文件落地,容易被抓包。
  • • 二代壳:解决了文件落地问题,但在内存中依然有完整的 DEX 映像,容易被内存 Dump。

那么,有没有一种方法,让代码在内存中也永远不以“完整 DEX”的形式存在呢?

当然有!这就是三代壳(函数抽取/指令抽取)四代壳(VMP 虚拟机保护)要解决的问题。它们把原本连续的代码打散,甚至把指令翻译成只有自己能懂的“天书”,让 Android 虚拟机和攻击者都摸不着头脑。

相关文章推荐

  • • 开源推荐|Launch:全网最全的 Android 环境检测工具

免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:二进制磨剑 二进制磨剑 二进制磨剑《拒绝“裸奔”!带你手搓一个 Android 加壳器(附源码解析)》

评论:0   参与:  0