☰
深入剖析直播App ClassLoader动态加载机制与加密Jar包逆向还原
2026/10/15 16:39:18 网站建设 项目流程

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的代码追踪能力顺着调用链看就行。如果方法体内嵌套太深,配合ctrl+B跳转调用关系和常量池交叉引用,通常能理顺脉络。

第二,识别有没有字符串加密。如果字符串被抽到映射表,通过分析映射表的构造方式写个小脚本批量还原,比手抄快得多。一般映射表初始化逻辑都在某几个固定类里,找到解密函数后,Frida写个脚本把映射表全量dump出来,再做一张反查表。

第三,实在绕不过去就上动态分析。核心函数的入参与返回值用Strace或Frida直接抓,从外部行为推断内部实现。很多加密协议、签名算法不用完全还原,抓住最终结果就够用了。

4.3 反调试与检测对抗的现实情况

做这类分析,反调试是绕不开的话题。目标App的反调试做的不算激进,但几个常用套路都齐了:检查调试端口、检测TracerPid、检查maps里是否有可疑so、对某些系统API做了防Hook处理。

坦白说,没有一套放之四海而皆准的绕反调试方案,因为每个App的检测逻辑和时机都不一样,只能一种一种破。我的基本思路是:先静态分析出检测点集中在哪个文件,再动态Hook掉对应的检测API,比如Process.myTracerPid()直接替换返回0,java.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的类似结构,大概率都会有很强的迁移价值。期望这篇笔记能帮你少走几步弯路,后续如果在实际项目里遇到更刁钻的加载链路,欢迎多交流。

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

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

立即咨询