去年年底,一个做工具类App的朋友找我说,他2.0版本刚发一周,就有人在论坛上放出了去广告版。解开一看,classes.dex拿jadx一拖,业务代码基本都是明文,包名改一改,签名换一换,一个“新版本”就出来了。这事不新鲜,但它让我意识到,Android dex加密与解密这件事,不只是安全工程师的分内活,凡是把核心逻辑放进Android端的开发者,都应该掌握它的基本原理和自己实现的路径。
这篇内容我不打算只讲概念,而是把原理和代码一起说清楚。你会看到DEX加密到底在防什么、DEX文件结构里的哪些字段能动手脚、ClassLoader的动态加载机制怎么配合解密逻辑、加密侧和解密侧的工具代码怎么写,以及网上那些教程不会告诉你的崩溃现场和修复思路。适合两类人看:一类是App里放了核心SDK、想自己动手做一层源码保护的Android工程师;另一类是刚接触Android逆向与安全加固、想理解加固产品底层原理的学习者。
1. 从一纸“换皮”预警说起:DEX加密要解决的真实问题
1.1 逆向者的三件套:解包、反编译、重打包
做过Android开发的人都清楚,APK本质上是个Zip压缩包。攻击者拿到一个APK之后,第一步是解包,把里面的classes.dex、资源文件、Manifest全部释放出来;第二步是反编译,把DEX转成Smali汇编,或者直接用jadx这类工具把它还原成接近原始的Java代码;第三步是重打包,修改逻辑、去掉校验、换上自己的签名,再分发出去。
整个过程里,真正让他们“读代码”的环节,核心对象就是classes.dex。只要这个文件里的字节码是明文的,代码逻辑就相当于直接暴露在桌面端编辑器里。很多人以为做了ProGuard混淆就够了,但混淆只是把类名、方法名变成a.b.c这种无意义短名,代码流程和字符串大体上还是能看懂的,而且市面上已经有反混淆工具可以恢复相当一部分语义。
1.2 DEX加密与代码混淆的分工边界
代码混淆做的事是“降低可读性”,而DEX加密做的事是“让静态状态拿不到代码本体”。两者互不替代,通常是叠加使用的。
DEX加密的直观效果是:APK解包之后,原来的classes.dex被替换成一个极小的“壳DEX”,真实业务DEX被加密后藏到了assets目录或其他位置。反编译工具看到壳DEX里只有入口逻辑,业务代码根本不在明面上,就无从下手。运行的时候,App通过自定义入口先把加密文件读出来、解密成完整的DEX字节流,再用动态加载的方式交给ClassLoader使用。
这样做有一个代价:解密所需的密钥和算法就在App自身进程里,理论上可以被动态调试、内存dump等手段抓到。所以DEX加密不是一劳永逸的“绝对安全”,它的作用是大幅抬高攻击门槛。一个缺乏经验的脚本小子可能直接放弃,而一个愿意投入时间的逆向工程师仍然可能拿到明文——这就像给门上了锁,防的不是开锁师傅,而是顺手推门的人。
1.3 自己动手实现DEX加密需要掌握什么
先给一个能力清单:
- 熟悉DEX文件格式的基本结构,至少要认识Header里的magic、file_size和class_defs这些字段。
- 理解ClassLoader机制里的PathClassLoader与DexClassLoader分工,知道动态加载的类是怎么被找到的。
- 掌握AES对称加密在Java/Android里的一套标准写法,包括密钥长度、IV、加密模式。
- 能在Application入口的
attachBaseContext阶段完成解密、注入和代理启动,因为真实业务代码此时还没被加载。
下面几章,我会按这个顺序一步步展开。
2. 底层机制速成:DEX文件结构、ClassLoader与启动时序
2.1 DEX文件的骨架:魔数、Header、ClassDefs
DEX文件不是随便一段字节流就叫DEX,它有一套严格的二进制格式。文件最开头是8个字节的魔数,64 65 78 0A 30 33 35 00,对应的ASCII就是dex\n035\0。系统在加载DEX的时候会校验这个魔数,如果你把一个普通文件重命名为.dex,系统会直接拒绝。
DEX的Header结构包含了很多关键信息:
| 偏移 | 长度 | 说明 |
|---|---|---|
| 0x00 | 8 | magic字段,即dex\n035\0 |
| 0x08 | 4 | checksum,文件校验和,adler32算法 |
| 0x0C | 20 | signature,SHA-1签名 |
| 0x20 | 4 | file_size,整个文件大小 |
| 0x24 | 4 | header_size,Header部大小,通常为0x70 |
| 0x38 | 4 | string_ids_size,字符串数量 |
| 0x3C | 4 | string_ids_off,字符串索引表偏移 |
| 0x6C | 4 | class_defs_size,类定义数量 |
| 0x70 | 4 | class_defs_off,类定义表偏移 |
对加密实现而言,最重要的认知是:**只要这个文件还是DEX格式,系统就是用同一套格式去读它;如果它不是DEX格式,系统加载时就会把它当成损坏文件直接拒绝。**所以我们加密后必须让壳工程能拿到合法的明文DEX再加载,不能把密文直接喂给ClassLoader。
2.2 从PathClassLoader到DexClassLoader:谁来加载类
Android的类加载机制是双亲委派模型。平时App加载自己代码用的是PathClassLoader,它主要加载安装在系统里的DEX文件,也就是APK包路径下的classes.dex。它的能力边界是:只能加载DexPathList里已经注册过的Element。
DexClassLoader是另一种加载器,它可以接收一个外部文件路径,比如/data/data/包名/cache/core-classes.dex,然后把文件加载进去。命名上看起来差别不大,但实际用途差异明显:DexClassLoader经常被插件化框架、热修复框架和加壳方案用来加载外部DEX。
关键点在于,DexClassLoader加载出来的类是“另一个世界里”的类。如果你的业务代码里有一个com.example.MainActivity,通过DexClassLoader加载之后,它在JVM里跟你App原本用PathClassLoader加载的同名类不是同一个类,直接使用会遇到类型转换异常。所以主流做法是在attachBaseContext阶段把外部加载进来的Element拼到当前PathClassLoader的pathList的dexElements数组里,这样整个App范围都能正常引用解密出来的类。
2.3 attachBaseContext里动手的最佳时机
Application有两个生命周期方法,attachBaseContext(Context base)和onCreate()。前者在Application被创建时最先调用,此时App的进程已经启动,但业务代码还没初始化,正是做解密和类注入的窗口。等到onCreate执行的时候,注入工作已经完成,业务类可以正常被引用。
这也是为什么加壳方案都要把自己包装成一个“壳Application”写在Manifest里,真正的业务Application通过解密加载后再被代理启动。
3. 整体方案设计:壳工程、加密时机与算法选型
3.1 两层壳模型:明文壳入口 + 加密核心
自己实现DEX加密,最清晰的工程模型是准备两个部分:
第一个是“壳工程”,也就是对外发布的APK。它的代码量很小,只做一件事:在Application入口里解密放在assets目录下的加密DEX文件,然后把解密后的DEX注入到运行时环境中。壳工程自身是有合法签名的,所以APK能正常安装、启动。
第二个是“业务工程”,也就是你真正要保护的代码。它编译产出的全部DEX被加密工具处理成密文,放进壳工程的assets目录。业务工程的Manifest和Application类都被“收”进加密DEX里,不再出现在明文APK中。
这样做的好处是职责清晰:壳工程永远保持简单,即使被逆向,看到的也只是解密加载逻辑,拿不到业务代码;业务DEX的更新可以直接替换assets里的密文,只要壳的加载逻辑不变,就不需要重新发版覆盖壳代码。
3.2 加密时机:Gradle任务 or 命令行工具
DEX加密的时机可以选在打包完成之后,也可以嵌进Gradle构建流程里。我自己的习惯是:先做命令行工具验证完整流程,跑通后再封装成Gradle插件。
命令行工具的输入是业务APK,流程如下:
- 解包业务APK,拿到
classes.dex(如果开了多DEX,则是classes2.dex、classes3.dex等)。 - 用AES算法对每个DEX文件做加密,输出一份密文文件。
- 把密文文件复制到壳工程的
assets/encrypted_dex/目录。 - 重新打包壳工程并签名发布。
这一步有朋友会问:“直接对classes.dex加密就行,为什么我看到市面上的加固方案要改名、要在文件头修改魔数?”其实修改魔数是一种“混淆文件类型”的辅助手段,让攻击者不知道该文件是什么格式。但我的建议是,文件头越“标准”,你自己在解密侧要处理的边界情况就越少;魔数校验可以在解密后显式做一次,而不是提前把文件改成非标准格式给自己埋雷。
3.3 算法选型:AES-CBC、AES-GCM与密钥管理
对称加密里可选方案很多,但真正适合DEX加密场景的主要是AES系列。DES算法密钥太短,已经不被推荐;异或运算这种自创算法强度太低,只能算“混淆”算不上“加密”。
在AES的具体模式上,我推荐AES/GCM/NoPadding。GCM模式天然带认证标签,能检测密文是否被篡改,比CBC更让人省心。需要说明的是,GCM模式在Android 4.4及以下版本的兼容性不太稳定,如果你还要兼容老设备,可以退回到AES/CBC/PKCS5Padding。两者在代码上的差别主要是IV处理和认证标签校验。
误差率高的项目里还有一个“土办法”:AES加密之后再Base64编码存成文本文件。但我在处理DEX这种体积通常超过1MB的文件时,不推荐再叠Base64编码,因为它会把文件体积扩大约三分之一,而DEX本来就不小。直接用字节流写入二进制文件更好。
密钥管理是另一个重点。把密钥硬编码在Java代码里,反编译后一眼就能看到。把密钥放在Native层(JNI)会好一些,但也不是绝对安全。个人项目的务实做法是:密钥拆分到Native层取一段、Java层持有一段,运行时拼接成完整密钥。这样即使Java层被反编译,也只拿到半个密钥。
3.4 多DEX与分包:加密粒度怎么定
当业务工程的classes.dex超过64K方法数限制,Android会启用多DEX分包机制,打包后产生classes.dex、classes2.dex、classes3.dex等。
这种情况下,加密策略有两种:一种是把所有DEX分别加密、分别解密、逐一注入,工作量稍大但每个文件很清晰;另一种是先合并成一个大的DEX再加密,但合并DEX在安卓高版本上要处理MultiDex的兼容逻辑,复杂度反而更高。
我建议保持“分别加密、按classes*.dex的顺序依次解密并注入”的方案。ClassLoader在加载类的时候会按Element数组顺序逐个查找,所以注入顺序要保持原有分包顺序,否则类加载会出现“找到了但不是预期实现”的诡异问题。
4. 关键代码实现一:加密侧工具与DEX加解密组件
4.1 命令行加密工具的完整实现
加密侧工具不需要Android环境,用JDK就可以跑。下面是一个简化版本的Java命令行工具,接收业务APK路径,输出密文文件。
import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.io.*; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; import java.util.Enumeration; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; public class DexEncryptTool { private static final String AES_ALGORITHM = "AES"; private static final String AES_TRANSFORMATION = "AES/GCM/NoPadding"; private static final int GCM_TAG_BITS = 128; private static final int GCM_IV_LENGTH = 12; public static void main(String[] args) throws Exception { if (args.length < 3) { System.out.println("usage: DexEncryptTool <apkPath> <outputDir> <base64Key>"); return; } String apkPath = args[0]; String outputDir = args[1]; byte[] key = java.util.Base64.getDecoder().decode(args[2]); ZipFile zipFile = new ZipFile(apkPath); Enumeration<? extends ZipEntry> entries = zipFile.entries(); int dexIndex = 1; while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); String name = entry.getName(); if (name.startsWith("classes") && name.endsWith(".dex")) { InputStream is = zipFile.getInputStream(entry); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { baos.write(buffer, 0, len); } byte[] dexBytes = baos.toByteArray(); is.close(); byte[] encrypted = encryptDex(dexBytes, key); String outputName; if ("classes.dex".equals(name)) { outputName = "core.dex.enc"; } else { outputName = "core" + dexIndex + ".dex.enc"; } Files.write(Paths.get(outputDir, outputName), encrypted); System.out.println("encrypted " + name + " -> " + outputName + ", size=" + dexBytes.length); dexIndex++; } } zipFile.close(); System.out.println("done."); } public static byte[] encryptDex(byte[] dexBytes, byte[] key) throws Exception { SecretKeySpec keySpec = new SecretKeySpec(key, AES_ALGORITHM); Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, keySpec); byte[] iv = cipher.getIV(); byte[] encrypted = cipher.doFinal(dexBytes); ByteArrayOutputStream baos = new ByteArrayOutputStream(); baos.write(iv); baos.write(encrypted); return baos.toByteArray(); } }这段代码做了三件事:遍历APK里的所有classes*.dex、逐个AES加密、把“IV + 密文”拼接后写出。为什么要把IV拼接在密文前面?因为GCM解密时需要同一个IV,把它和密文放一起,解密侧就不需要额外传递IV了。
4.2 AES加解密工具类的实现与细节
任何语言写AES都绕不开几个基础细节:密钥长度必须满足算法要求,AES-128对应16字节、AES-256对应32字节;IV不能重复使用,否则相同的明文和密钥会产出相同前缀;解密时GCM模式还要指定认证标签的位数。
下面给出解密侧的工具类,这段代码会直接放进壳工程:
public class AesCryptUtil { private static final String AES_ALGORITHM = "AES"; private static final String AES_TRANSFORMATION = "AES/GCM/NoPadding"; private static final int GCM_TAG_BITS = 128; private static final int GCM_IV_LENGTH = 12; public static byte[] decrypt(byte[] encryptedData, byte[] key) throws Exception { if (encryptedData == null || encryptedData.length < GCM_IV_LENGTH) { throw new IllegalArgumentException("encrypted data length invalid"); } SecretKeySpec keySpec = new SecretKeySpec(key, AES_ALGORITHM); byte[] iv = new byte[GCM_IV_LENGTH]; System.arraycopy(encryptedData, 0, iv, 0, GCM_IV_LENGTH); byte[] cipherBytes = new byte[encryptedData.length - GCM_IV_LENGTH]; System.arraycopy(encryptedData, GCM_IV_LENGTH, cipherBytes, 0, cipherBytes.length); Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_BITS, iv)); return cipher.doFinal(cipherBytes); } }大家可能注意到,我这里的AES_TRANSFORMATION是AES/GCM/NoPadding,这是Java 8和Android API 21之后都支持的写法。如果你要兼容Android 4.4以下的设备,就需要替换成CBC的写法,并且CBC模式下要自己补上PKCS5Padding的加解密填充。
4.3 DEX文件头校验与合法性检查
解密出来的字节流不一定就是合法的DEX。有可能是攻击者篡改了assets里的密文,有可能是文件在拷贝过程中截断了。如果直接把这个坏文件交给ClassLoader去加载,崩溃信息会很奇怪,很难追溯到源头。
所以在解密之后,我强烈建议做一次显式检查:
public static boolean isLegalDex(byte[] data) { if (data == null || data.length < 8) { return false; } return data[0] == 0x64 // d && data[1] == 0x65 // e && data[2] == 0x78 // x && data[3] == 0x0A // \n && data[4] == '0' && data[5] == '3' && data[6] == '5' && data[7] == 0x00; }这一步不只是安全校验,也是一个很好的调试辅助。拿到的文件如果不是DEX格式,就可以直接判断是解密失败还是密文被破坏,而不是把时间浪费在生产环境报错上。
5. 关键代码实现二:App启动时的解密、注入与代理
5.1 StubApplication与attachBaseContext的改造
壳工程里有一个StubApplication,它是Manifest里声明的Application。它的核心工作都在attachBaseContext里完成。
public class StubApplication extends Application { private static final String TAG = "StubApplication"; @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); try { long startTime = System.currentTimeMillis(); byte[] key = getDecryptKey(); File dexDir = getDir("dex_encrypted", Context.MODE_PRIVATE); if (!dexDir.exists()) { dexDir.mkdirs(); } String[] assetNames = {"core.dex.enc", "core2.dex.enc", "core3.dex.enc"}; File[] dexFiles = new File[assetNames.length]; for (int i = 0; i < assetNames.length; i++) { byte[] encryptedBytes = loadFromAssets(base, assetNames[i]); if (encryptedBytes == null) { continue; } byte[] dexBytes = AesCryptUtil.decrypt(encryptedBytes, key); if (!DexFileChecker.isLegalDex(dexBytes)) { throw new IllegalStateException("decrypted dex is invalid: " + assetNames[i]); } File dexFile = new File(dexDir, assetNames[i].replace(".enc", "")); writeToFile(dexFile, dexBytes); dexFiles[i] = dexFile; } boolean injected = DexInjector.injectDexFiles(base, dexFiles); if (injected) { Log.i(TAG, "dex inject success, cost=" + (System.currentTimeMillis() - startTime) + "ms"); } else { Log.e(TAG, "dex inject failed"); } } catch (Throwable tr) { Log.e(TAG, "attachBaseContext failed", tr); } } private byte[] getDecryptKey() { // 建议从多个来源拼接,这里只给示意 byte[] javaPart = "java-part-16byte".getBytes(StandardCharsets.UTF_8); // nativePart 可由JNI从so文件中取出 byte[] nativePart = new byte[0]; byte[] fullKey = new byte[16]; System.arraycopy(javaPart, 0, fullKey, 0, 16); return fullKey; } private byte[] loadFromAssets(Context context, String fileName) throws IOException { AssetManager am = context.getAssets(); InputStream is = am.open("encrypted_dex/" + fileName); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { baos.write(buffer, 0, len); } is.close(); return baos.toByteArray(); } private void writeToFile(File file, byte[] data) throws IOException { FileOutputStream fos = new FileOutputStream(file); fos.write(data); fos.flush(); fos.close(); } }这里最容易被忽略的地方是,assets目录下的文件在APK里可能是压缩存储的,open时系统自动解压成原始字节流,所以loadFromAssets拿到的是以内存中读取为准的原始DEX字节。如果你在Native层直接读取APK路径去取assets文件的压缩内容,需要先解压,这种间接方式容易引出一堆问题。
5.2 解密文件写入与缓存目录管理
解密后的DEX必须写入一个“APK可以访问且能执行”的路径。getDir("dex_encrypted", Context.MODE_PRIVATE)创建的是/data/data/包名/app_dex_encrypted目录,这个目录属于应用私有,其他应用不可读,符合安全要求。
不要使用getExternalFilesDir()来存放解密后的DEX,因为外部存储的文件可能被其他应用读取,也可能被用户手动导出。虽然现在的分区存储限制变严了,但没必要给自己加风险。
这里还有个好习惯:如果旧版本已经写过解密DEX,启动时可以先比对文件长度或hash,相同就不重新解密,能省下不少IO开销。DEX文件体积通常不小,频繁解密写盘会影响启动速度。
5.3 DexClassLoader加载与反射注入PathClassLoader
下面这部分是整套方案的核心中的核心。
public final class DexInjector { private static final String TAG = "DexInjector"; public static boolean injectDexFiles(Context context, File[] dexFiles) { if (dexFiles == null || dexFiles.length == 0) { return false; } try { PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader(); // 把解密后的DEX加载到一个临时DexClassLoader File optimizedDir = context.getCodeCacheDir(); StringBuilder dexPathBuilder = new StringBuilder(); for (File dexFile : dexFiles) { if (dexFile != null && dexFile.exists()) { if (dexPathBuilder.length() > 0) { dexPathBuilder.append(File.pathSeparator); } dexPathBuilder.append(dexFile.getAbsolutePath()); } } if (dexPathBuilder.length() == 0) { return false; } DexClassLoader dexClassLoader = new DexClassLoader( dexPathBuilder.toString(), optimizedDir.getAbsolutePath(), null, pathClassLoader); Object pathList = getField(pathClassLoader, "pathList"); Object dexElements = getField(pathList, "dexElements"); Object newPathList = getField(dexClassLoader, "pathList"); Object newDexElements = getField(newPathList, "dexElements"); Object mergedElements = mergeArray(dexElements, newDexElements); setField(pathList, "dexElements", mergedElements); return true; } catch (Throwable tr) { Log.e(TAG, "inject failed", tr); return false; } } private static Object getField(Object obj, String fieldName) throws Exception { Class<?> clazz = obj.getClass(); while (clazz != null) { try { Field field = clazz.getDeclaredField(fieldName); field.setAccessible(true); return field.get(obj); } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } throw new NoSuchFieldException(fieldName); } private static void setField(Object obj, String fieldName, Object value) throws Exception { Class<?> clazz = obj.getClass(); while (clazz != null) { try { Field field = clazz.getDeclaredField(fieldName); field.setAccessible(true); field.set(obj, value); return; } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } throw new NoSuchFieldException(fieldName); } private static Object mergeArray(Object oldArray, Object newArray) { int oldLen = Array.getLength(oldArray); int newLen = Array.getLength(newArray); Object merged = Array.newInstance(oldArray.getClass().getComponentType(), oldLen + newLen); System.arraycopy(oldArray, 0, merged, 0, oldLen); System.arraycopy(newArray, 0, merged, oldLen, newLen); return merged; } }关于这个注入方案的原理,可以这样理解:PathClassLoader内部维护了一个DexPathList,里面有Element[] dexElements数组,类加载时就是按这个数组顺序逐个Element去找类。当我们把加密DEX解出来后,用DexClassLoader把它变成一个新的DexPathList,再将两个dexElements数组合并,就相当于把“新DEX的资源”拼接进了当前App的类查找路径里。之后App里任何一个类引用,都会先从原有的classes.dex里找,找不到就会继续找我们注入进去的新DEX。
这里有一个很容易踩的点:getField反射时,pathList字段是在BaseDexClassLoader里声明的,不在PathClassLoader和DexClassLoader自己的类里。所以我在实现中用了一个while循环沿着父类向上找,这一步漏掉的话,在部分厂商ROM上会抛出NoSuchFieldException。
5.4 真实Application的代理启动
壳工程启动后,StubApplication已经完成了注入,但Manifest里没有声明业务工程的Application。业务工程真正要用到的Application类此时还没有实例化。所以StubApplication还需要完成一个动作:加载原业务Application并调用它的生命周期方法。
做一个约定:壳工程用一个字符串常量记录原Application的全限定名,比如com.example.business.App。然后在onCreate里通过反射创建并启动它。
public class StubApplication extends Application { private static final String REAL_APP_CLASS_NAME = "com.example.business.App"; @Override public void onCreate() { super.onCreate(); try { Class<?> realAppClass = Class.forName(REAL_APP_CLASS_NAME); Application realApp = (Application) realAppClass.newInstance(); Method attachMethod = Application.class.getDeclaredMethod("attach", Context.class); attachMethod.setAccessible(true); attachMethod.invoke(realApp, getBaseContext()); realApp.onCreate(); } catch (Exception e) { Log.e(TAG, "failed to start real app", e); } } }这里有两个细节:
Application.attach(Context)是系统隐藏方法,Android P(API 28)之后对隐藏API开启了反射限制。不过Application.attach这类方法在某些系统白名单里,实测多数真机没有问题,但你在做兼容测试时心里要有数。
原业务Application的onCreate会在这里被调用,意味着业务工程的启动流程已经走通了。原业务Application的attachBaseContext也会被调用,但此时已经不再需要做DEX解密相关操作了。
6. 上线前的避坑实战:解密成功却崩溃的完整排查链路
6.1 三个典型的崩溃现场
我最初跑通流程时,遇到了一连串问题。这里把最有代表性的三个崩溃贴出来,帮大家省时间。
第一个是ClassNotFoundException。现象是App启动到一半,直接抛出某个类找不到,而这个类明明就在加密DEX里。这个问题的典型原因是注入顺序不对或者注入没成功。如果你在自己的日志里看到“dex inject success”却仍然找不到类,那就要检查是不是多个DEX的Element合并顺序反了。
第二个是IllegalStateException: DexFile相关的异常。DexClassLoader在Android 8.0(API 26)之前要求指定optimizedDirectory目录,API 26之后这个参数被忽略。有些低版本ROM对optimizedDirectory目录是否可写比较敏感,如果传了一个不存在的目录就会崩。
第三个是NoClassDefFoundError。这个通常是类A引用了类B,但A和B不在同一个DEX里,或者B所在的Element在注入数组的后面,加载顺序失败。
6.2 排查路径:从日志到文件校验
遇到崩溃,第一件事不是改代码,而是确认三个事实:密文是否正常、解密是否成功、注入是否生效。
第一步,检查assets目录下的密文文件大小是否合理。如果密文只有几十字节,八成是加密工具没有正确读取到业务DEX,或者文件路径写错了。
第二步,在attachBaseContext里把解密后的文件长度和魔数打印出来。解密后的DEX文件长度应该和业务APK里原始的classes.dex大小完全一致。如果长度不一致,立刻能锁定是解密逻辑问题。
第三步,确认注入后能否通过Class.forName("com.example.business.App")加载到目标类。在injectDexFiles返回成功后,紧接着做一次主动加载:
Class<?> clazz = Class.forName("com.example.business.App"); Log.i(TAG, "real app class loaded: " + clazz.getName());这行日志能直接告诉你是注入没生效还是类本身有问题。
6.3 修复方案:API版本适配、odex目录与类冲突
针对API 26的optimizedDirectory变化,最佳实践是使用context.getCodeCacheDir()作为优化目录,这个API从16就开始有了,而且系统对code_cache目录有专门管理,不容易出现权限问题。在API 26以下,这个目录会被当作odex输出目录;API 26及以上,系统会忽略该参数但也不会崩溃。
针对类冲突,要特别注意:解密DEX里如果包含了跟原有classes.dex相同的类,注入后ClassLoader会优先加载原有类,导致你解密出来的新逻辑永远不会被拾取。这个问题常见于业务工程和壳工程引用了同一个SDK的重复类。处理方式是在打包壳工程时,排除掉与业务DEX冲突的依赖,尽量让壳工程保持最小依赖。
另外,ProGuard/R8混淆规则里要加上必要的keep规则:
-keep class com.example.business.App { *; } -keep class * extends android.app.Application -keepclassmembers class * { void attachBaseContext(android.content.Context); }混淆规则漏掉Application类是非常隐蔽的坑,因为混淆后类名变了,反射Class.forName(REAL_APP_CLASS_NAME)就会找不到。
6.4 修复后的回归验证清单
走完一条链路后,我建议建立一张回归验证清单:
- 冷启动时日志里能看到“dex inject success”,耗时在正常范围内。
- 业务工程里的Application、Activity、Service都能正常创建和跳转。
- 多渠道打包后,每个渠道包都做一次启动验证,确认assets目录下的密文没有因为打包脚本被遗漏。
- 覆盖安装老版本时,新版本能正常读取assets密文,不要依赖上一个版本留下的解密DEX缓存。
- 混淆和资源压缩开启的情况下,重新打一整条加密链路,确认密文文件未被删除。
7. 自研加固之外的加固思路:检测、对抗与选型
7.1 防调试、防注入与防dump的组合拳
DEX加密做的是静态防护,动态攻击又是另一层问题。攻击者可以把应用放到调试器里跑,在attachBaseContext解密完成后、注入之前,从内存里把明文DEX dump出来。所以自研加固如果做到一半不够彻底,也可以做一些基础的动态检测加大攻击难度。
常见的防护点包括:检测Debug.isDebuggerConnected()、读取/proc/self/status里的TracerPid看是否被附加、检测应用安装后是否被重签名对比签名信息、检测常用的Hook框架特征文件是否存在。这些都不是绝对安全,但组合起来确实能把脚本小子挡在外面。
比较推荐的检测时机是:attachBaseContext里做一次基础环境检测,如果发现调试器或Hook环境,可以选择延迟解密、抛出异常或者直接退出。但要注意别做得太激进,误杀正常用户会很伤体验。
7.2 完整性校验与多渠道包签名
加密的DEX文件本身需要做完整性校验。如果不做,攻击者可以替换assets目录下的密文,换成自己构造的DEX,从而劫持App启动流程。这个攻击方式在理论上完全可行,因为解密逻辑和密钥都在壳工程里,攻击者只要弄清了加载流程,就能用同样的逻辑加载自己的代码。
要挡住这一层,可以做完整性校验:在加密工具生成密文的同时,计算密文的SHA-256哈希值并封装到壳工程的Native层或服务端下发。启动时先计算assets里密文的哈希,与预期值做比对,不匹配就拒绝解密。如果要求更高,可以把校验逻辑放到服务端,启动时请求接口校验,但这样会造成启动依赖网络,体验要自己权衡。
7.3 什么时候该用商用加固,什么时候自研
自己实现DEX加密的价值在于理解原理和应对定制需求,但商用的加固方案在对抗强度、兼容性、稳定性上都远超个人实现的水平。市面上的主流加固产品不仅有DEX加密,还有VMP指令虚拟化、系统API Hook检测、内存Dump对抗、白盒密钥等一整套方案。如果你的App涉及支付、账号等高价值逻辑,我建议直接用商用加固,再叠加自研的动态检测逻辑。
如果做的是工具类、普通内容类App,主要目的是防“一键换皮”,自研DEX加密完全够用,维护成本也不算高。我自己目前的项目就是自研壳+中度混淆的组合,遇到小规模盗版的情况基本可以拦住,成本比商用方案低不少。
最后分享一点个人体会:做DEX加解密,最忌讳的就是只抄代码不理解原理。你只有把DEX文件格式、ClassLoader加载链路、Application生命周期这三个基础彻底吃透了,才能在遇到各种诡异崩溃时游刃有余。加密和解密本身只是AES加一段字节流的活,难的是把它嵌进Android复杂的运行机制里还不出错。找一台老版本Android设备、一台新版本Android设备,把这条链路仔细跑几遍,踩过的坑自然就变成你自己的经验了。