背景
中心重定向,顾名思义,就是将某些流程统一流转到某个中心,然后进行一些切面操作,之后再继续回到原有后续流程。某种意义上来说,中心重定向和 iOS
中的 Hook
非常相似,但是不是同一个东西,中心重定向必须要有1个中心,而Hook
可以没有。
本文将通过几个例子来举例说明。
如何用 JS 语法书写 iOS?
接触过 JSPatch
的同学应该对下面的代码有比较深刻的印象,看起来很像 iOS
,但实际上是 JS
语法。这种写法之所以可行,本质上和中心重定向有很大的关系。
1 | require('UIView') |
暴力映射
根据 JS
特性,若要让 UIView.alloc()
这句调用不出错,唯一的方法就是给 UIView
这个对象添加 alloc
方法,不然是不可能调用成功的。JS
对于调用没定义的属性/变量,只会马上抛出异常。所以为了能调用成功,需要把类名传入 OC
,OC
通过runtime方法找出这个类所有的方法返回给 JS
,JS
类对象为每个方法名都生成一个函数,函数内容就是拿着方法名去 OC 调用相应方法。生成的 UIView 对象大致是这样的:1
2
3
4
5
6
7{
__clsName: "UIView",
alloc: function() {…},
beginAnimations_context: function() {…},
setAnimationsEnabled: function(){…},
...
}
这不仅要遍历当前类的所有方法,还要循环找父类的方法直到顶层,整个继承链上的所有方法都要加到 JS
对象上,一个类就有几百个方法,这样把方法全部加到 JS
对象上,会有严重的内存暴涨问题,导致无法使用。
__c 重定向
JSPatch
最终的解决方案:就是调用一个不存在方法时,能转发到一个指定函数去执行。在 OC
执行 JS
前,通过正则把所有方法调用都改成调用 __c()
函数,做到了类似 OC/Lua/Ruby
等的消息转发机制
1 | UIView.alloc().init() |
给 JS
对象基类 Object
加上 __c
成员,这样所有对象都可以调用到 __c
,根据当前对象类型判断进行不同操作:1
2
3
4
5
6
7
8Object.defineProperty(Object.prototype, '__c', {value: function(methodName) {
if (!this.__obj && !this.__clsName) return this[methodName].bind(this);
var self = this
return function(){
var args = Array.prototype.slice.call(arguments)
return _methodFunc(self.__obj, self.__clsName, methodName, args, self.__isSuper)
}
}})
这样做不用去 OC
遍历对象方法,不用在 JS
对象保存这些方法,通过转发达到调用所有方法的目的。__c
成员似乎提供了一种元函数的能力,原来的函数则变成了元函数的参数,而这也正是中心重定向的魅力所在。
登录判定中心重定向
绝大部分 App
中都会有登录功能,许多操作在进行前需要去判断用户是否登录,已登录的情况下才允许继续去执行操作,所以业务代码中经常可以看到这样的逻辑判断:1
2
3
4
5
6if (!AccountManager.sharedManager.isLogin) {
// 如果还没有登录,先去登录,一大堆逻辑在这里,可能还有回调等逻辑
} else {
// do something
[self doSomethingNeedLogin];
}
这样写代码有两个问题:
- 要做的事情被打断了,登录回来也不会继续去这件事,需要用户二次操作,这是个比较严重的问题,尤其是对于一些付费场景。
- 代码中充斥了大量的登录逻辑判定,显得有些臃肿;一旦接口有变或者登录策略有变,所有的地方都得进行修改,难以维护。
如何解决这个问题呢?我们来试一试中心重定向: 把这个复杂的判断逻辑交给代理来做,把需要判定的地方都统一重定向到代理,而这个代理则是 iOS
的 NSProxy
,天然就用于转发。
定义一次性观察者
顾名思义,只观察1次,回调之后会进行删除,这个操作一般需要定义在登录管理器中1
2
3
4
5
6
7
8- (void)addOnceLoginObserver:(id)observer invocation:(NSInvocation *)invocation {
if (!observer || !invocation) {
return;
}
dispatch_main_async_safe(^{
[_onceLoginObserversMap setObject:invocation forKey:observer];
});
}
登录回调
- 登录成功后,主动触发注册的NSInvocation,触发之后从一次性观察集合中移除
1 | - (void)informOnceLoginObserverForSuccess { |
- 登录失败后或者取消登录调用:
1 | - (void)informOnceLoginObserverForFailure { |
定义登录代理
1 | // LoginProxy.h |
定义 NSObject 分类 LoginProxy
1 | @interface NSObject (LoginProxy) |
这样任意一个继承自 NSObject
的 OC
对象都可以获取到自己的登录代理。
调用流程
那么原来的登录判断流程就可以写的很简洁:1
[self.loginProxy doSomethingNeedLogin];
loginProxy
会把当前的target
(这里是self)记录下来;loginProxy
由于没有实现target
的方法doSomethingNeedLogin
,所以会走转发forwardInvocation
;- 在
forwardInvocation
会进行判断,如果已登录,直接触发invocation
; - 否则,使用
AccountManager
将target
添加到一次性观察集合中,然后去登录。- 如果登录成功,
AccountManager
会使用[invocation invokeWithTarget:target]
这样的方式触发doSomethingNeedLogin
逻辑,然后从一次性观察者中移除target
; - 如果登录失败,会直接从一次性观察者中移除
target
,不会触发doSomethingNeedLogin
逻辑;
- 如果登录成功,
从这个角度来看,登录中心重定向克服了前面提到的两个问题:
- 做的事情不会被打断,登录前需要做的事情,登录回来还会继续做;
- 登录判定逻辑都统一收敛到代理,而业务侧的调用则更加简洁;
如何中心重定向任意一个Block?
第一个示例通过脚本预处理达到了中心重定向,而第二个示例则利用了 OC
语言的天然特性来达到了中心重定向的效果。那么有没有更彻底一点,更底层一点,可以忽略语言特性的中心重定向呢?
我们知道,所有的 OC
方法最终都会调用到 objc_msgSend
家族,所以如果想重定向 OC
方法,只需要去 hook
objc_msgSend
家族即可。那么如何去重定向任意 1
个 Block
呢?
Block
虽然本质上也是OC
对象,但是它并不会走消息转发逻辑,而是类似于C
方法,直接调用。- 不同的
Block
的签名是不同的,参数的个数和返回值类型也是不确定的,要重定向任意一个Block
会特别困难,很难从OC
语言层面去解决这个问题。
经过调研了解到,中心重定向汇编框架 TrampolineHook
可以用于拦截指定函数,在函数被调用时,收到一个回调通知。它是一个不用关注底层架构 Calling Convention
(因为涉及到汇编),不用关心上下文信息保存、恢复,不用担心引入传统 Swizzle
方案在大型项目中有奇奇怪怪 Crash
问题的中心重定向框架。那么这个机制能不能用于 Block 的重定向呢?通过调研发现是可行的,我决定使用其原理机制,来实现一个 Block
的重定向框架:TrampolineBlockHook
,并同时支持 arm64
和 x86_64
架构。
目前效果
TrampolineBlockHook
提供了非常简单的 API
来重定向 Block
和撤销重定向1
2
3extern BOOL hook_block_with_stub(void *replaced, void *replacement, void *pre, void *post);
extern BOOL unhook_block(void *replaced);
深入讨论之前,先看下目前的效果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
31void pre(void *p1, void *p2) {
printf("before hook\n");
}
void post(void *p1, void *p2) {
printf("after hook\n");
}
- (void)testBlockWithAll {
__block BOOL didRun = NO;
Student (^Block)(id self) = ^Student (id blockself) {
didRun = YES;
printf("this is the original block\n");
Student s = {1,2,3,4,5,6};
return s;
};
Student (^ReplacedBlock)(id self) = ^Student (id blockself) {
didRun = YES;
printf("this is the replaced block\n");
Student s = {6,5,4,3,2,1};
return s;
};
hook_block_with_stub((__bridge void *)(Block), (__bridge void *)(ReplacedBlock), pre, after);
Student student = Block(self);
NSAssert(student.height == 2, @"");
unhook_block((__bridge void *)(Block));
student = Block(self);
NSAssert(student.height == 5, @"");
}
输出结果如图所示:
hook_block_with_stub
方法的第 2
个参数可以和第 1
个参数相同,代表并没有替换 Block
的实现,只是在Block
的前后添加了 2
个函数插桩:pre
和 post
。
vm_remap 技术
原理
iOS
系统存在限制,我们没有权限创建可写可执行内存,但 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
,我们可以创建可执行代码的多个副本,并且可以放置在任意地址。如图( 来自 Implementing imp_implementationWithBlock() ),我们可以事先编写一页可执行代码 Trampoline Page
,在运行的时候可以可以通过 vm_remap
机制创建副本:Trampoline Page Copy #1
和 Trampoline Page Copy #2
,用于执行代码。
然而,这仅仅解决了一半的问题。每个 Trampoline Page
可能包含多个 Trampoline Entry
(每有 1
个 Block
被重定向,就会有 1
个相应的 Trampoline Entry
),但是这些 Trampoline Entry
需要一些对应的信息(例如 Block
的原有地址,前后的插庄函数地址等),这些信息则需要在对应的 Trampoline Data
页面进行配置。 如果将Trampoline Data
页面映射到 Trampoline Page
页面旁边,则可以使用 PC
相对寻址从相邻的 Trampoline Data
页加载需要的数据。如图,如果 Trampoline Page Copy #1
中的某个 Trampoline Entry
需要一些信息,则可以到 Trampoline Data #1
页对应的位置去加载。
代码实践
这里我们通过 vm_allocate
分配连续的两页内存,通过 vm_deallocate
释放第二页内存,最后通过 vm_remap
将 trampoline_page
映射到第二页内存。这就构建好了我们需要的两页内存,第一页空的,用于存储一些数据信息,例如 Block
地址、插桩函数地址(例如 pre
和 post
)等,第二页映射好了所有的动态可执行地址。反映到图中,第一页对应 trampoline data page
,第二页对应 trampoline text page
。
1 | // alloc two pages |
仔细的同学会发现,第一个 tramp entry1
的前面有一段代码 tramp dispatch
,这段代码的大小决定了 tramp entry1
的偏移。有两种方式来计算这段代码的大小,一个是手动计算,另一种是利用工具 Hopper
。
Block 的结构表达
为了可以重定向任意的Block,而不用关心其参数、返回值等,需要找一种结构体来描述Block。这里可以借助https://github.com/apple-oss-distributions/libclosure/blob/main/Block_private.h 中的Block_layout来描述。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
31
32struct Block_descriptor {
/** Reserved value */
unsigned long int reserved;
/** Total size of the described block, including imported variables. */
unsigned long int size;
/** Optional block copy helper. May be NULL. */
void (*copy)(void *dst, void *src);
/** Optional block dispose helper. May be NULL. */
void (*dispose)(void *);
};
struct Block_layout {
/** Pointer to the block's Objective-C class. */
void *isa;
/** Block flags. */
int flags;
/** Reserved value. */
int reserved;
/** Block invocation function. */
void (*invoke)(void *, ...);
/** Shared block descriptor. */
struct Block_descriptor *descriptor;
// imported variables
};
流程跳转逻辑
- 把一个我们要替换的
Block
地址A
取出来,保存起来; - 给这个
Block
重定向一个动态分配的可执行地址B
; - 当执行这个
Block
的时候,会跳转到可执行地址B
; - 这个
B
经过一段操作,可以获取到原先保存的 地址A
; - 在跳转回地址
A
之前,跳转到pre stub
做一些事情; - 跳转到地址
A
,执行原有逻辑; - 执行完原有逻辑后,跳转到
post stub
再做一些事情。
上述重定向之后的跳转流程中,我们需要拿到 Block
的 pre stub
和 post stub
信息,这些代码的地址配置在数据页面,那么我们需要在重定向的时候完成这些配置操作。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20extern BOOL hook_block_with_stub(void *block, void *replacement, void *pre, void *post) {
trampoline *tramp;
struct Block_layout *bl = (struct Block_layout *)block;
if (bl->flags & BLOCK_USE_STRET) {
tramp = trampoline_alloc(&STRET_TABLE_CONFIG, &STRET_TABLE);
} else {
tramp = trampoline_alloc(&blockimp_table_page_config, &blockimp_table);
}
struct Block_layout *layout = (struct Block_layout*)block;
struct Block_layout *layoutReplaced = (struct Block_layout*)replacement;
void **config = (void **)trampoline_data_ptr(tramp->trampoline);
config[0] = Block_copy(replacement);
config[1] = tramp;
config[2] = layoutReplaced->invoke;
config[3] = pre;
config[4] = post;
layout->descriptor->reserved = (unsigned long)layout->invoke;
layout->invoke = (void (*)(void *, ...))tramp->trampoline;
return YES;
}
红色区域即相关数据,代表 1
个 tramp data
:包含 5
个槽,依次放置 ReplacedBlock
、tramp
、ReplacedBlock->invoke
、pre
和 post
。
另外我们需要将原 Block
的 invoke
指针指向代码区的 tramp entry
,这样 Block
执行的时候就可以直接跳转过去。同时为了能够正常撤销重定向,需要将 原 Block
原先的实现逻辑保存下来,正好这里可以借助于 layout->descriptor->reserved
字段来存放。
重定向之后,如果想撤销重定向,则很容易理解1
2
3
4
5
6
7
8
9
10
11
12
13
14BOOL unhook_block(void *block) {
struct Block_layout *layout = (struct Block_layout*)block;
void **config = trampoline_data_ptr(layout->invoke);
struct Block_layout *bl = config[0];
trampoline *tramp = config[1];
layout->invoke = (void (*)(void *, ...))layout->descriptor->reserved;
layout->descriptor->reserved = 0;
if (bl->flags & BLOCK_USE_STRET) {
trampoline_free(&STRET_TABLE, tramp);
} else {
trampoline_free(&blockimp_table, tramp);
}
return YES;
}
arm64 汇编实现
那么接下来最关键的就是汇编中怎么处理这些跳转逻辑
寄存器
先简单温习下 arm64
的寄存器基础1
2
3
4
5
6
7
8
9- x0 - x7:用于传递子程序参数和结果,使用时不需要保存,多余参数采用堆栈传递,子程序返回结果写入到 x0
- x8:用于保存子程序返回地址
- x9 - x15:临时寄存器
- x16 - x17:子程序内部调用寄存器
- x18:平台寄存器,它的使用与平台相关
- x19 - x28:临时寄存器
- x29:帧指针寄存器 fp(栈底指针),用于连接栈帧
- x30:链接寄存器 lr,保存了子程序返回的地址
- x31:堆栈指针寄存器 sp
汇编代码
1 | #if defined(__arm64__) |
在 arm64 中,一页是 0x4000,也就是 16KB,这里 .align 14 来确保
在 arm64 中,每行指令无论长短,一律为4个字节
接下来我们将逐行解释:
由于
Block
被重定向到tramp entry
(81~87行),所以Block
的调用会跳转到81
行(这里假设是第1
个被重定向的Block
,因为81
行 ~87
行的外面包着.rept
指令,会重复404
次)。为什么要重复404
次呢?这是通过计算得到的:每个页面的大小16KB=16384B
,减去偏移224B
,每个tramp entry
的大小为40B
,那么就(16384-224) / 40 = 404
。也就是说每个页面可以容纳404
个Block
被重定向。代码
81~87
行实际上展开如下所示,总共占40
个字节。此时lr
寄存器保存了Block
之后的地址,执行完汇编代码我们要能跳回去,所以这个地址需要保存下来,这里保存到x13
寄存器。然后跳转到_block_tramp_dispatch
。为什么后续要跟着8
个nop
?这是由于每个tramp data
的大小是40
字节,为了使得每个tramp data
和每个tramp entry
的大小和偏移都一一对应,所以这里需要凑够40
个字节,每个指令4
个字节,所以一共需要10
个指令1
2
3
4
5
6
7
8
9
10mov x13, lr
bl _block_tramp_dispatch;
nop
nop
nop
nop
nop
nop
nop
nop39~40
行,由于是从83
行跳转过来的,所以此时lr
里面存着84
行的地址。lr
减去8
则为81
行地址,81
行地址减去一个页面大小#0x4000
,则为对应的数据页面的tramp data
地址。1
2sub x12, lr, #0x8
sub x12, x12, #0x400043
行,将刚刚计算的tramp data
地址存储到栈帧上1
str x12, [sp, #-16]!
46
行,x13
寄存器此时存着Block
之后的地址,这里也把它存储到栈帧上。1
str x13, [sp, #-16]!
49~50
行,x12
寄存器存着tramp data
,tramp data
地址偏移24
个字节,取出pre stub
的地址,进行判空,如果为空,直接跳转到block
标签。这里为什么是24
个字节呢?可以看到我们在重定向的时候,把pre
放到了config[3]
,前面的config[0]~config[2]
,每个占8
个字节,一共24
个字节,所以需要偏移24
个字节。1
2ldr x13, [x12, #0x18]
cbz x13, block53~56
行,此时x13
寄存器保存着pre
的地址,blr
可以直接跳转调用。但是汇编过程中调用别的函数时,需要保存caller save
寄存器,调用完毕后,需要恢复caller save
寄存器。1
2
3
4pre:
save_register
blr x13
load_register59~61
行,x12
寄存器存着tramp data
,tramp data
地址偏移16
个字节,则为Block
的原先实现地址,加载到x13
寄存器,直接跳转。注意这里我们并没有保存和恢复caller save 寄存器,主要是因为这里要保持和重定向之前相同的现场:想想看,如果原来的Block返回某个值,我们也希望被重定向之后,还能返回相同的值。1
2
3block:
ldr x13, [x12, #0x10]
blr x1364~71
行,重新从栈帧中把tramp data
地址加载到x12
,x12
寄存器存着tramp data
,tramp data
地址偏移32
个字节,则为post
的地址。接下来的调用类似于pre
,不再赘述。这里为什么要重新加载tramp data
呢?主要是因为上一步block
的调用没有保存caller save
寄存器,可能会破坏掉x12
中的值。1
2
3
4
5
6
7
8ldr x12, [sp], #16
ldr x13, [x12, #0x20]
cbz x13,
post:
save_register
blr x13
load_register74~76
行,把Block
后面的地址从栈帧中加载到x13
寄存器,然后进行跳转。1
2
3end:
ldr x13, [sp], #0x10
br x13
Hopper示意图
仔细观察,会发现左图中 _block_tramp_dispatch
的地址为 0x0000000000008000
,是一个页面的首地址,同时从右图中可以看到首个 tramp entry
的地址为 0x00000000000080e0
,那么偏移为 0xE0
,正好是前面提到的224
。
x86_64 汇编实现
寄存器
汇编代码
在 x86_64 中,一页是 0x1000,也就是 4KB,所以首先就是 .align 12 来确保
在 x86_64 中,指令的大小是不固定的。
x86_64
的汇编代码逻辑和 arm64
的逻辑基本一致,这里就不再逐行展开
1 | save_register |
Hopper示意图
栈的对齐问题(坑点)
如图,在调试过程中,x86_64
经常会崩溃到 SSE
指令,例如 movaps
指令,十分诡异
通过排查最终定位到是栈的字节对齐问题,在文档 https://jyywiki.cn/pages/OS/manuals/sysv-abi.pdf 有提到:
The end of the input argument area shall be aligned on a 16 (32 or 64, if
m256 or m512 is passed on stack) byte boundary. In other words, the value
(%rsp + 8) is always a multiple of 16 (32 or 64) when control is transferred to
the function entry point. The stack pointer, %rsp, always points to the end of the
latest allocated stack frame.
System V AMD64
调用约定要求栈必须按 16
字节对齐,也就是说,在调用 call
指令之前,%rsp
指针必须是16
的倍数(对应 16
进制是最后1位是0)。按 16
字节对齐的原因是,现代x86_64
计算机引入了 SSE
和AVX
指令,这些指令支持 SIMD
,能极大提升处理速度。但是这些指令要求必须从 16
字节的整数倍的地址处取数据,为了顾及这些指令,才有了上述对齐要求。
回到上述崩溃点,打印一下寄存器的值,发现 movaps
指令涉及到的 rbp
寄存器的值为:0x7ff7bac48f98
,没有按照 16
字节对齐,偏移 -0x30
,仍然不会按照 16
字节对齐。通过进一步排查发现是由于保存 caller-save
寄存器的时候,入栈了奇数个寄存器,导致栈的地址没有按照 16
字节对齐。
1 | save_register |
解决方案也很简单,再压入1个寄存器,把第10行重复1次即可。1
2
3
4
5
6
7
8
9
10
11
12 save_register
push %rdi
push %rsi
push %rdx
push %rcx
push %r8
push %r9
push %r10
push %r11
push %rax
push %rax
为什么没有保存和恢复xmm0~xmm7?
仔细的同学会发现x86_64汇编并没有保存浮点寄存器,目前的现状是在xcode中提示语法不支持,暂时还没有找到好的办法。
思考
TrampolineHook
中心化重定向机制可以用来做很多事情:
- 例如苹果自身的
imp_implementationWithBlock
就是基于该技术实现的, - 比如苹果著名的
MainThreadChecker
也用了类似的技术。
本文实现的 TrampolineBlockHook
相当于在 Block
的前后做了 2
个函数插桩:pre
和 post
,围绕这 2
个插桩可以做很多事情,之后也将围绕这些方向点展开应用:
- 统计
Block
的耗时 - 给
Block
添加日志 - 埋点统计等
同时,TrampolineBlockHook
现在也还存在着一些问题,例如多线程问题、例如还没有经过大项目的实践,这些也都需要持续得打磨。
总结
文中聊到的几个中心重定向示例,虽然技术原理不曾相同,但是思想是相通的,而其中最关键的就是在于流转技术的实现。本文也是在实践TrampolineBlockHook
的时候有感而发,潦草落笔。另外在支持 x86_64
架构的时候的确碰到了比较严重的崩溃问题,走了不少弯路,正好也借此文档做个笔记。
参考链接
- x86_64架构下的函数调用及栈帧原理
- x86_64汇编之四:函数调用、调用约定
- GCC汇编语法与Intel汇编语法的几个差异点
- x86-64 下函数调用及栈帧原理
- 静态拦截iOS对象方法调用的简易实现
- 浅谈ARM64汇编
- 基于桥的全量方法 Hook 方案(2) - 全新升级
- 基于桥的全量方法 Hook 方案(3)- TrampolineHook
- 为什么使用汇编可以 Hook objectivec_msgSend(上)- 汇编基础
- 为什么使用汇编可以 Hook objc_msgSend(下)- 实现与分析
- x86_64 Linux 运行时栈的字节对齐
- https://github.com/g0dA/linuxStack/
- https://github.com/bang590/JSPatch/wiki/
- http://seanchense.github.io/2019/12/04/block-flags/
- https://clang.llvm.org/docs/Block-ABI-Apple.html
- Guide to x86-64
- https://juejin.cn/post/6844904098580398087
- https://blog.csdn.net/Desgard_Duan/article/details/107829106
- https://nickdesaulniers.github.io/blog/2014/04/18/lets-write-some-x86-64/
- https://evian-zhang.github.io/index.html
- https://en.wikipedia.org/wiki/X86_calling_conventions#System_V_AMD64_ABI
- https://juejin.cn/post/6988867705637961742
- https://satanwoo.github.io/2017/04/23/ARM64IndirectReturn/
- https://landonf.org/code/objc/imp_implementationWithBlock.20110413.html
- https://opensource.apple.com/source/libclosure/
- https://docs.oracle.com/cd/E19120-01/open.solaris/817-5477/eojde/index.html