技术交流

好好学习,天天向上。

0%

第七章 链接

[toc]

链接

  • 链接就是将各种代码和数据片段合并成一个单一文件的过程。链接可以发生在编译时,也可以发生在加载时候,还可以发生在运行的时候。
  • 操作系统的加载器将可执行文件加载到内存中,然后将控制转移到这个程序的开头。

静态链接

linux的ld,是一个静态链接器,负责生成静态可执行程序。编译器和汇编器生成的可重定位目标文件,以地址0开始,包含多个不同的section(分.data,.bss,text等)。链接器需要解析出其中的符号(符号解析),然后修改所有对这些符号的引用(重定位)。

目标文件

目标文件分三种:

  • 可重定位目标文件:例如gcc编译出的那些*.o*文件,可以被静态链接成可执行目标文件
  • 可执行目标文件:可以直接加载到内存中执行的二进制文件(可执行程序,甚至某些库)
  • 共享目标文件:比如so文件,可以在运行时或者加载时一同链接的可重定位二进制文件

目标文件是按照特定的目标文件格式来组织的,不同系统采用不同的格式。Windows采用PE(Portable Executable)格式,MacOS-X采用Mach-O格式,Linux和Unix采用ELF(Executable and Linkable Format)格式 。从这里可以看到,可执行程序和编译过程中产生的obj文件是同一种格式。

ELF文件结构

ELF头这一节,前16字节是文件的magic头,描述该目标文件适用的系统字大小、字节序等。剩下的部分用于辅助链接器,包含ELF头大小,目标文件类型(上述三种)、机器架构、后续各节的起始地址和大小等(后续每个节都在头节中存在一个表项)。

  • .text:机器代码

  • .rodata:只读数据,例如字面值常量或者switch的跳转表等等。

  • .bss:存放全局初始化为0的,全局静态未初始化的,局部未初始化或者初始化为0的符号。如果静态变量重名,编译器会负责重命名。这一节的数据在目标文件中不占据空间,只是目标文件在加载进内存的时候,会为该节分配内存。

  • .data:存放全局初始化非0的,或者局部静态初始化非0的。如果静态变量重名,编译器会负责重命名。局部变量保存在栈中,不存放在.bss或者.data中。

    全局未初始化 全局初始化0 全局初始化非0 局部未初始化 局部初始化0 局部初始化非0
    static .bss .bss .data .bss .bss .data
    非static .COMMON .bss .data none none none
  • .symtab:一张符号表,保存在程序中定义或者引用的函数以及全局变量信息。无论编译时是否用-g选项,目标文件中都会生成这个节。该节可以用strip显式的删除。

  • .rel.text:被模块引用或者定义的函数的重定位信息,在链接过程中需要修改这张表。该表一般不存在于可执行目标文件中,除非显式指定。

  • .rel.data:被模块引用或者定义的所有全局变量的重定位信息,在链接时需要修改这张表。

  • .debug:调试符号表,编译时增加-g选项时,目标文件中才会有这个节。

  • .line:原始C源程序的行号与.text节中机器指令之间的映射信息,编译时增加-g选项时,目标文件中才会有这个节。

  • .strtab:字符串表,这里面的字符串就是前面.symtab和.debug节中各个符号的名称,相当于前两者的格式可以整齐划一,用地址引用每个item的名称。

节头部表用于描述上述各个节,每个节在节头部表中都有一个表项。有三个特殊的伪节在节头部表中没有表项,这三种伪节只存在于可重定位目标文件中,在可执行目标文件中不存在。它们分别是:

  • .ABS:包含不该被重定位的符号
  • .UNDEF:包含未定义的符号,也就是本模块引用了,但是定义在其他地方的符号。
  • .COMMON:包含尚未被分配位置的未初始化的数据目标,现代GCC把未初始化的全局变量放在这个节中。

符号和符号表

每个可重定位模块,都有一个符号表(.symtab),它包含该模块定义和引用的符号信息,这里的符号信息主要分三种:

  • 本地定义全局符号:本模块定义,外部可引用的符号,比如全局变量、非静态函数

  • 本地引用全局符号:其他模块定义的,在本模块引用了的符号。比如其他模块定义的全局变量和非静态函数

  • 局部符号:本模块定义的,外部不可见的符号,比如static的函数或者全局变量。

1
2
3
4
5
6
7
8
9
10
// .symtab中,表项的格式
typedef struct {
int name; /* 指向.strtab中的符号名称字符串 */
char type:4; /* 该符号是数据还是函数 */
char binding:4; /* 本地的,还是全局的*/
char reserved;
short section; /* 每个符号都被分到目标文件的某个节中,本字段指向对应节的开头 */
long value; /* 该符号在该节的偏移,如果是可执行程序,则value是绝对值 */
long size; /* 符号所占内存大小 */
} Elf64_Symbol;

例子

linux可以通过readelf命令查看ELF文件的内容,下面从代码到readelf读取,分析下ELF的格式

  • test.c的代码如下
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
int var_a;                 // .COMMON
int var_b = 0; // .bss
int var_c = 1; // .data
static int var_d; // .bss
static int var_e = 0; // .bss
static int var_f = 1; // .data

extern int var_g; // .UNDEF
int func_1(int); // .UNDEF

static int func_2(int var) // .text
{
return func_1(var*var_g);
}

int func_3(void) // .text
{
int var_h; // 没有
int var_i = 0; // 没有
int var_j = 1; // 没有
static int var_k; // .bss
static int var_l = 0; // .bss
static int var_m = 1; // .data
return func_2(var_h + var_i + var_j + var_j + var_k + var_l + var_m);
}

  • main.c的代码如下
1
2
3
4
5
6
7
8
9
10
int var_g;               // .COMMON
int func_1(int var) // .text
{
return var*var_g;
}

int main(int argc, char* argv[]) // .text
{
return 0;
}
  • main.o的ELF读取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[root@localhost home]# readelf -s main.o

Symbol table '.symtab' contains 18 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 NOTYPE LOCAL DEFAULT 1 $x
6: 0000000000000000 0 SECTION LOCAL DEFAULT 5
7: 0000000000000000 0 SECTION LOCAL DEFAULT 7
8: 0000000000000000 0 SECTION LOCAL DEFAULT 8
9: 0000000000000000 0 SECTION LOCAL DEFAULT 10
10: 0000000000000000 0 SECTION LOCAL DEFAULT 12
11: 0000000000000000 0 SECTION LOCAL DEFAULT 14
12: 0000000000000014 0 NOTYPE LOCAL DEFAULT 15 $d
13: 0000000000000000 0 SECTION LOCAL DEFAULT 15
14: 0000000000000000 0 SECTION LOCAL DEFAULT 13
15: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM var_g
16: 0000000000000000 36 FUNC GLOBAL DEFAULT 1 func_1
17: 0000000000000024 24 FUNC GLOBAL DEFAULT 1 main
  • test.o的ELF读取
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
[root@localhost home]# readelf -s test.o

Symbol table '.symtab' contains 30 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS test.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 NOTYPE LOCAL DEFAULT 4 $d
6: 0000000000000000 0 NOTYPE LOCAL DEFAULT 3 $d
7: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 var_d
8: 0000000000000008 4 OBJECT LOCAL DEFAULT 4 var_e
9: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 var_f
10: 0000000000000000 0 NOTYPE LOCAL DEFAULT 1 $x
11: 0000000000000000 44 FUNC LOCAL DEFAULT 1 func_2
12: 000000000000000c 4 OBJECT LOCAL DEFAULT 4 var_k.3349
13: 0000000000000010 4 OBJECT LOCAL DEFAULT 4 var_l.3350
14: 0000000000000008 4 OBJECT LOCAL DEFAULT 3 var_m.3351
15: 0000000000000000 0 SECTION LOCAL DEFAULT 5
16: 0000000000000000 0 SECTION LOCAL DEFAULT 7
17: 0000000000000000 0 SECTION LOCAL DEFAULT 8
18: 0000000000000000 0 SECTION LOCAL DEFAULT 10
19: 0000000000000000 0 SECTION LOCAL DEFAULT 12
20: 0000000000000000 0 SECTION LOCAL DEFAULT 14
21: 0000000000000014 0 NOTYPE LOCAL DEFAULT 15 $d
22: 0000000000000000 0 SECTION LOCAL DEFAULT 15
23: 0000000000000000 0 SECTION LOCAL DEFAULT 13
24: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM var_a
25: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 var_b
26: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 var_c
27: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND var_g
28: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND func_1 // 这个func_1的value是0,重定位的时候会填入新的值
29: 000000000000002c 108 FUNC GLOBAL DEFAULT 1 func_3
  • a.out的ELF读取
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
[root@localhost home]# readelf -s a.out

Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.17 (2)
3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND abort@GLIBC_2.17 (2)
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable

Symbol table '.symtab' contains 164 entries:
Num: Value Size Type Bind Vis Ndx Name
...
113: 0000000000420030 4 OBJECT LOCAL DEFAULT 24 var_d
114: 0000000000420034 4 OBJECT LOCAL DEFAULT 24 var_e
115: 0000000000420020 4 OBJECT LOCAL DEFAULT 23 var_f
...
117: 00000000004005e4 44 FUNC LOCAL DEFAULT 13 func_2
118: 0000000000420038 4 OBJECT LOCAL DEFAULT 24 var_k.3349
119: 000000000042003c 4 OBJECT LOCAL DEFAULT 24 var_l.3350
120: 0000000000420024 4 OBJECT LOCAL DEFAULT 23 var_m.3351
...
136: 000000000042002c 4 OBJECT GLOBAL DEFAULT 24 var_b
...
151: 000000000042001c 4 OBJECT GLOBAL DEFAULT 23 var_c
152: 0000000000420044 4 OBJECT GLOBAL DEFAULT 24 var_g
153: 0000000000420040 4 OBJECT GLOBAL DEFAULT 24 var_a
...
158: 00000000004006a0 24 FUNC GLOBAL DEFAULT 13 main
159: 0000000000400610 108 FUNC GLOBAL DEFAULT 13 func_3
160: 000000000040067c 36 FUNC GLOBAL DEFAULT 13 func_1
...

符号解析

全局符号的“强”、“弱”属性

当有多个模块定义同名的全局符号的时候,链接器需要采取一定策略从同名符号中做出选择。当前,Linux系统基于符号的“强”和“弱”属性进行决策。可重定位目标文件的符号表中,全局符号具备“强”、“弱”属性。该属性由编译器输出,由汇编器编码写入符号表。其中,函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。根据“强”、“弱”属性,有以下规则:

  • 不允许有多个同名的强符号
  • 如果有一个强符号和多个弱符号同名,则选择强符号
  • 如果有多个弱符号同名,从多个弱符号中任意选择一个

这里的选择,指的是代码中所有通过该符号执行的操作,从内存层面均表现在被选中的那个对象上。从类型层面,仍然以模块内部的符号声明为准。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* test.c */
double a;

void func_test(void)
{
a = -0.789; // 由于test.o的a符号是弱符号,不会被选中,但是类型声明有效。所以此处以double类型写main中a所在内存
}

/* main.c */
#include<stdio.h>
void func_test(void);

int a = 123;
int b = 456;
int main (int argc, char* argv[])
{
func_test();
printf("%d, %d\n", a, b); // int 4字节,double 8字节,当func_test()被调用后,a和b均被修改
}

为了避免这种不同类型,但是符号名称相同而导致的错误,可以给编译器加上GCC-dno-common选项,或者-Werror选项。看到这里,应该明白了,.COMMON段就是编译器拿来存放弱符号的。

静态库

Linux的静态库,就是archive文件,或者说archive将obj文件打个包,Linux就可以把打包结果当静态库用了。archive文件有个文件头,用来描述每个成员文件的大小和位置。静态库文件链接的时候,只会将被用到的成员模块链接到可执行文件中。可以通过下面的命令生成静态库:

1
2
gcc -c a.c b.c
ar rcs libtest.a a.o b.o

静态库链接过程

假设有三个集合:E,可重定位目标文件集合;U,被引用但尚未解析的符号集合;D,已经定义符号集合。有以下链接步骤:

  • 对于命令行上的每个输入文件,链接器判断是目标文件还是静态库文件。如果是目标文件,将f纳入E,根据f的符号表,编辑U和D
  • 如果是个静态库文件,尝试利用archive文件中的成员去解析U,将可以解析U的所有成员m纳入E,直到最终收敛——无论将archive中剩下的哪一个成员纳入E,均不能减少U时,则丢弃余下的成员
  • 如果当链接器对所有命令行入参都做出了处理后,U仍然非空,则报错;否则,将E用于重定位,合并成可执行程序

按照上述规则,链接器的入参顺序很重要,不能将库文件太靠前,一般而言将库文件放在参数的末尾。如果库文件之间没有依赖,则不用考虑库文件的排列顺序,否则还要考虑库文件之间的依赖。如果库文件之间存在交叉依赖,一种方法是将这些库文件合并,另一种是在链接参数中重复输入库文件名称。

重定位

重定位阶段,链接器将不同目标文件的相同section合并,然后将运行时内存地址赋给聚合section。在这一步,链接器还将修改.text和.data中对每个符号的引用,让它们指向正确的地址。

重定位条目

当编译器生成一个目标文件,每当该目标文件引用了一个外部的函数或者全局变量的时候,就生成一个重定位条目。代码的重定位条目存放在.rel.text中,已初始化的数据的重定位条目存放在.rel.data中。

1
2
3
4
5
6
typedef struct {
long offset; /* 需要被重定位的符号在.data或者.text section中的位置 */
long type:32, /* 重定位类型 */
symbol:32; /* 指向需要重定位符号在.symtab中的项,该项的值在编译完成后为0,连接过程中由链接器填写真实的值 */
long addend; /* 一个修正量 */
} Elf64_Rela;

假设有两个目标文件a.o和b.o,a.o的.text引用了一个b.o的.text导出的符号sym。同时假设,在链接过程中,a.o的.text被聚合到一个大的聚合.text中,起始地址为0x40000;b.o的.text同样被聚合到这个大的聚合.text中,起始地址为0x80000

已知,在编译过程中,a.o的.text和b.o的.text都认为自己的起始地址是0。编译b.o的时候,在b.o的.symtab中记录——有一个名为sym的符号,存放于.地址0xe处。当编译a.o的时候,编译器碰到对sym的引用,发现这个符号存在于外部,于是产生一个重定位符号表项,说:

我a.out,在地址0xf(offset)处,遇到一个外部符号symbol(指向.symtab中的字符串sym项)。现在我不知道这个sym的地址,同时我只是在0xf处填了几个0占着位置。但是我想通过type方式来引用sym。在这种引用方式下,如果我知道sym的地址,我还应该加上一个addend修正一下,填入0xf处。

链接器看到这个表项的时候,就会做如下处理:

现在,我知道symbol属于b.o。它在b.o的相对偏移为0xe。现在由于我将b.out的.text放在了0x80000处,所以sym的地址实际为0x8000e,symbol指向的.symtab表项的value我已经改为0x8000e了。由于我把你的.text放在了0x40000处。所以你的真实引用点应该是0x4000f。如果对symbol的引用你想绝对引用,我就给你在引用点填上0x8000e + addend;如果想相对引用,那我现在把它填为0x80000e + addend - 0x4000f

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
##——————————————————————————a.o汇编伪代码——————————————————————————##
指令地址0xf: call 0x00000000 # 实际想跳转到sym处,相对PC跳转规则为 sym = pc + value,pc == 0xf+0x5
指令地址0xf + 0x5: ...
指令地址0x..: ...
指令地址0x..: 跳转 -3 # 实际跳转到 5 - 3 = 2处
指令地址0x..: ...

##—————————————————————————.symtab的sym表项—————————————————————————##
28: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sym // 这个sym的value是0,重定位的时候会填入新的值
##————————————————————————————重定位表项————————————————————————————##
typedef struct {
long offset; /* 0xf */
long type:32, /* R_X86_64_PC32,相对PC跳转 */
symbol:32; /* 28 */
long addend; /* -4 */
} Elf64_Rela;

##————————————————————————————重定位结果————————————————————————————##
#--# 指令
指令地址0x4000f: call 0x3FFFB # 0x3FFFFB == 0x8000e -4 - 0x4000f, sym = 0x4000f + 4 + 0x3FFFFB == 0x8000e
#--# .symtab
28: 0x8000e 36 FUNC GLOBAL DEFAULT 13 sym // 这个sym的value是0,重定位的时候会填入新的值

可执行目标文件

链接器会在生成可执行目标文件的时候,为每个段指定好虚拟地址。加载器会按照可执行文件中的描述,将可执行文件中必要的段加载到对应的虚拟地址上。

没错,对于可执行程序而言,通过readelf看到的.symtab各个符号的地址就是运行时虚拟地址。

可执行文件的加载

shell会调用系统调用execve系统调用,触发加载器流程。加载器复制shell的运行上下文,替换其中的代码段和数据段内容,然后各个pte都写上invalid,最后跳转到0x400000开始执行glibc的函数。由于pte的invalid,后面的page fault会负责内存页面的分配以及对真实文件内容的加载。

动态链接共享库

动态库

在内存中只有一个副本,灵活、方便、省内存。动态库可以通过下面的命令编译出来:

1
gcc -shared -fpic -o libtest.so a.c b.c # -fpic,创建与位置无关代码(Position-Independent Code), -shared,告诉链接器创建共享目标文件

自动链接

当加载器发现可执行程序存在名为*.interp*的section的时候,就会调用ld-linux.so,而不是像通常情况下加载器会在加载完后跳到_start执行。动态加载器会完成对动态库的重定位。

这里还有一个流程,就是建立进程到动态库的mmap。猜想这个流程是由execve完成的,不然的话动态加载器怎么能读取动态库的内容呢?

手动链接动态库

Linux 提供了动态加载并链接共享库的接口,这样进程就可以运行时动态的修改程序的功能。

1
2
3
4
5
#include <dlfcn.h>
void *dlopen(const char* filname, int flag); // 加载
int dlcose(void *handle); // 关闭
void* *dlsym(void *handle, char *symbol); // 查找符号
const char *dlerror(void); // 检查错误

对于使用动态加载库的可执行程序的编译,可采用如下命令

1
gcc -rdynamic -o a.out main.c -ldl #  -rdynamic的意思是a.out的全局符号,可见于手动加载的动态库; dl库是dlopen等函数所在的库

Java的JNI就是通过这套接口实现的,将C/C++的代码编译成.so,然后动态的加载和调用。

位置无关代码

动态库技术面临两个问题:1、动态库应该映射到一个进程的哪片地址空间上?动态库中各个符号的引用者,如何能确定各个符号在虚拟地址空间中的位置?首先,肯定不可能为每个库预留一块专用的地址空间,因为进程接口的和动态库的多变行,无法通过标准来应对。所以毫无疑问,对于问题一,Linux的回答是动态库可以灵活的映射到进程的地址空间上,而非固定的。具体映射位置,视进程映射时可用虚拟地址空间而定。而对于问题二,当前Linux主要通过GOT和PLT来解决。具体而言,首先,引用者分为两种情况,一种是动态库中的函数自己引用自己的全局符号,另外一种是外部的引用者调用库中的符号。

库函数自引用

由于对于一个具体的.so文件来说,.text和.data之间的距离是确定的常量,这是可以利用的。所以编译器在.data开始的地方创建了一个叫做全局偏移量表(Global Offset Table,GOT)的表型数据结构,这个表中的每个项对应着该.so的一个符号。在.so文件的装载时,会完成内部的重定位,每当需要引用某个符号的时候就有如下跳转,例如

这里的动态库函数addvec需要引用动态库中的全局变量addcnt,首先mov指令通过当前指令位置和GOT之间的绝对差值,取出GOT中对应符号的内存地址,然后进行访问。

外部进程引用

如果是外部进程引用动态库中的符号,外部引用者需要确定库文件在进程虚拟地址空间中映射位置。有人可能会想到,说可以用重定位表,让动态库中的符号在映射进虚拟地址空间的时候,对进程中的引用者进行重定位。但是这个方法并不是PIC,因为很明显这种方式会因为映射位置的来修改引用者的.text,这是地址有关技术而不是PIC技术。GNU编译系统采用GOT和PLT相结合的延迟绑定技术。没错,GNU编译系统为每个引用动态库的进程,在.data中都创建了GOT。还在.text中创建了类似的PLT。

首先看下GOT数据结构,GOT有如下描述

  • GOT[0]和GOT[1] 存放着动态链接器解析函数地址时需要的表项信息
  • GOT[2]存放着动态链接器的入口地址
  • GOT[3]开始之后的每个GOT表项,和PLT中PLT[2]开始每个表项以及动态库中的唯一符号构成三元组。在具体符号第一次被引用之前,GOT对应表项存放PLT对应表项的第二行地址。第一次访问之后,GOT对应表项中的值将会被动态链接器修改为目标符号在进程虚拟地址空间中的地址

再看下PLT数据结构,PLT有如下描述

  • 每个PLT条目,负责一个符号的引用。其中,PLT[0]是动态链接器入口的跳板
  • PLT[1]是系统进程启动函数(__libc_start_main)的入口
  • 从PLT[2]开始之后的每个PLT表项,和GOT[3]开始往后的表项以及动态库中的唯一符号构成三元组。从PLT[2]开始PLT表项的结构是固定的,第一行,跳转到对应的GOT;第二行压入一个唯一编号;第三步跳转到PLT[0],从这里进入动态链接器。

下面来看一次具体的引用,假设,进程需要调用动态库中的函数addvec

第一调用的时候,call指令跳转到符号对应的PLT。

  • PLT第一步,直接将控制权转跳转到对应的GOT指向的位置。由于符号尚未被调用过,GOT中对应表项指向当前PLT的第二条指令,也就是jmp的下一条指令。换句话说,这里跳了白跳了。
  • 然后第二条指令,将符号编号压入栈,然后进入PLT[0]跳板。
  • PLT[0]将GOT[1]等动态链接器需要的信息入栈,然后跳转到GOT[0]指向的动态链接器入口地址。
  • 动态链接器将取出符号编号和符号表格–>完成目标符号的绑定–>修改GOT中对应表项的值–>修改栈信息–>然后将控制权交给目标符号–>完成首次调用回到call引用点下一条指令继续执行。

当第二次调用的时候,有如下流程

  • call指令仍然将控制权转移到符号对应的PLT,PLT第一句仍然引用对应GOT的值。不过由于该符号的GOT表项已经被修改,现在已经指向了目标符号的虚拟地址,这里将直接完成对目标符号的调用。

打桩技术

编译时打桩一

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
/* 头文件 malloc.h */
#define malloc(size) mymalloc(size)
void *mymalloc(size_t size);

/* 打桩文件 test.c */
#include <stdio.h>
#include <malloc.h> // 注意这句话,
// 测试现象1、这句话<malloc.h>,可以用,不会替换,为什么?
// 测试现象2、这句话<malloc.h>,删除本地malloc文件,不能用,报错,好理解
// 测试现象3、这句话<stdlib.h>,可以用,好理解
// 测试现象4、这句话"malloc.h",不可以,下面的malloc会被替换为mymalloc,好理解
// 测试现象5、这句话不写,不可以,有警告,好理解
// 测试现象6、随便来个名称,不可以,有报错,好理解
void *mymalloc(size_t size)
{
void *ptr = malloc(size);
printf("malloc at %p for size = %d\n", ptr, (int)size);
return ptr;
}

/* 打桩对象 main.c */
#include <stdio.h>
#include <stdlib.h>
#include <malloc.h>
int main()
{
int *p = malloc(1024);
free(p);
return 0;
}

/* 编译命令 */
gcc -c ./test.c
gcc -I. ./main.c test.o

一个事实是,链接器只会为那些没有解析的符号向库文件请求解析。一旦用户自己链接了别的obj,实现了对符号的解析,库文件里哪怕有同名符号,也不会链入了,也不会报多定义错误。

基于这个事实可以自己编译obj文件,链接到打桩对象上,实现对目标函数的打桩。

编译时打桩二

gcc支持别名属性,语法如下:

1
type newname __attribute__((alias("oldname")));

通过别名属性,可以将源码中的目标函数转换为替换函数的别名

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
/*------------------------------------hook.c------------------------------------
* gcc -shared -fpic -o libhook_close.so hook.c -ldl
*------------------------------------hook.c----------------------------------*/
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <stdio.h>

int (*p_close)(int fd);
int hooked_close(int fd)
{
printf("hooked_close called");
if (p_close != NULL) {
return p_close(fd);
}
}
__typeof(hooked_close) close __attribute__((alias("hooked_close"))); // 将close符号转换成hooked_close的别名

// attribute应用(写法一)
__attribute__((constructor)) void init_hook(void)
{
char *error;
p_close = dlsym(RTLD_NEXT, "close"); // RTLD_NEXT的含义是,告诉dl库从接下来尝试连接的库的众多符号中,命中第一个匹配的函数
if ((error = dlerror()) != NULL) {
fputs(error, stderr);
p_close = NULL;
}
}
// attribute应用(写法二)
void init_hook(void) __attribute__((constructor));
void init_hook(void)
{
....
}

/*------------------------------------hook.c------------------------------------
* gcc ./main.c -lhook_close
*------------------------------------hook.c----------------------------------*/
#include <unistd.h>
int main()
{
close(2);
}

链接时打桩

链接器支持–wrap选项。当链接时加入–wrap选项的时候,链接器自动将对函数func的调用解析为对__warp_func的调用,同时将对__real_func的调用解析为对原函数的func的调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 打桩文件 test.c */
#include <stdio.h>
void * __real_malloc(size_t size);
void __real_free(void * ptr);

// malloc wrapper function
void * __wrap_malloc(size_t size) {
printf("%s enter %u\n", __FUNCTION__, size);
void * ptr = __real_malloc(size);
printf("malloc %p size %u\n", ptr, size);
return ptr;
}

// free wrapper function
void __wrap_free(void *ptr) {
__real_free(ptr);
printf("free %p\n", ptr);
}

/* 编译命令 */
gcc -Wl,--wrap,malloc -Wl,--wrap,free -o a.out main.c test.c # -Wl参数,告诉gcc将后续参数透明的传递给链接器,参数中的逗号相当于空格

运行时打桩

Linux有个LD_PRELOAD环境变量,当动态链接库在加载和解析未解析符号的时候,会首先从这个变量中所包含的库文件中查找对该符号的解析。之后,再从系统库中查找符号。在这种情况下,如果想动态的打桩某个函数,可以将打桩代码做成库,添加到LD_PRELOAD变量中。在书中,有这么一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
void *malloc(size_t size)
{
void *(*mallocp)(size_t size);
char *error;
mallocp = dlsym(RTLD_NEXT, "malloc"); // RTLD_NEXT的含义是,告诉dl库从接下来尝试连接的库的众多符号中,命中第一个匹配的函数
if ((error = dlerror()) != NULL) {
fputs(error, stderr);
}
char *ptr = mallocp(size);
printf("malloced %d bytes at %p\n", (int)size, ptr);
return ptr;
}

编译

1
gcc -shared -fpic -o hook_malloc.so hook_malloc.c -ldl

运行

1
export LD_PRELOAD="./hook_malloc.so"; ls;  export LD_PRELOAD

实测发现,机制本身没问题,但是这个打桩会导致栈溢出 。这是因为,printf会调用malloc,导致循环调用。详细分析见这个文章CSAPP第三版运行时打桩Segmentation fault

常用命令

  • ar

    静态库的创建和编辑软件

  • strings

    列出一个目标文件中所有可打印的字符串

  • strip

    从目标文件中删除符号表信息,注意是.symtab而不是.debug

  • nm

    列出一个目标文件中符号表定义的符号

  • size

    列出目标文件中,各个section的名称和大小

  • readelf

    解析elf文件的各个字段,

  • objdump

    所有二进制工具的基础,可以显示一个目标文件的所有信息。不过基本用来搞反汇编。

  • ldd

    列出可执行文件依赖的共享库