跃迁引擎

空気を読んだ雨降らないでよ

iOS Research & Development


通过fishhook探寻iOS动态加载过程

本文通过对 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
2
3
4
5
// 对动态链接的符号进行重新绑定,操作对象是进程的所有镜像
// @param rebindings 结构体数组
// @param rebindings_nel 数组长度
FISHHOOK_VISIBILITY
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);
1
2
3
4
5
6
7
8
9
10
// 对动态链接的符号进行重新绑定,操作对象是某个指定的镜像
// @param header 指定镜像的 Mach-o header
// @param slide 偏移量
// @param rebindings 结构体数组
// @param rebindings_nel 数组长度
FISHHOOK_VISIBILITY
int rebind_symbols_image(void *header,
intptr_t slide,
struct rebinding rebindings[],
size_t rebindings_nel);

这两个API除了操作对象不同外,其功能是一样的,在没有指定镜像需求的情况下,我们使用rebind_symbols函数就可以了。

其中 rebinding 是个结构体,其作用就是字面意思:用于重新绑定符号的特定结构,结构体定义如下:

1
2
3
4
5
struct rebinding {
const char *name; // 需要hook的函数名,C字符串
void *replacement; // 新函数的地址
void **replaced; // 原始函数地址的指针 (用来存放原始函数地址)
};

下面我们 尝试使用 fishhook 对 iOS 系统函数 NSLog 进行 hook。

首先我们进入Foundation/NSObjCRuntime.h 查看一下 NSLog 是如何定义的:

1
2
// 这里省略了部分代码,我们只需要只要函数定义即可
... void NSLog(NSString *format, ...) ...;

然后我们仿照系统的定义方式,来自己声明一个 system_nslog 函数,用来保存等下被替换掉的原始 NSLog 函数地址。

1
2
// 名字随便起,这里叫system_nslog
static void (*system_nslog)(NSString *format, ...);

接着我们需要实现一个自定义的 my_nslog 函数,用来替换被 hook 的 NSLog 函数:

1
2
3
void my_nslog(NSString *format, ...) {
printf("hook成功,执行my_nslog");
}

创建 rebinding 结构体,按照其结构内定义的变量进行传值:

1
2
3
4
5
6
7
8
9
// 创建一个rebinding结构体,命名为nslog_rebinding
// 3个参数对应着rebinding结构体定义的三个变量
struct rebinding nslog_rebinding = {"NSLog", my_nslog, (void *)&system_nslog};

// 或者你可以这么写
struct rebinding nslog_rebinding;
nslog_rebinding.name = "NSLog";
nslog_rebinding.replacement = my_nslog;
nslog_rebinding.replaced = (void *)&system_nslog;

调用 rebind_symbols 进行重新绑定符号:

1
2
3
4
5
6
7
// 重新绑定符号
// 第一个参数是前面创建的rebinding结构体数组,第二个参数的这个数组的长度
rebind_symbols((struct rebinding[1]){nslog_rebinding}, 1);

// 或者你可以这么写
struct rebinding rebs[1] = {nslog_rebinding};
rebind_symbols(rebs, 1);

在 ViewController.m 里试验一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#import "fishhook.h"
#import "ViewController.h"

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"首次执行NSLog");

struct rebinding nslog_rebinding = {"NSLog", my_nslog, (void *)&system_nslog};
rebind_symbols((struct rebinding[1]){nslog_rebinding}, 1);

NSLog(@"第二次执行NSLog");
}

static void (*system_nslog)(NSString *format, ...);

void my_nslog(NSString *format, ...) {
printf("hook成功,执行my_nslog");
}

@end

输出结果:

1
2
- 首次执行NSLog
- hook成功,执行my_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
2
3
4
5
6
7
8
- (void)viewDidLoad {
[super viewDidLoad];
// Mach-O的描述文件中会标明这里使用了 NSLog
// dyld动态链接器会访问我们Mach-O文件的描述文件,知道这里使用了 NSLog,对其动态库产生了依赖
// 接着会找到包含 NSLog 函数的系统动态库,并对其进行链接和绑定
NSLog(@"首次执行NSLog");
....
}

我们自己的代码会被以 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
2
3
4
5
6
7
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"首次执行NSLog"); // 这里

struct rebinding nslog_rebinding = {"NSLog", my_nslog, (void *)&system_nslog};
rebind_symbols((struct rebinding[1]){nslog_rebinding}, 1);
}

那么 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 运行的原理,就这么简单。

参考

Dyld之二: 动态链接过程

分析 fishhook

fishhook的实现原理浅析

最近的文章

如何在 CentOS 7 上搭建一个 Ghost 博客

Ghost 是一套基于 Node.js 构建的开原博客平台(Open source blogging platform),目标是取代臃肿的 Wordpress,界面简洁,专注写作,支持在线预览和在线写作。 …

, , 开始阅读
更早的文章

NSMutableArray原理揭露阅读笔记

NSArray是线性连续内存,这个很好理解。但是NSMutableArray是可以插入和删除的,那么如何做到高效?就比如插入,如何做到尽可能少的移动或者不移动插入元素后其他元素的内存?实现数据结构原理是什么? …

, , , , 开始阅读
comments powered by Disqus