今天在对某企业级 Android 加固方案进行逆向分析时,我通过 Frida 注入开展动态调试,过程中发现了一个明显违背 ELF 标准的反常现象:通过 dlopen 遍历已加载 SO 时,打印某 SO 的基地址与 JNI_OnLoad 函数地址后,我发现该 JNI_OnLoad 地址完全不属于该 SO 的内存区间,甚至出现了函数地址 < 模块基址的反常现象,严重违背 ELF/Linker 标准的内存布局规则。
深入排查后最终定位到根因:该加固壳劫持了系统 linker64 链接器、Hook 了 dlopen 系列函数,通过伪造虚假的 SO 信息(向 Frida 等调试工具暴露一个看似正常的 “壳 SO”),将真正的业务 SO 隐藏在内存中、绕开系统 linker 私自加载,以此实现反调试、反逆向的防护效果。
本文将详细拆解这类企业级加固的对抗思路,从底层原理、脚本编写、动态调试到脱壳还原,手把手教大家如何通过 Frida 从内存中精准 “钓” 出被壳隐藏的真实业务 SO,完成脱壳还原。全程实操为主、原理为辅,文中所有脚本均已脱敏处理,可直接复用。
水平有限,文中若有疏漏或错误,恳请各位大佬斧正。
本文用到的工具和前置知识:
1.mmap的分配原理和mmproct函数的权限修正: 东方玻璃大佬的底层知识讲解一直十分之详细,大家可以参考[原创]Android从ELF-Loader到自定义Linker的实现及原理-Android安全-看雪安全社区|专业技术交流与安全研究论坛
2.Frida stalker :Frida 是本次动态调试、Hook 系统函数的核心工具,需要大家熟悉 Frida 的基本注入命令、Interceptor 钩子用法、Module 相关 API;Frida Stalker 是 Frida 内置的高性能指令级追踪引擎,也是本次验证脱壳正确性的关键工具,需要了解其基本的追踪配置、指令捕获逻辑。
3.ELF 文件结构基础:需要了解 SO 文件的基本结构,包括导出表、代码段(text)、数据段(data)、soinfo 结构体的基本作用,明白符号表查询的基本流程,这样才能理解“导出表异常“地址倒挂”等反常现象的本质。
哈哈 发现本文用到的知识点上面东方玻璃的大佬刚刚发的的帖子都有讲到,大家可以看看
获取一款企业级加固安卓 APP 后,我通过 Frida 对进程进行注入,Hook 系统dlopen函数枚举加载的 SO 模块,成功定位到目标加固 SO。该 SO 采用典型的企业级加固方案,也是很经典的,加载完一个so就被杀进程了

进程崩溃后,首要任务是定位崩溃的核心位置。熟悉 Android SO 加载流程的朋友都知道,JNI_OnLoad 是 Android SO 库被系统加载时自动执行的第一个核心入口函数,也是企业级加固放置反调试、完整性校验、进程强杀等防护逻辑的关键位置,属于逆向分析中必查的核心节点。
因此,我优先针对 JNI_OnLoad 函数的执行流程进行排查,核心思路是:通过 Frida 挂钩 JNI_OnLoad 的 onEnter(函数进入)和 onLeave(函数退出)两个关键时机,并打印日志,以此验证进程能否完整执行完 JNI_OnLoad 函数、顺利走到 onLeave 阶段,从而快速判定崩溃是否发生在 JNI_OnLoad 函数内部。
排查jni函数脚本:
注入结果:

将上述脚本通过 Frida 注入 APP 进程(注入命令:frida -U -f 包名 -l 脚本名.js --no-pause),操作 APP 触发目标 SO 加载,观察日志输出:
从日志中可以清晰看到:脚本成功检测到目标 SO 加载,成功找到 JNI_OnLoad 函数地址,并打印“成功开始”,但始终没有打印“成功结束”。
这个现象直接印证了我的预判:进程并未成功执行完 JNI_OnLoad 函数并正常退出,崩溃发生在 JNI_OnLoad 函数内部。
因此,下一步的排查思路已十分清晰:提取 JNI_OnLoad 函数地址,dump 出完整的加固 SO 文件,通过 IDA 分析 SO 结构,再结合 Frida Stalker 追踪 JNI_OnLoad 的指令执行流,逐行分析执行流程,最终精准定位进程崩溃的“死亡点”(即加固的反调试触发点)。
想要通过 IDA 分析 SO,首先需要将内存中的 SO 文件 dump 到本地。这里的关键是选择合适的 Dump 时机——时机选择不当,可能会导致 dump 出的 SO 不完整、未解密,无法正常分析。
本次采用的 Dump 方案是:在 dlopen 的 onLeave 回调时机,筛选内存中加载的目标 SO 并直接 dump。选择这个时机的核心原因有 3 点:
dump脚本:
执行结果:

修复后用 IDA 打开,发现该 SO 导出表仅有一个 start 函数,完全没有 JNI_OnLoad。这与我之前 Frida 能正常 Hook 到 JNI_OnLoad 的现象完全矛盾,也让我意识到:我拿到的地址根本不属于这个 SO。

此时我立刻产生了一个猜想:难道是壳在执行完 JNI_OnLoad 函数后,对加固的 SO 进行了动态篡改,删除了导出表中的 JNI_OnLoad 信息,导致我 dump 出的 SO 没有该函数?
为了验证这个猜想,我立刻调整了 Dump 时机,将 Dump 操作放在 JNI_OnLoad 的 onEnter 阶段——也就是函数刚准备执行、还未进行任何操作时,就对 SO 进行内存 Dump。修改后的脚本核心逻辑的是:在 Hook JNI_OnLoad 的 onEnter 回调中,调用 dumpSo 函数,确保在函数执行前完成 Dump。
重新执行脚本、导出 SO 文件,再次用 IDA 打开后,结果和之前完全一致:导出表依旧只有一个 start 函数,没有 JNI_OnLoad。
这就排除了“壳动态篡改导出表”的猜想,也让我更加确定:问题出在 Frida 获取函数地址的底层流程上,壳一定篡改了符号查找的逻辑,导致 Frida 拿到了虚假的函数地址。
为了弄清楚壳到底是如何篡改符号查找逻辑的,这里给大家详细拆解 Frida 获取函数地址的底层细节,也为后面分析壳的篡改手法做足铺垫——我刚才脚本中获取 JNI_OnLoad 地址的方式,看似只有两行代码,背后却涉及系统 linker、soinfo 结构体、dlsym 函数的完整交互流程,具体拆解如下:
我脚本中核心的两行代码,负责获取 JNI_OnLoad 的内存地址,也是逆向分析中最常用的符号查找方式:
这两行代码的底层执行流程,并非 Frida 自身实现,而是完全依赖 Linux/Android 系统的动态链接机制,具体可以分为 4 个步骤,每一步都至关重要,也是壳篡改的核心靶点:
这行代码的核心作用,是遍历当前进程内核态维护的“已加载模块链表”(该链表由系统 linker 维护,记录了所有已加载到进程内存中的动态库信息),根据传入的 SO 文件名(so_name),匹配到目标 SO 模块,并返回一个包含该模块所有核心信息的对象。
返回的 module 对象中,最关键的两个信息是:
module.base:目标 SO 的内存基址,即 SO 被映射到进程虚拟内存中的起始地址,所有函数、数据的地址都基于该基址偏移;
module.handle:目标 SO 的模块句柄(本质是一个指针),这个句柄是后续查找函数地址的“钥匙”,用于定位系统 linker 维护的 soinfo 结构体。
简单来说,这一步相当于“找到目标 SO 在系统中的位置,并拿到它的id”,为后续查找函数地址做好准备。
这是最核心的一步,也是壳篡改的主要环节。很多朋友误以为这是 Frida 自身的函数查找逻辑,其实不然——该方法底层会直接调用 Android 系统的 dlsym 函数(定义在 libdl.so 中,是 Linux/Android 标准的动态符号查找函数),并传入两个核心参数:
dlsym 函数的作用,就是根据传入的 handle 句柄,找到对应的符号(函数名、变量名),并返回该符号在进程内存中的绝对地址。
dlsym 函数收到 handle 句柄后,会通过该句柄,找到系统 linker 为每一个加载的 SO 所维护的 soinfo 结构体——这个结构体是 SO 加载到内存后的“身份档案”,记录了 SO 的所有运行时信息,包括:
1.SO 的内存基址、模块大小;
2.SO 的运行时符号表(与 SO 文件中的静态导出表不同,运行时符号表是 SO 加载到内存后,由 linker 解析生成的,用于动态查找符号);
3.SO 的代码段、数据段地址范围;
4. 其他与 SO 运行相关的核心信息。
这里有一个关键知识点,也是我之前容易混淆的地方,重点强调:soinfo 是系统 linker 维护的运行时结构体,壳无法篡改或销毁系统内部的真实 soinfo——因为一旦篡改或销毁,会导致 linker 无法正常管理该 SO,进而引发进程崩溃,这是壳无法规避的内核级限制。但壳可以通过 Hook dlsym 函数,篡改传入的 handle 句柄,或者伪造 dlsym 的返回地址,让上层工具(如 Frida)以为地址来自当前模块,实际上却指向内存中另一块隐藏区域。
找到 soinfo 结构体后,dlsym 函数会遍历该结构体中的运行时符号表,根据传入的符号名("JNI_OnLoad"),匹配到对应的函数条目,进而获取该函数在进程内存中的绝对地址,并将该地址返回给 Frida,最终赋值给 jniAddr 变量。
重点提醒:这个流程完全不依赖 SO 文件的静态导出表!
也就是说,无论 SO 文件的静态导出表(即我们用 IDA 看到的导出表)是否被篡改、删除、加密,只要 soinfo 结构体正常、dlsym 函数未被劫持,Frida 就能通过这个流程,找到 JNI_OnLoad 的真实地址——这也是为什么很多加固会删除静态导出表,但依然能被 Frida Hook 到函数的原因。
上面拆解的,是系统未被篡改时的标准正常流程。但结合我们之前的异常现象(dump 出的 SO 没有 JNI_OnLoad 导出,却能通过 Frida 找到该函数地址),我意识到:壳一定篡改了这个流程。
为了验证这个猜想,我做了一个关键操作:打印 JNI_OnLoad 函数的地址和目标 SO 的模块基址,将两个地址进行比对,结果惊讶地发现了一个极度反常的现象:返回的 JNI_OnLoad 地址 < SO 模块基址。
这里给大家补充一个底层内存布局规则,帮大家理解这个现象的反常性:
正常情况下,SO 被加载到进程内存后,其内存布局遵循“基址 + 偏移”的规则——SO 的代码段(text)、数据段(data)等所有内容,都会被映射到模块基址之后的内存区间,因此,SO 中所有函数的地址,都必然落在 [模块基址, 模块基址 + 模块大小) 的合法区间内,且函数地址一定大于模块基址(因为函数位于代码段,代码段在基址之后)。
而我们发现的“JNI_OnLoad 地址 < 模块基址”,意味着该地址完全不属于当前 SO 模块,这是典型的地址伪造,也直接坐实了壳对底层函数查找机制和内存映射的恶意篡改。

结合前面的分析,我高度怀疑:壳通过劫持系统底层的 dlsym 函数,篡改了传入的 handle 句柄,或者直接伪造了函数地址,导致 Frida 拿到的 JNI_OnLoad 地址是虚假的,并非来自 dump 出的“壳 SO”,而是来自内存中隐藏的真实业务 SO。
为了实锤这个猜想,我编写了 Frida 脚本,Hook 系统的 dlsym 函数,打印每次调用的 handle 句柄、查询的符号名,以及 dlsym 返回的函数地址,重点监控与 JNI_OnLoad 相关的调用,以此捕获壳的篡改证据。
注入脚本:
执行结果:
将上述脚本注入 APP 进程,操作 APP 触发目标 SO 加载,观察日志输出,得到了关键证据:
当 dlsym 函数被调用查询 JNI_OnLoad 符号时,返回的函数地址完全不在任何已知模块的内存区间内——Process.findModuleByAddress(func_addr) 无法找到该地址所属的模块,直接打印“警告: JNI_OnLoad 地址不属于任何已知模块!地址伪造实锤!”。
这个结果直接印证了我的核心猜想,也彻底敲定了真相:
壳 SO 对系统 dlsym 函数做了劫持中转。正常情况下,dlsym 会根据传入的 handle 句柄,遍历目标模块的 soinfo 符号表查询函数地址;但这款加固壳专门拦截了 JNI_OnLoad 的查询请求,直接跳过了系统标准流程,转而跳转到自身私自隐藏加载的真实业务 SO 中进行查询,最终返回了一个虚假的内存地址——这个地址不属于任何已知模块,自然也不会落在 dump 出的“壳 SO”的内存区间内,这也是我们之前发现“地址倒挂”的根本原因。
到这里,我们已经明确了壳的核心防护手法:伪造壳 SO + 劫持 dlsym + 隐藏真实 SO。接下来,我们的核心任务就是:从内存中,把被壳隐藏的真实业务 SO 给“钓”出来。
mmap 是 Linux/Android 内核级的内存映射核心接口,也是加载可执行文件(SO、ELF)的底层基石,没有任何替代方案。
函数作用:将文件数据(如 SO 文件)或匿名内存,映射到当前进程的虚拟地址空间,分配一段连续的虚拟内存,并返回该内存的起始地址。简单来说,就是“给 SO 分配一块内存空间,让它能 在进程中运行”。
系统标准用法:系统 linker 加载 SO 时,必须通过 mmap 函数,将 SO 的代码段(text)、数据段(data)、只读数据段(rodata)等内容,逐一映射到进程内存中,这是 SO 能正常运行的第一步——没有 mmap 分配内存,SO 就无法被 CPU 访问和执行。
加固壳的死穴:壳为了隐藏真实业务 SO,会绕开系统 linker 私自加载(避免被 linker 记录到已加载模块链表中),但它绝对无法绕过 mmap 函数!这是内核级限制,无论壳的防护多强,要让真实 SO 运行,就必须调用 mmap 为其分配内存、写入解密后的代码和数据。
关键价值:每次调用 mmap 函数,都会返回内存基址(分配的内存起始地址)和映射大小(分配的内存长度),这两个信息正是我们定位隐藏 SO 的核心数据——只要捕获到符合壳特征的 mmap 调用,就能拿到隐藏 SO 的内存地址和大小。
mprotect 是专门用于修改内存页访问权限的内核函数,也是 SO 代码能够被 CPU 执行的前提条件,同样无法被绕过。
函数作用:为指定的内存区间,设置可读(R)、可写(W)、可执行(X)三种权限的组合(如 RW、RX、R、W、X 等)。
核心必要性:SO 的代码段(text)必须拥有“可读 + 可执行(RX)”权限,才能被 CPU 读取并执行;如果没有 RX 权限,CPU 会拒绝执行该内存中的代码,程序会直接崩溃。壳将隐藏 SO 解密并通过 mmap 映射到内存后,默认分配的内存权限通常是“可读 + 可写(RW)”(用于写入解密后的代码数据),之后必须调用 mprotect 函数,将代码段的权限修改为 RX,否则隐藏 SO 无法正常运行。
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 1天前
被reserve_zhou编辑
,原因: