聊聊iOS中的中心重定向

背景

中心重定向,顾名思义,就是将某些流程统一流转到某个中心,然后进行一些切面操作,之后再继续回到原有后续流程。某种意义上来说,中心重定向和 iOS 中的 Hook 非常相似,但是不是同一个东西,中心重定向必须要有1个中心,而Hook可以没有。
本文将通过几个例子来举例说明。

如何用 JS 语法书写 iOS?

接触过 JSPatch 的同学应该对下面的代码有比较深刻的印象,看起来很像 iOS,但实际上是 JS 语法。这种写法之所以可行,本质上和中心重定向有很大的关系。

1
2
3
4
require('UIView') 
var view = UIView.alloc().init()
view.setBackgroundColor(require('UIColor').grayColor())
view.setAlpha(0.5)

暴力映射

根据 JS 特性,若要让 UIView.alloc() 这句调用不出错,唯一的方法就是给 UIView 这个对象添加 alloc 方法,不然是不可能调用成功的。JS 对于调用没定义的属性/变量,只会马上抛出异常。所以为了能调用成功,需要把类名传入 OCOC 通过runtime方法找出这个类所有的方法返回给 JSJS 类对象为每个方法名都生成一个函数,函数内容就是拿着方法名去 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
2
3
UIView.alloc().init()
->
UIView.__c('alloc')().__c('init')()

JS 对象基类 Object 加上 __c 成员,这样所有对象都可以调用到 __c,根据当前对象类型判断进行不同操作:

1
2
3
4
5
6
7
8
Object.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
6
if (!AccountManager.sharedManager.isLogin) {
// 如果还没有登录,先去登录,一大堆逻辑在这里,可能还有回调等逻辑
} else {
// do something
[self doSomethingNeedLogin];
}

这样写代码有两个问题:

  1. 要做的事情被打断了,登录回来也不会继续去这件事,需要用户二次操作,这是个比较严重的问题,尤其是对于一些付费场景。
  2. 代码中充斥了大量的登录逻辑判定,显得有些臃肿;一旦接口有变或者登录策略有变,所有的地方都得进行修改,难以维护。

如何解决这个问题呢?我们来试一试中心重定向: 把这个复杂的判断逻辑交给代理来做,把需要判定的地方都统一重定向到代理,而这个代理则是 iOSNSProxy,天然就用于转发。

定义一次性观察者

顾名思义,只观察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];
});
}

登录回调

  1. 登录成功后,主动触发注册的NSInvocation,触发之后从一次性观察集合中移除
1
2
3
4
5
6
7
8
9
- (void)informOnceLoginObserverForSuccess {
dispatch_main_async_safe(^{
for (id target in _onceLoginObserversMap) {
NSInvocation *invocation = [_onceLoginObserversMap objectForKey:target];
[invocation invokeWithTarget:target];
}
[_onceLoginObserversMap removeAllObjects];
});
}
  1. 登录失败后或者取消登录调用:
1
2
3
- (void)informOnceLoginObserverForFailure {
[self removeAllOnceLoginObserver];
}

定义登录代理

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
32
33
34
// LoginProxy.h
@interface LoginProxy : NSProxy
@end

@interface LoginProxy ()
@property (nonatomic, weak) id target;
@end

// LoginProxy.m
@implementation LoginProxy
- (instancetype)initWithTarget:(id)target {
self.target = target;
return self;
}

- (void)forwardInvocation:(NSInvocation *)invocation {
invocation.target = nil;
AccountManager *accoutMgr = AccountManager.sharedManager;
if (! accoutMgr.isLogin) {
// retain arguments
[invocation retainArguments];
// add once observer
[accoutMgr addOnceAuthenticationObserver:self.target invocation:invocation];
// show login vc
[AccountLoginManager presentLoginViewControllerFromVC:nil];
} else {
// invoke directly
[invocation invokeWithTarget:self.target];
}
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [self.target methodSignatureForSelector:sel];
}

定义 NSObject 分类 LoginProxy

1
2
3
4
5
6
7
8
9
@interface NSObject (LoginProxy)
- (instancetype)loginProxy;
@end

@implementation NSObject (LoginProxy)
- (instancetype)loginProxy {
return [(id)[LoginProxy alloc] initWithTarget:self];
}
@end

这样任意一个继承自 NSObjectOC 对象都可以获取到自己的登录代理。

调用流程

那么原来的登录判断流程就可以写的很简洁:

1
[self.loginProxy doSomethingNeedLogin];

  1. loginProxy 会把当前的 target(这里是self)记录下来;
  2. loginProxy 由于没有实现 target 的方法 doSomethingNeedLogin,所以会走转发forwardInvocation
  3. forwardInvocation 会进行判断,如果已登录,直接触发 invocation
  4. 否则,使用 AccountManagertarget 添加到一次性观察集合中,然后去登录。
    • 如果登录成功,AccountManager 会使用 [invocation invokeWithTarget:target] 这样的方式触发 doSomethingNeedLogin 逻辑,然后从一次性观察者中移除 target
    • 如果登录失败,会直接从一次性观察者中移除 target,不会触发 doSomethingNeedLogin 逻辑;

从这个角度来看,登录中心重定向克服了前面提到的两个问题:

  1. 做的事情不会被打断,登录前需要做的事情,登录回来还会继续做;
  2. 登录判定逻辑都统一收敛到代理,而业务侧的调用则更加简洁;

如何中心重定向任意一个Block?

第一个示例通过脚本预处理达到了中心重定向,而第二个示例则利用了 OC 语言的天然特性来达到了中心重定向的效果。那么有没有更彻底一点,更底层一点,可以忽略语言特性的中心重定向呢?
我们知道,所有的 OC 方法最终都会调用到 objc_msgSend 家族,所以如果想重定向 OC 方法,只需要去 hook objc_msgSend 家族即可。那么如何去重定向任意 1Block 呢?

  1. Block 虽然本质上也是 OC 对象,但是它并不会走消息转发逻辑,而是类似于 C 方法,直接调用。
  2. 不同的 Block 的签名是不同的,参数的个数和返回值类型也是不确定的,要重定向任意一个 Block 会特别困难,很难从 OC 语言层面去解决这个问题。

经过调研了解到,中心重定向汇编框架 TrampolineHook 可以用于拦截指定函数,在函数被调用时,收到一个回调通知。它是一个不用关注底层架构 Calling Convention(因为涉及到汇编),不用关心上下文信息保存、恢复,不用担心引入传统 Swizzle 方案在大型项目中有奇奇怪怪 Crash 问题的中心重定向框架。那么这个机制能不能用于 Block 的重定向呢?通过调研发现是可行的,我决定使用其原理机制,来实现一个 Block 的重定向框架:TrampolineBlockHook,并同时支持 arm64x86_64 架构。

目前效果

TrampolineBlockHook 提供了非常简单的 API 来重定向 Block 和撤销重定向

1
2
3
extern 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
31
void 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个函数插桩:prepost

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 #1Trampoline Page Copy #2,用于执行代码。

然而,这仅仅解决了一半的问题。每个 Trampoline Page可能包含多个 Trampoline Entry(每有 1Block 被重定向,就会有 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_remaptrampoline_page 映射到第二页内存。这就构建好了我们需要的两页内存,第一页空的,用于存储一些数据信息,例如 Block 地址、插桩函数地址(例如 prepost)等,第二页映射好了所有的动态可执行地址。反映到图中,第一页对应 trampoline data page,第二页对应 trampoline text page

1
2
3
4
5
6
7
8
9
10
11
12
13
// alloc two pages
vm_address_t data_page = 0x0;
kern_return_t kt;
kt = vm_allocate (mach_task_self(), &data_page, PAGE_SIZE*2, VM_FLAGS_ANYWHERE);

// drop the second half of the allocation to make room for the trampoline table
vm_address_t trampoline_page = data_page+PAGE_SIZE;
kt = vm_deallocate (mach_task_self(), trampoline_page, PAGE_SIZE);

// remap the second half of the allocation
vm_prot_t cur_prot;
vm_prot_t max_prot;
kt = vm_remap (mach_task_self(), &trampoline_page, PAGE_SIZE, 0x0, FALSE, mach_task_self(), (vm_address_t) config->template_page, FALSE, &cur_prot, &max_prot, VM_INHERIT_SHARE);

仔细的同学会发现,第一个 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
32
struct 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
};

流程跳转逻辑

  1. 把一个我们要替换的 Block 地址 A 取出来,保存起来;
  2. 给这个 Block 重定向一个动态分配的可执行地址 B
  3. 当执行这个 Block 的时候,会跳转到可执行地址 B
  4. 这个 B 经过一段操作,可以获取到原先保存的 地址 A
  5. 在跳转回地址 A 之前,跳转到 pre stub 做一些事情;
  6. 跳转到地址 A,执行原有逻辑;
  7. 执行完原有逻辑后,跳转到 post stub 再做一些事情。

上述重定向之后的跳转流程中,我们需要拿到 Blockpre stubpost stub 信息,这些代码的地址配置在数据页面,那么我们需要在重定向的时候完成这些配置操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
extern 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;
}

红色区域即相关数据,代表 1tramp data :包含 5 个槽,依次放置 ReplacedBlocktrampReplacedBlock->invokeprepost
另外我们需要将原 Blockinvoke 指针指向代码区的 tramp entry,这样 Block 执行的时候就可以直接跳转过去。同时为了能够正常撤销重定向,需要将 原 Block 原先的实现逻辑保存下来,正好这里可以借助于 layout->descriptor->reserved 字段来存放。

重定向之后,如果想撤销重定向,则很容易理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BOOL 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
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#if defined(__arm64__)

# save caller register
.macro save_register
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]!
stp x8, x12, [sp, #-16]!
.endm

# load caller register
.macro load_register
ldp x8, x12, [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
.endm

.text
.align 14
.globl _blockimp_table_page
_blockimp_table_page:

_block_tramp_dispatch:

# trampoline address+8 is in lr
sub x12, lr, #0x8
sub x12, x12, #0x4000

# save x12, which represents the tramp data of Block
str x12, [sp, #-16]!

# save lr value which is in x13 rightnow
str x13, [sp, #-16]!

# get the address of pre stub
ldr x13, [x12, #0x18]
cbz x13, block

# execute the pre stub
pre:
save_register
blr x13
load_register

# execute the original block logic
block:
ldr x13, [x12, #0x10]
blr x13

# execute the post stub
ldr x12, [sp], #16
ldr x13, [x12, #0x20]
cbz x13, end

post:
save_register
blr x13
load_register

# load lr value from [sp]
end:
ldr x13, [sp], #0x10
br x13


.rept 404
# save lr value to x13
mov x13, lr
# jump to _block_tramp_dispatch
bl _block_tramp_dispatch;
.rept 8
nop
.endr
.endr

#endif

在 arm64 中,一页是 0x4000,也就是 16KB,这里 .align 14 来确保
在 arm64 中,每行指令无论长短,一律为4个字节

接下来我们将逐行解释:

  1. 由于 Block 被重定向到 tramp entry(81~87行),所以 Block 的调用会跳转到 81 行(这里假设是第 1 个被重定向的 Block,因为 81行 ~ 87行的外面包着 .rept 指令,会重复 404 次)。为什么要重复 404 次呢?这是通过计算得到的:每个页面的大小 16KB=16384B,减去偏移 224B,每个 tramp entry 的大小为 40B,那么就 (16384-224) / 40 = 404。也就是说每个页面可以容纳 404Block 被重定向。

  2. 代码 81~87 行实际上展开如下所示,总共占 40 个字节。此时 lr 寄存器保存了 Block 之后的地址,执行完汇编代码我们要能跳回去,所以这个地址需要保存下来,这里保存到 x13 寄存器。然后跳转到 _block_tramp_dispatch。为什么后续要跟着 8nop?这是由于每个 tramp data 的大小是 40 字节,为了使得每个 tramp data 和每个 tramp entry 的大小和偏移都一一对应,所以这里需要凑够 40个字节,每个指令 4 个字节,所以一共需要 10 个指令

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    mov x13, lr
    bl _block_tramp_dispatch;
    nop
    nop
    nop
    nop
    nop
    nop
    nop
    nop
  3. 39~40 行,由于是从 83 行跳转过来的,所以此时 lr 里面存着 84 行的地址。lr 减去 8 则为 81 行地址,81 行地址减去一个页面大小 #0x4000,则为对应的数据页面的 tramp data 地址。

    1
    2
    sub x12, lr, #0x8
    sub x12, x12, #0x4000
  4. 43 行,将刚刚计算的 tramp data 地址存储到栈帧上

    1
    str x12, [sp, #-16]!
  5. 46 行,x13 寄存器此时存着 Block 之后的地址,这里也把它存储到栈帧上。

    1
    str x13, [sp, #-16]!
  6. 49~50行,x12 寄存器存着 tramp datatramp data 地址偏移 24 个字节,取出 pre stub 的地址,进行判空,如果为空,直接跳转到 block 标签。这里为什么是 24 个字节呢?可以看到我们在重定向的时候,把 pre 放到了 config[3],前面的 config[0]~config[2],每个占 8 个字节,一共 24 个字节,所以需要偏移 24 个字节。

    1
    2
    ldr x13, [x12, #0x18]
    cbz x13, block
  7. 53~56 行,此时 x13 寄存器保存着 pre 的地址,blr 可以直接跳转调用。但是汇编过程中调用别的函数时,需要保存 caller save 寄存器,调用完毕后,需要恢复 caller save 寄存器。

    1
    2
    3
    4
    pre:
    save_register
    blr x13
    load_register
  8. 59~61 行,x12 寄存器存着 tramp datatramp data 地址偏移 16 个字节,则为 Block 的原先实现地址,加载到 x13 寄存器,直接跳转。注意这里我们并没有保存和恢复caller save 寄存器,主要是因为这里要保持和重定向之前相同的现场:想想看,如果原来的Block返回某个值,我们也希望被重定向之后,还能返回相同的值。

    1
    2
    3
    block:
    ldr x13, [x12, #0x10]
    blr x13
  9. 64~71 行,重新从栈帧中把 tramp data 地址加载到 x12x12 寄存器存着 tramp datatramp data 地址偏移 32 个字节,则为 post 的地址。接下来的调用类似于 pre,不再赘述。这里为什么要重新加载tramp data 呢?主要是因为上一步 block 的调用没有保存 caller save 寄存器,可能会破坏掉 x12 中的值。

    1
    2
    3
    4
    5
    6
    7
    8
    ldr x12, [sp], #16
    ldr x13, [x12, #0x20]
    cbz x13, end

    post:
    save_register
    blr x13
    load_register
  10. 74~76 行,把 Block 后面的地址从栈帧中加载到 x13 寄存器,然后进行跳转。

    1
    2
    3
    end:
    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
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
.macro save_register
push %rdi
push %rsi
push %rdx
push %rcx
push %r8
push %r9
push %r10
push %r11
push %rax
push %rax # you may
.endm

.macro load_register
pop %rax
pop %rax
pop %r11
pop %r10
pop %r9
pop %r8
pop %rcx
pop %rdx
pop %rsi
pop %rdi
.endm

.text
.align 12
.globl _blockimp_table_page
_blockimp_table_page:

_block_tramp_dispatch:
# save the rip to r11
movq (%rsp), %r11

subq $0x20, %rsp

# calculate the address of the tramp data of Block
sub $0x5, %r11
sub $0x1000, %r11

# the address of the tramp data of Block to stack
movq %r11, 0x8(%rsp)

# get the address of pre stub
mov 0x18(%r11), %r10
cmp $0x0, %r10
je block

# execute the pre stub
pre:
save_register
call *%r10
load_register

# execute the block
block:
call *0x10(%r11)

movq 0x8(%rsp), %r11
movq 0x20(%r11), %r10
cmpq $0x0, %r10
je end

# exeute the post stub
after:
save_register
call *%r10
load_register

end:
addq $0x20, %rsp
ret
.align 4

# trampoline entry
.rept 256
call _block_tramp_dispatch # 5 bytes
.rept 34
nop
.endr
ret
.endr

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
2
3
4
5
6
7
8
9
10
11
.macro save_register
push %rdi
push %rsi
push %rdx
push %rcx
push %r8
push %r9
push %r10
push %r11
push %rax
.endm

解决方案也很简单,再压入1个寄存器,把第10行重复1次即可。

1
2
3
4
5
6
7
8
9
10
11
12
.macro save_register
push %rdi
push %rsi
push %rdx
push %rcx
push %r8
push %r9
push %r10
push %r11
push %rax
push %rax
.endm

为什么没有保存和恢复xmm0~xmm7?

仔细的同学会发现x86_64汇编并没有保存浮点寄存器,目前的现状是在xcode中提示语法不支持,暂时还没有找到好的办法。

思考

TrampolineHook 中心化重定向机制可以用来做很多事情:

  1. 例如苹果自身的 imp_implementationWithBlock 就是基于该技术实现的,
  2. 比如苹果著名的 MainThreadChecker 也用了类似的技术。

本文实现的 TrampolineBlockHook 相当于在 Block 的前后做了 2 个函数插桩:prepost,围绕这 2 个插桩可以做很多事情,之后也将围绕这些方向点展开应用:

  1. 统计 Block 的耗时
  2. Block 添加日志
  3. 埋点统计等

同时,TrampolineBlockHook 现在也还存在着一些问题,例如多线程问题、例如还没有经过大项目的实践,这些也都需要持续得打磨。

总结

文中聊到的几个中心重定向示例,虽然技术原理不曾相同,但是思想是相通的,而其中最关键的就是在于流转技术的实现。本文也是在实践TrampolineBlockHook 的时候有感而发,潦草落笔。另外在支持 x86_64 架构的时候的确碰到了比较严重的崩溃问题,走了不少弯路,正好也借此文档做个笔记。

参考链接

  1. x86_64架构下的函数调用及栈帧原理
  2. x86_64汇编之四:函数调用、调用约定
  3. GCC汇编语法与Intel汇编语法的几个差异点
  4. x86-64 下函数调用及栈帧原理
  5. 静态拦截iOS对象方法调用的简易实现
  6. 浅谈ARM64汇编
  7. 基于桥的全量方法 Hook 方案(2) - 全新升级
  8. 基于桥的全量方法 Hook 方案(3)- TrampolineHook
  9. 为什么使用汇编可以 Hook objectivec_msgSend(上)- 汇编基础
  10. 为什么使用汇编可以 Hook objc_msgSend(下)- 实现与分析
  11. x86_64 Linux 运行时栈的字节对齐
  12. https://github.com/g0dA/linuxStack/
  13. https://github.com/bang590/JSPatch/wiki/
  14. http://seanchense.github.io/2019/12/04/block-flags/
  15. https://clang.llvm.org/docs/Block-ABI-Apple.html
  16. Guide to x86-64
  17. https://juejin.cn/post/6844904098580398087
  18. https://blog.csdn.net/Desgard_Duan/article/details/107829106
  19. https://nickdesaulniers.github.io/blog/2014/04/18/lets-write-some-x86-64/
  20. https://evian-zhang.github.io/index.html
  21. https://en.wikipedia.org/wiki/X86_calling_conventions#System_V_AMD64_ABI
  22. https://juejin.cn/post/6988867705637961742
  23. https://satanwoo.github.io/2017/04/23/ARM64IndirectReturn/
  24. https://landonf.org/code/objc/imp_implementationWithBlock.20110413.html
  25. https://opensource.apple.com/source/libclosure/
  26. https://docs.oracle.com/cd/E19120-01/open.solaris/817-5477/eojde/index.html
-------------本文结束 感谢您的阅读-------------

本文标题:聊聊iOS中的中心重定向

文章作者:lingyun

发布时间:2023年05月08日 - 23:05

最后更新:2023年05月14日 - 19:05

原始链接:https://tsuijunxi.github.io/2023/05/08/聊聊iOS中的中心重定向/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!

本文标题:聊聊iOS中的中心重定向

文章作者:lingyun

发布时间:2023年05月08日 - 23:05

最后更新:2023年05月14日 - 19:05

原始链接:https://tsuijunxi.github.io/2023/05/08/聊聊iOS中的中心重定向/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。