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 | @implementation AppDelegate |
先来看 -[AppDelegate simple]
的汇编:
w2 代表只用 2 号寄存器的 32 位。
bl
、blr
指令在跳转的同时,会将lr
寄存器的值设置为当前 pc+4byte。
从上面的汇编结合一点点思考,我们得出了一些 非常非常重要 的结论:
“不给别人添麻烦”:每个函数都有自己的一块操作空间,我们称其为“栈帧(stack frame)”。寄存器
fp
、sp
的值是栈帧范围的唯一标识,作为simple
函数,为了保证自己return
之后,调用者能继续正常执行,函数体需要自己负责维护fp/sp
的状态,保证进出时一致。而正是因为大家都遵循这样的规则,在后续代码中,我们才能放心的在调用完count:
之后,继续使用fp/sp
寄存器。“自己的事情自己做”:一个函数有多少参数、多少局部变量,进而需要多大的栈空间,只有函数自己知道,所以函数内部需要自己“挪动”
fp
、sp
的指向,来“声明”自己所需要的空间。“一切行动听指挥”:我们知道,
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 | __attribute__((__naked__)) static void hook_Objc_msgSend() { |
为了方便阅读,原开发者将大部分指令定义成了宏,阅读其展开后,我们可以总结出:
- 和传统函数不同,函数起头没有声明属于自己的栈空间:
fp
、sp
和上一栈帧一致。 - 把所有寄存器的值存在低址,并下移
sp
,保证后续函数调用不会修改到这部分栈空间。 - 把
lr
寄存器的值当做参数传递给before_objc_msgSend
,后者将其存至内存中(放到堆区的结构体里面了) - 在调用
orig_objc_msgSend
之前,恢复寄存器状态以及sp
指向,使栈空间的状态恢复到与第一步一致,此时调用orig_objc_msgSend
,所有寄存器、栈帧内容均和原始调用方执行跳转时一致。 after_objc_msgSend
将原有lr
的值作为返回值返回,并设置至lr
寄存器之中,确保能正确返回。
整套方案最巧妙的点在于,“复用”了调用方的整个栈帧,站在栈空间的角度来看,hook 方法对于调用和被调用方都是透明的;而为了达到 100% 还原栈帧,lr
的值便只能传递出去存至堆区了。
在几个关键点,堆栈和寄存器状态如图:
可以看到,在调用原实现时,唯一的不同点就是 lr
寄存器指向了 hook 方法,使原方法执行完后回到 hook 方法中。在汇编指令执行时,位于当前 sp
寄存器下面(低址)的内存属于“无人认领”的内存,可以任意使用。这就是为什么第二步我们需要下移 sp
,而第三步我们也不需要清理低址残留的寄存器值。
至此,我们理解了目前网上最流行的方案的整体流程和大致原理。
Hook objc_msgSend, 从 0.5 到 1
方案十分巧妙,似乎没有什么问题,接下来让我们开始使用,考虑如下代码:
1 | @implementation AppDelegate |
随后,让我们在 functionLevel3
之中下一个断点:
NO!函数调用堆栈几乎彻底消失了,假如我们希望实现监控之类的功能,需要长期将 hook 打开,这样的情况显然是不能接受的。那么能不能解决这个问题呢?
残缺的堆栈
如果你把问题设置为“hook 完 objc_msgSend 之后,Xcode 堆栈看不到了”,似乎很难找到一个切入点,那么我们换一个角度思考,站在更底层的角度看,调用堆栈到底是什么呢?我们知道每次函数调用的时候,栈空间都会有新的栈帧在低址开辟,一个一个按顺序摆放,那从低往高遍历,不就是咱们的调用栈吗?是不是 hook 之后,我们把栈区的排列弄坏了呢?我们来分析一下调用到 functionLevel3
时,栈区的栈帧们应该长什么样:
回想一下 hook 方法的实现,我们在调用原 objc_msgSend
时,为了保证原函数执行完能回到 hook 方法中,我们通过 blr
指令将 lr
寄存器的值改为了 hook_Objc_msgSend
内的指令地址,functionLevel1
、functionLevel2
也是一样。所以此时栈区的布局应该就是如图所示,如此一来,对 Xcode 堆栈的疑惑也就解决了一半:既然栈区存着的返回地址都指向的是 hook_Objc_msgSend
,那么 Xcode 只能显示出 hook 函数也就理所应当了;但问题的另一半依然令人费解:程序可以执行、不会崩溃,证明栈区栈帧的布局应该是正确的,那么尽管符号不正确,调用层级也应该能正确显示,为什么这里只显示了一层呢?
要弄清楚问题的原因,就必须弄清楚系统是如何查找调用栈的。由于问题出在编译器下断点时,所以我选择去看看 LLDB 的源码。我并没有在源码中直接找到答案,毕竟这是一个庞大而且缺乏 debug 手段的工程,但我在方法 UnwindLLDB::RegisterSearchResult RegisterContextLLDB::SavedLocationForRegister
中发现,堆栈的寻找似乎和一个叫 unwind
的机制有很大的关系,且源码中有大量日志打印,经过查阅,我最终发现使用在 lldb 中使用 log enable lldb unwind
指令后,就能在控制台打印出 lldb 回溯堆栈的相关日志了。
那么一次“正常”的堆栈查找,和我们 hook 之后的堆栈查找的区别在哪里呢?经过实验对比,我发现了一些端倪:
限于篇幅,并没有将日志截取完全,需要注意的是两图中标红的部分,以及一个叫 CFA
的概念。
先说说未贴出的日志:对于残缺的堆栈,由于 CFA
停止移动了,尝试获取上一个调用栈时,发现地址重复,就停止寻找了;而完整堆栈的 CFA
正常,可以一直回溯找到调用顶层。
建议读者自己打开 unwind 日志并进行调试,看看
CFA
和fp
,sp
寄存器的关系。
两边日志的转折点就在这标红的一行:hook 开启后,这个 CFA
似乎指向错误了。CFA
是 canonical 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 | .cfi_def_cfa w29, 16 |
CFA
,offset
,x29(fp)
,x30(lr)
,这几个东西组合起来令人兴奋!
原来堆栈的回溯,靠的并不是 “我按约定,把 fp、lr 存在这里,你需要就直接来读” 这样的隐式约定,而是通过白纸黑字的显示声明!还记得 hook_Objc_msgSend
方法是多么“干净”吗,尽管我们没有拓展出新的栈帧,但的确是进入了另一段指令地址,还没有任何回溯相关的声明。由于未对原栈帧进行任何修改,所以我们可以直接拷贝上述代码,用来声明我们存储 fp
,lr
的地方:
1 | __attribute__((__naked__)) static void hook_Objc_msgSend() { |
再次运行,调用层级的问题解决!
One more step
调用层级尽管正确了,但一连串的 hook_Objc_msgSend
其实依然不能为我们的调试提供有价值的信息,让我们再回头重新审视一下现在的栈区:
站在系统的角度而言,它其实捕获到了正确的堆栈,毕竟栈区存储的信息就是如此;而为了在调用完原方法后,系统能回到 hook 函数中继续走我们的 after 逻辑,我们又的确只能在调用 orig_bjc_msgSend
的时候,将 lr
寄存器的值设置为 hook 方法。其实核心矛盾点在这里:hook_Objc_msgSend
与调用者共享了栈帧,导致原 lr
在栈区无法存放;我们将 lr
值存到堆区,的确可以保证程序正常运行,但却使得系统回溯堆栈时无从下手。我们需要让 lr
值对系统可见,就必须在栈区开辟一个完整的栈帧,和一个普通函数一样按规范存储和声明各个寄存器的值:
那么我们自行开辟的栈帧应该多大,里面又应该放些什么呢?通过前面的例子我们知道,函数调用时,被调用方除了会从寄存器中读取参数外,也会从调用方的栈帧内读取参数,为了保证程序正常的执行,我们需要为被调用方提供正确的上下文,因此,我们需要拷贝调用方的栈帧。
思路明确之后,代码就非常好写了:由于进入 hook 方法时,寄存器状态、fp/sp
指向均是调用方设置的,我们只需要拷贝下来就可以了。唯一需要注意的一点是:我们拷贝的是除了栈顶之外的所有内容,因为栈顶我们要用来放 fp
寄存器的值。读者可以自行尝试实现这块的代码,作为参考,我将我的实现 放在这里。
所贴代码可以运行,但并非最优代码:在栈区存放
lr
之后,我们就可以不需要在堆区存储其值了。另外由于汇编水平有限,实现也并非最精简的代码。
在拷贝完栈帧,存放好 fp
、lr
的值,并声明回溯地址后,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
类似,动手试试吧!