说在前面:为什么要写DEX加密这回事
做Android安全或者逆向的朋友,应该都见过这样一种尴尬场景:用jadx或者dex2jar拉一下APK,业务代码直接以近乎源码的形式平铺在面前,包名、接口、逻辑全暴露了。有人说混淆就够了,但一个多年混迹安全圈的人都知道,ProGuard/R8那种重命名混淆,本质上只是提高了阅读成本,字符串常量、核心算法、签名校验逻辑依然是明文。真正让攻击者头疼的,是从APK里根本拿不到完整的、可分析的DEX文件——这就是DEX整体加密要解决的最基础问题。
今天这篇东西,基于我在实际加固与脱壳对抗过程中的一段完整实践,把Android下的DEX加密与解密原理从文件格式讲到代码实现,再到兼容性坑位和检测思路,一次性说透。适合三类人看:想入门Android安全加固的开发者、正在做逆向对抗的测试工程师,以及被“APK为什么没那么好反编译”这个问题勾起了好奇心的App开发。需要说明的是,下面的实现方案属于教学级整体加密方案,不是商业级VMP,但理解了这一层,后面再碰函数抽取、指令虚拟化这类高级方案会顺很多。
整篇我不会绕弯子,直接从“加密前”和“解密后”两头把逻辑串起来讲。
1. 先从问题出发:为什么要把DEX加密
1.1 DEX文件本身就是那个最显眼的靶子
我们正常用Android Studio打包出一个APK,本质上就是Zip压缩包,里面如果没做特殊处理,就有一个classes.dex。这个DEX承载了全部Java/Kotlin字节码,而字节码是可逆的。你自己试一下就明白了,哪怕只学过一个月Java,用jadx打开这个DEX,看到的是还原后的伪Java代码,变量名都在(如果没有混淆)。这个可读性对你来说是多方便,对攻击者来说就多方便。
早期很多App做的所谓“安全”,其实就是挂了个混淆,混淆完之后代码的可读性大幅下降,但字符串常量依然裸奔。做个简单实验:你把一个“https://api.example.com/secret”的URL放进代码里,混淆之后再反编译,这个URL还是原样躺在class文件的常量池里。这样攻击者不需要理解你的业务,光是一顿搜字符串就能把接口结构扒得底朝天。DEX加密的价值就在于:直接让攻击者连这一层都拿不到完整的原始DEX,他要分析,首先要经过“解密”这一步。
1.2 加密不是目的,延迟分析才是
这里必须先把预期管理好。DEX加密并不是数学意义上“破解不了”的加密,它的目的一直是用工程手段提高攻击者的分析成本。没有壳的App,攻击者拖进jadx,半小时就能理清核心逻辑;加了DEX整体加密的App,攻击者必须先找到壳的入口点,再把解密流程逆出来,还要解决内存dump和类加载时机的问题——时间成本从半小时拉到好几天,这已经足够劝退一大批脚本小子了。
所以,业内衡量DEX加固方案强弱,标准从来不是“能不能被脱壳”,而是“脱壳成本有多高”。整体DEX加密是最低成本的方案,必然存在成熟、公开的脱壳方法和工具,但它依然是入门加固绕不开的第一个台阶,因为它把“文件格式解析—加密算法选择—解密与类加载—兼容性处理”这一整条链路完整串联了起来。
1.3 加固方案的层级感,先建立一个整体认知
在展开代码之前,建议先把Android加固的层级关系在脑子里过一遍。从低到高大致是这样:
- 第一层:DEX整体加密。APK里放的不是原始DEX,而是加密后的文件,运行时解密加载。
- 第二层:DEX拆分与函数抽取。关键函数体从方法区抽走,运行时再填回去。
- 第三层:指令转换/VMP。把Dalvik字节码转成自定义指令集,运行时用解释器执行。
- 第四层:反调试与反内存dump。检测调试器、检测脱壳机的hook点,动态对抗。
这篇文章只讲第一层,但这一层里踩过的很多坑,比如类加载时机、系统校验、兼容性,后面每一层都会碰到。所以哪怕你最终的目标是去写商业级加固方案,也得先把第一层吃透。
2. DEX文件格式:加密前必须搞懂的东西
2.1 DEX头部的关键字段
说加密之前,必须花几分钟把DEX格式里跟加密、解密强相关的字段搞明白。DEX头部长度固定为0x70,即112个字节,里面存了一堆偏移和大小,但对我们写加密方案来说,下面这几个字段是重中之重。
偏移 大小 说明 0x00 8 magic,即"dex\n035\0" 0x08 4 checksum,Adler32校验值 0x0C 20 signature,SHA-1签名 0x20 4 file_size,文件总大小 0x24 4 header_size,头大小,固定为0x70 0x28 4 endian_tag,字节序标记 0x2C 4 link_size 0x30 4 link_off 0x34 4 map_off,map列表偏移我初次写加密方案的时候踩过一个很典型的坑:直接对整个文件加密,Android系统加载时报出“Failed to open dex”或者“Dex file header is invalid”。原因就是系统层在校验DEX的时候会检查头部的magic、checksum、file_size等信息。后来才意识到,加密的边界和校验逻辑必须分开处理——文件加密时,要保留头部或者解密后重建头部,否则系统根本不认这个文件。
2.2 为什么要关心checksum和signature
Android加载DEX分两种情况。如果是直接通过DexClassLoader加载字节数组,在某些Android版本上系统会用极简方式校验头部,但也有大量版本会走到dexopt或者dex2oat流程,此时系统会重新验证文件完整性,包括checksum和signature。自己写解密方案时,最稳妥的做法是:加密前先保存原始DEX完整内容,解密后完整还原,再通过DexClassLoader加载,不要自己改动原始文件内容。
这一点上我走过弯路。早期的实现里,解密完成后我会手动修改DEX头里的一些字段去适配内存加载,结果在Android 8.0以上的设备上偶发崩溃。后来老老实实做“全量还原”,问题消失。做这套方案时请记住一个铁律:解出来的东西要和原文件完全一致,一个字节都不改。
2.3 数据段的组织形式影响加密粒度
DEX文件除了头部,主要还有string_ids、type_ids、proto_ids、field_ids、method_ids、class_defs以及data段。这里面data段是所有字节码指令、方法体内联的地方,也是浓缩业务逻辑的地方。整体加密时,最简单粗暴的做法是加密整个文件,但稍微讲究一点的做法是只加密data段而非头部,这样即使加密被逆向,攻击者至少要先去解析头部偏移关系,多了一层障碍。
但只加密data段也有麻烦:文件的偏移关系全部建立在原始文件结构之上,如果你加密后修改了文件大小,所有偏移全部错位,解密还原的时候需要做“重建文件”的操作,复杂度上升不少。我第一次做的时候贪快,直接加密整个文件,只要在解密端完整还原,完全不用动偏移。后来为了对抗静态特征检测,才改成段加密,但付出的代价是解密后要先依据保存的长度信息重建DEX再加载。对初学者,建议从整文件加密起步,把链路走通,再考虑段加密优化。
2.4 一个DEX样例的十六进制初读
我这边有一个最简单DEX文件的开头,你直接看十六进制就能对上号:
64 65 78 0A 30 33 35 00 // dex\n035\0 A0 1B 4F 42 // checksum (Adler32) 9E 3C F6 7B 1F 00 00 00 // signature (SHA-1部分) ... 1C 05 00 00 // file_size,大小0x51C,约1308字节 70 00 00 00 // header_size,0x70 78 56 34 12 // endian_tag,小端序看到magic、checksum、file_size、header_size这些值都对了,就说明这个DEX是完好的。解密还原后,建议写个小工具比对原文件和解密文件的file_size、checksum,如果全部一致再加载,能省掉大量排查时间。
3. 加密解密方案的设计与代码实现
3.1 整体架构与流程拆解
一套标准的DEX整体加密方案,从APK构建那一刻开始介入。流程大致是这样:
- 正常编译App,得到未加密的classes.dex。
- 加密工具读取classes.dex,使用密钥加密,生成一份不可直接加载的加密文件。
- 将加密文件打包进APK的assets目录(比如命名成secured.dex)。
- 删掉原始classes.dex,用一份壳DEX(壳Application的字节码)替代。
- 修改AndroidManifest.xml,让application入口指向壳Application。
- 壳Application启动后,解密assets里的secured.dex,拿到原始DEX字节数组。
- 通过DexClassLoader或者内存加载方式加载原始DEX,反射替换当前ClassLoader。
这里有一个核心概念要理解:壳DEX存在的意义,是让App能启动。Android系统启动App时,默认加载APK根目录下的classes.dex,所以要伪造一个合法的、什么都不干的壳DEX骗过系统启动流程。真正的业务代码在解密之后才动态加入。
3.2 加密端代码:怎么把classes.dex变成加密文件
加密端工具我用Java写(方便跨平台打jar包用),核心逻辑就是读文件、加密、输出。算法上选用了RC4流式加密,原因有两个:一是RC4性能足够好,解密速度快,在低端手机上也能秒开;二是不需要额外引入BouncyCastle这种第三方库。当然RC4已不被推荐用于安全通信场景,但这里用于“防静态分析”而不是“防密码学攻击”,性能优先也没毛病。如果你有更高的安全要求,可以换成AES-CBC或者AES-GCM,思路完全一致。
下面是加密端的核心代码:
import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import java.io.*; import java.util.Random; public class DexEncryptor { private static final byte[] MAGIC = new byte[]{(byte) 0xDE, 0x10, 0xA1, 0x5E}; /** * RC4加密,流式对称,同key同结果 */ public static byte[] rc4(byte[] data, byte[] key) { int[] s = new int[256]; int[] k = new int[256]; for (int i = 0; i < 256; i++) { s[i] = i; k[i] = key[i % key.length]; } int j = 0; for (int i = 0; i < 256; i++) { j = (j + s[i] + k[i]) & 0xFF; int tmp = s[i]; s[i] = s[j]; s[j] = tmp; } int i = 0; j = 0; byte[] out = new byte[data.length]; for (int idx = 0; idx < data.length; idx++) { i = (i + 1) & 0xFF; j = (j + s[i]) & 0xFF; int tmp = s[i]; s[i] = s[j]; s[j] = tmp; int t = (s[i] + s[j]) & 0xFF; out[idx] = (byte) (data[idx] ^ s[t]); } return out; } public static void encrypt(String srcDexPath, String dstSecuredPath, byte[] key) throws IOException { byte[] dexBytes = readAllBytes(new File(srcDexPath)); byte[] encrypted = rc4(dexBytes, key); // 头部加自定义魔数,便于解密时识别文件类型 try (FileOutputStream fos = new FileOutputStream(dstSecuredPath)) { fos.write(MAGIC); fos.write(intToBytes(dexBytes.length)); fos.write(encrypted); } System.out.println("加密完成,明文大小=" + dexBytes.length + ",密文大小=" + encrypted.length); } private static byte[] readAllBytes(File file) throws IOException { try (FileInputStream fis = new FileInputStream(file)) { byte[] buf = new byte[(int) file.length()]; int offset = 0; int read; while (offset < buf.length && (read = fis.read(buf, offset, buf.length - offset)) != -1) { offset += read; } return buf; } } private static byte[] intToBytes(int value) { return new byte[]{ (byte) (value & 0xFF), (byte) ((value >> 8) & 0xFF), (byte) ((value >> 16) & 0xFF), (byte) ((value >> 24) & 0xFF) }; } }加密文件的结构自定义为:4字节魔数+4字节原始DEX长度+密文。这里保存原始长度非常关键,因为解密端需要提前知道解密后的数据大小,不然还原不了完整字节数组,也没办法做校验。RC4这个算法有个特性:加密解密用同一套代码,同样的密钥,对密文再执行一次rc4函数就能还原成明文。这意味着Android端的解密代码可以直接复用核心逻辑,不用单独写两套算法。
3.3 壳Application:解密还原与类加载的战场
壳Application是整套方案里最核心的启动入口。Android系统在创建Application对象时,会先走到构造函数,然后调用attachBaseContext(Context),最后才是onCreate()。attachBaseContext是所有生命周期方法里最早被调用的,而且此时类加载器还没有被业务代码使用过,是替换ClassLoader的绝佳时机。
下面是壳Application核心实现:
public class StubApplication extends Application { private static final String TAG = "DexGuard"; @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); try { // 1. 从assets目录读取加密后的DEX byte[] encryptedData = readFromAssets(base, "secured.dex"); // 2. 依次剥离自定义魔数与长度 byte[] magic = new byte[4]; System.arraycopy(encryptedData, 0, magic, 0, 4); if (!Arrays.equals(magic, new byte[]{(byte) 0xDE, 0x10, 0xA1, 0x5E})) { throw new RuntimeException("加密文件魔数不正确"); } int originalLen = bytesToInt(encryptedData, 4); byte[] encrypted = new byte[encryptedData.length - 8]; System.arraycopy(encryptedData, 8, encrypted, 0, encrypted.length); // 3. RC4解密还原DEX byte[] dexBytes = DexUtils.rc4(encrypted, getDecryptKey()); if (dexBytes.length != originalLen) { throw new RuntimeException("解密后长度校验失败"); } // 4. 加载解密后的DEX loadDexAndReplaceClassLoader(base, dexBytes); } catch (Exception e) { Log.e(TAG, "解密加载DEX失败", e); } } private void loadDexAndReplaceClassLoader(Context base, byte[] dexBytes) throws Exception { // 方案A:写入私有目录后通过DexClassLoader加载(兼容性最好) File optimizedDir = base.getDir("odex", Context.MODE_PRIVATE); File dexFile = new File(base.getDir("dex", Context.MODE_PRIVATE), "real.dex"); try (FileOutputStream fos = new FileOutputStream(dexFile)) { fos.write(dexBytes); } DexClassLoader dexClassLoader = new DexClassLoader( dexFile.getAbsolutePath(), optimizedDir.getAbsolutePath(), null, getClassLoader()); // 替换主thread的ClassLoader Object currentActivityThread = ReflectUtils.getStaticField( "android.app.ActivityThread", "sCurrentActivityThread"); Object mPackages = ReflectUtils.getInstanceField( currentActivityThread, "mPackages"); String packageName = getApplicationContext().getPackageName(); Object weakReference = ((HashMap<?, ?>) mPackages).get(packageName); Object appBindData = ReflectUtils.getStaticFieldOrNull( "android.app.ActivityThread", "sCurrentActivityThread"); // 常规做法是反射替换LoadedApk的mClassLoader字段 Object loadedApk = ReflectUtils.getInstanceField(weakReference, "get"); ReflectUtils.setInstanceField(loadedApk, "mClassLoader", dexClassLoader); // 还要替换Thread.currentThread().getContextClassLoader() Thread.currentThread().setContextClassLoader(dexClassLoader); } }这里要着重说明,为什么推荐方案A,也就是“落盘+new DexClassLoader”。有人可能会想,能不能不落盘,直接通过内存加载DEX?可以,但内存加载需要调用DexFile类未公开的构造方法,在Android 8.0以上因为hidden api限制会比较麻烦,需要配合FreezerAPI这类绕过手段才稳。落盘方案的缺点是私目录下会存在解密后的real.dex,攻击者若拿到文件系统权限可以直接读取。实战中平衡性能和安全性,可以落盘之后马上加一层文件访问权限控制,这是后话。
3.4 密钥保存与获取:别写在Java层明处
密钥管理是加密方案里最容易翻车的地方。新手最容易犯的错是把密钥写死在StubApplication的static final字符串里——攻击者把壳DEX反编译一下,密钥直接明文躺那儿,等于加密白做。正确做法是把密钥埋进so层,通过JNI返回,或者对密钥再做一次白盒处理。
我这里给一个有意义的最低成本方案:把密钥拆成两部分,一部分是so里硬编码的字符串,一部分是Java层通过Build字段动态计算出来的内容,两者拼接后再做一次SHA-256,取前N位做RC4密钥。这样攻击者只逆Java层拿不到完整密钥,只逆so层也拿不到。
private static byte[] getDecryptKey() { String part1 = getKeyFromNative(); String part2 = Build.BOARD + Build.BRAND + Build.DEVICE; String combined = part1 + part2; MessageDigest digest = null; try { digest = MessageDigest.getInstance("SHA-256"); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } byte[] hash = digest.digest(combined.getBytes()); // 取前16字节作为RC4密钥,可自行调整长度 return Arrays.copyOf(hash, 16); }对应的JNI层代码长这样:
JNIEXPORT jstring JNICALL Java_com_guard_dex_StubApplication_getKeyFromNative(JNIEnv *env, jobject thiz) { // 简单示例,实战里可以对这段字符串再做编码混淆 return (*env)->NewStringUTF(env, "N@t1ve-K3y-S3cr3t!"); }安全圈里有一句话叫“so层只是提高了分析门槛,并不能提供绝对安全”,确实如此。攻击者可以用IDA动态调试so,或者直接内存dump取出返回值。但对比Java层明文密钥,这个成本高了一个量级。安全方案从来都是层层叠加,每多一层,攻击者就要多花时间,这就是价值。
3.5 解密SDK的完整链路:用一个TestActivity验证
光说不练不行,加密端、壳Application都写完了,我得跑一条完整链路验证。我把测试工程拆成两个部分:加密工具放在Java工程里,运行在PC上;壳工程是一个完整的Android项目,其中StubApplication为入口。验证步骤:
- PC端运行DexEncryptor,把业务工程编译出来的classes.dex变成secured.dex。
- 业务工程改造成仅包含一个空的TestActivity项目,把secured.dex塞进assets目录。
- 壳工程DEX替换业务工程的classes.dex,修改Manifest入口为StubApplication。
- 打包签名安装,启动App,观察TestActivity能否正常跳转。
我实测第一次启动时,逻辑非常直观:MainActivity在Manifest里写了,但实际这个类不在壳DEX中,而在解密后的real.dex里。如果解密失败,系统直接崩溃找不到类;解密成功,反射替换ClassLoader后,系统按新Loader查找Activity,正常跳转。由此可以确认整条链路是通的。
4. 解密后的三个关键难点:系统校验、类加载时机与兼容性
4.1 系统对DEX完整性的校验
关于DEX完整性校验,我在前面的代码里直接还原了文件头全部字段,没有去改动。Android系统在不同版本上对DEX的合法性检验强度不一致:Android 4.x时代很多校验是空实现;Android 5.0之后ART全面接管,对DEX的校验明显变严;Android 6.0开始dex2oat会强制编译,加载时会动态验证头部字段,如果checksum不对,哪怕能跑起来也会在后续抛异常。
实践里我发现一种典型报错是"java.io.IOException: Failed to open dex files from /data/app/.../base.apk"。这不是你解密代码的问题,而是系统层面的ART在开APK时就发现DEX异常了。排查思路是:先用文本工具打开原始DEX和解密DEX的头文件字节,逐字段比对,尤其看magic、file_size、header_size、checksum、signature是否一致。如果file_size不一致,说明解密端size写错;如果checksum不一致,说明部分数据在传输/写文件过程中被截断。
4.2 入口Application与真实Application的替换
整体加密方案有一个绕不开的哲学问题:Android系统要启动App,必须先创建Manifest里声明的Application,而这个Application的字节码必须能被系统默认加载。系统没有能力先解密再加载,所以Manifest声明的只能是壳Application。那业务代码里原有的Application怎么办?
常规做法是:壳Application解密完成后,把自己替换成业务Application的加载器,然后创建真实的Application对象并替换ActivityThread里的mInitialApplication字段。这件事情如果漏了,业务代码里自定义Application初始化的逻辑全部失效,极难排查。
伪代码:
private void replaceApplication(Context base) throws Exception { // 通过反射创建真实的Application对象,并调用attach Class<?> realAppClass = Class.forName("com.business.RealApplication"); Application realApp = (Application) realAppClass.newInstance(); Method attach = Application.class.getDeclaredMethod("attach", Context.class); attach.setAccessible(true); attach.invoke(realApp, base); Object activityThread = ReflectUtils.getStaticField( "android.app.ActivityThread", "sCurrentActivityThread"); ReflectUtils.setStaticField(activityThread, "mInitialApplication", realApp); // 还要替换mAllApplications列表里的壳实例 List<Application> allApps = (List<Application>) ReflectUtils.getInstanceField( activityThread, "mAllApplications"); allApps.remove(this); allApps.add(realApp); }这套逻辑放在attachBaseContext里虽然早,但ActivityThread此刻已经初始化了一部分状态,直接替换mInitialApplication可能引发后续流程的偶发问题。我的建议是:务必做大批量机型适配测试,至少覆盖华为、小米、三星、Pixel的Android 8.0/9.0/10.0/11.0/12.0。不同ROM对ActivityThread反射字段的访问限制不同,有些ROM用了隐藏API限制之外的额外管控。
4.3 ClassLoader替换的坑
DexClassLoader加载出来的DEX,和APK原本的ClassLoader是两个体系。业务代码如果直接调用getClassLoader(),拿到的还是壳DEX的ClassLoader,找不到业务类,所以必须反射替换LoadedApk的mClassLoader。这里我补充一个我实际踩过的大坑:在Android 9.0上,如果只替换LoadedApk.mClassLoader,但不去改Thread.currentThread().getContextClassLoader(),那么很多网络库(比如OkHttp里的平台类加载逻辑)会拿到旧的ClassLoader,然后抛ClassNotFoundException。
正确的做法是两处都替换:
Thread.currentThread().setContextClassLoader(dexClassLoader); ReflectUtils.setInstanceField(loadedApk, "mClassLoader", dexClassLoader);还有一个容易忽略的字段路径:ActivityThread内部为每个包维护了一个LoadedApk对象,这些对象一般缓存在mPackages里,是一个Map<String, WeakReference >。替换mClassLoader时要先从mPackages拿到当前包名的LoadedApk,再改mClassLoader。如果漏掉mPackages这一步,直接改Thread上下文是没用的,因为系统最后还是从mPackages里取ClassLoader。
4.4 兼容海量机型的验证清单
说了这么多,整理一个我长期使用的验证清单,照着测基本能覆盖主流坑位:
- Android 5.0/5.1:ART刚上线,对DEX校验严格,测试解密还原字节数是否完整。
- Android 6.0/7.0:dex2oat普及,首次启动时间会变长,测试冷启动时间是否可接受。
- Android 8.0/8.1:语法上支持Java 8,对ClassLoader替换有严格反射限制,早测试早安心。
- Android 9.0+:hidden api-list限制,反射Thread.setContextClassLoader之外的隐藏API会被拦截。
- Android 10+:分区存储、文件路径变化,注意解密DEX写到Context.getDir()而不是/sdcard。
- 华为HarmonyOS兼容模式:部分机型对反射ActivityThread字段有额外管控,失败要有降级日志。
记住一点:兼容性不是写出来的,是测出来的。任何加固方案做出来之后,第一步就是买一批不同品牌的真机跑启动遍历测试,跑出崩溃就把错误日志收集起来,针对性地处理反射或者路径问题。
5. 加固的利弊权衡与检测对抗思路
5.1 这套方案能挡住什么,挡不住什么
DEX整体加密方案能挡住的,是“解包直接拖进jadx”的静态分析。它让攻击者无法直接从APK文件里看到业务代码,必须先构造解密环境。但它挡不住的是:动态调试、内存dump、脱壳机。脱壳机的基本思路是Hook系统里解析DEX的函数——比如dvmDexFileOpenPartial、DexFile_Open——在这些函数被调到的时候,从内存里把完整的DEX数据拷贝出来。你的DEX解密逻辑写得再复杂,最后总要把明文DEX交给系统的DexFile类去解析,在这个时点脱壳机就能拿到明文。
这里要有一个清醒的认知:整体DEX加密是防君子不防小人的第一道门。它能让很多脚本小子放弃,但挡不住认真研究的逆向工程师。
5.2 攻击者的常见检测视角
从攻击者的角度看,遇到一个DEX加密的App,首先会扫描启动流程:查看Manifest入口、看Application是不是替换过的、看assets目录有没有可疑加密文件。所以我们在写加固方案的时候,也可以从攻击者视角反推,调整自己的特征,减少被识别概率。
常见的暴露特征:
- 壳DEX很小,几乎只有StubApplication一个类。
- assets里有一个名字可疑的大文件(secured.dex这种名字,说不是密文谁信)。
- application入口类名带Stub、Protect、Guard等关键词。
- attachBaseContext里有大量反射调用。
针对这些特征,可以做的对抗手段包括:把壳Application的名字伪装成正常的业务类(比如MainApplication)、assets里的加密文件名伪装成资源文件(如icon_theme.bin)、密钥不在Java层任何地方出现。这些都不难,但每一条都能让自动化的检测工具少一些线索。
5.3 检测自己方案是否合格的黑盒验证法
分享一个最实用的黑盒验证法,也是我自己每次改动壳逻辑后必做的一步:拿一个没有加固的APK,用jadx打开,确认代码清晰可见;再用加密工具加固一次,重新打开,确认jadx里只剩壳代码;最后手机运行App,用frida脚本Hook dvmDexFileOpenPartial,看能不能dump完整DEX。如果frida能dump出来,说明整体方案的“防静态”目标达到了,“防动态”的预期就别太苛刻了。
我自己也会用一个脚本快速验证加固后的APK里是否残留业务类名:
import zipfile, re def check_apk(path): with zipfile.ZipFile(path) as z: dex_names = [n for n in z.namelist() if n.endswith('.dex')] for dex in dex_names: data = z.read(dex) # 朴素关键词扫描,业务类名理论上不应出现在壳dex里 if b'com/example/business' in data: print(f'[FAIL] {dex} contains business classes') else: print(f'[OK] {dex} is clean')这只是一个低成本的启发式检测,核心思路是确认壳DEX里只包含壳逻辑,不混入业务代码。
5.4 从整体加密走向更强的函数抽取
理解了DEX整体加密之后,你要想提高对抗强度,下一步很自然的演化方向是函数抽取。逻辑是这样:整体DEX加密最大的弱点是一旦解密成功,全量代码暴露;函数抽取则把敏感函数的方法体单独拎出来加密保存,运行时再填回去——即使DEX被dump,关键方法体也是缺失的,分析难度一下就上去了。Google Play上很多商业App用的就是这类方案,配合反调试工具,能有效对抗大批自动化脱壳机。
但函数抽取的工程复杂度比整体加密高得多:你需要修改DEX文件格式去标记哪些方法被抽走、需要修改解释器去恢复方法体、还需要处理ART和Dalvik之间的差异。整体加密是所有加固方案的基石,先把基石的原理和实现吃透,后面再研究函数抽取、VMP才不会被一堆专业术语劝退。
6. 实操经验:我踩过的几个坑和最后的建议
6.1 最常见的五个坑
第一个坑:路径写错导致解密失败。刚开始我写解密逻辑时,直接把解密后的DEX写到getCacheDir(),结果在Android 10以上的设备上报Permission Denied。因为getCacheDir()属于App内部存储,正常情况下没问题,但如果你在attachBaseContext阶段就去操作它,某些ROM上可能还没准备好。换成getDir("dex", MODE_PRIVATE)之后就稳定了。
第二个坑:classloader替换不彻底。只替换了LoadedApk.mClassLoader,没有替换Thread的ContextClassLoader,导致网络请求初始化时疯狂报错。印象最深的是Retrofit配合OkHttp在Android 8.0上报ClassNotFoundException,排查了半天,最后发现是Thread上下文加载器没换。
第三个坑:RC4密钥不固定。一开始我图新鲜,用随机生成的密钥,把密钥写进资产文件。结果每次加密出来的东西都不一样,到了真机解密时密钥对不上,App闪退。后来才明白:密钥要么内置在客户端,要么用固定逻辑生成,随机密钥适合在线分发场景,不适合把密文打包进APK的场景。
第四个坑:忽略Android 8.0之后的hidden api限制。反射替换mClassLoader用到了非SDK接口,在Android 9.0上直接被block,抛NoSuchFieldException。解决方式是在AndroidManifest里申请QUERY_ALL_PACKAGES权限,或者对特定包名豁免,即使这样也要做版本判断,用更稳定的公开API或者延迟反射兜底。
第五个坑:没有保留原DEX的SHA-256做完整性校验。有一版实现里,解密完没做任何校验,结果有一批华为机型上偶发解密后DEX非法,用户启动闪退,但复现率不高特别难查。后来在加密端先把原DEX的SHA-256存下来,解密后计算再比对,长度不对直接不走解密逻辑、走降级方案,问题才算从根上解决。
6.2 对新手的实操建议
如果你从来没写过DEX加密,我建议你按这个次序去推进。第一步,先拿一个小Demo写加密工具,把DEX文件读进来输出成加密文件,这一步不需要写Android端,只要验证文件被正确加密即可。第二步,写壳Application,在attachBaseContext里解密并替换ClassLoader,跑通最简单的Activity。第三步,加入密钥混淆和JNI层,提升抗分析强度。第四步,做真机兼容性测试清单,收集并处理ROM差异。
每一步都有独立的验证点,不必急着一次到位。尤其不要为了炫技而在第一次实现时就上函数抽取或者VMP,那些复杂度对你排查基本功问题只会帮倒忙。
6.3 一个附加的小技巧:失败降级与日志埋点
加壳最怕的是“加了壳之后线上崩了,但本地怎么都复现不了”,所以一套严谨的加固方案必须内置降级和日志。我的做法是这样的:如果解密失败,先不要直接崩溃,而是记录失败原因到私有目录下单独的日志文件,同时尝试从assets里读取原始DEX的备份,走一个最朴素的DexClassLoader加载。这样即使加密壳出现意外,业务还能继续启动,不至于全军覆没。
降级逻辑大概是这样:
try { byte[] dexBytes = decryptAndLoad(base); loadDexAndReplaceClassLoader(base, dexBytes); } catch (Throwable t) { saveLog(t); // 降级:尝试从assets里读取未加密的classes2.dex // 这个文件只在debug包中保留,release包不会带上 }这个降级方案对开发期调试特别友好,能保证你改了壳代码后,出问题时不至于直接无法开机,方便通过日志定位。生产包记得关掉降级路径,否则等于留了个明文的后门给攻击者。
6.4 关于加固方案选型的最终看法
这几年Android加固领域变化很快,商业壳层出不穷,但核心技术栈始终围绕DEX变换、加密、运行时还原、反调试这几个方向打转。对个人开发者来说,自研DEX加密最大的价值不是“替代商业壳”,而是真正理解Android类加载机制和文件解析流程。你亲手写一遍壳Application,比看十篇原理文章都记得牢。
我现在如果做一个面向内部工具的App,会优先用成熟的开源方案或者商业壳,因为维护成本低;但遇到需要深度定制的场景,比如游戏引擎资源保护、核心算法加固,还是会自己写一套基于DEX段加密和函数抽取的混合方案。从行业岗位角度看,掌握DEX加解密原理与代码实现,不仅是在学一个具体技巧,更是在打牢Android安全的基础底盘。后面的路还长,但第一步,就是从今天这份代码开始。