ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android DEX加密与解密:原理、实现与自研加固实践

Android DEX加密与解密:原理、实现与自研加固实践 去年年底一个做工具类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、ClassDefsDEX文件不是随便一段字节流就叫DEX它有一套严格的二进制格式。文件最开头是8个字节的魔数64 65 78 0A 30 33 35 00对应的ASCII就是dex\n035\0。系统在加载DEX的时候会校验这个魔数如果你把一个普通文件重命名为.dex系统会直接拒绝。DEX的Header结构包含了很多关键信息偏移长度说明0x008magic字段即dex\n035\00x084checksum文件校验和adler32算法0x0C20signatureSHA-1签名0x204file_size整个文件大小0x244header_sizeHeader部大小通常为0x700x384string_ids_size字符串数量0x3C4string_ids_off字符串索引表偏移0x6C4class_defs_size类定义数量0x704class_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 PAPI 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.0API 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设备把这条链路仔细跑几遍踩过的坑自然就变成你自己的经验了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进