本篇将主要结合CSAPP介绍ELF文件的格式,常用的分析工具如objdump和readelf,以及Linux程序链接和装载的流程。
工具介绍
readelf
参数
a
h,–file-header
显示elf文件头,一个elf文件头通常如下所示1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1070
Start of program headers: 64 (bytes into file)
Start of section headers: 15000 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 11
Size of section headers: 64 (bytes)
Number of section headers: 29
Section header string table index: 28l,–program-headers/–segments
显示段头部表(program header table),通常包含Program Headers和Section to Segment mappingS,–section-headers/–sections
显示节头部表(section header table),通常如下所示g,–section-groups
t,–section-details
更具体的Se
等价于h+l+S,显示所有的头部s,–syms/–symbols
可以组合其它选项:
-sD表示显示dynamic的symbol–dyn-syms
这个应该也是显示.dynsym的,它和-sD的区别是什么呢?n
r
u
d,–dynamic
展示.dynamic的内容V
A
c
D
见-sx,–hex-dump=<number|name>
将某个number/name的Section dump下来p
R
z
–dwarf-depth=N
–dwarf-start=N
–ctf=<number|name>
–ctf-parent=<number|name>
–ctf-symbols=<number|name>
–ctf-strings=<number|name>
-W
objdump
参数:
- -r
查看重定向表 - -R
查看动态重定向表
一个Demo
main.c
1 | void swap(); |
swap.c
1 | extern int buf[]; |
构建项目,注意为了吻合CSAPP的Demo,需要指定32位模式,在一些编译器上还需要指定-no-pie
,见后文说明
1 | gcc -m32 -g -o p main.c swap.c |
附上readelf -a p
记录
注意,里面有很多省略号,代表大块的0,这个可以通过-z
来强制显示。
附上objdump -x -D -s p
记录
我们的系统和编译器是
1 | DISTRIB_ID=Ubuntu |
为了支持m32,需要安装gcc-multilib
1 | apt install gcc-multilib |
对于gcc-multilib
的安装,如果出现下面的错误,查看是否自己的apt版本有问题,我是因为在Ubuntu16上面用来了Ubuntu14的/etc/apt/sources.list
1 | The following packages have unmet dependencies: |
编译,这里C库默认是动态链接的,如果要静态链接需要指定-static
。此外,某些版本会自动开启PIE,这样readelf会显示是DYN而不是EXEC,objdump无法得到绝对地址,这样就无法调试了,所以要指定-no-pie
。这么做的一个原因是地址空间配置随机加载(ASLR),让一个程序加载到随机而不是固定的0x400地址。可以通过下面的代码来验证。
1 |
|
ELF结构
历史
Unix首先提出了COFF格式,该格式后来演化为了PE和ELF格式。
COFF格式的特点是引入了段的机制。
Header
可以通过readelf -h
读取ELF头部。
其中:
- REL一般是.o等待重定位的文件
- DYN一般是动态库
但可执行文件可能也是DYN而不是EXEC。例如指定配pie编译。 - EXEC一般是可执行文件
两个头部表
段(segment)和节(section)
段(segment),这里指的比如代码段(.text)、数据段(.data)、只读数据段(.rodata)、Block Started by Symbol段(.bss)等。
【需要注意的是,这里段头部表和节头部表的中文语义似乎不明确,比如在自我修养一书中就称Section Header Table为段表,因此下面尽量避免使用这两个词,而是用program表和section表代替】
section header table描述了ELF二进制文件的布局。可以通过readelf -S
指令查看节头部表,注意不要和-s
选项混用,后者是查询符号表的。
program header table描述了ELF内存中的布局。可以通过readelf -l
指令查看段头部表。
Program表
移动到“装载”章节中。
Section表
查看section表。需要注意的是这里是静态ELF的表。动态ELF,例如SO文件的表会多一些字段。
section表被实现为Elf32_Shdr数组,其中的各个列的说明如下:
- Nr
- Name
表示section的名字,它实际位于.shstrtab这个字符串表中,在Elf32_Shdr结构中实际记录的是一个偏移。 - Type
Section Headers的Type具有下面的几种情况:- NULL:
一般是Nr=0端独有的Type - PROGBITS:代码段和数据段
.interp、.init、.fini
.plt、.plt.got、.got
.text
.data、.rodata - SYMTAB:符号表
.symtab段 - STRTAB:字符串表
.strtab和.dynstr段等 - RELA/REL:重定位表
.rel.text和.rel.data用来重定位.text和.data,这是静态链接。
.rel.dyn和.rel.plt段用来重定位.rel.data和.rel.text,这是动态链接。
对于这个段,Lk表示该段使用的符号表对应到section表中的Nr,一般是.symtab。
Inf表示这个重定位信息为哪个段服务的,例如.ref.text是为.text服务的,所以它的Inf就是.text的Nr。 - HASH:符号表的哈希表
- DYNAMIC:动态链接信息
.dynamic段 - NOTE:
- NOBITS:
.bss段 - SHLIB
- DYNSYM
.dynsym段
- NULL:
- Addr
如果该段可以被加载,那么Addr是加载后在进程地址空间中的虚拟地址。
如果该段不能被加载,则为0 - Off
对应于Addr,这里指的是相对于文件中的位置。 - Size
- ES(Section Entry Size)
如果这个段中包含了一些固定大小的项目,ES表示每个项目的大小。
例如.symtab的ES就是10,表示每个项是10字节。 - Flg
- Lk(Link)/Inf(Info)
链接相关,在上面讨论过了。 - Al(Section Address Alignment)
1 | $ readelf -S p |
介绍一下几个段名:
.symtab
是一个符号表,包含引用的函数和全局变量的信息。符号表并不一定需要-g
选项才会生成。可是在readelf -l p
上面我们并没有看到有.symtab
。其实执行readelf -S p
就能看到了。我觉得可能是因为readelf -l
只显示会被装载到内存中的段,而.symtab
是静态符号表,不需要被装载。反观.dynsym
就会被装载到内存中。.dynsym
是动态符号表。可以通过readelf -sD
查看动态符号表。
符号表.symtab
中往往也会包括动态符号,但动态符号表中只有动态符号。.dynamic
如果程序连C库和C++库都是静态链接的,例如开启了-static
,那么它就不会有dynamic段。
否则,它就会具有.dynamic段,表示动态链接相关信息。.rel.text
和.rel.data
用来记录在.text
中遇到的外部符号,例如printf
函数的位置;对应的还有.rel.data
服务于.data
。
容易看出,这两个段是对目标文件存在的。对于静态的可执行文件来说,是不存在这两个字段的,因为已经没有什么需要重定位的了。对于动态的可执行文件,实验也没发现有这样的段。.strtab
字符串表,里面是普通的字符串。.shstrtab
是段表字符串表。包含段名等信息。可以通过下面命令来查看字符串表的内容。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22$ readelf -x33 p
Hex dump of section '.shstrtab':
0x00000000 002e7379 6d746162 002e7374 72746162 ..symtab..strtab
0x00000010 002e7368 73747274 6162002e 696e7465 ..shstrtab..inte
0x00000020 7270002e 6e6f7465 2e414249 2d746167 rp..note.ABI-tag
0x00000030 002e6e6f 74652e67 6e752e62 75696c64 ..note.gnu.build
0x00000040 2d696400 2e676e75 2e686173 68002e64 -id..gnu.hash..d
0x00000050 796e7379 6d002e64 796e7374 72002e67 ynsym..dynstr..g
0x00000060 6e752e76 65727369 6f6e002e 676e752e nu.version..gnu.
0x00000070 76657273 696f6e5f 72002e72 656c2e64 version_r..rel.d
0x00000080 796e002e 72656c2e 706c7400 2e696e69 yn..rel.plt..ini
0x00000090 74002e70 6c742e67 6f74002e 74657874 t..plt.got..text
0x000000a0 002e6669 6e69002e 726f6461 7461002e ..fini..rodata..
0x000000b0 65685f66 72616d65 5f686472 002e6568 eh_frame_hdr..eh
0x000000c0 5f667261 6d65002e 696e6974 5f617272 _frame..init_arr
0x000000d0 6179002e 66696e69 5f617272 6179002e ay..fini_array..
0x000000e0 64796e61 6d696300 2e646174 61002e62 dynamic..data..b
0x000000f0 7373002e 636f6d6d 656e7400 2e646562 ss..comment..deb
0x00000100 75675f61 72616e67 6573002e 64656275 ug_aranges..debu
0x00000110 675f696e 666f002e 64656275 675f6162 g_info..debug_ab
0x00000120 62726576 002e6465 6275675f 6c696e65 brev..debug_line
0x00000130 002e6465 6275675f 73747200 ..debug_str..got
、.got.plt
和.plt
【相关详细说明,见后文】
这些表是用来在装载动态链接库的时候寻找动态符号实际的位置的。其中.got
表记录了每个对象装载后的实际地址。.got.plt
表记录了每个函数装载后的实际地址。.plt
中列出了所有的@plt
为后缀的“函数”,这个段也是可执行的,用来实现PLT延时绑定的机制。.got
、.got.plt
共同构成了GOT表。
它们可以通过objdump -D
来查看,注意这里是-D
而不是-d
,这是因为-D
能够反汇编所有Section。可以通过objdump -R
查看GOT中的重定位项。.rel.dyn
和.rel.plt
如果动态链接库还依赖于其他动态链接库,那它里面的符号也需要重定位。无论是否是PIC模式,都需要重定位。我们在后文探讨。
对应于.rel.data
和.rel.text
,相当于动态链接的重定位表。.init
和.fini
可以用来执行C++中全局/静态变量的构造或者析构操作。.line
是C中的行号和机器地址的对照表。debug相关段
.debug_*
目前ELF下debug相关的段的格式称为DWARF(Debug With Arbitrary Record Format)。PE下的是CodeView。
调试信息可以在编译时通过-g
得到,对应用程序运用strip
消除。
当程序真正被加载到Linux内存中时,呈现下面的布局。可以看到,栈是从高往低增长的,ESP是栈顶;堆是由低往高增长的,由brk限制。
符号表
结构
符号表是目标文件/可执行文件中的一个或者多个section,记录了里面用到的所有符号。
首先介绍一下结构。
- Ndx
表示符号所在的Section。取值为- ABS
也就是readelf -S
看到的Nr序号。 - COM
表示是一个COMMON块类型的符号,例如未初始化的全局符号。参考下面的bufp1
。
COMMON块听起来很熟悉,其实也确实来自Fortran的一个概念。 - UND
表示是一个未定义的符号,通常说明是在这个文件中被引用,但定义在其他文件中。
- ABS
- Value
需要讨论:- 如果是目标文件,且符号不是COMMON类型
Value为该符号在序号为Ndx的段Section中的偏移。 - 如果是目标文件,且符号是COMMON类型
表示对齐属性 - 如果是可执行文件
Value表示符号的虚拟地址,对动态链接器来说很有用。
- 如果是目标文件,且符号不是COMMON类型
- Bind
GLOBAL表示全局符号。
LOCAL表示局部符号。例如static变量只在当前编译单元内可见,所以它是LOCAL的。
WEAK表示弱引用。
注意,弱引用和弱符号的概念还有区别:强符号包含函数和已初始化的全局变量;弱符号包括未初始化的全局变量。可以通过__attribute__((weak))
annotate(自我修养里面用的是定义)一个符号是弱符号。
可以通过__attribute__((weakref))
定义对一个外部函数的引用是弱引用。对于弱引用,在链接期如果找不到它的定义,不会报错,而是赋一个0或者其他便于识别的值,如果在运行期还找不到定义,才会出错。
弱引用的一个作用是,我们可以声明一个pthread_create
函数为弱引用,然后我们在运行期判定pthread_create
的地址是否为0,来判断是否是多线程程序。 - Type
- NOTYPE,未知类型
- OBJECT,数据对象
例如一个scalar,或者数组 - FUNC,函数或者其他可执行代码
容易想到,FUNC对应.text的Ndx - SECTION,表示一个Section
没错,Section也会体现在符号表中。Session符号的Bind一定是LOCAL。
readelf中这些条目并不包含具体Section的名字,可以通过Ndx去readelf -S
的结果中对应寻找,或者可以通过objdump -t p
打印出符号表来看到。 - FILE,文件名,此时这个符号的Bind一定是LOCAL,并且Ndx一定是一个ABS序号
- Vis
可见性,一般是DEFAULT,诸如__x86.get_pc_thunk.ax
的是HIDDEN。
可以总结得到下面的规律:
- 初始化的全局变量
Type为Object、Bind为Global、Ndx通常为.data或者.bss,取决于是否初始化为0。 - 未初始化的全局变量
Type为Object、Bind为Global、Ndx为COM。 - 静态变量
Type为Object、Bind为Local、Ndx为.data或者.bss。 - 不在当前编译单元内定义的函数
Type为NOTYPE、Bind为Global、Ndx为UND。 - 在当前编译单元内定义的函数
Type为FUNC、Bind为Global、Ndx为.text。 - 文件名
Type为FILE、Bind为Local、Ndx为ABS。 - Section
Type为SECTION、Bind为Local、Ndx为对应Section的Nr、Name为空,但可以由Ndx查到。
Demo
查看main.o的符号表,发现其中的swap
符号对应是UND
的,表示是在本模块中引用了,但是未定义(其实在另一个编译单元swap.o中定义)的符号。
1 | $ readelf -s main.o |
而buf对应了Ndx
为3,查看Section表,3表示.data
段。在源码中有int buf[2] = {1, 2};
,因此buf被分到.data是容易理解的。
1 | $ readelf -S main.o |
下面我们再看看swap.o的符号表,这里buf是UND容易理解,因为它是一个外部变量extern int buf[]
。
【Q】关于bufp1我是有不解的,这是一个全局未初始化的变量,我觉得应该是走zero initialization而不是default initialization,所以bufp1
的应该是在bss段,即Ndx=4,但是这边给出的是Ndx=COM。其实两者都没有错,如果按照C++的方式编译g++ --std=c++14 -m32 -g -c -o swap.o swap.cpp
,就可以发现bufp1确实就在bss段了。所以在处理全局未初始化变量上,C和C++的标准是不一样的。
1 | Num: Value Size Type Bind Vis Ndx Name |
Name Mangling
GCC
1 | _Z [N 名字空间1长度 名字空间1 名字空间2长度 名字空间2 ...] 函数名长度 函数名 E 参数列表 |
MSVC
链接
两步链接(Two-phase linking):
- 分配空间和地址
收集每个目标文件各个段的信息。
构建全局符号表。 - 解析符号和重定位符号
Section的地址
可以通过objdump -h
(或者readelf -S
)查看每个Section的地址,通过readelf -l
查看Section和Segment的对应关系。其中:
- VMA(Virtual Memory Address)
表示虚拟地址,也就是这个段在程序运行时候的地址。 - LMA(Load Memory Address)
表示加载地址,也就是我们把这些段里面的内容复制到内存的某个地址处。
容易想到,程序加载到哪里,程序就在哪里运行,所以VMA应该等于LMA。但是这个有特例。在一些嵌入式系统中,程序被加载在ROM中。即使可以在ROM中执行.text
段的代码,也不能在ROM中修改.data
段的数据,更何况ROM的读取速度要慢于RAM。因此不可避免地需要将程序从ROM中的LMA,复制到RAM中的VMA上。 - File Off
(下面展示了objdump -h
的部分结果)
1 | p: file format elf32-i386 |
刚说过VMA和LMA不一定相等,其实VMA和File Off并不一定相等,比如:
对于可执行文件
事实上,如果我们readelf -x14 p
,查看.text
的段的内容的话,也能看到它起始地址0x080482e0
。1
2Hex dump of section '.text':
0x080482e0 31ed5e89 e183e4f0 50545268 a0840408 1.^.....PTRh....对应地,查看Section表,发现地址也已经被确定了。
1
2
3$ readelf -S p
Section Headers:
[14] .text PROGBITS 080482e0 0002e0 0001a2 00 AX 0 0 16对于目标文件
objdump -h
一下main.o,可以看到它的起始地址是0x0
,而File Off是0x0000003c
。这是因为程序代码中使用的都是虚拟地址,在没有实际分配空间前,目标文件中代码段的起始地址为0。1
2
3
4Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000024 00000000 00000000 00000034 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE对应地,观察main.o里面的Section表,发现代码段.text的Addr仍然为0;
1
2
3
4
5
6$ readelf -S main.o
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000024 00 AX 0 0 1
[ 2] .rel.text REL 00000000 0003a0 000008 08 I 18 1 4使用
readelf -x1 main.o
看一下,发现起始地址是0,因此不是以File Off为基准的。1
2
3Hex dump of section '.text':
NOTE: This section has relocations against it, but these have NOT been applied to this dump.
0x00000000 8d4c2404 83e4f0ff 71fc5589 e55183ec .L$.....q.U..Q..
目标文件的地址
其实,不仅目标文件中.text的初始地址为0,具体代码中引用的地址也是0。我们打开main.c中的b注释,其反编译如下(AT&T格式)
1 | 00000000 <main>: |
可以发现:
- 函数调用的地址是
fc ff ff ff
,即-4,也就是转移到IP-4(即15-4=11)的位置。这个位置是fc
自己,而不是一个函数地址。 - 赋值的地址是0。
总而言之,这两个地方都不是一个合法的地址,在链接器最终装配成可执行文件后,这些地址才有可能被确定。现在问题来了,我们并不能从反汇编的代码中看到0x0
和fc ff ff ff
究竟是什么变量,这两个placeholder并没有什么意义。如果我来设计,我会给这个目标文件里面的变量/函数分配一个序号,然后在这里把序号填上去。但不管怎么样,ELF的实际做法,在“重定向”章节中进行介绍。
【Q】在WSL2上,反编译的结果不太一样,这是为什么?
重定向(静态链接)
本节中查看静态链接的重定向问题。
为什么存在这样的问题呢?原因有两个:
- 编译期并不知道所有引用符号的地址,所以需要重定向。我们每引用一次外部符号,就会在重定位表中产生一个条目。
- 代码跳转,需要指定对于当前IP的一个相对地址,这导致要花一章时间来讲是怎么算的。
对于静态链接而言,重定向只发生在静态链接期。例如,看到对于swap.o或者main.o来说,是存在重定位表.rel.text
;但是在最终形成的被静态链接的可执行文件p中就不存在上述字段了。
观察目标文件和可执行文件的地址变化
观察p文件(可执行文件)
观察最终结果./p的汇编。注意,在我的Ubuntu 18.04.5和GCC 7.5.0上面实际上会看到main的地址并不是8028开头的,这个可能是因为开启了PIE,可以通过-no-pie
禁用掉。
下面介绍一下call
指令,它通过e8
来识别,具有一个操作数0a 01 00 00
即0x010a
。call的行为是将下一条地址0x80482f6
压栈,然后跳转到被调用的函数swap
的第一条命令0x8048400
处。0x8048400
这个地址是怎么算出来的呢?注意到0x80482f6+0x010a=0x8048400
,所以call
命令的参数是相对于EIP
寄存器的偏移,给的是相对地址而不是绝对地址。并且,此时EIP指向的是下一条指令的地址而不是0x80482f1
,这是因为取指之后就会更新EIP了。
1 | $ objdump -d p |
从objdump中可以看到,0x8048400
处确实是swap的代码
1 | $ objdump -d p |
观察main.o文件(目标文件)
接着观察main.o的汇编,这个在上面“目标文件的地址”中已经讲解过了,但由于源程序和编译器稍有不一样,所以这里再保留代码。
这里call
的偏移是0xfffffffc(-4)
,经过计算就是16-4=12
,对应的是fc ff ff ff
的开头。
1 | $ objdump -d main.o |
现象总结
可以看到,从不可执行的main.o到可执行的./p,发生了两个显著的变化。
- 左边的地址对应了main.o的代码实际被加载的地址
32位程序默认会被加载到0x8048000
处,而正如之前提到的,在目标文件中这里是0。 - 右边call的地址,从
0xfffffffc(-4)
变为了0x8048400
,这个对应了swap
的实际装载地址
原理
重定位表
对于每个需要被重定位的ELF段,都需要有一个重定位表,这个表也是一个Section,因此重定位表也叫做重定位段。如果.text
中有重定位的地方,重定位表就是.rel.text
段;如果.data
中有重定位的地方,它的重定位表就是.rel.data
段。我们可以通过readelf -r
查看重定位表,可以通过objdump -r
或objdump -R
分别查看静态重定位表和动态重定位表。
什么是动态重定位表呢?对于静态链接的可执行文件来说,是没有.rel.text
和.rel.data
段的,因为他们需要的符号都被静态链接了。但对于一般的可执行文件,可能还存在.rela.dyn
(rel.dyn
)和.rela.plt
(rel.plt
)的动态重定位表。这是因为动态链接中需要的符号,可能在装载时才会被加载。
每个需要重定位的符号的出现,都会在重定位表中产生一个条目,例如调用一个外部函数swap多次,或者访问一个外部变量多次,那么会产生对应数量的条目。
介绍重定位表中各列的含义:
Offset
这个重定位条目需要修改的地址相对于所处Section段头的偏移。也就是后面看到的r.offset
。
另外,当被用来查看可执行文件或者so文件时,这个值表示需要修改的位置的虚拟地址。Info
低8位:重定位条目的类型。例如下面的0x02
。
高24位:重定位条目的符号在符号表中的下标。1
2
3
4
5$ readelf -r main.o
Relocation section '.rel.text' at offset 0x3a0 contains 1 entries:
Offset Info Type Sym.Value Sym. Name
00000012 00000f02 R_386_PC32 00000000 swap例如重定位表中的
0x00000f02
,高24位的值是15,可以对应查到swap在符号表中的位置。并可以看到,swap的Ndx是UND的,也正说明需要借助于重定位表才能确定具体的位置。1
2
3
4
5$ readelf -s main.o
Num: Value Size Type Bind Vis Ndx Name
...
15: 00000000 0 NOTYPE GLOBAL DEFAULT UND swapType
对于动态链接,还有更多的重定位类型,先不提。目前包含两种,在后面细讲:R_386_PC32
=2:相对寻址R_386_32
=1:绝对寻址
Sym.Value
定义两个指标:
refaddr
refaddr
表示需要修改的call的参数的地址。即0x80482f2
(=0x80482f1+1
)。*refptr
*refptr
表示call参数的最新值。
查看main.o的重定位条目表.rel.text
,它提供了下列信息:
- 需要修改的重定位地址
refaddr
相对于main
的段头的偏移量是12
这个12,也对应了call的参数的位置 - 把重定位地址
refaddr
处的值改成程序swap
处的地址
计算过程讲解
如下图所示,重定位计算主要的一个过程是*refaddr=*refptr
。对应到重定位swap,就是给call指令填上合适的参数。
寻址过程:
R_386_PC32
=2:相对寻址1
S + A - P
其中:
- S是
swap
的绝对地址,即0x08048400
。 - A是
0xfffffffc(-4)
,也就是*refptr
的旧值0xfffffffc(-4)
。 - P是被修正位置的地址,也就是call参数的位置,即
0x80482f1
。
- S是
R_386_32
=1:绝对寻址1
S + A
相对寻址:函数swap
下面进行具体计算,从重定位表看出,这是一个相对寻址,即R_386_PC32
,因此:
计算
refaddr
它等于ADDR(s) + r.offset
等于ADDR(main) + r.offset
,从前面的objdump的结果可以看到0x080482e0 + 0x12
为0x80482f2
,与实际结果吻合。计算
*refptr
因为call是相对偏移,所以用swap
的绝对地址减去EIP的值得到。
swap
绝对地址可以从objdump看到,是0x8048400
,而call
命令的地址是0x80482f1
,所以*refptr
的值是1
0x8048400 - 0x80482f1 = 0x10f
不对啊,之前看到实际的命令的参数是
0x10a
啊?没错,我们还需要减去A。
因为基址是0x80482f2+4=0x80482f6
,正好是下一条指令的开头。而这又是因为EIP(即PC)始终是指向下一条待执行的指令,所以计算偏移的时候,我们需要以此时实际EIP指向的位置来加上call指定的偏移。而0xfffffffc=-4
这个值正好帮助EIP跳过自己占用的四个字节,所以这进一步解释了为什么这里填4。
绝对寻址:变量buf
绝对寻址比较简单,一般用于变量。
此时S就是变量的实际地址,A为0x0。
重定位中某符号多次定义的类型不一致
容易看出,重定位中并不考虑符号的类型。因此会有下面的情况:
某强符号多次定义的类型不一致
因为强符号不能多次定义,所以一定会报错。某强符号和其他的弱符号定义的类型不一致
按照强符号为准。但如果发现有弱符号的size更大,则会产生一条警告1
alignment x of symbol `xxx` in yyy is smaller than y in zzz
某弱符号的多个定义的类型不一致
这里采用COMMON类型的链接规则,也就是按照多个定义中,size最大的为准。
通过实验可以得到下面的结果
1 | // 在Ubuntu 18.04下 |
进一步看一下之前的未初始化的全局变量在Common端的情况。我们nm一下,这里的ccc
确实是一个C标记,表示在Common端中。
1 | $ nm bss.o |
但是当我们将这个bss.c生成可执行文件(可能要去掉诸如f
等未定义符号,并且加上main)后,在看ccc
的标记,发现变成了B。
1 | $ nm bss |
所以可以看出,Common段实际上是一个中间状态。链接器会按照取size最大的原则去merge同名的弱符号,或者取强符号。对于C语言而言,这个中间状态是必要的,因为编译器无法知道符号的类型,所以即使说要在编译期在bss段分配空间,也不知道要分配多大的。只有当链接器找到弱符号的所有出现之后,才能确切知道。
至于C++为什么变了,可能是因为未初始化的全局变量也变成强符号了吧。实验在两个c文件中写int i;
,然后用gcc编译能过,但是用g++编译就过不了,显示下面的错误了。
1 | /tmp/ccPeXBAb.o:(.bss+0x0): multiple definition of `i' |
装载
装载流程
简介Linux的装载流程:
- 分配虚拟地址空间
- 读取并校验ELF头
- 寻找.interp段,设置动态链接器路径
- 将文件映射到虚拟地址空间中
- 初始化进程
- 将系统调用的返回地址修改为可执行文件的入口点
对于静态链接的可执行文件,入口点是ELF文件头中e_entry指向的地址。
对于动态链接的可执行文件,入口点是动态链接器。
Program表
program表展示了从文件的Section,到虚拟地址的Segment之间的映射关系。内存中的一个Segment通常包含多个文件中的Section,属性相同的Section会被链接器聚集在一起,将来可以映射到一个Segment中。
查看program表,发现有两个LOAD段,分别对应了下面的02和03两个段(从0开始数),一般来说只有这两个段会被加载到内存。第一个段包含了.text
、.rodata
等,这些段是只读的,所以Flags
有R,并且代码段.text
是可执行的,所以Flags
还有E。这两个LOAD段都是按照0x1000,即4096字节对齐的。
这个Entry point对应程序入口,即_start
函数,可以通过例如ld -e
来修改。
1 | $ readelf -l p |
介绍一下列名:
- Type
表示类型。
对于LOAD类型来说,内存大小不可能小于文件大小。但是有时候在内存中会分配多于文件大小的空间。其实这一部分就对应了BSS段所占用的空间。我们可以结合Section表的来观察,首先,我们找到.bss
的大小是0x8,然后我们找到第二个LOAD段的大小是0x124,而0x124-0x8的差值0x11c就是LOAD段的FileSiz。 - Offset
表示这个Segment在文件中的偏移。 - VirtAddr
表示Segment的第一个字节在进程虚拟地址空间中的起始位置,应该就是VMA。
对于动态链接的共享库,可以在运行时通过dl_iterate_phdr
函数来判断当前库,和库里面的每个Segment被加载到的真实地址。 - PhysAddr
就是LMA,一般和VirtAddr是一样的。 - FileSiz
表示Segment在文件中占用的长度,它是可能为0的。 - MemSiz
表示Segment在内存中占用的长度,它也可能为0。
一般来说,MemSiz不会小于FileSiz,否则这个Segment都无法装载到虚拟空间里面。但MemSiz可能大于FileSiz,例如我们可以将.data所在的MemSiz调大,剩下来的部分会被填充为0,对应给到.bss就可以了。Linux对这个机制的实现会导致包含.data的LOAD段的结束地址比期望要小。 - Align
表示对齐。
在进程运行时,可以通过cat proc/.../maps
(macOS中使用vmmap)来看到进程p的其他VMA,通过下面的语句,可以看到./p在运行时的所有VMA,例如[heap]、[stack]、[vdso]等。
1 | cat /proc/`ps aux | grep -w p | grep -v grep | grep -v gdb | awk '{print $2}'`/maps |
第一列表示VMA的地址范围。
rwx表示读写执行权限,p表示私有,s表示共享。
后面一列是用来索引到映像文件(比如被装载的可执行文件)。
诸如00:00 0
的字段表示主设备号、次设备号和文件节点号。对于[stack]而言它们都是0,表示这些Segment没有被映射到文件中。这种VMA称为Anonymous Virtual Memory Area。
1 | 555ad5321000-555ad5342000 rw-p 00000000 00:00 0 [heap] |
通过比较,可以简单进行划分:
- 代码VMA
rx - 数据VMA
rwx - 堆VMA
rwx,可以往更小的地址扩展 - 栈VMA
rw,可以往更大的地址扩展
段页映射
段地址对齐
PAE技术
PAE技术允许在最大64G的内存中同时跑多个进程,但每个进程最大还是4GB。
动态链接
相比静态链接的重定位,动态链接过程因为很多符号的地址要在加载时才能确定,所以更加复杂。
下面我们将讨论一些方案,包括使用PLT的延迟加载。
动态链接程序的加载
动态链接程序在装载时必须重定位。否则,可以考虑下面的情况:
- 程序A需要地址1000-1999
- 程序B需要地址2000-2999
- 程序C需要地址3000-3999
- 程序D需要动态加载程序A和B
那么3000-3999的空间会被浪费 - 程序E需要动态加载程序A和C
那么2000-2999的空间会被浪费
有人说,在链接期强行把程序E里面的程序C挪到2000-2999就行了,这种手动为每个动态链接库分配的方式在大型程序维护上会非常痛苦。例如,链接后可执行文件都固定了,如果后来动态链接库升级,导致空间增长,原来的虚拟地址空间放不下了,这就很麻烦。
类似于目标文件的地址都是在链接期才确定一样,可以让动态对象在任意地址加载,而真正被加载的地址,从链接期推迟到运行期再决定。
在确定动态符号的地址时,动态链接器不能直接模仿静态链接器一样修改代码,不过这个是不现实的,因为.text
段是只读的。根据thegreenplace上的这篇文章,在解决加载动态链接库中的重定向问题时,通常有两种办法:
- 基于Load-time Relocation机制
这个机制对应了-shared
的编译模式。
可以理解为将重定向从链接期delay到了执行期。主要思路是模块被装载到地址A,而模块中代码段的Offset为b,函数f对于代码段的Offset为c,则遍历模块中的所有重定位表,将所有对f的引用都改为A+b+c。
这个方案的缺点是同一个库被加载到不同进程时,指令是一样的,不同的只是地址。所以原则上,我们可以将这些不同的地方提出来放到数据段,这样同一个库在内存中只会有一个副本了。这思路可以节省内存,对应于下面的PIC机制。
当然,也有优点,因为在装载后地址全部被重定位了,因此在每次访问对象或者函数时,会少一层间接,所以速度可能快。 - 基于地址无关代码(PIC, Position-independent Code)机制
这个机制是目前主流且通用的,对应了-fPIC -shared
的编译模式。
这个方案将代码中需要修改地址的部分分离出来,放到数据段中。这样剩余的代码是地址无关的,因此在整个系统中只有一份,而每个进程维护自己的地址相关的副本。
PIC
先不考虑动态库的事情。首先考虑一个模块,要实现为地址无关的,也就是要将地址相关的代码剥离掉。有什么代码是位置相关的呢?这包含两部分:
- 调用外部过程
- 引用全局变量
据此,我们可以将模块中的地址引用分为下面四种方式:
模块内函数调用
call
命令肯定是位置无关的,因为它的参数是相对EIP的偏移。所以不需要重定位。模块内数据访问
因为要做到地址无关,所以在代码中不能出现绝对地址(类似于R_386_32的方案)了。剩下的一条路是相对寻址,这是因为同一个模块的布局导致代码和数据所在页的相对位置是固定的,所以根据代码的位置,通过一些偏移就能算出数据相对于代码存放的位置。因此,类似“模块内函数调用”,我们又选择了PC作为了标杆。
需要注意的是,数据寻址没有像call
一样直截了当,使用相对偏移做参数。换句话说,32位程序数据的相对寻址没有相对于PC的寻址方式。所以得通过get_pc_thunk
函数(实际上可能叫__x86.get_pc_thunk.ax
)来获得PC的地址,然后通过PC地址加上偏移量的方式来访问。对应汇编类似于1
2
3call get_pc_thunk.ax
add xxx, %ecx
movl $val, yyy(%ecx)get_pc_thunk
的原理是调用call会把下一条指令的地址压到栈顶,即esp指向位置。模块间数据访问
相比于“模块内数据访问”,其难点在于要到运行期加载完对应模块,才知道偏移量。所以不能按照之前的方案算一个偏移量出来。那还能怎么办?查表呗。
因此引入全局偏移表GOT存放每个符号的地址。例如,当需要访问变量b时,首先查找GOT中对应的条目,再根据条目列出的地址来索引到变量正确的位置。
在链接时,虽然每个模块的位置确定不下来,但GOT表的位置是可以确定下来的。所以链接器实际上使用“模块内数据访问”的方案确定GOT相对于PC的偏移,然后再从GOT表找到变量的位置。可以看出,多了层间接。
GOT位于.got段,可以通过objdump -R
查看GOT中的重定位项。在有了延迟绑定机制后,GOT表会分别存储在.got和.got.plt段中。模块间函数调用
实际上也可以通过类似3的办法来解决,即call *(%eax)
这样。但考虑到函数的加载数量是比较大的,每次多一层中转,会影响性能。实际实现采用了延迟绑定的技术,通过PLT表来实现。Global Symbol Interposition问题
在模块内部被引用的变量,除了模块内部的静态变量(比如局部变量、具有内部链接性的static变量等)之外,还存在模块内部的全局变量的情况。比如在module.c中对extern int global
这样的引用,其中global
可能被定义在共享对象中,也可能被定义在同模块的另一个.o里面。
如果module.c是可执行文件的一部分,因为可执行文件不是PIC的,那么必然会在bss段给global
创建一个副本。对这种情况,就需要将共享对象中的global
指向这个副本。
如果module.c在共享对象中,那么它肯定是按照PIC编译的,此时就会按照模块间引用的方式生成代码。
从Global Symbol Interposition问题引申开去,可以发现,如果在so里面定义一个全局变量G,并且进程A和B都使用了该so,那么这两个程序也都是访问的自己独立的副本。
在下面的代码中,其实无法确定b和ext是在同模块的其他.o中,还是在其他的共享库中。对于这种情况,只能全部当做跨模块的情况来处理。
1 | static int a; |
之前提到,地址无关代码里面并不能做到绝对地址。但C++中有个叫指针的东西,它就是绝对地址。对于这种数据段中出现的绝对地址引用,将在后面介绍R_386_RELATIVE
机制。
1 | static int a; |
如何判断一个ELF文件是PIC的呢?
根据爆栈网,可以用下面的办法判断一个目标文件是不是PIC的。但下面的评论也指出了对SO,以及对-m32
编译选项是不适用的。
1 | readelf --relocs foo.o | egrep '(GOT|PLT|JU?MP_SLOT)' |
另外,在程序员的自我修养中提到,执行下面的命令如果有输出,则不是PIC代码。这是因为PIC代码不包含重定向表。
1 | readelf -d foo.so | grep TEXTREL |
动态链接器
考虑动态链接的场景,程序依赖的外部程序并没有在静态链接期链接到可执行文件中,而需要在运行期动态加载。负责这个过程的称为动态链接器,负责在装载完可执行文件后,实际执行动态链接工作。它也是一个共享对象,由INTERP
段指定,Linux上的默认值为/lib/ld-linux.so.2
。
动态加载器是GLIBC的一部分,它的版本号往往和GLIBC的版本号一样,当GLIBC升级时,需要手动指定/lib/ld-linux.so.2
这个文件到新的路径。为什么能够保证兼容性,可以查看后面的论述。
1 | $ readelf -l p |
- ld-linux是静态链接的
动态链接库的版本和SO-NAME
为什么依赖的so文件通常都是libname.so.6
,但是实际上它会指向libname.so.6.0.23
这样的库呢?下面将解答。
动态链接库的版本如下所示
1 | /libname.so.x.y.z |
其中:
- 主版本号
x
不同的主版本号的动态链接库是不兼容的。这通常暗示着在系统中需要保留旧版本的so库,否则依赖旧版本的libname的程序可能无法运行。 - 次版本号
y
表示增量升级。会增加新符号,但不会改变旧符号。因此,高的y会自然兼容低版本的y。正因为如此,我们不需要指定次版本号,我们升级系统的so到最新版本就行,反正不会影响老版本。 - 发布版本号
当然,诸如libc和ld并不采用上面的命名方式。
rpath(-R)和-L
添加目录到 runtime library search path。通常是把 ELF 可执行文件和共享对象一起链接的时候会遇到。所有的 rpath 参数会被一起传给 runtime linker,并且被用来在运行期寻找共享对象。
rpath 也被用来用来定位在链接时显式指定的共享对象,参考后面的 rpath-link 参数,
如果 rpath 没有被指定,那么就使用 LD_RUN_PATH。
搜索顺序(gcc)
The linker uses the following search paths to locate required
shared libraries:
- 用
-rpath-link
指定的所有目录 - 用
-rpath
指定的所有目录。和上面的区别在于,-rpath
在运行期起作用,而-rpath-link
只在编译器起作用。
Searching -rpath in this way is only supported by
native linkers and cross linkers which have been configured
with the –with-sysroot option. - On an ELF system, for native linkers, if the
-rpath
and-rpath-link
options were not used, search the contents of the environment variable “LD_RUN_PATH”. - On SunOS, if the -rpath option was not used, search any directories specified using -L options.
- 使用
LD_LIBRARY_PATH
. - For a native ELF linker, the directories in “DT_RUNPATH” or “DT_RPATH” of a shared library are searched for shared libraries needed by it. The “DT_RPATH” entries are ignored if “DT_RUNPATH” entries exist.
- The default directories, normally /lib and /usr/lib.
- For a native linker on an ELF system, if the file /etc/ld.so.conf exists, the list of directories found in that file.
如何确定使用的编译器,比如 clang 或者 gcc 的版本呢?用 strings 判断 binary 的编译器,搜 gcc 和 clang。如下所示
1 | > strings bin/tiflash/tiflash | grep clang |
动态链接库依赖其他的库
动态链接库依赖其他的库时,是一个递归的过程。
但其中有一个陷阱,考虑函数 foo:
- foo1.cpp 中定义了 foo,返回1
- foo2.cpp 中也定义了 foo,但是返回2
- libfoo.cpp 中定义了 test 函数,返回 foo()
- 把 libfoo.cpp 编译为动态库 libfoo.so,并链接 foo1
- main.cpp 中调用 test 和 foo,按顺序链接 foo2 和 libfoo.so
1 | // libfoo.cpp |
1 | g++ foo1.cpp -c -o foo1 |
考虑执行 main,test 和 foo 的调用分别输出什么呢?
朴素的思想是,因为编译动态库的时候用的是 foo1,那么 test 就应该输出1。但实际上 test 输出的是 2。
原因是编译 main 时,查找符号的顺序是 foo2 然后是 libfoo.so。而在 foo2 中就找到了返回 2 的 foo 函数。
查找丢失的符号
有的时候,可能加载了错误版本的库,这导致找不到一些符号。可以通过 ldd 诊断这一类的问题。
1 | $ ldd -r /usr/lib/libatlas.so |
动态链接程序的构建:重定位机制
GOT机制概述
动态链接程序怎么重定位呢?答案很简单,查表呗。这个表就是GOT表。编译和链接的时候,预先分配好这个表,加载的时候把实际的变量/函数位置给填上去就行。
在一个程序中会维护一个GOT表,因为它是唯一的,所以在构建时就可以确定其地址,这样可以通过类似静态连接的重定位的方法,根据IP算出GOT相对于当前指令的偏移。GOT表中记录了所有跨模块函数和变量的地址,包含.got
和.got.plt
两个段。
因此.got
中保存全局变量引用的地址,.got.plt
保存全局函数引用的地址。拆成两个段的原因是:
- 为了实现后面的PLT机制,需要将对于函数引用的实际地址从
.got
中拆出 .got.plt
需要有执行权限。
通过GOT表访问变量的方法很简单,就是在GOT表中找到对应变量的偏移即可。
通过GOT访问函数的是使用最多的场景,如果每次都去检查GOT表有没有被初始化,显然比较浪费。为此,我们有PLT机制。
PLT机制概述
.got.plt
的前三个条目有特殊意义:
-
.dynamic
段地址 - 本模块ID
_dl_runtime_resolve
函数地址
这个函数负责绑定模块A中的f函数。即,给定模块名A和函数名f,它能找到对应的地址
.got.plt
后面的条目就是每个被import的函数的实际地址。
PLT机制的过程是如下代码(来自自我修养一书)所示,这代码位于.plt
段。
在调用某跨模块的函数例如bar@plt
时,首先通过GOT中的索引进行跳转。如果这个索引条目已经加载了bar的正确地址,那么就会自然跳转到对应位置。
但在一开始,*(bar@GOT)
指向的是push n
指令,而不是函数加载的地址。这里的n就是需要查找的符号bar在重定位表.rel.plt
中的下标。
从push
到jump _dl_runtime_resole
的这一小段代码实际上就是去加载bar的正确地址。_dl_runtime_resole
的参数就是刚push进去的两个值。在该函数加载完毕后,将结果填写到GOT表(.got.plt
)中的*(bar@GOT)
条目中,这样后续调用就不走push n
了。
简单来说,第一个 jmp 一开始指向它的下一行,也就是 push 和 jump _dl_runtime_resolve
。后续在 resolve 到了真的地址之后,就会指向真正的地址了。
1 | bar@plt: |
现在还有个问题,什么是.rel.plt
呢?它类似于的静态连接的.rel.text
,但它不是修正.text
,而是修正GOT表的.got.plt
段中的地址,实际上是对函数引用的修正。我们在后面详细讲解。
为什么跨模块访问变量不使用PLT?
首先,全局变量往往比较小。一个高内聚低耦合的程序会尽量减少全局变量的使用。
动态链接程序的构建:实际构造
在这一章节中,主要讲解在编译器设计地址无关(PIC)动态链接程序的编译结果,使得后续的加载过程中的动态链接过程更有效率。
尽管如此,还是会和基于Load-time Relocation机制进行比较,并介绍一些共同的部分。
编译过程
虽然动态链接的过程中不会把so文件真的打包到可执行文件中。但我们需要依赖so文件来了解某个符号是否定义在so中。如果是,则会标记不对它在编译期进行重定位。
.dynamic段
.dynamic段看上去像是属于这个so文件的“ELF文件头”,可以通过readelf -d swap.so
来查看.dynamic段的信息:
- (INIT)
.init地址 - (FINI)
.fini地址 - (INIT_ARRAY)
- (INIT_ARRAYSZ)
- (FINI_ARRAY)
- (FINI_ARRAYSZ)
- (GNU_HASH)
动态的.hash - (STRTAB)
.dynstr地址,可以对应到readelf -S
的结果。 - (SYMTAB)
.dynsym地址,可以对应到readelf -S
的结果。 - (STRSZ)
.dynstr大小 - (SYMENT)
- (PLTGOT)
- (RELA)
动态重定位表.rel.dyn地址。 - (RELASZ)
readelf -S
里面.rela.dyn项目的Size。 - (RELAENT)
readelf -S
里面.rela.dyn项目的EntSize。 - (RELACOUNT)
动态重定位表数量 - (NULL)
动态链接重定位表
介绍.rel.dyn和.rel.plt
在静态链接中,重定位表可以用来在链接时决定编译时尚未确定的导入符号的地址。
在动态链接时,很多符号的地址在甚至在链接期都无法确定。当可执行文件/共享库中一旦依赖于在其他的共享对象中的符号,就需要动态重定位表。
如果是基于Load-time Relocation机制,这很容易理解,因为实际上相当于把重定位符号直接从链接期移到执行期了。但对于PLT机制,同样需要重定位表,这是因为:
- 代码段地址无关,因此不需要重定位
- 数据段中还包含对绝对地址的引用,例如GOT表
没错,这些绝对地址已经在装载时由动态链接器填到GOT表的对应位置了。但总得想办法让代码去访问对应的GOT表的条目啊。
于是 - 数据段中还包含对绝对地址的引用,例如指针
对应于R_386_RELATIVE
类型。
总结一下,动态链接重定位表.rel.dyn
和.rel.plt
对应于静态链接的.rel.data
和.rel.text
,分别修正数据和函数引用:
.rel.dyn
修正位于.got
段和数据段中的地址。它对应R_386_GLOB_DAT
类型,以及另一种R_386_RELATIVE
类型。.rel.plt
修正.got.plt
的地址。它对应R_386_JUMP_SLOT
。
我们可以用readelf -r
去查询动态链接重定位表。
当然事情也不绝对,事实上导入函数的重定位入口可能出现在.rel.dyn
中。这发生在不使用PIC编译时,导入的外部函数,比如printf
就会在.rel.dyn
中,并且类型也变成了R_386_PC32
。在后面的实验中将进行验证。
相比静态链接,动态链接重定位表多了一些重定位类型:
R_386_JUMP_SLOT
这个类型的条目,重定位表中Offset字段指向.got.plt中对应函数条目的地址。
例如在动态加载时,查到printf
的地址为X,然后就去动态重定位表中找Sym.Name为printf
的条目,将X填到它Offset所指向的位置上。
这个位置位于.got.plt表中。回想.got.plt表的结构,前三个项目分别是.dynamic
段地址、模块ID和_dl_runtime_resolve
地址,所以这个Offset应该至少是从第四个条目开始的。
TODO 补充一下printf的例子R_386_GLOB_DAT
这种类型的条目,对应的重定位Offset指向的位置在.got中,与R_386_JUMP_SLOT
很类似R_386_RELATIVE
这种类型的重定位即Rebasing。这种方式的出现,是因为上面论述的四种动态重定位方式还不够周全。我们考虑某个共享对象中有如下的代码:1
2static int a;
static int* p = &a;这段代码中,它是一个模块内的数据访问,比如如果我们仅仅去访问a,那么按照上面的论述,是可以通过相对地址来访问的。
但现在问题复杂了,p
持有a
的指针,这是一个绝对地址。容易发现,当共享对象被加载到不同的程序中时,a
的地址是会变化的,但我们必须在装载时把这个地址算出来,这个就是R_386_RELATIVE
重定位要做的事情。
在编译时,共享对象的地址是从0开始的,我们记录此时a
的偏移是B
,此时p
的值应该是B
;当共享对象被装载到A
处时,此时p
的值应该是A+B
了。因此,R_386_RELATIVE
标记出来的这些符号在装载时都需要加上一个A
的值,才成为最终结果。
实验
可以通过readelf -r xxx.so
查看动态重定位表。
在Ubuntu 16.04.7,使用gcc (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609,去编译SO,指令
1 | gcc -shared -fPIC -m32 -g -c -o test_printf.so test_printf.c |
如果用了-fPIC
,有
1 | Relocation section '.rel.text' at offset 0x4bc contains 4 entries: |
而不使用,有
1 | Relocation section '.rel.text' at offset 0x414 contains 2 entries: |
可以看出,确实变成了R_386_PC32
。
但是SO文件里面似乎都没有.rel.dyn
段,并且也没.got
或者.got.plt
段,也许这两个段只会在可执行文件中出现,而不会在动态链接库中出现?这是因为错误加了-c
,去掉-c
重新看一下
1 | gcc -shared -fPIC -m32 -g -o test_printf.so test_printf.c |
如果用了-fPIC
有
1 | Relocation section '.rel.dyn' at offset 0x35c contains 9 entries: |
否则有
1 | Relocation section '.rel.dyn' at offset 0x35c contains 12 entries: |
实验
Load-time Relocation实验
实验设置
1 | gcc -shared -m32 -g -o swap_no_pic.so swap.c |
PIC和PLT实验
实验设置
下面编译一个最简单的程序
1 |
|
我们添加下面两个选项,以得到一个位置无关的swap.so。
-fPIC
创建位置无关代码-shared
创建共享库
1 | gcc -fPIC -m32 -g -o swap_no_shared.so swap.c |
生成动态链接库
1 | gcc -shared -fPIC -m32 -g -o swap.so swap.c |
生成可执行程序
1 | gcc -fPIC -m32 -g -o p_pic main.c swap.c |
注意,在编译动态库的时候,不能加-c
,否则链接器就不工作,会导致一系列问题。例如一些重定位项在.rel.text
中,而不是在.rel.dyn
中。
执行下面语句的编译结果
1 | gcc -shared -fPIC -m32 -g -o swap.so swap.c |
readelf -a swap.so
objdump -x -D -s swap.so
实验过程
下面我们以具体的实验来学习PLT机制。
反编译动态链接的结果,发现实际调用的是puts@plt
这个函数
1 | (gdb) disass main |
附上main.s的代码用来对照
1 | $ cat main_pic.s |
看看puts@plt
的实现,看起来是两个jmp和一个push,这个很奇怪。
1 | (gdb) disass puts |
这些地址都是啥呢?检查Section表,发现第一个jmp落在.got.plt
段上,而第二个jmp落在.plt
段上。【结合上面的介绍,第一个jmp应该是@plt
的到吗】
1 | (gdb) info symbol 0x804a00c |
这.plt
所在的段是可读可执行不可写的,可以认为是一段代码。而.got.plt
所在的段是可写的,也就是在运行时会被更新。
1 | $ readelf -S pic |
第一个jmp
查看第一个jmp的代码,发现没有啥意义,其实这个只是一个地址查询表,我们打印出来是一个要跳转的地址0x8049f14
1 | (gdb) disass 0x804a00c |
而这个地址恰好对应了puts@plt
的下一行代码
1 | (gdb) info symbol 0x80482e6 |
第二个jmp
接着,我们入栈了一个0x0
,接着jmp到0x80482d0
,这个似乎不好直接disass,于是我们采用下面的办法。
1 | (gdb) disass 0x80482d0 |
发现这边没有函数信息,于是强行disass一波,现在有了。看起来,我们入栈了一个0x804a004
之后,又jmp到了*0x804a008
这个位置。
1 | (gdb) disass 0x80482d0,0x080482f0 |
我们发现*0x804a008
居然是0。很奇怪,难道是没有运行的缘故?于是我们打断点,并执行
1 | (gdb) |
发现执行完之后,这里竟然有值了!但依然没有函数名,对这种情况,我们可以安装一个debug版本的glibc来解决
1 | ldd ./pie # 确定版本 |
这样在gdb里面就可以add symbol上面
1 | add-symbol-file /usr/lib/debug/lib/x86_64-linux-gnu/ld-2.27.so 0xf7fd6ab0 |
这个值实际上是_dl_runtime_resolve
的函数。这个函数主要内容就是入栈5个参数,然后调用_dl_fixup
,最后再弹出栈。
1 | (gdb) disass 0xf7fee000 |
ret的操作数指定了在弹出返回地址后,需要释放的字节/字数量。通常,这些字节/字被是作为调用的输入
1 | 0xf7fee01b <+27>: ret $0xc |
我们还可以从重定向表中,可能找到0x0804a00c
这偏移对应的表项。
1 | $ readelf -r pic |
实验
如何获得constexpr值?
1 |
|
1 | $ readelf -s t | grep MAGIC |
我的so有问题?
注意先ldd看下动态库的路径