写着玩-强撸静态链接(续)
0x00 口胡
没啥好说的,接着撸。。。
0x01 静态链接中的空间与地址分配
采用相似段合并的策略,即将相同性质的段合并到一起,给张图:
要明确虚拟地址空间以及文件空间的概念
在linux下,ELF可执行文件默认从地址0x08048000开始分配。
使用这种将相同性质的段合并策略的链接器一般都采用两步链接的方法:
给定一个例子,方便下面用它来说明一些问题:
采用合并相同性质段时,对于符号地址的确定就显得很简单,就是简单的段基址加偏移。即根据符号表的st_name字段得知该符号的名称,根据st_shndx字段指定段+st_value指定段内偏移可以得到该符号对应的具体信息所在的地址。对于处在b.o中的符号,其相对于基址的偏移为a的text(以text段为例)段的size+st_value。
0x02 符号解析与重定位
这里要跟上面所提到的符号地址的确定区别开,符号地址的确定只是把符号所在的具体地址给计算出来了,而符号解析与重定位侧重于讲解在未链接之前,引用外部符号的位置是什么样子的以及如何将这个在链接过程中确定的地址给回填到相应的引用中,侧重于回填的概念
在未链接之前,我们将a.o目标文件进行反汇编:
1. 怎么修正(回填)的-重定位表
重定位表专门用来保存这些与重定位相关的信息,告诉链接器哪些指令需要被调整,这些指令的哪些部分需要被调整以及怎么调整,在ELF文件中往往是一个段或多个段。
不算是废话的废话:
重定位表的结构定义
ELF32_Rel各字段含义
2. 符号解析
重定位过程呢也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。重定位时,为了确定该符号的目标地址,链接器会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。
0xff 坑
这里看到全局符号表我是进行了一番思考的。。。符号表如何组成全局符号表是一个值得思考的问题,结合强弱符号以及符号的修饰及相同符号的合并规则等等来考虑如何实现全局符号表的组成,这些细节由于自己检索能力太烂,所以找不到相关资料,这里就留个坑在这吧,日后希望能填上。。。
接着说。。。
3. 指令修正方式
具体的修正细节:
修正后,在访问时就可以根据具体的访存指令(不同的访存指令对于要访问的地址的计算方式不同,所以为了迎合访存指令,编译器在编译到相关的访存指令时,会事先安排好重定位时采用何种修正方式使得修正后的结果能正好满足这个访存指令的需要)
这里还是要口胡一下,就是比如说mov指令和call指令对于跟在他后面地址操作数的计算方式是不同的,拿call来说,它就认为后面的地址操作数就是要调用的位置与紧跟着call后面的那条语句地址差,这些指令的意思是预先定义的,所以编译器在看见call指令时,如果call后面的地址需要重定位,它就会知道,“哦,原来是call后面的地址需要重定位,根据call的意义,我编译器知道它的重定位修正方式为相对寻址修正”,所以,编译器会在重定位表中填入相对地址修正的信息,就是这样。
0x03 COMMOM块
为什么会有COMMAOM块:
由于弱符号机制允许同一个符号的定义存在于多个文件中,所以可能会导致当一个弱符号定义在多个目标文件中,而他们的类型又不同,而我们知道链接器是不支持符号的类型的,即变量类型对于链接器来说是透明的,那么此时链接器该如何处理,这时就需要COMMON块。
什么是COMMON块:
当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准
现在的链接机制在处理弱符号时,采用的就是COMMON块的机制,编译器会将弱符号(典型的弱符号如未初始化的全局变量定义)的类型置为SHN_COMMON类型,对于相同名称不同类型的弱符号,就采用COMMON块机制-以空间最大的那个为准。
在目标文件中,编译器为什么不直接把未初始化的全局变量也当做未初始化的局部静态变量一样处理,为它在BSS段分配空间,而是将其标记为一个COMMON类型的变量?
0x04 C++程序的二进制兼容性问题
C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作。最主要的有两个方面,一个是C++的重复代码消除,还有一个就是全局构造和析构。
1. 重复代码消除
C++编译器在很多时候会产生重复的代码,比如模板、外部内联函数和虚函数表都有可能在不同的编译单元中生成相同的代码。
比较有效的一个策略就是,拿模板来说,将每个模板的实例代码都单独的存放在一个段里,每个段只包含一个模板实例。用这种段的名字来区别不同的段和相同的段。
这种做法的确被目前主流的编译器采用。GNU GCC编译器和Visual C++编译器都采用了类似的方法。GCC把这种类似的需要在最终链接时合并的段叫”Link Once”,它的做法就是将这种类型的段命名为”.gnu.linkonce.name”,其中”name”是该模板函数实例的修饰后名称。Visual C++编译器做法稍有不同,它吧这种类型的段叫”COMDAT”,这种”COMDAT”段的属性字段(PE文件的段表结构里面的IMAGE_SECTION_HEADER的Characteristic成员)都有IMAGE_SCN_LINK_COMDAT(0x00001000)这个标记,在链接器看到这个标记后,它就认为该段是COMDAT类型的,在链接时会将重复的段丢弃
不仅模板是这样,对于会造成代码重复的机制,这种方法或与此类似的方法都是被采用的。像上面提到的外部内联函数、虚函数表。还有默认构造函数、默认拷贝构造函数和赋值操作符等。
又一问题:
折中的解决方案-函数级别链接
2. 全局构造与析构
C++的全局对象的构造函数在main之前被执行,C++全局对象的析构函数在main之后执行。linux系统下一般程序的入口是”_start”,这个函数是linux系统库(Glibc)的一部分。
黑姿势(以前在某CTF中被坑过)
当我们的程序与Glibc库链接在一起形成最终可执行文件以后,这个函数(_start)就是程序的初始化部分的入口,程序初始化部分完成一系列初始化过程之后,会调用main函数来执行程序的主体。在main函数执行完以后,返回到初始化部分,它进行一些清理工作,然后结束进程。对于有些场合,程序的一些特定的操作必须在main函数之前被执行,还有一些操作必须在main函数之后被执行,其中很具有代表性的就是C++的全局对象的构造和析构函数。因此ELF文件还定义了两种特殊的段。
- .init 该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main函数被调用之前,Glibc的初始化部分安排执行这个段中的代码。
- .fini 该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。
这两个段.init和.fini的存在有着特别的目的,如果一个函数放到.init段,在main函数执行前系统就会执行它。同理,假如一个函数放到.fini段,在main函数返回后该函数就会被执行。利用这两个特性,C++的全局构造和析构函数得以实现。
3. C++与ABI
ABI介绍
也就是说解决目标文件的跨编译平台问题
4. 静态库链接
我们前面说过,一个静态库可以简单的看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。
通常人们使用”ar”压缩程序将这些目标文件压缩到一起,并且对其编号和索引,以便于查找和索引,就形成了libc.a这个静态库文件。也可以使用”ar”工具来查看静态库包含哪些文件:1
$ar -t libc.a
VC++页提供了与linux下的ar类似的工具,叫lib.exe。这个程序可以用来创建、提取、列举.lib文件中的内容。使用”lib /LIST libcmt.lib”就可以列举出libcmt.lib中所包含的目标文件。
我们从hello.c出发,来看静态库的链接过程。根据前面的姿势,我们可以知道”printf”函数被定义在”libc.a”中的”printf.o”这个目标文件中。那么,似乎,找到了一种看似可行的链接方法,那就是”hello.c”程序编译出来的目标文件只要和libc.a里面的printf.o连接在一起就可以形成一个可执行文件了。我们来验证一下,使用下面的bash语句编译:1
$gcc -c -fno-builtin hello.c
这里说明一下,使用”-fno-builtin”参数是因为在默认情况下,GCC会自作聪明的将hello world程序中只使用了一个字符串参数的printf替换成puts(这也算是个坑点)。
然后,我们用ar工具解压libc.a1
$ar -x libc.a
然后链接1
$ld hello.o printf.o
结果意料中,那就是报错了,链接失败
给出编译链接的中间过程的打印结果:
为什么静态运行库里面一个目标文件只包含一个函数?比如libc.a里面printf.o只有printf()函数,为什么要这样组织?
0x05 小结
关于静态链接就到这了,重点是目标文件在被链接成最终可执行文件时,输入目标文件中的各个段是如何被合并到输出文件中的,链接器如何为它们分配在输出文件中的空间和地址。当然比较有意思的就是符号的解析与重定位了,一旦输入段的最终地址被确定,接下来就可以进行符号的解析与重定位了,链接器会把各个输入目标文件中对于外部符号的引用进行解析,把每个段中需要重定位的指令和数据进行修补,使他们都指向正确的位置。
还口胡了一些问题,COMMON块啊等等,总之,静态链接很好理解,恩,就是这样。