首页
社区
课程
招聘
[原创]Frida企业级加固对抗实战: 通过 mmap/mprotect 钓出壳隐藏的真实 SO
发表于: 1天前 1135

[原创]Frida企业级加固对抗实战: 通过 mmap/mprotect 钓出壳隐藏的真实 SO

1天前
1135

今天在对某企业级 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编辑 ,原因:
收藏
免费 44
支持
分享
最新回复 (33)
雪    币: 7
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
感谢分享
1天前
0
雪    币: 682
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
tql
1天前
0
雪    币: 12604
活跃值: (8996)
能力值: ( LV12,RANK:250 )
在线值:
发帖
回帖
粉丝
4
思路不错, mprotect改匿名R-X段太明显了 我文章给的脚本可以配合hook dlopen, 匹配到目标so名后在onLeave中扫内存, 可以得到解密后的so, 即自定义linker和目标so同名. 估计自定义linker在init/init_array/JNI_OnLoad执行了解密操作, onLeave时解密完成所以可以dump.
1天前
0
雪    币: 1280
活跃值: (344)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
5
东方玻璃 思路不错, mprotect改匿名R-X段太明显了[em_065] 我文章给的脚本可以配合hook dlopen, 匹配到目标so名后在onLeave中扫内存, 可以得到解密后的so, 即自定义lin ...
谢谢大佬指点!回头就去试试这个好方法
1天前
0
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
6
感谢分享
1天前
0
雪    币: 162
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7
大佬,如何找到dumpso的JNI_OnLoad的地址啊
1天前
0
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
8
tql
1天前
0
雪    币: 756
活跃值: (2942)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
9
感谢分享
1天前
0
雪    币: 1280
活跃值: (344)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
10
mb_cfjwplfo 大佬,如何找到dumpso的JNI_OnLoad的地址啊
frida官方有一个专门查全局函数的API在文档里面有体现,你先把这个jnionload绝对地址给查出来  和他申请的匿名内存块的绝对地址进行相减,就能得到它的真实偏移了  他把jnionload函数重定位到隐藏so了
1天前
0
雪    币: 756
活跃值: (2942)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
11
mb_cfjwplfo 大佬,如何找到dumpso的JNI_OnLoad的地址啊
hook dlsym 
1天前
0
雪    币: 162
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
12
reserve_zhou frida官方有一个专门查全局函数的API在文档里面有体现,你先把这个jnionload绝对地址给查出来 和他申请的匿名内存块的绝对地址进行相减,就能得到它的真实偏移了 他把jnionload函数 ...
大佬我发现你文章那个jnionload算错了,偏移应该是函数减基地址,你算的是基址减偏移,函数地址是大于基址的
1天前
0
雪    币: 1280
活跃值: (344)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
13
mb_cfjwplfo 大佬我发现你文章那个jnionload算错了,偏移应该是函数减基地址,你算的是基址减偏移,函数地址是大于基址的
并没有算错 就是因为函数小于这个地址,才有了这篇文章 因为他把这个重定向到了另外一块地址
1天前
0
雪    币: 1280
活跃值: (344)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
14
mb_cfjwplfo 大佬我发现你文章那个jnionload算错了,偏移应该是函数减基地址,你算的是基址减偏移,函数地址是大于基址的
然后看一下我文章的开头前言,因为这个可能是对地址做了一下重定位  他把这个linker给劫持了
1天前
0
雪    币: 162
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
15
reserve_zhou 然后看一下我文章的开头前言,因为这个可能是对地址做了一下重定位 他把这个linker给劫持了
我问了ai,而图片中给出的偏移是 0xfffffffffb5cd114,这实际上是 -0x4a32eec 的补码表示(即基址减函数地址的结果)。因此,偏移计算的方向反了,得出的结果不正确。正确的偏移应为正数 0x4a32eec
1天前
0
雪    币: 1280
活跃值: (344)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
16
mb_cfjwplfo 我问了ai,而图片中给出的偏移是 0xfffffffffb5cd114,这实际上是 -0x4a32eec 的补码表示(即基址减函数地址的结果)。因此,偏移计算的方向反了,得出的结果不正确。正确的偏移应 ...

正因为它劫持了 Linker,导致  JNI_OnLoad  不在正常 ELF 段内,才出现了这种反常值。所谓的“正数偏移”在此场景下并不成立,这不是计算错误,是内存布局被篡改的典型现象。

最后于 1天前 被reserve_zhou编辑 ,原因:
1天前
0
雪    币: 1280
活跃值: (344)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
17
mb_cfjwplfo 我问了ai,而图片中给出的偏移是 0xfffffffffb5cd114,这实际上是 -0x4a32eec 的补码表示(即基址减函数地址的结果)。因此,偏移计算的方向反了,得出的结果不正确。正确的偏移应 ...
你把我的开头喂给AI让AI帮你讲解一下,应该就能得到你想要的答案了
1天前
0
雪    币: 162
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
18
reserve_zhou mb_cfjwplfo 我问了ai,而图片中给出的偏移是 0xfffffffffb5cd114,这实际上是 -0x4a32eec 的补码表示(即基址减函数地址 ...
你用了基址减函数这个偏移一定是负数。
看这里文章的这里,jni是0x70fe,基址是0x70f9
而我们发现的“JNI_OnLoad 地址 < 模块基址”,
1天前
0
雪    币: 99
活跃值: (1382)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
19
感谢分享!!
1天前
0
雪    币: 1280
活跃值: (344)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
20
mb_cfjwplfo 你用了基址减函数这个偏移一定是负数。 看这里文章的这里,jni是0x70fe,基址是0x70f9 而我们发现的“JNI_OnLoad 地址 < 模块基址”,

感谢指正!刚刚的图片在复现时的情况和第一次不一样,第一次遇到情况时,匿名so块的基本地址小于壳so的基本地址,所以才会出现jni_onload重定位到小于base地址的情况!我写帖子复现时出现了第二种结果,匿名so块的基本地址大于壳so基本地址,所以出现了图片的有悖情况!现已将图片替换成第一种情况方便大家确认偏移的异常!但是本质都是劫持linker,这是文章的重点,再次感谢指正!

最后于 1天前 被reserve_zhou编辑 ,原因:
1天前
0
雪    币: 100
活跃值: (125)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
21

大佬,遇到防止hook的壳怎么办,会直接卡第一屏,检测很厉害

最后于 1天前 被wx_妳还是不懂编辑 ,原因:
1天前
0
雪    币: 1280
活跃值: (344)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
22
wx_妳还是不懂 大佬,遇到防止hook的壳怎么办,会直接卡第一屏,检测很厉害
我有一篇帖子专门讲了过检测的思路,还在脱敏 应该过一阵会放出来。这篇帖子就是过检测的前置
1天前
0
雪    币: 104
活跃值: (8122)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
23
rbq
1天前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
24
666
1天前
0
雪    币: 447
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
25
++
14小时前
0
游客
登录 | 注册 方可回帖
返回