ELF文件链接和装载

本篇将主要结合CSAPP介绍ELF文件的格式,常用的分析工具如objdump和readelf,以及Linux程序链接和装载的流程。

工具介绍

readelf

参数

  1. a

  2. h,–file-header
    显示elf文件头,一个elf文件头通常如下所示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    ELF 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: 28
  3. l,–program-headers/–segments
    显示段头部表(program header table),通常包含Program Headers和Section to Segment mapping

  4. S,–section-headers/–sections
    显示节头部表(section header table),通常如下所示

  5. g,–section-groups

  6. t,–section-details
    更具体的S

  7. e
    等价于h+l+S,显示所有的头部

  8. s,–syms/–symbols
    可以组合其它选项:
    -sD表示显示dynamic的symbol

  9. –dyn-syms
    这个应该也是显示.dynsym的,它和-sD的区别是什么呢?

  10. n

  11. r

  12. u

  13. d,–dynamic
    展示.dynamic的内容

  14. V

  15. A

  16. c

  17. D
    见-s

  18. x,–hex-dump=<number|name>
    将某个number/name的Section dump下来

  19. p

  20. R

  21. z

  22. –dwarf-depth=N

  23. –dwarf-start=N

  24. –ctf=<number|name>

  25. –ctf-parent=<number|name>

  26. –ctf-symbols=<number|name>

  27. –ctf-strings=<number|name>

  28. -W

objdump

参数:

  1. -r
    查看重定向表
  2. -R
    查看动态重定向表

一个Demo

main.c

1
2
3
4
5
6
7
8
9
10
void swap();
int buf[2] = {1, 2};
// static int s1; // a1
// static int s2 = 3; // a2

int main(){
// buf[0] = 99; // b
swap();
return 0;
}

swap.c

1
2
3
4
5
6
7
8
9
10
11
extern int buf[];
int * bufp0 = &buf[0];
int * bufp1;

void swap(){
int t;
bufp1 = &buf[1];
t = *bufp0;
*bufp0 = *bufp1;
*bufp1 = t;
}

构建项目,注意为了吻合CSAPP的Demo,需要指定32位模式,在一些编译器上还需要指定-no-pie,见后文说明

1
2
3
gcc -m32 -g -o p main.c swap.c
gcc -m32 -g -c -o main.o main.c
gcc -m32 -g -c -o swap.o swap.c

附上readelf -a p记录
注意,里面有很多省略号,代表大块的0,这个可以通过-z来强制显示。
附上objdump -x -D -s p记录

我们的系统和编译器是

1
2
3
4
5
6
7
8
9
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04
DISTRIB_CODENAME=xenial
DISTRIB_DESCRIPTION="Ubuntu 16.04 LTS"

g++ (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

为了支持m32,需要安装gcc-multilib

1
2
3
apt install gcc-multilib
gcc -no-pie -fPIC -m32 -g -o pic pic.c
gcc -m32 -static -g -o nopic pic.c

对于gcc-multilib的安装,如果出现下面的错误,查看是否自己的apt版本有问题,我是因为在Ubuntu16上面用来了Ubuntu14的/etc/apt/sources.list

1
2
3
4
The following packages have unmet dependencies:
libc6-dev-i386 : Depends: libc6-i386 (= 2.19-0ubuntu6.15) but it is not going to be installed
Depends: libc6-dev (= 2.19-0ubuntu6.15) but 2.23-0ubuntu11.2 is to be installed
Recommends: gcc-multilib but it is not going to be installed

编译,这里C库默认是动态链接的,如果要静态链接需要指定-static。此外,某些版本会自动开启PIE,这样readelf会显示是DYN而不是EXEC,objdump无法得到绝对地址,这样就无法调试了,所以要指定-no-pie。这么做的一个原因是地址空间配置随机加载(ASLR),让一个程序加载到随机而不是固定的0x400地址。可以通过下面的代码来验证。

1
2
3
4
5
6
#include <stdio.h>
int main()
{
printf("%p\n",main);
return 0;
}

ELF结构

历史

Unix首先提出了COFF格式,该格式后来演化为了PE和ELF格式。

COFF格式的特点是引入了段的机制。

可以通过readelf -h读取ELF头部。
其中:

  1. REL一般是.o等待重定位的文件
  2. DYN一般是动态库
    但可执行文件可能也是DYN而不是EXEC。例如指定配pie编译。
  3. 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数组,其中的各个列的说明如下:

  1. Nr
  2. Name
    表示section的名字,它实际位于.shstrtab这个字符串表中,在Elf32_Shdr结构中实际记录的是一个偏移。
  3. Type
    Section Headers的Type具有下面的几种情况:
    1. NULL:
      一般是Nr=0端独有的Type
    2. PROGBITS:代码段和数据段
      .interp、.init、.fini
      .plt、.plt.got、.got
      .text
      .data、.rodata
    3. SYMTAB:符号表
      .symtab段
    4. STRTAB:字符串表
      .strtab和.dynstr段等
    5. 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。
    6. HASH:符号表的哈希表
    7. DYNAMIC:动态链接信息
      .dynamic段
    8. NOTE:
    9. NOBITS:
      .bss段
    10. SHLIB
    11. DYNSYM
      .dynsym段
  4. Addr
    如果该段可以被加载,那么Addr是加载后在进程地址空间中的虚拟地址。
    如果该段不能被加载,则为0
  5. Off
    对应于Addr,这里指的是相对于文件中的位置。
  6. Size
  7. ES(Section Entry Size)
    如果这个段中包含了一些固定大小的项目,ES表示每个项目的大小。
    例如.symtab的ES就是10,表示每个项是10字节。
  8. Flg
  9. Lk(Link)/Inf(Info)
    链接相关,在上面讨论过了。
  10. Al(Section Address Alignment)
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
44
45
46
$ readelf -S p
There are 37 section headers, starting at offset 0x1c4c:

Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 08048154 000154 000013 00 A 0 0 1
[ 2] .note.ABI-tag NOTE 08048168 000168 000020 00 A 0 0 4
[ 3] .note.gnu.build-i NOTE 08048188 000188 000024 00 A 0 0 4
[ 4] .gnu.hash GNU_HASH 080481ac 0001ac 000020 04 A 5 0 4
[ 5] .dynsym DYNSYM 080481cc 0001cc 000040 10 A 6 1 4
[ 6] .dynstr STRTAB 0804820c 00020c 000045 00 A 0 0 1
[ 7] .gnu.version VERSYM 08048252 000252 000008 02 A 5 0 2
[ 8] .gnu.version_r VERNEED 0804825c 00025c 000020 00 A 6 1 4
[ 9] .rel.dyn REL 0804827c 00027c 000008 08 A 5 0 4
[10] .rel.plt REL 08048284 000284 000008 08 AI 5 24 4
[11] .init PROGBITS 0804828c 00028c 000023 00 AX 0 0 4
[12] .plt PROGBITS 080482b0 0002b0 000020 04 AX 0 0 16
[13] .plt.got PROGBITS 080482d0 0002d0 000008 00 AX 0 0 8
[14] .text PROGBITS 080482e0 0002e0 0001a2 00 AX 0 0 16
[15] .fini PROGBITS 08048484 000484 000014 00 AX 0 0 4
[16] .rodata PROGBITS 08048498 000498 000008 00 A 0 0 4
[17] .eh_frame_hdr PROGBITS 080484a0 0004a0 000034 00 A 0 0 4
[18] .eh_frame PROGBITS 080484d4 0004d4 0000e0 00 A 0 0 4
[19] .init_array INIT_ARRAY 08049f08 000f08 000004 00 WA 0 0 4
[20] .fini_array FINI_ARRAY 08049f0c 000f0c 000004 00 WA 0 0 4
[21] .jcr PROGBITS 08049f10 000f10 000004 00 WA 0 0 4
[22] .dynamic DYNAMIC 08049f14 000f14 0000e8 08 WA 6 0 4
[23] .got PROGBITS 08049ffc 000ffc 000004 04 WA 0 0 4
[24] .got.plt PROGBITS 0804a000 001000 000010 04 WA 0 0 4
[25] .data PROGBITS 0804a010 001010 000014 00 WA 0 0 4
[26] .bss NOBITS 0804a024 001024 000008 00 WA 0 0 4
[27] .comment PROGBITS 00000000 001024 000035 01 MS 0 0 1
[28] .debug_aranges PROGBITS 00000000 001059 000040 00 0 0 1
[29] .debug_info PROGBITS 00000000 001099 00010f 00 0 0 1
[30] .debug_abbrev PROGBITS 00000000 0011a8 000102 00 0 0 1
[31] .debug_line PROGBITS 00000000 0012aa 000071 00 0 0 1
[32] .debug_str PROGBITS 00000000 00131b 00009e 01 MS 0 0 1
[33] .debug_ranges PROGBITS 00000000 0013b9 000010 00 0 0 1
[34] .shstrtab STRTAB 00000000 001af4 000158 00 0 0 1
[35] .symtab SYMTAB 00000000 0013cc 0004f0 10 36 54 4
[36] .strtab STRTAB 00000000 0018bc 000238 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)

介绍一下几个段名:

  1. .symtab
    是一个符号表,包含引用的函数和全局变量的信息。符号表并不一定需要-g选项才会生成。可是在readelf -l p上面我们并没有看到有.symtab。其实执行readelf -S p就能看到了。我觉得可能是因为readelf -l只显示会被装载到内存中的段,而.symtab是静态符号表,不需要被装载。反观.dynsym就会被装载到内存中。

  2. .dynsym
    是动态符号表。可以通过readelf -sD查看动态符号表。
    符号表.symtab中往往也会包括动态符号,但动态符号表中只有动态符号。

  3. .dynamic
    如果程序连C库和C++库都是静态链接的,例如开启了-static,那么它就不会有dynamic段。
    否则,它就会具有.dynamic段,表示动态链接相关信息。

  4. .rel.text.rel.data
    用来记录在.text中遇到的外部符号,例如printf函数的位置;对应的还有.rel.data服务于.data
    容易看出,这两个段是对目标文件存在的。对于静态的可执行文件来说,是不存在这两个字段的,因为已经没有什么需要重定位的了。对于动态的可执行文件,实验也没发现有这样的段。

  5. .strtab
    字符串表,里面是普通的字符串。

  6. .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.
  7. .got.got.plt.plt
    【相关详细说明,见后文】
    这些表是用来在装载动态链接库的时候寻找动态符号实际的位置的。其中.got表记录了每个对象装载后的实际地址。.got.plt表记录了每个函数装载后的实际地址。.plt中列出了所有的@plt为后缀的“函数”,这个段也是可执行的,用来实现PLT延时绑定的机制。 .got.got.plt共同构成了GOT表
    它们可以通过objdump -D来查看,注意这里是-D而不是-d,这是因为-D能够反汇编所有Section。可以通过objdump -R查看GOT中的重定位项。

  8. .rel.dyn.rel.plt
    如果动态链接库还依赖于其他动态链接库,那它里面的符号也需要重定位。无论是否是PIC模式,都需要重定位。我们在后文探讨。
    对应于.rel.data.rel.text,相当于动态链接的重定位表。

  9. .init.fini
    可以用来执行C++中全局/静态变量的构造或者析构操作。

  10. .line
    是C中的行号和机器地址的对照表。

  11. debug相关段.debug_*
    目前ELF下debug相关的段的格式称为DWARF(Debug With Arbitrary Record Format)。PE下的是CodeView。
    调试信息可以在编译时通过-g得到,对应用程序运用strip消除。

当程序真正被加载到Linux内存中时,呈现下面的布局。可以看到,栈是从高往低增长的,ESP是栈顶;堆是由低往高增长的,由brk限制。

符号表

结构

符号表是目标文件/可执行文件中的一个或者多个section,记录了里面用到的所有符号。
首先介绍一下结构。

  1. Ndx
    表示符号所在的Section。取值为
    1. ABS
      也就是readelf -S看到的Nr序号。
    2. COM
      表示是一个COMMON块类型的符号,例如未初始化的全局符号。参考下面的bufp1
      COMMON块听起来很熟悉,其实也确实来自Fortran的一个概念。
    3. UND
      表示是一个未定义的符号,通常说明是在这个文件中被引用,但定义在其他文件中。
  2. Value
    需要讨论:
    1. 如果是目标文件,且符号不是COMMON类型
      Value为该符号在序号为Ndx的段Section中的偏移。
    2. 如果是目标文件,且符号是COMMON类型
      表示对齐属性
    3. 如果是可执行文件
      Value表示符号的虚拟地址,对动态链接器来说很有用。
  3. Bind
    GLOBAL表示全局符号。
    LOCAL表示局部符号。例如static变量只在当前编译单元内可见,所以它是LOCAL的。
    WEAK表示弱引用。
    注意,弱引用和弱符号的概念还有区别:强符号包含函数和已初始化的全局变量;弱符号包括未初始化的全局变量。可以通过__attribute__((weak))annotate(自我修养里面用的是定义)一个符号是弱符号。
    可以通过__attribute__((weakref))定义对一个外部函数的引用是弱引用。对于弱引用,在链接期如果找不到它的定义,不会报错,而是赋一个0或者其他便于识别的值,如果在运行期还找不到定义,才会出错。
    弱引用的一个作用是,我们可以声明一个pthread_create函数为弱引用,然后我们在运行期判定pthread_create的地址是否为0,来判断是否是多线程程序。
  4. Type
    1. NOTYPE,未知类型
    2. OBJECT,数据对象
      例如一个scalar,或者数组
    3. FUNC,函数或者其他可执行代码
      容易想到,FUNC对应.text的Ndx
    4. SECTION,表示一个Section
      没错,Section也会体现在符号表中。Session符号的Bind一定是LOCAL。
      readelf中这些条目并不包含具体Section的名字,可以通过Ndx去readelf -S的结果中对应寻找,或者可以通过objdump -t p打印出符号表来看到。
    5. FILE,文件名,此时这个符号的Bind一定是LOCAL,并且Ndx一定是一个ABS序号
  5. Vis
    可见性,一般是DEFAULT,诸如__x86.get_pc_thunk.ax的是HIDDEN。

可以总结得到下面的规律:

  1. 初始化的全局变量
    Type为Object、Bind为Global、Ndx通常为.data或者.bss,取决于是否初始化为0。
  2. 未初始化的全局变量
    Type为Object、Bind为Global、Ndx为COM。
  3. 静态变量
    Type为Object、Bind为Local、Ndx为.data或者.bss。
  4. 不在当前编译单元内定义的函数
    Type为NOTYPE、Bind为Global、Ndx为UND。
  5. 在当前编译单元内定义的函数
    Type为FUNC、Bind为Global、Ndx为.text。
  6. 文件名
    Type为FILE、Bind为Local、Ndx为ABS。
  7. Section
    Type为SECTION、Bind为Local、Ndx为对应Section的Nr、Name为空,但可以由Ndx查到。

Demo

查看main.o的符号表,发现其中的swap符号对应是UND的,表示是在本模块中引用了,但是未定义(其实在另一个编译单元swap.o中定义)的符号。

1
2
3
4
5
6
7
$ readelf -s main.o

Num: Value Size Type Bind Vis Ndx Name
...
13: 00000000 8 OBJECT GLOBAL DEFAULT 3 buf
14: 00000000 36 FUNC GLOBAL DEFAULT 1 main
15: 00000000 0 NOTYPE GLOBAL DEFAULT UND swap

而buf对应了Ndx为3,查看Section表,3表示.data段。在源码中有int buf[2] = {1, 2};,因此buf被分到.data是容易理解的。

1
2
3
4
5
6
7
8
9
$ 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
[ 3] .data PROGBITS 00000000 000058 000008 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 000060 000000 00 WA 0 0 1

下面我们再看看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
2
3
4
5
Num:    Value  Size Type    Bind   Vis      Ndx Name
13: 00000000 4 OBJECT GLOBAL DEFAULT 3 bufp0
14: 00000000 0 NOTYPE GLOBAL DEFAULT UND buf
15: 00000004 4 OBJECT GLOBAL DEFAULT COM bufp1
16: 00000000 54 FUNC GLOBAL DEFAULT 1 swap

Name Mangling

GCC

1
_Z [N 名字空间1长度 名字空间1 名字空间2长度 名字空间2 ...] 函数名长度 函数名 E 参数列表

MSVC

链接

两步链接(Two-phase linking):

  1. 分配空间和地址
    收集每个目标文件各个段的信息。
    构建全局符号表。
  2. 解析符号和重定位符号

Section的地址

可以通过objdump -h(或者readelf -S)查看每个Section的地址,通过readelf -l查看Section和Segment的对应关系。其中:

  1. VMA(Virtual Memory Address)
    表示虚拟地址,也就是这个段在程序运行时候的地址。
  2. LMA(Load Memory Address)
    表示加载地址,也就是我们把这些段里面的内容复制到内存的某个地址处。
    容易想到,程序加载到哪里,程序就在哪里运行,所以VMA应该等于LMA。但是这个有特例。在一些嵌入式系统中,程序被加载在ROM中。即使可以在ROM中执行.text段的代码,也不能在ROM中修改.data段的数据,更何况ROM的读取速度要慢于RAM。因此不可避免地需要将程序从ROM中的LMA,复制到RAM中的VMA上。
  3. File Off

(下面展示了objdump -h的部分结果)

1
2
3
4
5
6
7
8
9
10
p:     file format elf32-i386

Sections:
Idx Name Size VMA LMA File off Algn
0 .interp 00000013 08048154 08048154 00000154 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
...
13 .text 000001c2 080482e0 080482e0 000002e0 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
...

刚说过VMA和LMA不一定相等,其实VMA和File Off并不一定相等,比如:

  1. 对于可执行文件
    事实上,如果我们readelf -x14 p,查看.text的段的内容的话,也能看到它起始地址0x080482e0

    1
    2
    Hex 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
  2. 对于目标文件
    objdump -h一下main.o,可以看到它的起始地址是0x0,而File Off是0x0000003c。这是因为程序代码中使用的都是虚拟地址,在没有实际分配空间前,目标文件中代码段的起始地址为0。

    1
    2
    3
    4
    Sections:
    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
    3
    Hex 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
2
3
4
5
6
7
8
9
10
00000000 <main>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 e4 f0 and $0xfffffff0,%esp
6: c7 05 00 00 00 00 63 movl $0x63,0x0
d: 00 00 00
10: e8 fc ff ff ff call 11 <main+0x11>
15: b8 00 00 00 00 mov $0x0,%eax
1a: c9 leave
1b: c3 ret

可以发现:

  1. 函数调用的地址是fc ff ff ff,即-4,也就是转移到IP-4(即15-4=11)的位置。这个位置是fc自己,而不是一个函数地址。
  2. 赋值的地址是0。

总而言之,这两个地方都不是一个合法的地址,在链接器最终装配成可执行文件后,这些地址才有可能被确定。现在问题来了,我们并不能从反汇编的代码中看到0x0fc ff ff ff究竟是什么变量,这两个placeholder并没有什么意义。如果我来设计,我会给这个目标文件里面的变量/函数分配一个序号,然后在这里把序号填上去。但不管怎么样,ELF的实际做法,在“重定向”章节中进行介绍。

【Q】在WSL2上,反编译的结果不太一样,这是为什么?

重定向(静态链接)

本节中查看静态链接的重定向问题。
为什么存在这样的问题呢?原因有两个:

  1. 编译期并不知道所有引用符号的地址,所以需要重定向。我们每引用一次外部符号,就会在重定位表中产生一个条目。
  2. 代码跳转,需要指定对于当前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 000x010a。call的行为是将下一条地址0x80482f6压栈,然后跳转到被调用的函数swap的第一条命令0x8048400处。
0x8048400这个地址是怎么算出来的呢?注意到0x80482f6+0x010a=0x8048400,所以call命令的参数是相对于EIP寄存器的偏移,给的是相对地址而不是绝对地址。并且,此时EIP指向的是下一条指令的地址而不是0x80482f1这是因为取指之后就会更新EIP了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ objdump -d p
Disassembly of section .text:

080482e0 <main>:
80482e0: 8d 4c 24 04 lea 0x4(%esp),%ecx
80482e4: 83 e4 f0 and $0xfffffff0,%esp
80482e7: ff 71 fc pushl -0x4(%ecx)
80482ea: 55 push %ebp
80482eb: 89 e5 mov %esp,%ebp
80482ed: 51 push %ecx
80482ee: 83 ec 04 sub $0x4,%esp
80482f1: e8 0a 01 00 00 call 8048400 <swap>
80482f6: 83 c4 04 add $0x4,%esp
80482f9: 31 c0 xor %eax,%eax
80482fb: 59 pop %ecx
80482fc: 5d pop %ebp
80482fd: 8d 61 fc lea -0x4(%ecx),%esp
8048300: c3 ret

从objdump中可以看到,0x8048400处确实是swap的代码

1
2
3
4
5
6
7
8
9
10
$ objdump -d p
08048400 <swap>:
8048400: a1 20 a0 04 08 mov 0x804a020,%eax
8048405: 8b 0d 1c a0 04 08 mov 0x804a01c,%ecx
804840b: c7 05 28 a0 04 08 1c movl $0x804a01c,0x804a028
8048412: a0 04 08
8048415: 8b 10 mov (%eax),%edx
8048417: 89 08 mov %ecx,(%eax)
8048419: 89 15 1c a0 04 08 mov %edx,0x804a01c
804841f: c3 ret

观察main.o文件(目标文件)

接着观察main.o的汇编,这个在上面“目标文件的地址”中已经讲解过了,但由于源程序和编译器稍有不一样,所以这里再保留代码。
这里call的偏移是0xfffffffc(-4),经过计算就是16-4=12,对应的是fc ff ff ff的开头。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ objdump -d main.o

main.o: file format elf32-i386
Disassembly of section .text:

00000000 <main>:
0: 8d 4c 24 04 lea 0x4(%esp),%ecx
4: 83 e4 f0 and $0xfffffff0,%esp
7: ff 71 fc pushl -0x4(%ecx)
a: 55 push %ebp
b: 89 e5 mov %esp,%ebp
d: 51 push %ecx
e: 83 ec 04 sub $0x4,%esp
11: e8 fc ff ff ff call 12 <main+0x12>
16: b8 00 00 00 00 mov $0x0,%eax
1b: 83 c4 04 add $0x4,%esp
1e: 59 pop %ecx
1f: 5d pop %ebp
20: 8d 61 fc lea -0x4(%ecx),%esp
23: c3 ret

现象总结

可以看到,从不可执行的main.o到可执行的./p,发生了两个显著的变化。

  1. 左边的地址对应了main.o的代码实际被加载的地址
    32位程序默认会被加载到0x8048000处,而正如之前提到的,在目标文件中这里是0。
  2. 右边call的地址,从0xfffffffc(-4)变为了0x8048400,这个对应了swap的实际装载地址

原理

重定位表

对于每个需要被重定位的ELF段,都需要有一个重定位表,这个表也是一个Section,因此重定位表也叫做重定位段。如果.text中有重定位的地方,重定位表就是.rel.text段;如果.data中有重定位的地方,它的重定位表就是.rel.data段。我们可以通过readelf -r查看重定位表,可以通过objdump -robjdump -R分别查看静态重定位表和动态重定位表。
什么是动态重定位表呢?对于静态链接的可执行文件来说,是没有.rel.text.rel.data段的,因为他们需要的符号都被静态链接了。但对于一般的可执行文件,可能还存在.rela.dyn(rel.dyn)和.rela.plt(rel.plt)的动态重定位表。这是因为动态链接中需要的符号,可能在装载时才会被加载。
每个需要重定位的符号的出现,都会在重定位表中产生一个条目,例如调用一个外部函数swap多次,或者访问一个外部变量多次,那么会产生对应数量的条目。
介绍重定位表中各列的含义:

  1. Offset
    这个重定位条目需要修改的地址相对于所处Section段头的偏移。也就是后面看到的r.offset
    另外,当被用来查看可执行文件或者so文件时,这个值表示需要修改的位置的虚拟地址。

  2. 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 swap
  3. Type
    对于动态链接,还有更多的重定位类型,先不提。目前包含两种,在后面细讲:

    1. R_386_PC32=2:相对寻址
    2. R_386_32=1:绝对寻址
  4. Sym.Value

定义两个指标:

  1. refaddr
    refaddr表示需要修改的call的参数的地址。即0x80482f2(=0x80482f1+1)。
  2. *refptr
    *refptr表示call参数的最新值。

查看main.o的重定位条目表.rel.text,它提供了下列信息:

  1. 需要修改的重定位地址refaddr相对于main的段头的偏移量是12
    这个12,也对应了call的参数的位置
  2. 把重定位地址refaddr处的值改成程序swap处的地址

计算过程讲解

如下图所示,重定位计算主要的一个过程是*refaddr=*refptr。对应到重定位swap,就是给call指令填上合适的参数。

寻址过程:

  1. R_386_PC32=2:相对寻址

    1
    S + A - P

    其中:

    • S是swap的绝对地址,即0x08048400
    • A是0xfffffffc(-4),也就是*refptr的旧值0xfffffffc(-4)
    • P是被修正位置的地址,也就是call参数的位置,即0x80482f1
  2. R_386_32=1:绝对寻址

    1
    S + A

相对寻址:函数swap

下面进行具体计算,从重定位表看出,这是一个相对寻址,即R_386_PC32,因此:

  1. 计算refaddr
    它等于ADDR(s) + r.offset等于ADDR(main) + r.offset,从前面的objdump的结果可以看到0x080482e0 + 0x120x80482f2,与实际结果吻合。

  2. 计算*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。

重定位中某符号多次定义的类型不一致

容易看出,重定位中并不考虑符号的类型。因此会有下面的情况:

  1. 某强符号多次定义的类型不一致
    因为强符号不能多次定义,所以一定会报错。

  2. 某强符号和其他的弱符号定义的类型不一致
    按照强符号为准。但如果发现有弱符号的size更大,则会产生一条警告

    1
    alignment x of symbol `xxx` in yyy is smaller than y in zzz
  3. 某弱符号的多个定义的类型不一致
    这里采用COMMON类型的链接规则,也就是按照多个定义中,size最大的为准。

通过实验可以得到下面的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在Ubuntu 18.04下
// bss.c
static int aaa = 0; // bss
static int bbb = 1; // data
int ccc; // common
int ddd = 0; // bss
int eee = 1; // data
int f();
extern int ex;
int call(){
f();
ex = 1;
}
__attribute__((weak)) w1 = 1;
__attribute__((weak)) w0 = 0;
__attribute__((weak)) w;

进一步看一下之前的未初始化的全局变量在Common端的情况。我们nm一下,这里的ccc确实是一个C标记,表示在Common端中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ nm bss.o
00000004 b aaa // 小写表示local
00000000 d bbb
00000000 T call // T表示在text段
00000004 C ccc // C表示在common段中
00000000 B ddd // B表示出现在bss段中
00000004 D eee // D表示在data段中
U ex
U f
U _GLOBAL_OFFSET_TABLE_
00000000 T __x86.get_pc_thunk.ax
00000008 V w
00000004 V w0
00000008 V w1

但是当我们将这个bss.c生成可执行文件(可能要去掉诸如f等未定义符号,并且加上main)后,在看ccc的标记,发现变成了B。

1
2
3
4
5
$ nm bss
00002024 b aaa
00002008 d bbb
00002014 B __bss_start
00002028 B ccc

所以可以看出,Common段实际上是一个中间状态。链接器会按照取size最大的原则去merge同名的弱符号,或者取强符号。对于C语言而言,这个中间状态是必要的,因为编译器无法知道符号的类型,所以即使说要在编译期在bss段分配空间,也不知道要分配多大的。只有当链接器找到弱符号的所有出现之后,才能确切知道。

至于C++为什么变了,可能是因为未初始化的全局变量也变成强符号了吧。实验在两个c文件中写int i;,然后用gcc编译能过,但是用g++编译就过不了,显示下面的错误了。

1
2
3
/tmp/ccPeXBAb.o:(.bss+0x0): multiple definition of `i'
/tmp/ccikr1Hs.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status

装载

装载流程

简介Linux的装载流程:

  1. 分配虚拟地址空间
  2. 读取并校验ELF头
  3. 寻找.interp段,设置动态链接器路径
  4. 将文件映射到虚拟地址空间中
  5. 初始化进程
  6. 将系统调用的返回地址修改为可执行文件的入口点
    对于静态链接的可执行文件,入口点是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
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
$ readelf -l p
Elf file type is EXEC (Executable file)
Entry point 0x8048301
There are 9 program headers, starting at offset 52

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
INTERP 0x000154 0x08048154 0x08048154 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x005b4 0x005b4 R E 0x1000
LOAD 0x000f08 0x08049f08 0x08049f08 0x0011c 0x00124 RW 0x1000
DYNAMIC 0x000f14 0x08049f14 0x08049f14 0x000e8 0x000e8 RW 0x4
NOTE 0x000168 0x08048168 0x08048168 0x00044 0x00044 R 0x4
GNU_EH_FRAME 0x0004a0 0x080484a0 0x080484a0 0x00034 0x00034 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
GNU_RELRO 0x000f08 0x08049f08 0x08049f08 0x000f8 0x000f8 R 0x1

Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .eh_frame_hdr
07
08 .init_array .fini_array .jcr .dynamic .got

介绍一下列名:

  1. Type
    表示类型。
    对于LOAD类型来说,内存大小不可能小于文件大小。但是有时候在内存中会分配多于文件大小的空间。其实这一部分就对应了BSS段所占用的空间。我们可以结合Section表的来观察,首先,我们找到.bss的大小是0x8,然后我们找到第二个LOAD段的大小是0x124,而0x124-0x8的差值0x11c就是LOAD段的FileSiz。
  2. Offset
    表示这个Segment在文件中的偏移。
  3. VirtAddr
    表示Segment的第一个字节在进程虚拟地址空间中的起始位置,应该就是VMA。
    对于动态链接的共享库,可以在运行时通过dl_iterate_phdr函数来判断当前库,和库里面的每个Segment被加载到的真实地址。
  4. PhysAddr
    就是LMA,一般和VirtAddr是一样的。
  5. FileSiz
    表示Segment在文件中占用的长度,它是可能为0的。
  6. MemSiz
    表示Segment在内存中占用的长度,它也可能为0。
    一般来说,MemSiz不会小于FileSiz,否则这个Segment都无法装载到虚拟空间里面。但MemSiz可能大于FileSiz,例如我们可以将.data所在的MemSiz调大,剩下来的部分会被填充为0,对应给到.bss就可以了。Linux对这个机制的实现会导致包含.data的LOAD段的结束地址比期望要小。
  7. 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
2
3
4
555ad5321000-555ad5342000 rw-p 00000000 00:00 0                          [heap]
7ffe2ad14000-7ffe2ad35000 rw-p 00000000 00:00 0 [stack]
7ffe2ad9c000-7ffe2ad9e000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]

通过比较,可以简单进行划分:

  1. 代码VMA
    rx
  2. 数据VMA
    rwx
  3. 堆VMA
    rwx,可以往更小的地址扩展
  4. 栈VMA
    rw,可以往更大的地址扩展

段页映射

段地址对齐

PAE技术

PAE技术允许在最大64G的内存中同时跑多个进程,但每个进程最大还是4GB。

动态链接

相比静态链接的重定位,动态链接过程因为很多符号的地址要在加载时才能确定,所以更加复杂。
下面我们将讨论一些方案,包括使用PLT的延迟加载。

动态链接程序的加载

动态链接程序在装载时必须重定位。否则,可以考虑下面的情况:

  1. 程序A需要地址1000-1999
  2. 程序B需要地址2000-2999
  3. 程序C需要地址3000-3999
  4. 程序D需要动态加载程序A和B
    那么3000-3999的空间会被浪费
  5. 程序E需要动态加载程序A和C
    那么2000-2999的空间会被浪费

有人说,在链接期强行把程序E里面的程序C挪到2000-2999就行了,这种手动为每个动态链接库分配的方式在大型程序维护上会非常痛苦。例如,链接后可执行文件都固定了,如果后来动态链接库升级,导致空间增长,原来的虚拟地址空间放不下了,这就很麻烦。

类似于目标文件的地址都是在链接期才确定一样,可以让动态对象在任意地址加载,而真正被加载的地址,从链接期推迟到运行期再决定。

在确定动态符号的地址时,动态链接器不能直接模仿静态链接器一样修改代码,不过这个是不现实的,因为.text段是只读的。根据thegreenplace上的这篇文章,在解决加载动态链接库中的重定向问题时,通常有两种办法:

  1. 基于Load-time Relocation机制
    这个机制对应了-shared的编译模式。
    可以理解为将重定向从链接期delay到了执行期。主要思路是模块被装载到地址A,而模块中代码段的Offset为b,函数f对于代码段的Offset为c,则遍历模块中的所有重定位表,将所有对f的引用都改为A+b+c。
    这个方案的缺点是同一个库被加载到不同进程时,指令是一样的,不同的只是地址。所以原则上,我们可以将这些不同的地方提出来放到数据段,这样同一个库在内存中只会有一个副本了。这思路可以节省内存,对应于下面的PIC机制。
    当然,也有优点,因为在装载后地址全部被重定位了,因此在每次访问对象或者函数时,会少一层间接,所以速度可能快
  2. 基于地址无关代码(PIC, Position-independent Code)机制
    这个机制是目前主流且通用的,对应了-fPIC -shared的编译模式。
    这个方案将代码中需要修改地址的部分分离出来,放到数据段中。这样剩余的代码是地址无关的,因此在整个系统中只有一份,而每个进程维护自己的地址相关的副本。

PIC

先不考虑动态库的事情。首先考虑一个模块,要实现为地址无关的,也就是要将地址相关的代码剥离掉。有什么代码是位置相关的呢?这包含两部分:

  1. 调用外部过程
  2. 引用全局变量

据此,我们可以将模块中的地址引用分为下面四种方式:

  1. 模块内函数调用
    call命令肯定是位置无关的,因为它的参数是相对EIP的偏移。所以不需要重定位。

  2. 模块内数据访问
    因为要做到地址无关,所以在代码中不能出现绝对地址(类似于R_386_32的方案)了。剩下的一条路是相对寻址,这是因为同一个模块的布局导致代码和数据所在页的相对位置是固定的,所以根据代码的位置,通过一些偏移就能算出数据相对于代码存放的位置。因此,类似“模块内函数调用”,我们又选择了PC作为了标杆。
    需要注意的是,数据寻址没有像call一样直截了当,使用相对偏移做参数。换句话说,32位程序数据的相对寻址没有相对于PC的寻址方式。所以得通过get_pc_thunk函数(实际上可能叫__x86.get_pc_thunk.ax)来获得PC的地址,然后通过PC地址加上偏移量的方式来访问。对应汇编类似于

    1
    2
    3
    call get_pc_thunk.ax
    add xxx, %ecx
    movl $val, yyy(%ecx)

    get_pc_thunk的原理是调用call会把下一条指令的地址压到栈顶,即esp指向位置。

  3. 模块间数据访问
    相比于“模块内数据访问”,其难点在于要到运行期加载完对应模块,才知道偏移量。所以不能按照之前的方案算一个偏移量出来。那还能怎么办?查表呗。
    因此引入全局偏移表GOT存放每个符号的地址。例如,当需要访问变量b时,首先查找GOT中对应的条目,再根据条目列出的地址来索引到变量正确的位置。
    在链接时,虽然每个模块的位置确定不下来,但GOT表的位置是可以确定下来的。所以链接器实际上使用“模块内数据访问”的方案确定GOT相对于PC的偏移,然后再从GOT表找到变量的位置。可以看出,多了层间接。
    GOT位于.got段,可以通过objdump -R查看GOT中的重定位项。在有了延迟绑定机制后,GOT表会分别存储在.got和.got.plt段中

  4. 模块间函数调用
    实际上也可以通过类似3的办法来解决,即call *(%eax)这样。但考虑到函数的加载数量是比较大的,每次多一层中转,会影响性能。实际实现采用了延迟绑定的技术,通过PLT表来实现。

  5. 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
2
3
4
5
6
7
8
9
10
11
12
static int a;
extern int b;
extern void ext();

void bar() {
a = 1; // Type 2
b = 2; // Type 3 & Type 5
}
void foo() {
bar(); // Type 1
ext(); // Type 4
}

之前提到,地址无关代码里面并不能做到绝对地址。但C++中有个叫指针的东西,它就是绝对地址。对于这种数据段中出现的绝对地址引用,将在后面介绍R_386_RELATIVE机制。

1
2
static int a;
start int * p = &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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ readelf -l p

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
...
INTERP 0x000154 0x08048154 0x08048154 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]

$ objdump -s -j ".interp" p

p: file format elf32-i386

Contents of section .interp:
8048154 2f6c6962 2f6c642d 6c696e75 782e736f /lib/ld-linux.so
8048164 2e3200 .2.
  1. ld-linux是静态链接的

动态链接库的版本和SO-NAME

为什么依赖的so文件通常都是libname.so.6,但是实际上它会指向libname.so.6.0.23这样的库呢?下面将解答。

动态链接库的版本如下所示

1
/libname.so.x.y.z

其中:

  1. 主版本号x
    不同的主版本号的动态链接库是不兼容的。这通常暗示着在系统中需要保留旧版本的so库,否则依赖旧版本的libname的程序可能无法运行。
  2. 次版本号y
    表示增量升级。会增加新符号,但不会改变旧符号。因此,高的y会自然兼容低版本的y。正因为如此,我们不需要指定次版本号,我们升级系统的so到最新版本就行,反正不会影响老版本。
  3. 发布版本号

当然,诸如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:

  1. -rpath-link 指定的所有目录
  2. -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.
  3. 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”.
  4. On SunOS, if the -rpath option was not used, search any directories specified using -L options.
  5. 使用 LD_LIBRARY_PATH.
  6. 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.
  7. The default directories, normally /lib and /usr/lib.
  8. 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
2
3
4
5
> strings bin/tiflash/tiflash | grep clang
/usr/local/bin/clang++
/usr/local/bin/clang
clang version 13.0.0 (/llvm-project/clang d7b669b3a30345cfcdb2fde2af6f48aa4b94845d)
__clang_call_terminate

动态链接库依赖其他的库

动态链接库依赖其他的库时,是一个递归的过程。

但其中有一个陷阱,考虑函数 foo:

  1. foo1.cpp 中定义了 foo,返回1
  2. foo2.cpp 中也定义了 foo,但是返回2
  3. libfoo.cpp 中定义了 test 函数,返回 foo()
  4. 把 libfoo.cpp 编译为动态库 libfoo.so,并链接 foo1
  5. main.cpp 中调用 test 和 foo,按顺序链接 foo2 和 libfoo.so
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// libfoo.cpp
int foo();
int test(){return foo();}

// foo1.cpp
int foo() {
return 1;
}

// foo2.cpp
int foo() {
return 2;
}

// test.cpp
#include<stdio.h>

int foo();
int test();
int main() {
printf("foo %d test %d\n", foo(), test());
}
1
2
3
4
5
6
7
8
9
g++ foo1.cpp -c -o foo1
g++ foo2.cpp -c -o foo2

g++ -fPIC libfoo.cpp -L. foo1 -shared -o libfoo.so
ar -cr libfoo1.a foo1
ar -cr libfoo2.a foo2

g++ test.cpp -o test -L. -lfoo1 -lfoo -o test && ./test
g++ test.cpp -o test -L. -lfoo2 -lfoo -o test && ./test

考虑执行 main,test 和 foo 的调用分别输出什么呢?
朴素的思想是,因为编译动态库的时候用的是 foo1,那么 test 就应该输出1。但实际上 test 输出的是 2。
原因是编译 main 时,查找符号的顺序是 foo2 然后是 libfoo.so。而在 foo2 中就找到了返回 2 的 foo 函数。

查找丢失的符号

有的时候,可能加载了错误版本的库,这导致找不到一些符号。可以通过 ldd 诊断这一类的问题。

1
2
3
4
5
6
7
8
9
10
11
12
$ ldd -r /usr/lib/libatlas.so
linux-gate.so.1 => (0xb7841000)
libm.so.6 => /lib/libm.so.6 (0xb74f2000)
libpthread.so.0 => /lib/libpthread.so.0 (0xb74d8000)
libc.so.6 => /lib/libc.so.6 (0xb737a000)
/lib/ld-linux.so.2 (0xb7842000)
undefined symbol: ATL_thread_free (/usr/lib/libatlas.so)
undefined symbol: ATL_join_tree (/usr/lib/libatlas.so)
undefined symbol: ATL_free_tree (/usr/lib/libatlas.so)
undefined symbol: ATL_wait_tree (/usr/lib/libatlas.so)
undefined symbol: ATL_signal_tree (/usr/lib/libatlas.so)
undefined symbol: ATL_thread_init (/usr/lib/libatlas.so)

动态链接程序的构建:重定位机制

GOT机制概述

动态链接程序怎么重定位呢?答案很简单,查表呗。这个表就是GOT表。编译和链接的时候,预先分配好这个表,加载的时候把实际的变量/函数位置给填上去就行。

在一个程序中会维护一个GOT表,因为它是唯一的,所以在构建时就可以确定其地址,这样可以通过类似静态连接的重定位的方法,根据IP算出GOT相对于当前指令的偏移。GOT表中记录了所有跨模块函数和变量的地址,包含.got.got.plt两个段。

因此.got中保存全局变量引用的地址,.got.plt保存全局函数引用的地址。拆成两个段的原因是:

  1. 为了实现后面的PLT机制,需要将对于函数引用的实际地址从.got中拆出
  2. .got.plt需要有执行权限。

通过GOT表访问变量的方法很简单,就是在GOT表中找到对应变量的偏移即可。

通过GOT访问函数的是使用最多的场景,如果每次都去检查GOT表有没有被初始化,显然比较浪费。为此,我们有PLT机制。

PLT机制概述

.got.plt的前三个条目有特殊意义:

  1. .dynamic段地址
  2. 本模块ID
  3. _dl_runtime_resolve函数地址
    这个函数负责绑定模块A中的f函数。即,给定模块名A和函数名f,它能找到对应的地址

.got.plt后面的条目就是每个被import的函数的实际地址。

PLT机制的过程是如下代码(来自自我修养一书)所示,这代码位于.plt
在调用某跨模块的函数例如bar@plt时,首先通过GOT中的索引进行跳转。如果这个索引条目已经加载了bar的正确地址,那么就会自然跳转到对应位置。

但在一开始,*(bar@GOT)指向的是push n指令,而不是函数加载的地址。这里的n就是需要查找的符号bar在重定位表.rel.plt中的下标。

pushjump _dl_runtime_resole的这一小段代码实际上就是去加载bar的正确地址。_dl_runtime_resole的参数就是刚push进去的两个值。在该函数加载完毕后,将结果填写到GOT表(.got.plt)中的*(bar@GOT)条目中,这样后续调用就不走push n了。

1
2
3
4
5
bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resole

现在还有个问题,什么是.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段的信息:

  1. (INIT)
    .init地址
  2. (FINI)
    .fini地址
  3. (INIT_ARRAY)
  4. (INIT_ARRAYSZ)
  5. (FINI_ARRAY)
  6. (FINI_ARRAYSZ)
  7. (GNU_HASH)
    动态的.hash
  8. (STRTAB)
    .dynstr地址,可以对应到readelf -S的结果。
  9. (SYMTAB)
    .dynsym地址,可以对应到readelf -S的结果。
  10. (STRSZ)
    .dynstr大小
  11. (SYMENT)
  12. (PLTGOT)
  13. (RELA)
    动态重定位表.rel.dyn地址。
  14. (RELASZ)
    readelf -S里面.rela.dyn项目的Size。
  15. (RELAENT)
    readelf -S里面.rela.dyn项目的EntSize。
  16. (RELACOUNT)
    动态重定位表数量
  17. (NULL)

动态链接重定位表

介绍.rel.dyn和.rel.plt

在静态链接中,重定位表可以用来在链接时决定编译时尚未确定的导入符号的地址。

在动态链接时,很多符号的地址在甚至在链接期都无法确定。当可执行文件/共享库中一旦依赖于在其他的共享对象中的符号,就需要动态重定位表。

如果是基于Load-time Relocation机制,这很容易理解,因为实际上相当于把重定位符号直接从链接期移到执行期了。但对于PLT机制,同样需要重定位表,这是因为:

  1. 代码段地址无关,因此不需要重定位
  2. 数据段中还包含对绝对地址的引用,例如GOT表
    没错,这些绝对地址已经在装载时由动态链接器填到GOT表的对应位置了。但总得想办法让代码去访问对应的GOT表的条目啊。
    于是
  3. 数据段中还包含对绝对地址的引用,例如指针
    对应于R_386_RELATIVE类型。

总结一下,动态链接重定位表.rel.dyn.rel.plt对应于静态链接的.rel.data.rel.text,分别修正数据和函数引用:

  1. .rel.dyn修正位于.got段和数据段中的地址。它对应R_386_GLOB_DAT类型,以及另一种R_386_RELATIVE类型。
  2. .rel.plt修正.got.plt的地址。它对应R_386_JUMP_SLOT

我们可以用readelf -r去查询动态链接重定位表。

当然事情也不绝对,事实上导入函数的重定位入口可能出现在.rel.dyn中。这发生在不使用PIC编译时,导入的外部函数,比如printf就会在.rel.dyn中,并且类型也变成了R_386_PC32。在后面的实验中将进行验证。

相比静态链接,动态链接重定位表多了一些重定位类型:

  1. 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的例子

  2. R_386_GLOB_DAT
    这种类型的条目,对应的重定位Offset指向的位置在.got中,与R_386_JUMP_SLOT很类似

  3. R_386_RELATIVE
    这种类型的重定位即Rebasing。这种方式的出现,是因为上面论述的四种动态重定位方式还不够周全。我们考虑某个共享对象中有如下的代码:

    1
    2
    static 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
2
3
4
5
6
Relocation section '.rel.text' at offset 0x4bc contains 4 entries:
Offset Info Type Sym.Value Sym. Name
00000008 00001102 R_386_PC32 00000000 __x86.get_pc_thunk.ax
0000000d 0000120a R_386_GOTPC 00000000 _GLOBAL_OFFSET_TABLE_
00000018 00000509 R_386_GOTOFF 00000000 .rodata
00000020 00001304 R_386_PLT32 00000000 printf

而不使用,有

1
2
3
4
Relocation section '.rel.text' at offset 0x414 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
0000000c 00000501 R_386_32 00000000 .rodata
00000011 00000f02 R_386_PC32 00000000 printf

可以看出,确实变成了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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Relocation section '.rel.dyn' at offset 0x35c contains 9 entries:
Offset Info Type Sym.Value Sym. Name
00001efc 00000008 R_386_RELATIVE
00001f00 00000008 R_386_RELATIVE
00002010 00000008 R_386_RELATIVE
00001fe8 00000106 R_386_GLOB_DAT 00000000 _ITM_deregisterTMClone
00001fec 00000306 R_386_GLOB_DAT 00000000 __cxa_finalize@GLIBC_2.1.3
00001ff0 00000406 R_386_GLOB_DAT 00000000 __gmon_start__
00001ff4 00000b06 R_386_GLOB_DAT 00002014 mya
00001ff8 00000506 R_386_GLOB_DAT 00000000 _Jv_RegisterClasses
00001ffc 00000606 R_386_GLOB_DAT 00000000 _ITM_registerTMCloneTa

Relocation section '.rel.plt' at offset 0x3a4 contains 1 entries:
Offset Info Type Sym.Value Sym. Name
0000200c 00000207 R_386_JUMP_SLOT 00000000 printf@GLIBC_2.0

否则有

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Relocation section '.rel.dyn' at offset 0x35c contains 12 entries:
Offset Info Type Sym.Value Sym. Name
00000540 00000008 R_386_RELATIVE
00001f10 00000008 R_386_RELATIVE
00001f14 00000008 R_386_RELATIVE
0000200c 00000008 R_386_RELATIVE
00000537 00000b01 R_386_32 00002010 mya
0000054d 00000b01 R_386_32 00002010 mya
00000545 00000202 R_386_PC32 00000000 printf@GLIBC_2.0
00001fec 00000106 R_386_GLOB_DAT 00000000 _ITM_deregisterTMClone
00001ff0 00000306 R_386_GLOB_DAT 00000000 __cxa_finalize@GLIBC_2.1.3
00001ff4 00000406 R_386_GLOB_DAT 00000000 __gmon_start__
00001ff8 00000506 R_386_GLOB_DAT 00000000 _Jv_RegisterClasses
00001ffc 00000606 R_386_GLOB_DAT 00000000 _ITM_registerTMCloneTa

实验

Load-time Relocation实验

实验设置

1
2
gcc -shared -m32 -g -o swap_no_pic.so swap.c
gcc -m32 -g -o no_pic main.c swap.c

PIC和PLT实验

实验设置

下面编译一个最简单的程序

1
2
3
4
5
#include <stdio.h>

int main(){
puts("Hello, world!");
}

我们添加下面两个选项,以得到一个位置无关的swap.so。

  1. -fPIC创建位置无关代码
  2. -shared创建共享库
1
gcc -fPIC -m32 -g -o swap_no_shared.so swap.c

生成动态链接库

1
2
3
gcc -shared -fPIC -m32 -g -o swap.so swap.c
gcc -fPIC -m32 -g -S -o main_pic.s main.c
gcc -fPIC -m32 -g -c -o main_pic.o main.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
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
(gdb) disass main
Dump of assembler code for function main:
0x0804840b <+0>: lea 0x4(%esp),%ecx
0x0804840f <+4>: and $0xfffffff0,%esp
0x08048412 <+7>: pushl -0x4(%ecx)
0x08048415 <+10>: push %ebp
0x08048416 <+11>: mov %esp,%ebp
0x08048418 <+13>: push %ebx
0x08048419 <+14>: push %ecx
0x0804841a <+15>: call 0x8048447 <__x86.get_pc_thunk.ax>
0x0804841f <+20>: add $0x1be1,%eax ; $_GLOBAL_OFFSET_TABLE_, %eax
0x08048424 <+25>: sub $0xc,%esp
0x08048427 <+28>: lea -0x1b30(%eax),%edx ; leal .LC0@GOTOFF(%eax), %edx
0x0804842d <+34>: push %edx
0x0804842e <+35>: mov %eax,%ebx
0x08048430 <+37>: call 0x80482e0 <puts@plt>
0x08048435 <+42>: add $0x10,%esp
0x08048438 <+45>: mov $0x0,%eax
0x0804843d <+50>: lea -0x8(%ebp),%esp
0x08048440 <+53>: pop %ecx
0x08048441 <+54>: pop %ebx
0x08048442 <+55>: pop %ebp
0x08048443 <+56>: lea -0x4(%ecx),%esp
0x08048446 <+59>: ret
End of assembler dump.

disass 0x8048447
Dump of assembler code for function __x86.get_pc_thunk.ax:
0x08048447 <+0>: mov (%esp),%eax
0x0804844a <+3>: ret

我们附上了main.s的代码用来对照

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ cat main_pic.s
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ebx
pushl %ecx
call __x86.get_pc_thunk.ax ; call 10 <main+0x10> ; call 8048407 <__x86.get_pc_thunk.ax>
addl $_GLOBAL_OFFSET_TABLE_, %eax ; add $0x1,%eax ; add $0x1c11,%eax
movl %eax, %ebx
call swap@PLT ; call 1c <main+0x1c> ; call 804840b <swap>
movl $0, %eax
popl %ecx
popl %ebx
popl %ebp
leal -4(%ecx), %esp
ret
__x86.get_pc_thunk.ax:
movl (%esp), %eax
ret

看看puts@plt的实现,看起来是两个jmp和一个push,这个很奇怪。

1
2
3
4
5
6
(gdb) disass puts
Dump of assembler code for function puts@plt:
0x080482e0 <+0>: jmp *0x804a00c
0x080482e6 <+6>: push $0x0
0x080482eb <+11>: jmp 0x80482d0
End of assembler dump.

这些地址都是啥呢?检查Section表,发现第一个jmp落在.got.plt段上,而第二个jmp落在.plt段上。【结合上面的介绍,第一个jmp应该是@plt的到吗】

1
2
3
4
(gdb) info symbol 0x804a00c
_GLOBAL_OFFSET_TABLE_ + 12 in section .got.plt
(gdb) info symbo 0x80482d0
No symbol matches 0x80482d0.

.plt所在的段是可读可执行不可写的,可以认为是一段代码。而.got.plt所在的段是可写的,也就是在运行时会被更新。

1
2
3
4
5
6
7
8
9
10
11
12
$ readelf -S pic
There are 36 section headers, starting at offset 0x1a94:

Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 9] .rel.dyn REL 08048290 000290 000008 08 A 5 0 4
[10] .rel.plt REL 08048298 000298 000010 08 AI 5 24 4
[12] .plt PROGBITS 080482d0 0002d0 000030 04 AX 0 0 16
[13] .plt.got PROGBITS 08048300 000300 000008 00 AX 0 0 8
[14] .text PROGBITS 08048310 000310 0001a2 00 AX 0 0 16
[23] .got PROGBITS 08049ffc 000ffc 000004 04 WA 0 0 4
[24] .got.plt PROGBITS 0804a000 001000 000014 04 WA 0 0 4

第一个jmp

查看第一个jmp的代码,发现没有啥意义,其实这个只是一个地址查询表,我们打印出来是一个要跳转的地址0x8049f14

1
2
3
4
5
6
7
(gdb) disass 0x804a00c
Dump of assembler code for function _GLOBAL_OFFSET_TABLE_:
0x0804a000: adc $0x9f,%al
End of assembler dump.

(gdb) print /x *0x804a00c
$1 = 0x80482e6

而这个地址恰好对应了puts@plt的下一行代码

1
2
(gdb) info symbol 0x80482e6
puts@plt + 6 in section .plt

第二个jmp

接着,我们入栈了一个0x0,接着jmp到0x80482d0,这个似乎不好直接disass,于是我们采用下面的办法。

1
2
(gdb) disass 0x80482d0
No function contains specified address.

发现这边没有函数信息,于是强行disass一波,现在有了。看起来,我们入栈了一个0x804a004之后,又jmp到了*0x804a008这个位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
(gdb) disass 0x80482d0,0x080482f0
Dump of assembler code from 0x80482d0 to 0x80482f0:
0x080482d0: pushl 0x804a004
0x080482d6: jmp *0x804a008
0x080482dc: add %al,(%eax)
0x080482de: add %al,(%eax)
0x080482e0 <puts@plt+0>: jmp *0x804a00c
0x080482e6 <puts@plt+6>: push $0x0
0x080482eb <puts@plt+11>: jmp 0x80482d0
End of assembler dump.

(gdb) print /x *0x804a008
$3 = 0x0

我们发现*0x804a008居然是0。很奇怪,难道是没有运行的缘故?于是我们打断点,并执行

1
2
3
4
5
6
7
8
9
10
(gdb) 
b *0x080482e0
b *0x080482e6
b *0x080482eb
run
...
s
Cannot find bounds of current function
print /x *0x804a008
$1 = 0xf7fee000

发现执行完之后,这里竟然有值了!但依然没有函数名,对这种情况,我们可以安装一个debug版本的glibc来解决

1
2
ldd ./pie # 确定版本
apt install libc6-dbg:i386

这样在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
2
3
4
5
6
7
8
9
10
11
12
(gdb) disass 0xf7fee000
Dump of assembler code for function _dl_runtime_resolve:
0xf7fee000 <+0>: push %eax
0xf7fee001 <+1>: push %ecx
0xf7fee002 <+2>: push %edx
0xf7fee003 <+3>: mov 0x10(%esp),%edx
0xf7fee007 <+7>: mov 0xc(%esp),%eax
0xf7fee00b <+11>: call 0xf7fe77e0 <_dl_fixup>
0xf7fee010 <+16>: pop %edx
0xf7fee011 <+17>: mov (%esp),%ecx
0xf7fee014 <+20>: mov %eax,(%esp)
0xf7fee017 <+23>: mov 0x4(%esp),%eax

ret的操作数指定了在弹出返回地址后,需要释放的字节/字数量。通常,这些字节/字被是作为调用的输入

1
2
   0xf7fee01b <+27>:	ret    $0xc
End of assembler dump.

我们还可以从重定向表中,可能找到0x0804a00c这偏移对应的表项。

1
2
3
4
5
6
7
8
9
10
$ readelf -r pic

Relocation section '.rel.dyn' at offset 0x290 contains 1 entries:
Offset Info Type Sym.Value Sym. Name
08049ffc 00000206 R_386_GLOB_DAT 00000000 __gmon_start__

Relocation section '.rel.plt' at offset 0x298 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
0804a00c 00000107 R_386_JUMP_SLOT 00000000 puts@GLIBC_2.0
0804a010 00000307 R_386_JUMP_SLOT 00000000 __libc_start_main@GLIBC_2.0

实验

如何获得constexpr值?

1
2
3
4
5
6
#include <iostream>
#include <cstdint>
constexpr uint32_t MAGIC_NUMBER = 0xaabbccdd;
int main(){
printf("%u", MAGIC_NUMBER);
}
1
2
3
4
5
$ readelf -s t | grep MAGIC
36: 0000000000400768 4 OBJECT LOCAL DEFAULT 14 _ZL12MAGIC_NUMBER
$ readelf -x14 t
Hex dump of section '.rodata':
0x00400760 01000200 00000000 ddccbbaa 257500 ............%u.

我的so有问题?

注意先ldd看下动态库的路径

Reference

  1. http://flint.cs.yale.edu/cs422/doc/ELF_Format.pdf
  2. CSAPP
  3. 程序员的自我修养—链接、装载与库
  4. https://stackoverflow.com/questions/64872385/why-readelf-dont-show-symtab
  5. https://linuxtools-rst.readthedocs.io/zh_CN/latest/tool/readelf.html
  6. https://www.cnblogs.com/ck1020/p/6078214.html
    对PAE技术的介绍