开源Android加固方案实践:DEX加密与壳工程动态加载
2026/9/9 6:37:00 网站建设 项目流程

先把结论放在前面:去年有段时间我一直在折腾 Android 加固,起因是自己的一个 App 上线没多久,APK 被人拖进反编译工具里看了一个底朝天。当时我第一反应是去找商业加固平台,注册了一圈基础版,但用下来总有各种别扭:包体被撑得很大、崩溃率报表常年飘红、有些平台还要求把 APK 传上去。这时候才真正下定决心去研究开源方案。折腾了两周之后,我在社区那套“DEX 整体加密 + 壳工程动态加载”的经典思路上搭了一个完全自己可控的加固壳。说它比商业平台基础版强,不是夸张,至少在反编译防护、崩溃率控制、定制自由度这三个维度,体验是真的不在一个级别。

整个过程中我从 Android Studio 新建工程、写 Python 加壳脚本,到用 Android SDK 里的 build-tools 做对齐签名,再到用 adb shell 抓日志逐个排查问题,把一套完整链路走通了。这篇文章不打算把代码逐行贴出来,只把最核心的原理、落地流程、踩过的坑都掰开讲清楚。如果你也想给自己的 Android App 加一层壳,或者单纯对“开源加固方案到底能不能打”这件事感兴趣,这篇应该对你有用。

1. 为什么我会放弃商业加固基础版,转而折腾开源方案

1.1 从一次 APK 被反编译说起

我至今记得那天的画面:同事把 release 包从 CI 拉下来,拖进 jadx,几秒钟之后项目里自定义的加密密钥、接口签名逻辑全部摊在眼前。我那个 App 虽然不是什么大厂核心资产,但里面包含了自己写的协议加密库。对于一个做技术的人来说,看到自己写的代码像 PPT 一样被别人翻看,心是真的拔凉拔凉的。

后来我们复盘为什么会被反编译,原因很简单:APK 本身就是一个 zip 压缩包,classes.dex 可以轻而易举地从里面提取出来。只要没做加固,反编译工具就能直接把 dex 还原成近乎可读的 Java 代码。网上那些“一行命令反编译 APK”的教程,操作门槛低到只要会装软件就能跑通。所以对于一个正式上架的应用来说,加固已经不是“要不要做”的问题,而是“用哪种方式做”的问题。

当时我们有两个选择:第一个是直接用商业加固平台,注册个账号、上传 APK、等待处理、下载加固包;第二个是研究一下社区里的开源加固方案,自己搭一套。我一开始觉得商业平台省事,就先去试了一圈,结果发现基础版确实够“基础”。

1.2 商业版本基础版的“免费套餐”到底给了什么

我不是想否定商业加固平台的价值,事实上他们的高级版确实很强,VMP 指令虚拟化、So 文件加固、Dex2C 这些能力,不是普通开源方案能轻易追上的。但问题恰恰出在“基础版”这三个字上。作为引流产品,很多平台把能体现技术壁垒的能力都切到了高级版,基础版往往只给你一个最基础的 DEX 整体加密。

我把自己实际体验整理成了下面这张表,供还在观望的人参考:

对比维度商业加固平台基础版开源自建加固方案
价格免费或极低免费
APK 是否要上传云端多数需要不需要
DEX 整体保护支持支持
加固后启动速度额外开销较大,部分平台明显卡顿可控,解密一次约几十毫秒
崩溃率部分平台基础版较高自己可调可修
自定义能力基本上没有完全放开
So 文件保护基础版多数不支持可以自己加
VMP 指令虚拟化一般没有自己实现难度很高
出问题后的排查效率只能提工单等排期自己看堆栈,当天就能修

尤其是“APK 上传”这条,很多公司对信息安全卡得严,不希望把核心应用安装包交到第三方手里。我接触过的几个商业平台基础版,操作入口都是先上传 APK,再进行加固,最后下载加固后产物。哪怕从流程上走一遍,心里都会打一个问号:我的源代码和签名信息,到底在云端经历了什么?这不是不信任平台,而是工程团队应有的安全边界意识。

1.3 开源方案真正让人心动的地方

开源方案最打动我的,是它把“黑盒”变成了“白盒”。你不在代码里挣扎,你就永远不知道加固到底加了什么;而当你把加固壳的源码摊在面前时,每一步加密、每一次加载、每一个反射调用都是透明的。

具体来说我觉得有四点:

第一,代码在自己手里。加密算法可以自己换,密钥可以自己管理,不会存在“第三方平台可能知道你的 APK 内容”这种隐患。哪怕后面不想用我搭的这套壳了,想换成另一套开源方案,迁移成本也可控。

第二,不用把 APK 往云端传。编译机本地跑一个 Python 脚本就能生成加固包,CI 里集成也很方便,整个加固流程可以做到完全离线。

第三,出 bug 能调能修。商业平台基础版崩溃了,你能看到的只有一行“加固服务异常”或者模糊的崩溃回执,剩下的就是等工单排期;而自己搭的壳崩溃了,用 adb logcat 抓一下堆栈,问题基本能定位到自己写的哪一行代码。

第四,没有被过度包装的成本。商业版基础虽然免费,但往高级版引导的产品设计,有时候比付费还难受。你想要一个简单的“DEX 不要被一键反编译”的能力,就得忍受一堆不需要的功能按钮和抽奖式的崩溃率。

2. 开源加固方案的原理:一个壳是怎么实现“偷梁换柱”的

2.1 先理解 App 启动流程里最关键的“缝隙”

要理解加固壳,必须先理解 Android 应用的启动过程。系统启动一个 App,大概流程是这样的:AMS 向 Zygote 发消息,Zygote fork 出子进程,子进程里 ActivityThread 开始跑,然后 LoadedApk 根据 AndroidManifest.xml 里配置的 android:name,通过 ClassLoader 加载我们写的 Application 类并实例化它。

这里有一个可以插入的缝隙:Application 的 attachBaseContext 比 onCreate 要早。加固壳要做的事情,就是在 attachBaseContext 这个时机里,把真正的 DEX 加载进去,然后在系统执行后续逻辑之前,想办法让“真正的 Application”替代掉外层壳 Application。说直白点,整个 App 的入口在 manifest 里被写成壳的 ProxyApplication,壳启动后先把加密的 classes.dex 解出来塞进内存,再把真正的 MainApplication 换上去。

为什么必须在 attachBaseContext 里做?因为 attachBaseContext 是整个 Application 生命周期里最早的时机。等到 onCreate 再加载就晚了,而且更关键的是,ClassLoader 在 attachBaseContext 阶段还没有被后续的业务逻辑使用,此时注入 DEX 才有一个干净的环境。

2.2 DEX 加密加载体链路的三步走

一套标准的 DEX 整体加固壳,核心链路就三步:

第一步,构建期加密。在打包阶段把原 classes.dex 用 AES 加密成二进制文件,放进 assets 目录。这一步由加壳脚本完成,通常是一个 Python 脚本或者 Java 工具。

第二步,运行期解密。壳 Application 启动后,从 assets 目录读取密文,用内置的密钥解出原始 DEX,写到应用私有目录下。这一步的关键是密钥不能硬编码得太明显,否则反编译壳 dex 就能直接拿到。

第三步,加载与替换。把解密后的 dex 注入当前 ClassLoader,或者用 DexClassLoader 单独加载,然后通过反射替换 Application 引用。这一步是整个加固壳最复杂的部分,因为涉及 ActivityThread、LoadedApk 内部字段的修改。

用生活化的类比:原 APK 的 classes.dex 是一本被锁进保险柜的笔记本,壳工程就是保险柜外面贴的使用说明书。系统读完说明书后,才能找到保险柜钥匙,打开柜子拿到笔记本。而“说明书”本身不能被藏起来,它必须是一个系统能直接看懂的文件,这就是壳 dex 存在的原因。

2.3 替换 Application 是最容易翻车的地方

整个过程里最容易出问题的就是最后一步:替换 Application。系统在 fork 进程后,已经按照 manifest 把 ProxyApplication 实例化好了。你想换成真正的 MainApplication,常见的做法是反射 ActivityThread。ActivityThread 里面维护着 mInitialApplication、mAllApplications 这些字段,还有 LoadedApk 里的 mApplication。你需要把这些引用全部替换掉,并且手动调用真实 Application 的 attach 方法,最后把之前的 ProxyApplication 从 ActivityThread 的缓存里移除。

为什么这么麻烦?因为 Android 内部代码在完成 Application 创建后,很多地方都会持有 Application 引用。如果只换掉其中一个,不换另一个,就会出现 getApplication() 拿到的是 ProxyApplication、而系统内部持有的是 realApplication 这种诡异情况,业务代码里一旦做类型强转,立刻崩溃。

另一个选择是纯 delegate 方案:不反射替换 Application 引用,而是在 ProxyApplication 里持有一个 realApplication 实例,把所有生命周期方法转发给它。这种方案实现简单、兼容性好,但有一个致命缺点:Activity 里调用 getApplication() 返回的还是 ProxyApplication。如果业务代码里有getApplication() as MainApplication这种写法,直接闪退。所以很多业务规模比较大的 App,最终还是会选反射替换方案。

3. 亲手落地一个最小可用的开源加固壳

3.1 准备工具链

实操之前,先把工具链备齐:

  • Android Studio,用来编译壳工程。壳工程是个独立的 Android 项目,里面只放 ProxyApplication 和加解密加载相关代码。
  • Android SDK build-tools,主要用 zipalign 和 apksigner 这两个命令做对齐和签名。
  • Python3 安装 pycryptodome 库,用来写加壳脚本。
  • 一个待加固的普通 Android Demo App,随便写两个页面就行,重点是要有一个自定义的 MainApplication。

我用 Android Studio 新建了一个空壳工程,applicationId 直接用待加固 App 的包名,manifest 里的 android:name 指向壳的 ProxyApplication。注意这里不能把原 App 的 Application 类也编译进壳工程,否则两个类重名就乱了。

3.2 加壳脚本:把原 DEX 加密并替换壳 DEX

加壳脚本的作用很简单:读取原始 APK,把里面的 classes.dex 加密成密文,塞进新 APK 的 assets 目录,再把壳工程的 classes.dex 放到原位置。

下面是我实际用的脚本骨架:

# -*- coding: utf-8 -*- import os import zipfile import hashlib from Crypto.Cipher import AES from Crypto.Util.Padding import pad APK_PATH = "input.apk" SHELL_DEX = "shell.dex" OUT_APK = "output.apk" KEY = hashlib.sha256(b"open-dex-shell-key").digest() IV = b"0123456789abcdef" def encrypt_dex(data: bytes) -> bytes: cipher = AES.new(KEY, AES.MODE_CBC, iv=IV) return IV + cipher.encrypt(pad(data, AES.block_size)) def main(): with zipfile.ZipFile(APK_PATH, "r") as zin, \ zipfile.ZipFile(OUT_APK, "w", zipfile.ZIP_DEFLATED) as zout: for item in zin.infolist(): data = zin.read(item.filename) if item.filename == "classes.dex": enc = encrypt_dex(data) zout.writestr("assets/encrypted_dex.bin", enc) with open(SHELL_DEX, "rb") as shell_f: zout.writestr("classes.dex", shell_f.read()) else: zout.writestr(item, data) if __name__ == "__main__": main()

这里有一个重点:加密后的 DEX 是二进制密文,我把它存成了 assets/encrypted_dex.bin,同时把壳工程的 classes.dex 占用了原来 classes.dex 的位置。这样打包出来的 APK,系统加载 DEX 时走的完全是壳代码,原始的业务 DEX 分毫没有暴露。

密钥和 IV 这里只是为了演示,真实项目里不能这么写。密钥至少要拆成几段分散在代码里,或者放到 native 层去做白盒加密,否则反编译壳 dex 后直接用同样的方式解回来就尴尬了。

3.3 壳工程的核心:加载加密 DEX 并替换 Application

壳工程的核心代码量不大,但每一行都值得仔细推敲。最简单可控的方式是先做 delegate 版本,保证 App 能跑起来,再考虑反射强替换。

class ProxyApplication : Application() { private var realApplication: Application? = null override fun attachBaseContext(base: Context) { super.attachBaseContext(base) try { val encryptedDex = assets.open("encrypted_dex.bin").readBytes() val realDex = decrypt(encryptedDex) val dexFile = File(cacheDir, "origin.dex") dexFile.writeBytes(realDex) val pathClassLoader = classLoader as PathClassLoader val dexClassLoader = DexClassLoader( dexFile.absolutePath, cacheDir.absolutePath, null, pathClassLoader ) injectIntoClassLoader(pathClassLoader, dexClassLoader) realApplication = loadRealApplication(dexClassLoader) } catch (t: Throwable) { Log.e("Shell", "load origin dex failed", t) } } override fun onCreate() { super.onCreate() realApplication?.onCreate() } private fun decrypt(encryptedDex: ByteArray): ByteArray { val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) return cipher.doFinal(encryptedDex.copyOfRange(16, encryptedDex.size)) } private fun loadRealApplication(dexClassLoader: DexClassLoader): Application? { val appClass = dexClassLoader.loadClass("com.example.original.MainApplication") return appClass.newInstance() as? Application } }

注意 attachBaseContext 里要先把解密后的 dex 注入当前 ClassLoader,否则后面 loadRealApplication 用 DexClassLoader 加载时,类之间互相依赖会找不到。injectIntoClassLoader 的核心思路是反射获取 PathClassLoader 的 pathList,再调用 makeDexElements 方法把新 dex 的 Elements 追加进去,最后替换 dexElements 数组。

这个反射过程在 Android 9 之前很顺畅,但从 Android 9 开始,部分接口被列入 non-SDK interface 限制名单,直接反射可能被系统警告甚至拦截。我实际测试下来,makeDexElements 这个方法和 pathList 字段在大部分真机上仍然可以访问,但你在自己的项目里不能盲抄,一定要在目标机型上验证。

3.4 反射替换的细节实现与经验

如果业务代码有getApplication() as MainApplication这种强转,就必须做强替换。流程是这样的:

先通过Class.forName("android.app.ActivityThread").getMethod("currentActivityThread").invoke(null)拿到 ActivityThread 对象。然后分别反射修改它的 mInitialApplication、mAllApplications、mBoundApplication 里的 info 字段,以及 LoadedApk 里的 mApplication 字段,全部指向 realApplication。

最后还有一个关键步骤:调用真实 Application 的 attach 方法,把当前 ProxyApplication 的 baseContext 传给真实 Application。attach 是 Application 里的一个非公开方法,需要反射调用:

val attach = Application::class.java.getDeclaredMethod("attach", Context::class.java) attach.isAccessible = true attach.invoke(realApp, baseContext)

这几步做完,还需要在 ProxyApplication 的 onCreate 里把生命周期转发给 realApp。因为系统在 handleBindApplication 阶段已经持有了 ProxyApplication 引用,即使 ActivityThread 里的缓存被替换了,后面 onCreate 调用还是落在 ProxyApplication 头上。这个时候如果不转发,真实 App 的初始化逻辑就永远不会执行,就会出现“壳起来了,业务没起来”的诡异现象。

我的经验是,第一次做反射替换时不要一上来就追求“完整替换”。先做 delegate 版本,让业务跑通,再逐步加上反射替换。否则反射字段一旦在某个 Android 版本上报 NoSuchFieldException,整个 App 直接黑白屏,排错成本特别高。

3.5 重打包、签名和安装验证

壳工程编译好后,用 Android Studio 生成 APK,取出里面的 classes.dex,这就是 shell.dex。然后在命令行里按顺序执行:

python3 pack.py $ANDROID_HOME/build-tools/34.0.0/zipalign -f 4 output.apk aligned.apk $ANDROID_HOME/build-tools/34.0.0/apksigner sign --ks keystore.jks --ks-key-alias key aligned.apk adb install aligned.apk adb shell am start -n com.example.shell/.MainActivity

这里有一个顺序问题:先 zipalign,再 apksigner 签名。如果先签名后对齐,签名会失效,因为 zipalign 会改动 APK 内部数据对齐方式,导致签名校验失败。这是新手最容易踩的坑。

安装启动成功后,用 Android 测试里的常规手段验证一下:把 output.apk 拖进 jadx,看看 classes.dex 的内容。如果是加固成功,你会发现 jadx 只能看到壳工程那几百行代码,原始业务代码完全看不到。同时检查 assets 目录下有一个二进制文件,那就是加密后的原始 DEX,直接打开是乱码。

4. 加固后常见的坑与排查经验

4.1 加固后启动闪退,看不到任何日志

这是最常见的现象。闪退本身不可怕,可怕的是 logcat 里只有一行FATAL EXCEPTION: main Process: com.example, PID: 1234,然后下面没有任何堆栈。

我遇到这种情况时,第一反应是去 ProxyApplication 的 attachBaseContext 里加日志。注意 catch 到 Throwable 后一定要打印完整堆栈,不要只打印 message。很多加固壳的异常来自 ClassNotFoundException、NoSuchMethodException、NoSuchFieldException,这些异常如果只打 message,经常是一串“Failed to load dex”之类的模糊信息,根本定位不到哪里挂了。

我的排查习惯是把 attachBaseContext 里每一步关键操作都打一行日志:

Log.d("Shell", "start attachBaseContext") Log.d("Shell", "decrypt done, size=${realDex.size}") Log.d("Shell", "inject done") Log.d("Shell", "realApp loaded=${realApplication?.javaClass?.name}")

这样崩在哪一目了然。

4.2 Android 版本兼容:从 7.0 到 Android 15

Android 版本碎片化是绕不开的话题。Android 7.0 之后 ClassLoader 的加载机制有过调整;Android 8.0 开始引入 non-SDK interface 限制,对反射私有字段越来越不友好;Android 14 和 15 对 targetSdk 34、35 的要求进一步提高,壳工程自身的 targetSdk 不能太低。

我实际测试过,反射替换 ActivityThread 相关字段在国产 ROM 上的表现差异很大。部分 ROM 改过系统实现,字段名和原生 Android 不一样,反射必然失败。出现这种情况时,不要和 ROM 死磕,直接在代码里做版本判断和异常兜底:能替换就替换,不能替换就退回 delegate 方案。

另外,Android 11 之后系统的 APEX 机制让部分系统模块可以独立更新,这也意味着不同手机上 ActivityThread 的实现可能不同。加固壳如果只针对 AOSP 原生代码写死字段名,迟早会遇到兼容性问题。所以在壳工程里写反射代码,一定要有“失败也能降级”的预案。

4.3 加固与热修复、插件化不能共存的坑

热修复框架本身就在 hook ClassLoader,加固壳也在 hook ClassLoader,两者叠加在一起非常容易出问题。我见过一个项目,接入加固后,原来的热修复补丁打不上了,原因是加固壳把 DEX 顺序调整了,热修复框架找不到预期中的类。

解决办法只有两个:要么在加固壳内部兼容热修复框架,主动暴露一些扩展点让热修复逻辑能接入;要么干脆放弃热修复,走发版更新的路子。对小团队来说,第二个选择其实更省事。热修复本身的稳定性就参差不齐,再加上加固壳的复杂度,一旦线上出问题,排查成本会成倍放大。

4.4 加固后包体和启动时间的变化

加固后 APK 体积会增大,但增量一般在几百 KB 以内,因为加密后的 DEX 本身还是原来的大小,只是多了壳 dex 和少量资源。真正的性能开销在启动阶段:读取 assets 密文、AES 解密、写入私有目录、DexClassLoader 加载并优化,这一套下来在普通中端机上大概几十毫秒,体感还好。

但如果你的 App 本身有多个 DEX,或者 classes.dex 体积非常大,解密和加载时间会明显变长。我建议只在正式 release 包启用加固,debug 包保持原样,否则每次调试都要等加固壳解密一遍,开发效率会变得很低。另外,加固壳的代理 Application 里尽量不要做任何重量级初始化,把该做的事都留给真正的 Application。

5. 一点个人体会

折腾完这套壳之后,我最大的体会是:如果你只是想让反编译门槛高一点,开源加固方案完全够用;如果你要对抗的是专业逆向团队,那开源方案的工程量会立刻膨胀起来,VMP 那层真的不是简单几行代码能做的。但无论如何,源码在自己手里、错误能自己修、打包流程能自己接,这种踏实感是商业平台的基础版给不了你的。

以后如果再有人问我“Android 加固用什么平台好”,我大概率会反问一句:你愿不愿意先花一个下午,自己搭一个壳试试看?哪怕最后你发现自建方案还是不如商业版,这个过程也会让你对“加固”这件事的理解提升一个档次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询