本文通过对 fishhook 运行原理的分析,来探寻一个 iOS 应用在动态加载的过程中究竟做了哪些操作。
前言
阅读本文前,你需要了解 Mach-O 文件的基本结构以及 dyld 的基本作用,不清楚的同学可以通过下面两篇文章做下简单的了解,下文将不会对这些基础概念进行额外介绍:
fishhook
fishhook 是 Facebook 提供的一个动态修改链接 Mach-O 文件的工具。一句话概括就是:利用 Mach-O 文件加载原理(动态绑定机制),通过修改懒加载和非懒加载两个表的指针,达到 hook 系统 C 函数的目的。
在 iOS 中, dyld 通过更新 Mach-O 二进制文件的 _data 段的特定部分中的指针来绑定懒加载和非懒加载符号,而 fishhook 就是通过传递给 rebinind_symbols 的每个符号的更新位置,写出相应的替换符,从而重新绑定这些符号,来完成对系统 C 函数的 hook。
在懒加载或非懒加载指针表中查找给定条目名称的过程如下:
fishhook 非常轻量级,总共也只有300多行代码,但其中蕴含的思想价值却非常值得我们研究,本文不会对其源码进行逐行分析,相关文章网上已有许多,需要可以自行查找。下文仅会罗列我们在分析过程中需要使用到的函数,重点是对其运行原理的探究,以此来窥探 iOS 应用在启动加载过程中究竟做了哪些工作。
fishhook的简单应用
fishhook的头文件中,提供了下面这两个API:
1 | // 对动态链接的符号进行重新绑定,操作对象是进程的所有镜像 |
1 | // 对动态链接的符号进行重新绑定,操作对象是某个指定的镜像 |
这两个API除了操作对象不同外,其功能是一样的,在没有指定镜像需求的情况下,我们使用rebind_symbols
函数就可以了。
其中 rebinding
是个结构体,其作用就是字面意思:用于重新绑定符号的特定结构,结构体定义如下:
1 | struct rebinding { |
下面我们 尝试使用 fishhook 对 iOS 系统函数 NSLog 进行 hook。
首先我们进入Foundation/NSObjCRuntime.h 查看一下 NSLog 是如何定义的:
1 | // 这里省略了部分代码,我们只需要只要函数定义即可 |
然后我们仿照系统的定义方式,来自己声明一个 system_nslog 函数,用来保存等下被替换掉的原始 NSLog 函数地址。
1 | // 名字随便起,这里叫system_nslog |
接着我们需要实现一个自定义的 my_nslog 函数,用来替换被 hook 的 NSLog 函数:
1 | void my_nslog(NSString *format, ...) { |
创建 rebinding 结构体,按照其结构内定义的变量进行传值:
1 | // 创建一个rebinding结构体,命名为nslog_rebinding |
调用 rebind_symbols 进行重新绑定符号:
1 | // 重新绑定符号 |
在 ViewController.m 里试验一下:
1 |
|
输出结果:
1 | - 首次执行NSLog |
Ok,hook 系统 NSLog 成功。
fishhook是如何工作的
通过前面的演示,我们可以看到 fishhook 很轻松的就对 iOS 的系统库 NSLog 函数进行了 hook,虽然 Facebook 官方告诉我们 fishhook 是利用 Mach-O 文件动态绑定机制,通过对符号进行重新绑定来实现的 hook,但它究竟是如何工作的呢?我们都知道 C 函数的静态的,也就是说在编译期 IDE 就知道了它的函数地址,那为什么 fishhook 仍然能够改变 C 函数的调用呢,这其中是什么原理,让我们来一探究竟。
ASLR
我们知道 Mach-O 文件是通过 dyld 进行加载的,但在 dyld 对 Mach-O 进行加载的过程中,出于安全考虑使用了一项技术 ASLR(Address space layout randomization),翻译过来就叫地址空间布局随机化,其作用就是在 dyld 加载每一个可执行文件(Mach-O)的时候,在这个文件的地址前,加上了一段偏移地址,并且这段偏移地址是一个随机值。
由于 ASLR 的使用,导致了每次 dyld 加载 Mach-O 的时候其地址都不一样。而我们自己定义的 .h .m 文件都会被编译成二进制文件.o,它是 Mach-O 的一种文件类型,所以也会被 dyld 进行加载。而系统库函数 NSLog 则不在我们的 Mach-O 当中,它存在于系统的动态库 Foundation 里。
共享缓存区
iOS 系统有许多动态库(dylib),只有在使用的时候,这些动态库才会被系统加载到内存中。这些系统的动态库有一个公用的共享缓存区(也叫共享缓存库),由于动态库在系统中只保留一份内存,所以当某个 APP 使用到这个动态库的时候,就会去访问这个共享缓存区。
PIC
当我们自己的 Mach-O 被 dyld 加载进内存后,会运行 dyld 动态链接器(Dynamic Linker)来检查 Mach-O 对动态库的依赖,并以此为依据对动态库进行链接。以我们之前写的代码为例:
1 | - (void)viewDidLoad { |
我们自己的代码会被以 Mach-O 的形式进行加载,而 Mach-O 采用了PIC(Position-independent code)技术,即位置独立代码。
当你的应用要调用一个 Mach-O 外部的系统函数时(例如 NSLog),那么 PIC 会在你的 Mach-O 文件的 __DATA
段中创建一个指针(这个指针也就是我们说的符号,symbol),并赋予 8bit 的空间用来保存外部函数的地址;这个指针由 dyld 操作,当 dyld 加载了 Mach-O 之后,在我们自己的 Mach-O 中,是不知道系统库函数 NSLog 的具体地址的,这个时候只有对 Mach-O 以及 dylib (准确地说应该是dylib的共享缓存库)进行加载的 dyld 才知道它们各自的具体位置(毕竟是通过它来加载的嘛,如果连地址都不知道,怎么能正常加载呢),所以这个时候 dyld(这里指的是动态链接器) 会去访问 Mach-O 文件的 Load commands
描述文件,在描述文件中会标明这个 Mach-O 依赖了哪些库(LoadCommand 里面的会有一个cmd来描述 dynamic loader info)。
以 NSLog 为例,当 dyld 发现我们的 Mach-O 依赖了 NSLog 所在的动态库,那么这个时候就会开始进行绑定(bind),绑定的过程就是将动态库里 NSLog 的地址赋值给我们之前说的 __DATA 段里创建的那个指针 ,所以 Apple 采用 PIC 这个技术就是为了让 C 的底层也能拥有动态性。
一句话概括就是:dyld利用PIC将内部函数的指针(符号)与外部函数的指针(符号)绑定在一起。
结论
也许现在你就会明白 dyld 为什么会叫这个名字了,因为它是Dynamic Loader,动态加载。
从这里我们也可以看出:C语言虽然是静态的,但是系统可以造就其动态性。
fishhook 正是利用了 PIC 技术做了这么两个操作:
- 将指向系统方法(外部函数)的指针重新进行绑定指向内部函数/自定义 C 函数。
- 将内部函数的指针在动态链接时指向系统方法的地址。
这样就把系统方法与自己定义的方法进行了交换,达到 HOOK 系统 C 函数(共享库中的)的目的。
所以fishhook的函数名叫rebind_symbols(重新绑定符号),这同样也解释了为什么fishhook只能hook系统C函数,而不能hook我们自己的函数。
观察动态加载的过程
通过MachOView分析我们的 Mach-O文件,我们可以看到文章开头所说的两张表,非懒加载表和懒加载表。
其中 Offset 表示 ASLR 产生的偏移量,Value 则是函数指针的真实地址(这个值由 dyld 在运行的那一刻提供)。
有没有发现,NSLog 函数并没有出现在非懒加载表中,而是懒加载表中。这说明,NSLog 函数是以懒加载的方式进行加载的,也就是说,如果没有调用 NSLog,那么在 Mach-O 中是看不到它的。因为我们在 rebind_symbols 之前调用了一次 NSLog,所以我的 Mach-O 中的懒加载表里可以看到它:
1 | - (void)viewDidLoad { |
那么 NSLog 现在在内存的哪个区域呢?可以通过 lldb 查看下,在 Xcode 中 rebind_symbols((**struct** rebinding[1]){nslog_rebinding}, 1);
行出打上断点,输入下列命令
1 | image list // 打印当前所有模块列表 |
可以看到,在首先加载了我们 Demo 的可执行文件后,紧接着就是 dyld、Foundation、dylib …
因为我们想看 NSLog 在内存的哪个区域,所以我们读一下它的 Mach-O 文件的内存地址 0x0000000104934000
(就是图中FishhookDemo可执行文件前面的地址),lldb 输入下列命令
1 | x 0x0000000104934000+0x8018 // 加号后面的0x8018为NSLog的偏移量 |
其中冒号左边的值是指针(symbol)的地址,冒号右边的到00中间这部分是该指针的值。那么这个指针的值是指向的谁呢?是 NSLog 吗?我们不妨通过打印汇编代码来验证一下,由于 iOS 的 CPU 是小段序的,所以我们要从右往左读3c e5 2e 84 01
,也就是:
1 | dis -s 0x01842ee53c |
结果果然不出所料,就是 NSLog,这样一来,我们就找到了 NSLog 在内存中的实际地址了。那么下面第二行那段又是指向谁的地址呢?
1 | dis -s 0x018802cfb4 |
发现它指向的地址是我们的 UIApplicationMain 函数。
由于我们断点打在了rebind_symbols((**struct** rebinding[1]){nslog_rebinding}, 1);
这一行,说明fishhook的重绑定还未开始,NSLog 还未交换成我们自己的函数,所以地址指向的仍然是 NSLog,那么当fishhook完成重绑定后又会变成什么呢?
向下执行到这一行
1 | NSLog(@"第二次执行NSLog"); |
现在我们再来看看刚才指向 NSLog 的指针(symbol)地址现在指向的是谁,也就是之前绑定了 NSLog 的符号现在绑定的是谁,继续打印 Mach-O 文件的地址
1 | x 0x0000000104934000+0x8018 |
冒号左边的指针地址没变,说明还是同一个指针(symbol),但是冒号右边指针的值缺明显发生改变了。现在它指向的是谁呢?我们来看一下:
1 | dis -s 01049394d4 |
现在之前指向 NSLog 的指针指向了我们自定义的 my_nslog 函数的地址,也就是说,绑定 NSLog 的符号现在被 fishhook 重新绑定到了 my_nslog 上,这也就是 fishhook 运行的原理,就这么简单。