一、前言
之前看了很多的关于延迟绑定的文章,对stub
、stub_helper
、la_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所示。
图中的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展示了main
方法的汇编代码,可以看到printf
在main
中被调用了两次,两次均是通过bl 0x10464a624
来尽心跳转调用的,这里系统已经帮我们查询到了0x10464a624
对应的符号,就是symbol stub for: printf
,这也就是printf
的桩函数。
先不着急看桩函数的实现,我们先来看一下这里的调用地址。由于ASLR
(Address 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的时候,这个偏移量每次都会变化。
回归到printf
的调用过程,我们通过LLDB
命令breakpoint set -a 0x10464a624
在printf
的桩函数处打一个断点,让程序继续运行,跳转到了图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所示:
可以看到,0x104650038
这个地址存储的数据是0x10464a69c
,这与图5中Xcode给我们解析出来的调用地址是相吻合的。而0x104650038
这个地址存在于二进制中的__DATA.__la_symbol_ptr
段的第56偏移行。我们通过MachOView去定位,发现这正好存储的就是_printf
符号的懒加载指针数据,如图7所示。同时我们也可以通过偏移地址来进行验证,0x104650038 - 0x4644000 = 0x10000C038
,也正好匹配MachOView中显示的地址。
那么桩函数的作用很清楚了,就是读取lazy_symbol_ptr
中对应符号的地址,然后跳转。但是此时我们读取到的跳转地址0x10464a69c
并不是printf
真正的调用地址。我们继续追踪一下这个地址。可以看到,这个地址位于当前二进制的__TEXT.__stub_helper
段的108偏移行。我们在这个地址打一个断点,继续让程序运行。
如图9所示,这里面有作用的就是前三行,第一行是将0x10464a6a4
这个地址中存储的数据读入到w16
寄存器中,而将0x10464a6a4
这个地址正好是第三行,它实际上存储的就是一个硬编码数0xb9
,所以此时w16 = 0xb9
。而第二行直接将当前的程序跳转到了0x10464a630
这个地址。那么我们追踪的地址就转换成了0x10464a630
,而w16
中存储的0xb9代表的意义也是我们关心的(很明显这是一个传参)。在0x10464a630
打个断点,继续运行程序。
如图10所示
图 100x10464a630: 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
是表示的是printf
在Dynamic 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
提供符号名、动态库名信息),每个跳板最终还是会调用通用绑定方法来实现最终的绑定。
第二次运行printf
,在读取la_symbol_ptr
的时候,我们发现不需要再进行绑定了,0x000000019f24c978
就是printf
的调用地址,直接跳转这个地址就是调用了printf
。
四、一些疑问
我们上面的流程已经把如何寻找printf
的符号的过程说清楚了,但是有一点还未涉及的问题
1、就是当首次绑定获取到了printf
的地址后,如何回写至printf
的la_symbol_ptr
数据段中的呢?
猜想:通过二进制的中的Dynamic Symbol Table
可以获取到printf
这个符号对应的信息,其中就包括其调用地址在la_symbol_ptr
段的位置,这样就能够进行数据的回写。如下图:
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
这个符号的地址一定是在二进制加载的时候就已经确定了,而不是通过后期的延迟绑定
3、_dyld_private
这个传参代表什么意义呢?这个暂时还不清楚,有深入研究的大神希望给予一些帮助。
五、结语
延迟绑定本是一个非常常见的技术点,但是在不同的平台可能会存在不同的实现方式。之前经常看一些大V的文章,但是很多时候都是说的一知半解,真正上手操作的时候还是能够学到不少的新东西,对知识点的融会贯通非常有帮助。上面的问题也仅仅是本人现阶段的一个理解,如果有什么问题还请批评指正。