本来以为是双休日,结果五一调休本周末只休一天,懵逼。不过还算完成了承诺,赶了出来。
开源地址:https://github.com/SatanWoo/TrampolineHook
TrampolineHook 是什么
之前杨萧玉在看到我《基于桥的全量方法 Hook 方案(2) - 全新升级》 后就问我这个和直接用 method_exchangeImplementation
之类的 runtime
方法交换 IMP
性能对比咋样?
所以这篇文章开头先占用大家宝贵的两分钟,简要说明下。
TrampolineHook
本质上不是用来 Swizzling
的框架,取 Hook
这个名字只是为了读起来顺口。它实际上是一个中心重定向框架。 换句话说,你可以认为它是为了通过一个函数替换/拦截所有你想要函数的框架。
其实这个中心重定向的思想并不新潮,很多人(包括我自己)在内就曾经利用重载 objc_msgForward
干过这样的事。
但是这个方式我在之前的文章里也提到过对应的缺点,比如:
- 性能慢
- 不能替换/拦截同一个继承链上的多个类。
所以可以认为 TrampolineHook
是一个让你不用关注底层架构Calling Convention(因为涉及到汇编),不用关心上下文信息保存、恢复,不用担心引入传统 Swizzle 方案在大型项目中有奇奇怪怪 Crash 问题的中心重定向框架。
TrampolineHook 技术原理
整个技术原理其实可以分为三部分:
vm_remap
技术。流程设计。
汇编实现。
vm_remap 的价值
通俗意义上,我们访问的内存都是按照页来组织。而在程序加载后分配的页之中,会对应有不同的权限,比如代码占用的页,就是可读且可执行,但是一般不具备可写的权限;而存放数据的页呢,就对应是可读且可写,但不能拥有可执行权限。
在绝大多数情况下,当我们编写完一个程序运行的时候,动态分配的页都是用来做数据保存、访问的,不太会有涉及执行权限。
而要做到可以将动态分配出来的内存页具备可执行权限,就需要利用 vm_remap
。 它的定义是这样的:
On Darwin, vm_remap() provides support for mapping an existing code page at new address, while retaining the existing page protections; using vm_remap(), we can create multiple copies of existing, executable code, placed at arbitrary addresses.
从定义中我们可以知道两点信息:
vm_remap
可以让内存页具备被 map 的页的特性,如果是可执行页被 map,那新创建的页自然而然页具备了这个权限。vm_remap
也不是肆无忌惮的创建任何可执行的页,通俗理解,它只是一个 copy 映射。
因此,我们可以通过在编写代码的过程中,精心构造、预留在程序二进制的代码页,在运行时不断“复制映射”,来完成特殊的使命。
在我们的定义中,我们是构造了连续的两个页。
流程设计
要构造特殊的程序二进制代码,首先还是要梳理我们的目的,我们的诉求是所有的函数都能先进入我们的一个中心重定向函数,执行自定义的操作,然后返回原函数,同时这个调用栈不能乱。
- 把一个我们要替换的原方法 IMP A 取出来,保存起来。
- 给这个原方法塞一个动态分配的可执行地址 B。
- 当执行这个原方法的时候,会跳转到 可执行地址 B。
- 这个 B 经过一段简短的运算操作,可以获取到原先保存的 IMP A。
- 在跳转回 IMP A 之前,统一拦截函数先做些事情,比如检查是不是主线程调用之类的。
【注意】:在整个过程中,我们要保证参数寄存器、返回地址等不能错乱。
汇编实现
既然 vm_remap
是按页的维度来映射,我们要构造的代码自然而然要页对齐。在 arm64 中,一页是 0x4000,也就是 16KB,所以首先就是 .align 14
来确保。
然后上一下最关键部分的代码,感兴趣的还是去 Github 上阅读完整的代码吧。
_th_entry:
// 不要小看这五行汇编
nop
nop
nop
nop
nop
sub x12, lr, #0x8
sub x12, x12, #0x4000
mov lr, x13
ldr x10, [x12]
stp q0, q1, [sp, #-32]!
stp q2, q3, [sp, #-32]!
stp q4, q5, [sp, #-32]!
stp q6, q7, [sp, #-32]!
stp lr, x10, [sp, #-16]!
stp x0, x1, [sp, #-16]!
stp x2, x3, [sp, #-16]!
stp x4, x5, [sp, #-16]!
stp x6, x7, [sp, #-16]!
str x8, [sp, #-16]!
// 加载自定义的拦截器,并跳转过去。
ldr x8, interceptor
blr x8
ldr x8, [sp], #16
ldp x6, x7, [sp], #16
ldp x4, x5, [sp], #16
ldp x2, x3, [sp], #16
ldp x0, x1, [sp], #16
ldp lr, x10, [sp], #16
ldp q6, q7, [sp], #32
ldp q4, q5, [sp], #32
ldp q2, q3, [sp], #32
ldp q0, q1, [sp], #32
br x10
.rept 2032
mov x13, lr
bl _th_entry;
.endr
整段汇编可以分为几个部分:
设计一大堆的动态可执行地址,即:
.rept 2032 mov x13, lr bl _th_entry; .endr
这里最早我的实现是复制粘贴一大堆重复性代码,在 HookZZ 作者的指导下,我优化成了上述这样。
执行统一的运行过程,通过偏移计算等方式获取保留的原始 IMP。
要注意特定的寄存器用处,
x8-x18
是临时寄存器,里面的值在函数调用后可能被修改,这些寄存器为caller-saved。所以在我们自身函数可以用,但是要在调用别的函数之前保存好。要特别注意对 LR 寄存器的处理,没处理好,调用栈就回不去了。
保存对应的参数、浮点参数等寄存器,避免上下文被我们自己的处理函数破坏。
b / bl
的跳转范围非常有限,由于我们是动态地址分配,不能保证拦截函数的范围偏移,所以要采用blr
的方式。
TrampolineHook 用处
和传统的 Swizzle
需要提供对应的替换后的函数实现不同,中心化重定向思想可以帮助你实现很多有意思的事情:
比如网上很常见的
hook objc_msgSend
,可以帮你查看任意被 Hook 二进制中的函数耗时和调用链路。比如
Bang
/AnyMethodLog
这样的重定向 Log 日志框架等等。
苹果著名的 MainThreadChecker
也用了类似的技术。由于我才疏学浅,只是大致完成了对其实现的逆向,通过 TrampolineHook
进行了重写。 因为效果还不错,所以也开源了出来,地址是:https://github.com/SatanWoo/TrampolineHook/tree/master/Example/MainThreadChecker
这次在重写 MainThreadChecker
的过程中,我也对比了下和 2017 年苹果实现的差异。在整体流程上没有比较大的差异,但是还是有一些细节可以分享分享:
- iOS 10 的时候对应的二进制是
UIKit
,到了 iOS 12/13 成了UIKitCore
,所以原先获取二进制的逻辑失效了,为了避免后续版本的变更干扰,我采用了苹果自身的守候,通过class_getImageName([UIResponder class])
来保证获取的就是我们理解上的UIKit
动态库。
当然 TrampolineHook
的作用不止于此,争取过段时间把我的一些想法做完善再和大家交流。
后续思考
本质上 Trampoline
和 vm_remap
技术不是新的技术,很早就有人应用了,构造 Trampoline
实际上在苹果自身关于 Block
的实现中就有。业界也有 SwiftTrace
也是用了对应的技术。
真正的关键在于你用 Trampoline
做什么?用途的不同也决定了效果的不同,这也是我把之前的代码重写 TrampolineHook
中所收获的,而且随着 TrampolineHook
相对我自身之前实现的优化,我发现眼前豁然开朗,能玩的事情还有很多,哈哈。
对了,如果有朋友对 arm64 的汇编比较熟悉,同时对函数调用也比较了解的话,会很快的发现我上述提供的汇编代码存在一个漏洞(虽然这个漏洞绝大多数人用不到),感兴趣的朋友可以微信交流下。
开源地址:https://github.com/SatanWoo/TrampolineHook 如果大家有什么想法或者遇到了自身项目中的 Bug,欢迎 issue。