深入剖析直播App ClassLoader动态加载机制与加密Jar包逆向还原 1. 项目背景与逆向目标梳理1.1 为什么直播类App偏爱ClassLoader动态加载接触过Android逆向的同行应该都有这种体感直播类App是动态加载的重灾区。某薇直播为规避风险以下统称目标App的加固方案在同类产品里算比较典型的——壳外再套壳核心业务代码不在安装包里的classes.dex直接裸奔而是拆成若干jar包或dex文件加密后存放在assets目录或者服务器端App启动后再通过ClassLoader动态加载进内存执行。这种设计并非直播类App独创但直播业务对它天然有强需求。一是直播SDK体量大聊天、礼物、美颜、连麦、弹幕这些模块更新频率极高如果每次发版都要走应用市场审核版本迭代速度根本跟不上运营节奏。二是动态加载天然形成一道代码保护屏障逆向者扒开APK只能看到一堆看不到业务逻辑的壳代码核心玩法、签名校验、通信协议全被藏起来了。实际上我拆过的很多头部直播App都有类似的“影子Dex”体系只不过有的叫插件化有的叫热更新框架底层思路大差不差。这次接到的任务就是针对某薇直播的ClassLoader加载链路做一次完整剖析目标很明确找出App启动后加载的那个加密jar包还原解开后的字节码让后续的业务分析能流畅推进。这类工作在实际攻防里太常见了比如做合规检测、恶意行为分析、竞品调研或者单纯想搞懂一个App的实现细节都绕不开这一步。1.2 逆向目标的边界确认动手之前先确认清楚这次要解的目标到底是什么。目标App的安装包解压后常规的classes.dex、classes2.dex都在通过静态工具扫一遍能看到的都是壳和框架代码。真正的业务实现被藏在一个通过ClassLoader加载的jar包里但这个jar包在APK里并不是明文存在的——assets目录下躺着一堆大小不一的加密数据文件后缀名五花八门有的叫.dat有的叫.bin还有干脆没有后缀的。这些加密文件在App运行后会被特定逻辑读取、解密、转换成DexClassLoader能识别的字节流最终加载进内存。所以这条链路的关键节点有三个加密文件从哪来、解密逻辑在哪个函数里、解密后的字节流怎么交给ClassLoader。这三个节点串起来就是完整的解密链路。后面所有分析工作都是围绕这条链路展开的目标清晰才不会在茫茫代码里绕晕。2. ClassLoader动态加载机制原理解析2.1 PathClassLoader与DexClassLoader的工作差异在深入目标App之前有必要先把Android的ClassLoader机制捋一遍。很多刚入门的兄弟容易把PathClassLoader和DexClassLoader搞混实际上两者的差别在源码层面就写得很明白。PathClassLoader主要用于加载系统ClassPath里的类它只能加载已安装APK中的代码不支持从外部文件系统加载jar/dex。DexClassLoader则灵活得多它专门用于加载外部存储路径下的dex/jar/apk文件内部通过DexPathList维护了一个Element数组每个Element对应一个dex或者zip文件。当你调用DexClassLoader的loadClass方法时实际上是遍历这个Element数组从每个dex文件里搜索对应的类。对于热更新、插件化这类场景业界一般都用DexClassLoader因为它的构造参数里可以指定一个optimizedDirectory优化后的odex文件存放目录较高版本Android上这个参数已不再生效底层直接使用ART的运行时机制。目标App自然也不例外的用了DexClassLoader我们要关注的动态加载逻辑就落在它身上。不过注意一点DexClassLoader加载完的类并不会自动注册到全局的ClassLoader体系中。如果目标App里的业务代码需要在加载后与主工程代码互相调用它往往还会做一步“插桩”操作——把加载进来的类路径追加到PathClassLoader的dexPath里或者通过反射把DexClassLoader的pathList塞进系统ClassLoader的pathList中。这一步操作想通了后面通过Hook定位加载入口的时候也就顺手了。2.2 加密加载链路的通用模型从逆向分析视角看一个加密Dex/Jar的加载链路十有八九长这样App启动或特定时机触发加载逻辑从assets或外部存储读取加密数据进入解密函数用硬编码Key、so层Key或服务器下发的Key做解密运算解密成功后得到原始dex/jar字节数组将字节数组写入临时文件通常是应用私有目录下调用DexClassLoader加载该临时文件通过反射获取Class实例并初始化或调用其方法这条链路上每一步都有对应的Hook点。静态分析时我们通常从ClassLoader的使用处倒推也就是先找到DexClassLoader的newInstance位置再往前追溯它的构造参数来自哪里。构造参数是文件路径那这个文件是从哪来的、是谁写的顺藤摸瓜就能找到解密写入函数。动态分析时Hook点更多。比如可以通过Hookjava.io.File的构造方法监听临时文件的创建情况也可以HookDexClassLoader.loadClass捕获被加载类的名字和类加载器实例更通用的是Hookjava.lang.ClassLoader的loadClass方法把所有动态加载的类都记录下来。这里想强调一个比较隐蔽的细节很多App会把解密后的dex文件命名成xxxx.tmp或者干脆无后缀写入路径也选在/data/data/包名/cache/或者/data/data/包名/app_opt/这种不惹眼的位置。如果你只顾着扫assets目录很容易漏掉真正的dex是在运行时才落盘的。所以监听文件创建是动态分析里性价比很高的一步。3. 定位加密入口与解密实操流程3.1 从静态特征定位ClassLoader调用点静态分析阶段我用jadx打开目标App的dex全局搜索几个高频关键字DexClassLoader、PathClassLoader、loadClass、defineClass、System.load再加一组字符串特征assets、cache、temp、odex。结果一下就清晰了主工程里有一处专门处理加载逻辑的类姑且叫它LoaderHelper里面不仅拼接了路径还调用了readEncryptedData这个函数函数名直白得有点过分——就是读取assets下某个加密文件再做AES解密。jadx反编译出来的伪代码看起来大致是这么个逻辑public ClassLoader buildClassLoader() { // 从assets读取名为 res_enc.dat 的加密文件 byte[] encData readFromAssets(res_enc.dat); // 调用本地方法解密 byte[] decrypted decryptByNative(encData); // 写入应用私有目录 String optFile getCacheDir() /live_core.jar; FileUtils.writeBytes(optFile, decrypted); // 构建DexClassLoader return new DexClassLoader(optFile, getCacheDir().getPath(), null, getClassLoader()); }这段代码虽然简单但信息量足够大。第一加密文件在assets下名为res_enc.dat第二解密动作在一个native方法decryptByNative里说明核心密钥或算法在so层这正是很多App喜欢的设计——Java层的代码太好逆了直接把解密搬到so里能增加一点分析成本但也仅此而已。接着我用IDA打开目标App的lib目录下的so文件搜字符串AES、密钥相关的常量很快定位到解密函数。其实这里没什么太高深的技巧按字符串表和交叉引用顺着走就行。so层做AES解密密钥并不在Java层硬编码而是在JNI_OnLoad阶段通过一组字节运算动态拼出来拼完之后存在静态变量里。这种方式对付新手足够了但对老手来说只要找到那几条byte赋值指令等效于把密钥直接摆桌上。3.2 动态Hook方案对比与选型静态分析给出的是“理论路径”落地的时候最好再配合动态Hook做一次验证因为App运行时的实际加载链路未必和反编译代码完全一致尤其是存在多入口、热更新开关这类情况时静态分析容易漏。动态Hook的方案我对比过三种第一种HookDexClassLoader的构造函数。这是最直接的办法只要App里任何地方new了DexClassLoader都能被捕获到从而拿到传入的dexPath、optimizedDirectory以及parent ClassLoader。缺点是只能拿到路径拿不到解密前后的具体数据如果你还需要还原原始字节就得在文件写入处继续往下挖。第二种Hookjava.io.FileOutputStream.write或者File.createNewFile。这个是我个人比较推荐的做法因为我们已知解密后的数据一定会被写入一个文件只要监听文件创建和写入就能把解密后的dex/jar完整dump下来。具体做法是用Frida的Java.perform替换目标方法读取写入的byte数组内容写到手机SD卡上导出。第三种Hook String相关的构造函数或者native层的函数。这种最底层但需要处理的数据量大统计干扰项也多一般不建议优先采用。我在这次分析中选的是第二种理由很简单它用最少的Hook点覆盖了最完整的解密链路而且拿到的是已经是解密后的产物直接可分析省去了另外拼接字节的功夫。具体Frida脚本的骨架大致长这样Java.perform(function () { var FileOutputStream Java.use(java.io.FileOutputStream); FileOutputStream.write.overload([B, int, int).implementation function (buffer, off, len) { var file this.getFD(); var path this.getFD().toString() | Java.use(java.io.FileDescriptor).newInstance().toString(); console.log([Write] path); // 这里可以根据文件路径过滤只保存我们关心的jar/dex send({file: path, size: len, data: Array.from(buffer.subarray(off, off len))}); return this.write(buffer, off, len); }; });实际上目标App里的FileOutputStream引用比较频繁我加上了一层过滤条件只dumpgetCacheDir()路径下的文件或者文件名包含.jar的文件。过滤条件一定要写不然后台刷日志刷到你怀疑人生。3.3 解密后的jar包还原与业务分析通过Hook拿到解密后的jar文件后第一步就是验证它的完整性和格式。用file命令看一眼如果是zip格式再unzip解压正常情况下你会看到classes.dex或者com/xxx/...这一包业务类结构。如果解出来是一堆不可读的字节大概率是dump时机不对写到半截就被截获了需要在文件写入结束的close()方法处再补一个dump点。拿到完整jar之后后续处理就很顺了。把classes.dex拖进jadx或者JEB里继续分析一般就能看到目标App核心业务代码的完整面貌。如果类里还有字符串混淆、花指令这类二次加固手段可以继续用dexdump、jadx反混淆插件、或者Unidbg跑so做动态还原这些都是后话。这里要特意提一个点在实操中极容易翻车的细节某些App不会把解密后的dex直接写入文件而是直接以byte[]形式传进DexClassLoader的一个自定义变体里比如自己改了ClassLoader在内存中直接完成dex解析。这种情况用文件Hook方案就拿不到东西了得改从native层入手。好在实践中这种用法不够稳定而且高版本Android对内存Dex的校验越来越严多数App还是老老实实走临时文件路线。4. 常见问题与排错实录4.1 动态加载不生效的排查思路动态Hook方案看着简单实际跑起来多少会遇到点问题。最常见的一个Frida attach之后App的加载逻辑压根没触发日志里什么都有就是没有DexClassLoader相关的输出。排查思路按顺序来先确认App版本是否自带了反调试检测。直播类App的反调试是标配目标App也不例外会定期检查/proc/self/status里的TracerPid或者通过Debug.isDebuggerConnected做检测。简单测一下Frida默认的attach方式如果被检测到App会直接闪退或者走虚拟机保护逻辑。这种情况或者换成spawn方式、或者给Frida服务端改个端口再或者配合反反调试的脚本先绕过检测层。再确认是不是时机问题。动态加载通常发生在特定业务节点比如进入直播间才触发。如果App启动后停在首页加载逻辑可能压根没被走到那就要想办法模拟出触发场景手动进直播间或者点开某个功能页。最后确认Hook点本身没错。目标App可能分别在Application和某个Activity里各加载了一次ClassLoader前一次跑在attach之前后一次真正执行了业务加载。如果只盯着一处Hook容易漏掉真正的业务加载点。4.2 解密后的代码仍然混淆怎么办排除动态加载问题后又遇到一个新情况jar包里解出来的classes.dex代码读起来异常痛苦。类名全是a.b.c.d方法名全是a()b()字符串全被抽到一张映射表里。这种情况说明目标App不仅做了加密还在jar包里做了二次混淆。处理这类问题的经验是分三步走第一先看混淆强度。如果只是类名方法名混淆不影响静态分析逻辑依靠jadx的代码追踪能力顺着调用链看就行。如果方法体内嵌套太深配合ctrlB跳转调用关系和常量池交叉引用通常能理顺脉络。第二识别有没有字符串加密。如果字符串被抽到映射表通过分析映射表的构造方式写个小脚本批量还原比手抄快得多。一般映射表初始化逻辑都在某几个固定类里找到解密函数后Frida写个脚本把映射表全量dump出来再做一张反查表。第三实在绕不过去就上动态分析。核心函数的入参与返回值用Strace或Frida直接抓从外部行为推断内部实现。很多加密协议、签名算法不用完全还原抓住最终结果就够用了。4.3 反调试与检测对抗的现实情况做这类分析反调试是绕不开的话题。目标App的反调试做的不算激进但几个常用套路都齐了检查调试端口、检测TracerPid、检查maps里是否有可疑so、对某些系统API做了防Hook处理。坦白说没有一套放之四海而皆准的绕反调试方案因为每个App的检测逻辑和时机都不一样只能一种一种破。我的基本思路是先静态分析出检测点集中在哪个文件再动态Hook掉对应的检测API比如Process.myTracerPid()直接替换返回0java.io.File的exists()方法对特定路径返回false或者扫maps时过滤掉frida相关的映射。只要反调试处理掉了剩下的事情就顺顺当当。真实项目中还存在一部分App使用VMP虚拟化保护来执行核心代码这类情况单纯靠静态分析很难还原完整的业务逻辑需要结合指令级动态跟踪超出本文讨论范围了。好在此次目标App的so层只是做了AES解密还不算变态级别。5. 实操心得与经验沉淀这次某薇直播的逆向分析任务从定位ClassLoader入口到Hook解密逻辑再到还原业务代码整个流程走下来大概花了一天时间。回头复盘有几点想分享给做同样方向的朋友。第一静态分析和动态验证千万不要只做一边。静态分析能快速给出整体脉络但真实运行时往往偏离静态代码否则你根本想象不到一个看似简单的res_enc.dat文件会在加载前经历多少层转发。动态Hook的价值不是“验证”静态结论而是补齐运行时链路中静态分析看不到的部分。第二Hook点在精不在多。新手容易犯的毛病是恨不得把所有类都过一遍Hook脚本越写越长日志刷到失控最后反而抓不到关键数据。正确的做法是先保目标拿到解密之后的文件产物即可其他辅助数据都砍掉。一个FileOutputStream.write的Hook足够用了剩下的交给耐心。第三售后环节最容易忽略——二次混淆。如果能在一开始就预判目标App可能做二次混淆dump下来的jar第一时间就做字符串映射表分析而不是傻乎乎地一行行读代码效率会高很多。实战里我吃过两次亏之后现在拿到dex第一件事就是跑一遍字符串特征扫描先摸清混淆强度再决定下一步怎么分析。逆向这条路本质上是对耐心和细心的双重考验没有哪一步是真正意义上“做不到”的。某薇直播的这套ClassLoader加密加载方案在同类型App里属于耐看但不难啃的典型把它吃透了以后遇到其他直播类、社交类App的类似结构大概率都会有很强的迁移价值。期望这篇笔记能帮你少走几步弯路后续如果在实际项目里遇到更刁钻的加载链路欢迎多交流。