iOS Lazy Bind 你真的弄懂了吗?

4,093 阅读11分钟

一、前言

之前看了很多的关于延迟绑定的文章,对stubstub_helperla_symbol_ptr这些概念有了一定的认识,知道对于外部定义的函数调用,首次调用需要在运行时期间借助stub_helper来动态寻找到函数调用的地址,然后存储到la_symbol_ptr段的数据段中。第二次访问的时候,就直接调到该函数调用的地址就可以了。但是要是问到具体的细节还是一知半解,今天就准备自己动手把它弄懂弄透。

二、什么是lazy bind?

在我们的程序进行编译时,一些外部定义的函数(系统库或者App自身的动态库中包含的函数)调用是无法在编译器确定它的调用地址的,因为依赖的动态库在运行环境中加载的地址是不确定的。当然,这里说的外部定义函数一版是指C/C++的静态调用,如果是OC的方法其实不需要延迟绑定。因为OC的方法调用都是依赖于msg_send来动态搜索类中的方法列表类来找获取调用地址。所以即使调用了外部定义的OC方法,只要这个OC类被加载了,就可以通过OC运行时进行调用。 在编译过程中,外部定义的C方法会在二进制中进行记录,这些需要进行延迟绑定的符号都被记录在二进制文件中的Dynamic Loader Info -> Lazy Binding Info中,通过 MachOView 我们可以观察二进制里的段信息,如下图1所示。

图 1

图中的printf是在app中调用的方法,这个方法的定义肯定是由系统库实现的,那么在程序编译过程中,在链接阶段可以知道printf这个方法是在libSystem.B.dylib这个系统库中实现的,那么在程序运行的时候我们就需要从libSystem.B.dylib中寻找printf的函数调用地址。而在程序运行期间,系统库的加载地址是不会改变的,printf的调用地址相对于libSystem.B.dylib的相对地址也是不变的,这就意味着printf的调用地址在程序的运行期间都不会改变,这个行为只需要进行一次。这个过程就叫作延迟绑定(lazy bind)。

三、lazy bind的过程

为了探究这个过程,写了一个Demo,在main函数里调用了两次printf的方法。

#import <UIKit/UIKit.h>

int main(int argc, char * argv[]) {
    printf("1");
    printf("2");
}

我们要追踪的就是printf这个函数是如何调用的。在我的arm64真机设备上进行调试。首先在printf("1")处打一个断点,通过Debug->Debug Workflow->Always Show Disassembly将代码切换至汇编,方便我们观察调用地址。如图2所示。

图 2

图2展示了main方法的汇编代码,可以看到printfmain中被调用了两次,两次均是通过bl 0x10464a624来尽心跳转调用的,这里系统已经帮我们查询到了0x10464a624对应的符号,就是symbol stub for: printf,这也就是printf的桩函数。 先不着急看桩函数的实现,我们先来看一下这里的调用地址。由于ASLRAddress Space Layout Randomization,地址空间布局随机化)的存在,每次我们的App启动时,二进制加载的首地址都是被随机分配的,这是iOS防范黑客的一种手段。在我们的示例中,可以看到main函数的入口是地址是0x10464a300。在MachOView中我们可以在__TEXT,__text段中搜索main方法,如图3所示。在图中可以看到,main函数的首地址是0x100006300,它是由_main方法在二进制文件中的偏移0x00006300,加上__PAGEZERO段的虚拟内存0x100000000(图4)计算得到的。也就是说,如果没有ASLR,那么main函数的地址就应该是0x100006300。我们通过diff就可以知道,当前我们的程序运行时的ASLR偏移是0x10464a300 - 0x100006300 = 0x4644000。记住这个偏移量,我们后续会用到。当你重新启动或者Debug的时候,这个偏移量每次都会变化。

图 3

图 4

回归到printf的调用过程,我们通过LLDB命令breakpoint set -a 0x10464a624printf的桩函数处打一个断点,让程序继续运行,跳转到了图5所示的桩函数。

图 5

这里只有3行代码,nop代表一个空操作,真正有意义的是后两句。第二句代码的意思是讲当前的PC与立即数0x5a10相加,得到的结果是一个地址,将这个地址存储的数读入到x16寄存器。第三句代码就是跳转到x16存储的地址处。所以我们要关心的就是这个地址是从哪里来的,它里面存储的数据(br最终跳转的地址)又代表着什么呢?

0x10464a624 <+0>: nop                       // 空操作,忽略
0x10464a628 <+4>: ldr    x16, #0x5a10       // x16 = *(pc + 0x5a10) = *(0x10464a628 + 0x5a10) = *(0x104650038)
0x10464a62c <+8>: br     x16                // 跳转至x16存储的地址处

通过计算我们知道,这个地址是0x104650038,通过LLDB命令我们跟踪这个地址的相关信息,如图6所示:

图 6

可以看到,0x104650038这个地址存储的数据是0x10464a69c,这与图5中Xcode给我们解析出来的调用地址是相吻合的。而0x104650038这个地址存在于二进制中的__DATA.__la_symbol_ptr段的第56偏移行。我们通过MachOView去定位,发现这正好存储的就是_printf符号的懒加载指针数据,如图7所示。同时我们也可以通过偏移地址来进行验证,0x104650038 - 0x4644000 = 0x10000C038,也正好匹配MachOView中显示的地址。

图 7

那么桩函数的作用很清楚了,就是读取lazy_symbol_ptr中对应符号的地址,然后跳转。但是此时我们读取到的跳转地址0x10464a69c并不是printf真正的调用地址。我们继续追踪一下这个地址。可以看到,这个地址位于当前二进制的__TEXT.__stub_helper段的108偏移行。我们在这个地址打一个断点,继续让程序运行。

图 8

如图9所示,这里面有作用的就是前三行,第一行是将0x10464a6a4这个地址中存储的数据读入到w16寄存器中,而将0x10464a6a4这个地址正好是第三行,它实际上存储的就是一个硬编码数0xb9,所以此时w16 = 0xb9。而第二行直接将当前的程序跳转到了0x10464a630这个地址。那么我们追踪的地址就转换成了0x10464a630,而w16中存储的0xb9代表的意义也是我们关心的(很明显这是一个传参)。在0x10464a630打个断点,继续运行程序。

图 9

如图10所示

图 10
0x10464a630: adr    x17, #0x6e38     // pc + 0x6e38 = 0x104651468写入x17寄存器,对应的无偏移地址是[0x000000010000d468] (__DATA.__data + 0),对应的是_dyld_private这个符号的值,如图11所示
0x10464a634: nop                     // 空操作 
0x10464a638: stp    x16, x17, [sp, #-0x10]! // 移动SP栈指针,将存储在x16和x17中数据入栈
0x10464a63c: nop                     // 空操作
0x10464a640: ldr    x16, #0x19c0     // 0x000000019f3dfb44: dyld_stub_binder调用地址写入x16寄存器中
0x10464a644: br     x16              // 跳转至dyld_stub_binder方法处(libdyld.dylib.__TEXT.__text + 11568)

图 11

dyld_stub_binder就是dyld库中进行延迟绑定的方法,我们调用这个方法进行了两个传参,一个是x16也就是之前存储的0xb9,还有一个是x17中存储的_dyld_private(这个到底表示什么意义,暂时还不清楚)。那么问题来了,x16参数携带的到底是什么参数呢?试想,如果我们调用dyld_stub_binder方法来进行绑定,起码要告诉函数我们要寻找哪个方法的调用地址(这里是printf),那既然第二个参数不是,那第一个参数就一定跟这个信息有关。其实0xb9是表示的是printfDynamic Loader Info -> Lazy Binding info -> Actions段的偏移,可以参考图1。0x10351 - 0x10298 = 0xb9。我们可以看到在这个数据段里不仅记录了符号的名称_printf,而且还记录了这个符号所在的动态库名称libSystem.B.dylib,通过这两个信息dyld就可以去对应的动态库寻找对应的符号,从而将地址返回,回写到printf对应的la_symbol_ptr数据段中。到MachOView中观察stub_helper数据段,我们能够更加清晰的发现这个数据段之间的内在联系。最上面是一段通用绑定的代码,其目的就是通过调用dyld_stub_binder来获取符号的地址。而下面的数据段,每3行一组,其作用是当做一个中间的跳板,每个符号的la_symbol_ptr在初始化时都指向一个这样的跳板,这个跳板就是提供对应符号的信息(例如本例中通过提供0xb9这个偏移给dyld_stub_binder提供符号名、动态库名信息),每个跳板最终还是会调用通用绑定方法来实现最终的绑定。

图 12

第二次运行printf,在读取la_symbol_ptr的时候,我们发现不需要再进行绑定了,0x000000019f24c978就是printf的调用地址,直接跳转这个地址就是调用了printf

图 13

四、一些疑问

我们上面的流程已经把如何寻找printf的符号的过程说清楚了,但是有一点还未涉及的问题

1、就是当首次绑定获取到了printf的地址后,如何回写至printfla_symbol_ptr数据段中的呢?

猜想:通过二进制的中的Dynamic Symbol Table可以获取到printf这个符号对应的信息,其中就包括其调用地址在la_symbol_ptr段的位置,这样就能够进行数据的回写。如下图:

图 14

2、dyld_stub_binder这个真正寻找符号地址的方法,其本身也是一个外部定义的符号,它的调用地址是如何确定呢?

猜想:在lazy_bind_info中并没有找到dyld_stub_binder这个符号的信息,也就是说,它并不是通过延迟绑定机制确定的地址。在Dynamic Symbol Table中看到,与printf不同,其关联地址是在__DATA_CONST,__got这个数据段,而这个数据段如图16所示,是一个Non-Lazy Symbol Pointers。所以dyld_stub_binder这个符号的地址一定是在二进制加载的时候就已经确定了,而不是通过后期的延迟绑定

图 15

图 16

3、_dyld_private这个传参代表什么意义呢?这个暂时还不清楚,有深入研究的大神希望给予一些帮助。

五、结语

延迟绑定本是一个非常常见的技术点,但是在不同的平台可能会存在不同的实现方式。之前经常看一些大V的文章,但是很多时候都是说的一知半解,真正上手操作的时候还是能够学到不少的新东西,对知识点的融会贯通非常有帮助。上面的问题也仅仅是本人现阶段的一个理解,如果有什么问题还请批评指正。

六、参考资料

www.jianshu.com/p/f6bfccfa5…

www.jianshu.com/p/3aacb919b…

www.jianshu.com/p/857855cda…

www.jianshu.com/p/67ba8a230…