Hook objc_msgSend -- 从 0.5 到 1

转载请注明出处。

如果你是一名 objective-c 开发者,那么相信你多多少少都接触过那神秘而危险的动态替换机制:Method Swizzling;当动态改变 oc 代码无法满足需求的时候,或许你还使用过 fishhook 或是其它用于动态 rebind c 函数的钩子库。在开源社区如此活跃的今天,对上述两种 hook 方法的原理和源码的分析可以说是面面俱到了。

本文并不分析这两种通用的钩子的实现方式,而是对准那一个让所有 oc 开发者魂牵梦绕的 c 方法:objc_msgSend,我们来聊聊 hook 它所带来的问题,以及解决问题的方案。

阅读本文之前,你应该具备一定阅读汇编的能力,同时对函数调用时,栈空间的分配有一定的概念。
本文所有汇编代码基于 arm64。

Hook objc_msgSend, 从 0 到 0.5

函数调用 - 栈空间详解

调试的时候,lldb 的 register read 命令是你的好朋友。
下文中,会频繁出现寄存器名称,如 fp, sp, lr。我会通过“寄存器”和“值”来区别我所指的对象。

我们先从函数调用的幕后说起。

如果你之前了解过函数调用时栈空间的栈分配情况,你应该见过类似下面的栈图:

在这里,我们需要明确两块内存空间:一块是“栈空间”,上图中蓝色框中的内存属于这里,fp、sp 之中存储的通常是这个空间的地址;一块是“代码空间”,是用于存放我们所编写的函数的汇编指令的,上图中的 IMP A/B/C 存放在这里。你可以这样理解他们之间的关系:我们写的代码被编译成汇编指令后,被 runtime 加载进“代码空间”并开始执行,汇编执行过程中,所产生的中间变量(函数传参、值类型局部变量等),被存储在“栈空间”。当然,为了实现函数跳转、返回、堆栈捕获等功能,栈空间还额外存储了 fp 值以及 lr 值(注意是值!)。

让我们结合实际例子,来加深对上图的理解,并且弄清楚这个堆栈是如何生成的,考虑如下代码:

1
2
3
4
5
6
7
8
9
10
@implementation AppDelegate
- (void)simple
{
[self count:1];
}
- (int)count:(int)i
{
return i;
}
@end

先来看 -[AppDelegate simple] 的汇编:

w2 代表只用 2 号寄存器的 32 位。
blblr 指令在跳转的同时,会将 lr 寄存器的值设置为当前 pc+4byte。

从上面的汇编结合一点点思考,我们得出了一些 非常非常重要 的结论:

  1. “不给别人添麻烦”:每个函数都有自己的一块操作空间,我们称其为“栈帧(stack frame)”。寄存器 fpsp 的值是栈帧范围的唯一标识,作为 simple 函数,为了保证自己 return 之后,调用者能继续正常执行,函数体需要自己负责维护 fp/sp 的状态,保证进出时一致。而正是因为大家都遵循这样的规则,在后续代码中,我们才能放心的在调用完 count: 之后,继续使用 fp/sp 寄存器。

  2. “自己的事情自己做”:一个函数有多少参数、多少局部变量,进而需要多大的栈空间,只有函数自己知道,所以函数内部需要自己“挪动” fpsp 的指向,来“声明”自己所需要的空间。

  3. “一切行动听指挥”:我们知道,lr 寄存器存储着返回地址,当函数内部又有函数调用时,我们不可避免的需要改变 lr 寄存器的值,那么为了保证自己能成功回到上一级,函数自己需要将 lr 地址存好,并在执行结束之前重新设置给 lr 寄存器。

在上面的代码中,我们看到 simple 的汇编代码将参数值 1 放进了 2 号寄存器中,那 count: 如何知道我将参数放在了这里呢?我们再看看 count: 方法的汇编代码:

count: 的汇编,我们得出了另外一个重要结论
函数参数的传递并不是口耳相传,而是依赖强类型约束和汇编规范的硬编码!不但寄存器的顺序有严格规定,实际的读写操作也和数据类型强相关(int 类型,寄存器选择用 w 而非 x,存储到栈空间偏移量也不是 8 的整数倍)。但是寄存器的数量终归是有限的,当函数内部需要调用多参方法的时候,调用者的会扩大其栈帧的大小,将寄存器塞不下的参数按一定顺序放置在栈帧中。而被调用方,同样是通过硬编码 offset 的方式,一个一个的从前一个函数的栈帧中读取出来。

你可以自己写一个多参数的函数验证这一结论。

Let’s hook

结合上面所得到的结论,我们再来看看如何 hook objc_msgSend(网上有许多开源项目对 objc_msgSend 进行了 hook,实现体大同小异,本文源码均基于美图的开源性能工具 MTHawkeye):

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
28
29
30
__attribute__((__naked__)) static void hook_Objc_msgSend() {
// Save parameters.
save()
__asm volatile("mov x2, lr\n");
__asm volatile("mov x3, x4\n");

// Call our before_objc_msgSend.
call(blr, &before_objc_msgSend)

// Load parameters.
load()

// Call through to the original objc_msgSend.
call(blr, orig_objc_msgSend)

// Save original objc_msgSend return value.
save()

// Call our after_objc_msgSend.
call(blr, &after_objc_msgSend)

// restore lr
__asm volatile("mov lr, x0\n");

// Load original objc_msgSend return value.
load()

// return
ret()
}

查看此文件的完整代码

为了方便阅读,原开发者将大部分指令定义成了宏,阅读其展开后,我们可以总结出:

  1. 和传统函数不同,函数起头没有声明属于自己的栈空间:fpsp 和上一栈帧一致。
  2. 把所有寄存器的值存在低址,并下移 sp,保证后续函数调用不会修改到这部分栈空间。
  3. lr 寄存器的值当做参数传递给 before_objc_msgSend,后者将其存至内存中(放到堆区的结构体里面了)
  4. 在调用 orig_objc_msgSend 之前,恢复寄存器状态以及 sp 指向,使栈空间的状态恢复到与第一步一致,此时调用 orig_objc_msgSend,所有寄存器、栈帧内容均和原始调用方执行跳转时一致。
  5. after_objc_msgSend 将原有 lr 的值作为返回值返回,并设置至 lr 寄存器之中,确保能正确返回。

整套方案最巧妙的点在于,“复用”了调用方的整个栈帧,站在栈空间的角度来看,hook 方法对于调用和被调用方都是透明的;而为了达到 100% 还原栈帧,lr 的值便只能传递出去存至堆区了。

在几个关键点,堆栈和寄存器状态如图:

可以看到,在调用原实现时,唯一的不同点就是 lr 寄存器指向了 hook 方法,使原方法执行完后回到 hook 方法中。在汇编指令执行时,位于当前 sp 寄存器下面(低址)的内存属于“无人认领”的内存,可以任意使用。这就是为什么第二步我们需要下移 sp,而第三步我们也不需要清理低址残留的寄存器值。

至此,我们理解了目前网上最流行的方案的整体流程和大致原理。

Hook objc_msgSend, 从 0.5 到 1

方案十分巧妙,似乎没有什么问题,接下来让我们开始使用,考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@implementation AppDelegate
- (BOOL)application:(__unused UIApplication *)application didFinishLaunchingWithOptions:(__unused NSDictionary *)launchOptions {
[self functionLevel1];
return YES;
}
- (void)functionLevel1
{
[self functionLevel2];
}
- (void)functionLevel2
{
[self functionLevel3];
}
- (void)functionLevel3
{
NSLog(@"Hey, it's level 3!");
}
@end

随后,让我们在 functionLevel3 之中下一个断点:

NO!函数调用堆栈几乎彻底消失了,假如我们希望实现监控之类的功能,需要长期将 hook 打开,这样的情况显然是不能接受的。那么能不能解决这个问题呢?

残缺的堆栈

如果你把问题设置为“hook 完 objc_msgSend 之后,Xcode 堆栈看不到了”,似乎很难找到一个切入点,那么我们换一个角度思考,站在更底层的角度看,调用堆栈到底是什么呢?我们知道每次函数调用的时候,栈空间都会有新的栈帧在低址开辟,一个一个按顺序摆放,那从低往高遍历,不就是咱们的调用栈吗?是不是 hook 之后,我们把栈区的排列弄坏了呢?我们来分析一下调用到 functionLevel3 时,栈区的栈帧们应该长什么样:

回想一下 hook 方法的实现,我们在调用原 objc_msgSend 时,为了保证原函数执行完能回到 hook 方法中,我们通过 blr 指令将 lr 寄存器的值改为了 hook_Objc_msgSend 内的指令地址,functionLevel1functionLevel2 也是一样。所以此时栈区的布局应该就是如图所示,如此一来,对 Xcode 堆栈的疑惑也就解决了一半:既然栈区存着的返回地址都指向的是 hook_Objc_msgSend,那么 Xcode 只能显示出 hook 函数也就理所应当了;但问题的另一半依然令人费解:程序可以执行、不会崩溃,证明栈区栈帧的布局应该是正确的,那么尽管符号不正确,调用层级也应该能正确显示,为什么这里只显示了一层呢?

要弄清楚问题的原因,就必须弄清楚系统是如何查找调用栈的。由于问题出在编译器下断点时,所以我选择去看看 LLDB 的源码。我并没有在源码中直接找到答案,毕竟这是一个庞大而且缺乏 debug 手段的工程,但我在方法 UnwindLLDB::RegisterSearchResult RegisterContextLLDB::SavedLocationForRegister 中发现,堆栈的寻找似乎和一个叫 unwind 的机制有很大的关系,且源码中有大量日志打印,经过查阅,我最终发现使用在 lldb 中使用 log enable lldb unwind 指令后,就能在控制台打印出 lldb 回溯堆栈的相关日志了。

那么一次“正常”的堆栈查找,和我们 hook 之后的堆栈查找的区别在哪里呢?经过实验对比,我发现了一些端倪:

残缺堆栈打印的日志

完整堆栈打印的日志

限于篇幅,并没有将日志截取完全,需要注意的是两图中标红的部分,以及一个叫 CFA 的概念。
先说说未贴出的日志:对于残缺的堆栈,由于 CFA 停止移动了,尝试获取上一个调用栈时,发现地址重复,就停止寻找了;而完整堆栈的 CFA 正常,可以一直回溯找到调用顶层。

建议读者自己打开 unwind 日志并进行调试,看看 CFAfp, sp 寄存器的关系。

两边日志的转折点就在这标红的一行:hook 开启后,这个 CFA 似乎指向错误了。CFAcanonical frame address 的简称,在谷歌上搜,排名靠前又有一些实际内容的答案就只有 这一篇,回答中指出:

Typically, the CFA is defined to be the value of the stack pointer at the call site in the previous frame

可以看到,它确实和查找上一级调用有着密切关系。巧合的是,这篇问答贴了很多汇编代码,一眼就让人觉得熟悉;现在,随便去打开一个 .m 文件的汇编,你会看到非常相似的代码模式:一些汇编指令,夹杂着一些以 “.” 开头的汇编修饰,上下滑动,看看各个函数的汇编,你会惊讶的发现所有的方法都有如下的几句:

1
2
3
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16

CFAoffsetx29(fp)x30(lr),这几个东西组合起来令人兴奋!

原来堆栈的回溯,靠的并不是 “我按约定,把 fp、lr 存在这里,你需要就直接来读” 这样的隐式约定,而是通过白纸黑字的显示声明!还记得 hook_Objc_msgSend 方法是多么“干净”吗,尽管我们没有拓展出新的栈帧,但的确是进入了另一段指令地址,还没有任何回溯相关的声明。由于未对原栈帧进行任何修改,所以我们可以直接拷贝上述代码,用来声明我们存储 fplr 的地方:

1
2
3
4
5
6
7
8
9
10
__attribute__((__naked__)) static void hook_Objc_msgSend() {

__asm volatile(".cfi_def_cfa w29, 16\n"
".cfi_offset w30, -8\n"
".cfi_offset w29, -16\n")
;

// Save parameters.
save()

...
}

再次运行,调用层级的问题解决!

One more step

调用层级尽管正确了,但一连串的 hook_Objc_msgSend 其实依然不能为我们的调试提供有价值的信息,让我们再回头重新审视一下现在的栈区:

和上面的是同一张图

站在系统的角度而言,它其实捕获到了正确的堆栈,毕竟栈区存储的信息就是如此;而为了在调用完原方法后,系统能回到 hook 函数中继续走我们的 after 逻辑,我们又的确只能在调用 orig_bjc_msgSend 的时候,将 lr 寄存器的值设置为 hook 方法。其实核心矛盾点在这里:hook_Objc_msgSend 与调用者共享了栈帧,导致原 lr 在栈区无法存放;我们将 lr 值存到堆区,的确可以保证程序正常运行,但却使得系统回溯堆栈时无从下手。我们需要让 lr 值对系统可见,就必须在栈区开辟一个完整的栈帧,和一个普通函数一样按规范存储和声明各个寄存器的值:

那么我们自行开辟的栈帧应该多大,里面又应该放些什么呢?通过前面的例子我们知道,函数调用时,被调用方除了会从寄存器中读取参数外,也会从调用方的栈帧内读取参数,为了保证程序正常的执行,我们需要为被调用方提供正确的上下文,因此,我们需要拷贝调用方的栈帧。

思路明确之后,代码就非常好写了:由于进入 hook 方法时,寄存器状态、fp/sp 指向均是调用方设置的,我们只需要拷贝下来就可以了。唯一需要注意的一点是:我们拷贝的是除了栈顶之外的所有内容,因为栈顶我们要用来放 fp 寄存器的值。读者可以自行尝试实现这块的代码,作为参考,我将我的实现 放在这里

所贴代码可以运行,但并非最优代码:在栈区存放 lr 之后,我们就可以不需要在堆区存储其值了。另外由于汇编水平有限,实现也并非最精简的代码。

在拷贝完栈帧,存放好 fplr 的值,并声明回溯地址后,Xcode 中的堆栈现在变成了这样:

堆栈已经能够完整展示了!

从 0.5 到 0.x

尽管本文的标题是《Hook objc_msgSend – 从 0.5 到 1》,但实际上解决方案并不完美。一方面,我们拷贝了每一个 oc 方法的栈帧,在空间和时间上都有一定的牺牲;另一方面,lldb 所捕获的堆栈有大量冗余的 hook_Objc_msgSend 符号,当调用层级很深的时候,体验相当不好,但本文的探索到这里就止步了,后续如果有更好的解决方案,请务必联系我。

练习时刻

对基类方法的调用并不是走 objc_msgSend 流程,而是走的 objc_msgSendSuper,在鼓励继承的 oc 开发中,漏掉对基类的监控可不是什么好事。hook_objc_msgSendSuper 的实现与 hook_Objc_msgSend 类似,动手试试吧!

Gocy wechat
觉得不错?请我喝杯咖啡吧~