32

跟着 WWDC 一起探秘符号解析的魔法

 6 years ago
source link: http://blog.gocy.tech/2018/08/01/behindthescenes-symbol-resolve/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Objective-C 的世界中,对对象的方法调用都会被转为消息发送,通过 self_cmd ,在 method_list 中寻找对应的函数指针,最终触发函数调用。对于我们自己代码中的类,这很好理解,Runtime 会维护一个巨大的列表,存储着我们的类的信息。但对于系统 framework 中的类型呢?难道 Runtime 会在启动时预先将所有系统类也加载进来吗?更广义一点说,诸如 printf 这样的方法又是如何找到函数指针的呢?

剧透在前

我们都知道,在 .m 中编写代码时,我们只需要引入 .h 头文件,就可以使用对应文件中定义的类与方法,而不需要关心对应实现。这是因为,在静态阶段,我们并不真正执行任何二进制代码,只是生成指令而已,因此,我们只需要知道我们希望调用的函数的 符号 ,而不需要知道地址。而这些符号,将在运行时被转化为实际的内存地址,以供调用。

事实上,由于大量系统库以及 ASLR 的存在,我们也不可能在静态阶段就得到所有实际代码的执行地址。

macOSiOS 系统中,可执行文件的格式为 Mach-OMach-O 内部一般由三个部分组成:

JN3uiq7.jpg!web

其中 __TEXT 段为存放应用代码以及常量的地方,为 只读 属性。 __DATA 段存放全局变量、静态变量等数据,为 读写 属性。 __LINKEDIT 存放着加载该二进制时的一些元数据。这张截图自 2016 年的 Optimizing App Startup Time ,对以上三个段的解释简短清晰,但也留下了一些问题:在我们自己编写的代码中,夹杂了对系统库的调用,这些调用在编译后会变成符号,但我们的代码最终是放在只读的 __TEXT 段的,系统要怎么把符号转成地址呢?另外 __LINKEDIT 又到底存放了哪些信息呢?

从汇编说起

要弄清楚这些问题,就要先看看我们的代码经过编译,究竟会变成什么样,考虑如下代码:

@implementation ViewController
- (void)viewDidLoad {
    NSDictionary *obj = [NSDictionary new];
    NSLog(@"%@",obj);
}
@end

对应的汇编在精简后大概是这样(所有 .开头的均不是实际汇编代码):

"-[ViewController viewDidLoad]":        ; @"\01-[ViewController viewDidLoad]"
Lfunc_begin0:
	sub	sp, sp, #48             ; =48
	stp	x29, x30, [sp, #32]     ; 8-byte Folded Spill
	add	x29, sp, #32            ; =32
    
	adrp	x8, l_OBJC_SELECTOR_REFERENCES_@PAGE
	add	x8, x8, l_OBJC_SELECTOR_REFERENCES_@PAGEOFF
	adrp	x9, l_OBJC_CLASSLIST_REFERENCES_$_@PAGE
	add	x9, x9, l_OBJC_CLASSLIST_REFERENCES_$_@PAGEOFF
	stur	x0, [x29, #-8]
	str	x1, [sp, #16]
Ltmp0:
	ldr	x9, [x9]
	ldr	x1, [x8]
	mov	x0, x9
	bl	_objc_msgSend
	str	x0, [sp, #8]
	ldr	x8, [sp, #8]
	mov	x9, sp
	str	x8, [x9]
	adrp	x0, l__unnamed_cfstring_@PAGE
	add	x0, x0, l__unnamed_cfstring_@PAGEOFF
	bl	_NSLog
	add	x9, sp, #8              ; =8
	mov	x0, x9
	mov	x1, x8
	bl	_objc_storeStrong
	ldp	x29, x30, [sp, #32]     ; 8-byte Folded Reload
	add	sp, sp, #48             ; =48
	ret

如果你不熟悉汇编,也没太大关系,其中大部分代码都是在往“正确”的寄存器里面塞参数,保证函数调用的正确性,我们关心的是这几句:

// selector 以及 class 的符号
adrp	x8, l_OBJC_SELECTOR_REFERENCES_@PAGE
add	x8, x8, l_OBJC_SELECTOR_REFERENCES_@PAGEOFF
adrp	x9, l_OBJC_CLASSLIST_REFERENCES_$_@PAGE
add	x9, x9, l_OBJC_CLASSLIST_REFERENCES_$_@PAGEOFF
// 常量 string 的符号
adrp	x0, l__unnamed_cfstring_@PAGE
add	x0, x0, l__unnamed_cfstring_@PAGEOFF

你可以在 ARM Information Center 搜到 adrp 的详细文档,简单地说它就是用来取某个“符号”的地址的

限于篇幅,我们先只关注其中 NSDictionary 相关的,在汇编代码中搜索 l_OBJC_CLASSLIST_REFERENCES_ ,最终可以发现他落在这样一个地方:

.section	__DATA,__objc_classrefs,regular,no_dead_strip
.p2align	3               ; @"OBJC_CLASSLIST_REFERENCES_$_"
l_OBJC_CLASSLIST_REFERENCES_$_:
.quad	_OBJC_CLASS_$_NSDictionary

可以看到,这个符号实际是在 __DATA 段中的,也就是说,我们在 __TEXT 中的符号,实际指向的是 __DATA 段里的东西

挖掘 Mach-O

到这一步,我们就需要借助一些工具,来继续看看 __DATA 段又发生了什么事情,我个人使用 MachOView 来查看文件内部大体布局,用 Hopper 来查看具体内存地址是什么。

根据上面汇编代码给出的信息,我们很容易就可以在 __objc_classrefs 中找到我们的指针。

BVVZ7ne.png!web

我们利用 offset,在 Hopper 中跳到对应地址,将会看到另一条非常简单的汇编语句:

mIriArY.png!web

Hopper 提供非常强大的跳转功能,双击图中黄色高亮的符号,又可以跳到该符号定义的地方:

7FnuqaM.png!web

如果你稍稍向上滑动一下,会发现这段代码对应在 __DATA 段的 External Symbols Segment 中,而除了我们的 _OBJC_CLASS_$_NSDictionary ,还有诸如 UIResponderNSObject 以及 NSLog 等等系统符号。就目前来看,符号最终指向了 0x00 ,也就是 null ,这也是符合预期的,正如前文提到的,这些系统库在 runtime 时的地址是我们编译阶段无法确定的,需要在装载可执行文件时动态确定,也就是说,系统一定会在某个时机,将 0x00 改为实际地址。

那么这一步发生在什么时候呢?

MZz2amR.jpg!web

截图同样来自 Optimizing App Startup Time ,在系统动态加载 Mach-O 文件的时候,会经过 Rebase 以及 Bind 两个阶段,其中 Rebase 是将内部指针进行固定数值的偏移(slide),而 Bind 则正是用于将外部符号转为实际指针的步骤。在 2018 年的 Behind the Scenes of the Xcode Build Process session 中也提到了这一个步骤:

6jEzIzM.png!web

可以看到,整个流程 1-2-3-4 均和我们上面的分析相符,而上图中的最后一个步骤正是 Bind__LINKEDIT 中的信息就是建立应用内符号到系统函数符号的映射。这时候我们打开 MachOView 找到对应 Bind 阶段的信息,可以发现 _OBJC_CLASS_$_NSDictionary 的确是在这一步完成绑定的。

7Jv2AnU.png!web

至此,符号解析的流程就走通了, __TEXT 中的代码段指向 __DATA 中的符号,在装载二进制时,系统会根据 __LINKEDIT 中的信息,再将 __DATA 中的符号和实际系统函数地址建立映射。

Lazy Binding

细心的你一定还发现, __DATA 段中还有一个 __la_symbol_ptr ,而上面的截图中也存在 Lazy Binding Info 的字样。这是因为,并不是所有符号都是在启动时进行解析绑定的,出于性能考虑,一部分符号将会在首次调用时进行绑定。那么绑定的过程是如何的呢?

为了方便调试,我们稍微修改一下代码,让 app 启动时直接进入 lazybind 流程。

//main.m
int main(int argc, char * argv[]) {
    printf("hi, pritf");
}

重新编译后,用 Hopper 重新打开你的 Mach-O ,记得 不要 勾选 Resolve Lazy Bindings。

用寻找 NSDictionary 符号相似的方法,我们可以很快在 Hopper 中定位到如下位置:

jYfMBfQ.png!web

若继续跟踪代码,我们会来到一个似乎看不出什么端倪的地方:

zaUJNnE.png!web

这段代码实际属于 __stub_helper ,不确定是否是我所使用的 Hopper 版本问题,同样的段落在 MachOView 中查看,可以看到正确的代码:

Rn2eQrQ.png!web

这里,我们将 0x100006c38 地址处的内容,存入了 w16 ,回到 Hopper ,可以看到该处的值为 0x46 (具体值因 lazy pointer 数量而异)。随后我们将调用 0x100006bf4 处的函数:

FRVVv2V.png!web

此处逻辑是将 0x100008008 指针 传递给 x17 ,随后调用 dyld_stub_binder 方法。

该方法是 dyld 内部的方法( 源码在这 ),作用就是将 __la_symbol_ptr 当前指向 __stub_helpers 段的指针绑定到真实的函数地址上。结合 dyld 源码以及 Xcode 的 Always Show Assembly 选项,我们得以了解这个绑定的大概流程:

rARf2yU.png!web

我们传入的首参,也就是之前放置于 x17 处的指针,实际上是用于让 dyld 创建 ImageLoader 的内存区域, ImageLoader 负责处理可执行文件及其依赖关系的抽象类,在本例中,其具体实例为 ImageLoaderMachOCompressed 类,在它的头文件中,也注明了它的作用:

ImageLoaderMachOCompressed is the concrete subclass of ImageLoader which loads mach-o files that use the compressed LINKEDIT format.

dyld 创建了负责处理 __LINKEDIT 信息的实例后,程序最终会进入:

ImageLoaderMachOCompressed::doBindFastLazySymbol(uint32_t lazyBindingInfoOffset, 
const LinkContext& context, 
void (*lock)(), 
void (*unlock)())

其中参数 context 为初始化时 dyld 为该实例设置的一系列上下文信息, lockunlock block 则为特定环境中的加解锁方法。简单阅读该方法,可以发现它主要干这么几件事:

首先,通过 fLinkEditBase + fDyldInfo->lazy_bind_off 确定 __LINKEDIT 段中存放 Lazy Binding Info 信息的基址,然后根据我们传入的偏移值(也就是最开始塞进 w16 中的 0x46 ),定位到具体位置。

回头在 MachOView 上看看这块的信息:

eYBzy2A.png!web

0x46 正好就是基址 F0printf 信息段 36 的偏移量。到了这一步,我们就可以拿到:

  1. __la_symbol_ptr 段中,待改写的指针地址。
    IJZnAzy.png!web
  2. 我们想要找到的目标符号以及其对应在哪个 dylib 之中。

接着,调用 bindAt 方法,通过上一步中获取的符号名称,在目标库中获取函数地址(也就是 printflibSystem.B.dylib 中的实际地址),然后调用 bindLocation 改写对应 __la_symbol_ptr 中的指向。

实际运行起来,也证实了我们的想法:

JJFbMn3.png!web

resolve 之前,我们已经拿到了 _printf 符号和待改写指针,在 bindLocation 之前,我们已经拿到了 printf 的真实地址了,而在 bindLocation 之后,我们的 __la_symbol_ptr 就指向实际函数地址,变得不再 lazy 了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK