作者:郝连福,业界资深计算机技术专家,现任声网 Agora 首席前端架构师。先后担任过 Principal Engineer/Engineering Director(UTStarcom)、Sr. architect(Intel)、T4 architect(YY)等职,曾设计开发电信核心网专用操作系统、高性能 TCP/IP 协议栈、以及声网 SDK 架构重构等重大项目。
引言
本文是声网 Agora 与 RTC 开发者社区共同发起的 Dev for Dev(Developer for Developer)互动创新实践活动的开篇,同时也是开源技术爱好者在一线工作中的真实记录。文中遇到的情况颇具代表性,特整理分享出来以飨读者。
通常在 iOS 中实现应用 Hook 的方式有以下三种:
Method Swizzling:利用 OC(Objective C)的 Runtime 特性,动态改变 SEL(方法编号)和 IMP(方法实现)的对应关系,达到 OC 方法调用流程改变的目的,只适用于动态的 OC 方法;
Fishhook:FaceBook(现更名为 Meta)提供的一个动态修改链接 Mach-O 文件的工具,利用 Mach-O 文件加载原理,通过修改懒加载和非懒加载两个表的指针实现 C 函数 HOOK 的效果;适用于静态的 C 方法;
Cydia Substrate:原名为 Mobile Substrate,是一个强大的框架,它的主要作用是针对 OC 方法、C 函数以及函数地址进行 HOOK 操作,适用于 OC 方法、C 函数以及函数地址,亦适用于 Android 平台。
Fishhook 是一个由 Meta 公司开源的第三方框架,它能够在模拟器和设备上动态地重新绑定运行在 iOS/macOS 上的 Mach-O 二进制文件的符号,从而实现动态修改 C 语言函数,常用于应用的调试/追踪。这个框架只包含两个核心文件:fishhook.c 以及 fishhook.h 所以非常轻量,在许多企业级应用中颇受青睐。然而这个以精练著称的开源项目中,却埋藏着一个不易察觉的问题……
随着 iOS 15 Beta 版的发布,许多开发者发现了普遍的应用程序崩溃──这通常由系统兼容性问题引发,而随着排查过程的不断深入,我们发现问题并没有那么简单。起初,开发者把问题反馈到 Fishhook 之后,有不同的团体和个人贡献了好几个修复的 PR,但都未能从根本上解决这个问题。在仔细分析了 iOS 和 macOS 的操作系统内核 XNU 源码后,我们最终定位到了问题的 RootCause。
对 Fishhook Crash 问题的溯源
为了定位问题,我们通常会根据现有的报错日志尝试对问题进行复现,通过调试追踪我们发现,在 iOS 15 或者 macOS 12 的环境下 Fishhook 代码在重绑定符号时会 100% 地发生崩溃现象,正是这个崩溃导致集成了 Fishhook 的应用变的不可用。鉴于这个问题的影响很大,一些使用了 fishhook 项目的应用在发现问题后紧急移除了该组件以缓解其影响。
造成 fishhook 崩溃的根本原因
Fishhook 的工作原理需要 Hook 修改符号动态绑定数据段,这些数据段的默认权限一般是只读的,所以需要加上 “写” 权限才能修改,而问题恰好就出在这里──我们在排查过程中发现 Fishhook 里增加 “写” 权限的代码存在 Bug,问题相关代码如下:
这段代码里面有 3 个严重错误,为了便于阅读,我们分别以红绿蓝 3 个颜色的框将相关代码标识出来,对这些错误的具体解释如下:
首先,不能仅根据 __DATA_CONST 这个 segname 来判断是否需要增加 “写” 权限,因为从 iOS 14.5 甚至更早的版本开始,都需要 Hook 一个叫 __AUTH_CONST 的 segment,因此只 Hook 一个 __DATA_CONST 字段是不够的;
其次,获取当前的 vm prot 时,传错了地址,不应该是 rebindings,因为我们要写入的地址是 indirect_symbol_bindings;
最后,XNU 内核的 C-O-W 机制与 Linux Kernel 不同,对于 RO 的 vm segment mapping 需要显式指定 VM_PROT_COPY 才能增加 “写” 权限,但是 XNU BSD 的 mprotect 系统调用根本就做不到这一点,故而这句 mprotect 系统调用形同虚设,相当于什么也没做!XNU MACH 关键代码逻辑如下:
Fishhook 代码存在的上述 3 个错误叠加在一起,最终导致在修改 indirect_symbol_bindings 所指向的数据时发生了 “写” 保护错误,进而发生的 Crash 影响了整个应用系统。
修复 Fishhook 崩溃的最佳方法
既然我们已经找到了 Bug 位置所在,修复的思路便只需对症下药即可:
将原来写错的地址 rebindings 修改成 indirect_symbol_bindings;
将 mprotect 系统调用改成使用 vm_protect 系统调用,并增加 VM_PROT_COPY 选项;
代码逻辑上修改为只有 vm_protect 系统调用执行成功时,才能去做 “写” 动作。
因此 Bug 修复的核心代码如下:
这里需注意,首先,为符号动态绑定的数据段增加 “写” 权限时一定要添加 VM_PROT_COPY
选项,否则写入操作会失败;其次,要在代码逻辑中添加 “只有 vm_protect
系统调用返回成功” 才能真正去执行 “写” 这些数据段的操作,否则就什么都不要做。
经过严格的测试和反复验证,我们彻底修复了这个 Bug,并在 2021 年的 6 月 12 日向 Fishhook 官方提交了 PR(https://github.com/facebook/fishhook/pull/87), Fishhook 的维护团队在对比了多个修复方案后,最终选择 Merge 了我们的修复补丁并将其合并进主分支,至此该问题最终得以解决。
系统升温(级)使 “冰封” 的 Bug 得见天日
读者大概率会好奇,为什么在 iOS 15 或者 macOS 12 之前的版本没有这个问题呢?
事实上,在 iOS 15 或者 macOS 12 之前的操作系统自身也存在这个缺陷,对这些数据段的保护并不严谨,对应该 “只读” 的数据段并没有去掉 “写” 权限,我们调查到相关的证据如下:
在上述证据片段中,protection 数值 3 表示权限为 “可读可写”,因此 Fishhook 代码里面做 Hook 动作的 “写” 操作在老版本的 iOS/macOS 中并没有任何问题。但是 iOS 15/macOS 12 新版本操作系统中对这些数据段的保护更加严格,对相应的权限做了一些调整──将本应赋予 “只读” 数据段的 “可读可写” 权限修正为 “只读”,也就是说上述证据片段中 protection 的数值发生了变化,相关的证据如下:
上述代码片段中的 protection 数值 1 代表 “只读” ──也理应如此。但正是这种 “修正” 与原来 “不当” 的配置产生了逻辑上的冲突,最终 Fishhook 的这个 Bug 在较新的 iOS 15/macOS 12 系统中暴露出来,导致了严重的崩溃问题。从代码的角度来看 Fishhook 的这个 Bug 显然是一直存在的,只是在早期的 iOS 和 macOS 版本中没有构成触发的条件,故而隐患一直被雪藏,直到相关的条件被改变。
总结
通常在应用开发过程中,本着不重复造轮子和快速上线、不断迭代的原则,我们经常会引入第三方模块,尤其是有着广泛应用的底层开源组件。但随着 IT 基础设施的变迁,系统环境会随着时间的推移不断增加新特性、抛弃旧实现,在这个过程中由于依赖问题我们的应用不可避免地会不断遭遇不可用的挑战。作为业务应用的开发者,我们必须不断提高向上游组件进行问题溯源的能力,秉持开发者的初心,取自开源、回馈开源。
Dev for Dev 专栏介绍
Dev for Dev(Developer for Developer)是声网 Agora 与 RTC 开发者社区共同发起的开发者互动创新实践活动。透过工程师视角的技术分享、交流碰撞、项目共建等多种形式,汇聚开发者的力量,挖掘和传递最具价值的技术内容和项目,全面释放技术的创造力。