ELF文件链接和装载

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

一个Demo

main.c

1
2
3
4
5
6
7
void swap();
int buf[2] = {1, 2};

int main(){
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

ELF结构

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

  1. REL一般是.o等待重定位的文件
  2. DYN一般是动态库
  3. EXEC一般是可执行文件

两个头部表

段头部表,program header table,在内存中。可以通过readelf -l指令查看段头部表。
节头部表,section header table,在二进制文件中。可以通过readelf -S指令查看节头部表,注意不要和-s选项混用,后者是查询符号表的。
需要注意的是,这里段头部表和节头部表的中文语义似乎不明确,比如在自我修养一书中就称Section Header Table为段表,因此下面尽量避免使用这两个词,而是用program表和section表代替。

Program表

查看program表,发现有两个LOAD段,分别对应了下面的02和03两个段(从0开始数),一般来说只有这两个段会被加载到内存。第一个段包含了.text.rodata等,这些段是只读的,所以Flags有R,并且代码段.text是可执行的,所以Flags还有E。
这个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(TODO 确认一下)。
    对于动态链接的共享库,可以在运行时通过dl_iterate_phdr函数来判断当前库,和库里面的每个Segment被加载到的真实地址。
  4. PhysAddr
    就是LMA,一般和VirtAddr是一样的。
  5. FileSiz
    表示Segment在文件中占用的长度,它是可能为0的。
  6. MemSiz
    表示Segment在内存中占用的长度,它也可能为0。
  7. Align
    表示对齐。

在进程运行时,可以通过cat proc/.../maps来看到进程p的其他VMA,例如[heap]、[stack]、[vdso]等。诸如00:00 0的字段表示主设备号、次设备号和文件节点号。

1
cat /proc/`ps aux | grep -w p | grep -v grep | grep -v gdb | awk '{print $2}'`/maps

对于[stack]而言它们都是0,表示这些Segment没有被映射到文件中。这种VMA称为Anonymous Virtual Memory Area。

Section表

我们可以查看对应的section表。需要注意的是这里是静态ELF的表。动态ELF,例如SO文件的表会多一些字段。

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. .debug_*
    这个段才是真正由-g生成的。
  3. .line
    是C中的行号和机器地址的对照表。
  4. .dynamic
    如果说,我们的程序连C库和C++库都是静态链接的,那么它就不会有dynamic段。例如,我们开启了-static
  5. .rel.text.rel.data
    用来记录在.text中遇到的外部符号,例如printf函数的位置;对应的还有.rel.data服务于.data
    容易看出,对于静态的可执行文件来说,是不存在这两个字段的,因为已经没有什么需要重定位的了。
  6. .strtab
    字符串表。
  7. .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.
  8. .dynsym
    是动态符号表。可以通过readelf -sD查看动态符号表。
    符号表.symtab中往往也会包括动态符号,但动态符号表中只有动态符号。

  9. .got.got.plt.plt
    【相关详细说明,见后文】
    这两个表是用来在装载动态链接库的时候寻找动态符号实际的位置的。
    这三个表可以通过objdump -D来查看,注意这里是-D而不是-d,这是因为-D能够反汇编所有Section。可以通过objdump -R查看GOT中的重定位项。
    .plt中列出了所有的@plt为后缀的“函数”,所以这个段也是可执行的。
  10. .rel.dyn.rel.plt
    对应于.rel.data.rel.text,相当于动态链接的重定位表。.rel.dyn用来修正位于.got段和数据段中的地址,实际上是对数据引用的修正。.rel.plt修正.got.plt段中的地址,实际上是对函数引用的修正。
    但事情也不绝对,事实上导入函数的重定位入口可能出现在.rel.dyn中。这种情况一般发生在我们只使用-shared而不用-fPIC编译我们的SO文件时,那我们在这当中导入的外部函数,比如printf就会在.rel.dyn中,并且类型也变成了R_386_PC32
    我们在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
  11. .init.fini
    可以用来执行C++中全局/静态变量的构造或者析构操作。

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:重定位表
  6. HASH:符号表的哈希表
  7. DYNAMIC:动态链接信息
    .dynamic段
  8. NOTE:
  9. NOBITS:
    .bss段
  10. REL:重定位信息
    .rel.dyn和.rel.plt段
  11. SHLIB
  12. DYNSYM
    .dynsym段

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

符号表

查看main.o的符号表,首先介绍一下结构。

  1. Ndx
    表示符号所在的Section。取值为
    1. ABS
      也就是readelf -S看到的Nr序号。
    2. COM
      表示是一个COMMON块类型的符号,例如未初始化的全局符号。参考下面的bufp1
      COMMON块听起来很熟悉,其实也确实来自Fortran的一个概念。
    3. UND
      表示是一个未定义的符号,通常说明是在这个文件中被引用,但定义在其他文件中。
  2. Bind
    GLOBAL表示全局符号。LOCAL表示局部符号。WEAK表示弱引用。
    注意,弱引用和弱符号的概念还有区别。弱符号在C++中static关键字的用法这篇文章中已经有了讲解,即强符号包含函数和已初始化的全局变量;弱符号包括未初始化的全局变量。可以通过__attribute__((weak))annotate(自我修养里面用的是定义)一个符号是弱符号。
    可以通过__attribute__((weakref))定义对一个外部函数的引用是弱引用。对于弱引用,在链接期如果找不到它的定义,不会报错,而是赋一个0或者其他便于识别的值,如果在运行期还找不到定义,才会出错。
    弱引用的一个作用是,我们可以声明一个pthread_create函数为弱引用,然后我们在运行期判定pthread_create的地址是否为0,来判断是否是多线程程序。
  3. Type
    1. NOTYPE,未知类型。
    2. OBJECT,数据对象。
    3. FUNC,函数或者其他可执行代码。
    4. SECTION,表示一个Section,此时这个符号的Bind一定是LOCAL。
      可以通过objdump -t p打印出符号表来看到。
    5. FILE,文件名,此时这个符号的Bind一定是LOCAL,并且Ndx一定是一个ABS序号。
  4. Vis

发现其中的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段。

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的符号表,这里出现了一个COM,表示是未初始化的符号。

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

我觉得bufp1的应该是在bss段,即Ndx=4,但是这边给出的是COMMON段。因为bss也是自称被用来存放未初始化的全局变量的段。但事实上我们通过实验可以得到下面的结果

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;

为什么int ccc不行呢?我们nm一下,这里的ccc确实是一个C标记。

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同名的弱符号,或者取强符号。这个中间状态是必要的,因为编译器无法知道符号的类型,所以即使说要在编译期在bss段分配空间,我们也不知道要分配多大的。只有当链接器找到弱符号的所有出现之后,我们才能确切知道。

链接

两步链接(Two-phase linking):

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

可以通过objdump -h命令查看每个段的地址。其中:

  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
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和File Off并不一定相等,比如对目标文件而言:

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

    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

    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表,发现代码段的地址仍然为空;

    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..

重定向

本节,我们查看静态链接的重定向问题。
对于静态链接而言,重定向只发生在静态链接期。我们可以看到对于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。它的行为是将下一条地址0x80482f6压栈,然后跳转到被调用的函数swap的第一条命令0x8048400处。
可是,0x8048400这个地址是怎么算出来的呢?我们注意到0x80482f6+0x010a=0x8048400,所以call命令的参数是相对于EIP寄存器的偏移,给的是相对地址而不是绝对地址。
我们现在有问题1:为什么是以下一条指令的EIP0x80482f6而不是当前的0x80482f1为基准计算呢?

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

观察main.o文件

接着,我们观察main.o的汇编。这里call的偏移是0xfffffffc(-4),经过计算就是16-4=12,对应的是fc ff ff ff的开头。
我们现在有问题2:这个跳转非常奇怪,为什么相对位移是-4呢?

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处。
  2. 右边call的地址,从0xfffffffc(-4)变为了0x8048400,这个对应了swap的实际装载地址
    从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

计算过程

下面我们来看看,目标的1和2是如何得到的。

代码

主要的一个过程是*refaddr=*refptr,即我们应该算得0x80482f2位置的地址应当是0a 01 00 00。为此我们需要计算下面两个指标:

  1. refaddr
    refaddr表示需要修改的call的参数的地址。
  2. *refptr
    *refptr表示call参数的最新值。

重定位表

对于每个需要被重定位的ELF段,都需要有一个重定位表,例如.text的重定位表就是.rel.text段。我们可以通过readelf -r查看重定位表。注意,对于静态可执行文件来说,是没有.rel.text.rel.data段的,因为他们需要的符号都被静态链接了。
介绍一个各列的含义:

  1. Offset
    这个重定位条目需要修改的地址相对于所处段段头的偏移。
  2. Info
    低8位:重定位条目的类型。例如下面的0x02
    高24位:重定位条目的符号在符号表中的下标。例如下面的0x00000f
    我们可以检查一下swap在符号表中的位置,果然是15。

    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:相对寻址

      1
      S + A - P

      其中S是swap的绝对地址,是0x08048400
      A是0xfffffffc(-4),也就是*refptr的旧值0xfffffffc(-4)
      P是被修正位置的地址,也就是call参数的位置,即0x80482f1。我们将在*refptr的具体计算过程中详细讨论。

    2. R_386_32=1:绝对寻址
      1
      S + A
  4. Sym.Value

我们查看重定位条目表.rel.text,它提供了三个信息:

  1. 需要修改的重定位地址refaddr相对于main的段头的偏移量是12。
  2. R_386_PC32表示重定位一个32位的相对地址的引用。特别地,还有一种R_386_32表示绝对地址。对于动态链接,还有更多的重定位类型,我们先不提。
  3. 把重定位地址refaddr处的值改成程序swap处的地址。
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

具体计算

refaddr

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

*refptr

计算*refptr,我们用swap的绝对地址减去基址的值得到。
swap绝对地址可以从objdump看到,是0x8048400,而call命令的地址是0x80482f1,所以*refptr的值是

1
0x8048400 - 0x80482f1 = 0x10f

不对啊,之前看到实际的命令的参数是0x10a啊?没错,我们还需要减去A。
这里提供另一个理解方式,因为基址是0x80482f2+4=0x80482f6,正好是下一条指令的开头。而这又是因为EIP(即PC)始终是指向下一条待执行的指令,所以计算偏移的时候,我们需要以此时实际EIP指向的位置来加上call指定的偏移。而0xfffffffc=-4这个值正好帮助EIP跳过自己占用的四个字节,这也解答了我们的问题2。

动态链接

动态链接机制概述

根据thegreenplace上的这篇文章,在解决加载动态链接库中的重定向问题时,通常有两种办法:

  1. 基于PIC机制
    这个机制是目前主流且通用的,对应了-fPIC -shared的编译模式。
  2. 基于Load-time Relocation机制
    这个机制对应了-shared的编译模式。

GOT机制概述

我们考虑动态链接的场景,程序依赖的外部程序并没有在静态链接期链接到可执行文件中,而需要在运行期动态加载。负责这个过程的动态加载器由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. 直接模仿静态链接器一样修改代码,不过这个是不现实的,我们从前文知道,.text段是只读的。
  2. 将地址放在可读可写的数据段中,并在.text中用代码加载正确地址。这个对应了我们后面提到的GOT表。

PIC

PIC的意思是位置无关代码,在之前看到的call命令肯定是位置无关的,因为它的参数是相对EIP的偏移,那么有什么代码是位置相关的呢?这包含两部分:

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

据此,我们可以将模块中的地址引用分为下面四种方式,我们将讨论着四种方式的重定位方式。

  1. 模块内函数调用
    不需要重定位。
  2. 模块内数据访问
    需要通过get_pc_thunk函数(实际上可能叫__x86.get_pc_thunk.ax)来获得PC的地址,这是因为32位程序数据的相对寻址没有相对于PC的寻址方式。原理是调用call会把下一条指令的地址压到栈顶,即esp指向位置。
  3. 模块间数据访问
    将地址相关的部分放到数据段里面。特别地,这些数据段对于不同的程序都有不同的副本,这也是显然的,因为他们肯定不一定相同,不然为啥不直接放代码段里面。
    这时候我们引入全局偏移表GOT,并将符号的地址放在全局偏移表GOT中。例如,当需要访问变量b时,首先查找GOT中对应的条目,再根据条目列出的地址来索引到变量正确的位置。因此在构建时,我们实际上确定的是GOT相对于当前指令的偏移。GOT位于.got段,可以通过objdump -R查看GOT中的重定位项。
  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编译的,此时就会按照模块间引用的方式生成代码。

如何判断一个ELF文件是PIC的呢?

根据爆栈网,可以用下面的办法判断一个目标文件是不是PIC的。但下面的评论也指出了对SO,以及对-m32编译选项是不适用的。

1
readelf --relocs foo.o | egrep '(GOT|PLT|JU?MP_SLOT)'

PLT机制概述

PLT将对于函数引用的实际地址从.got中拆出,放到了.got.plt表中。因此.got中保存全局变量引用的地址,.got.plt保存全局函数引用的地址。
.got.plt的前三个条目有特殊意义:

  1. .dynamic段地址
  2. 本模块ID
  3. _dl_runtime_resolve函数地址。这个函数负责绑定模块A中的f函数。

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

PLT机制的过程是如下代码(来自自我修养一书)所示,这代码位于.plt段。
首先,通过GOT中的索引进行跳转。如果这个索引条目已经加载了bar的正确地址,那么就会自然跳转到对应位置。否则,会调到push n指令处。

1
2
bar@plt:
jmp *(bar@GOT)

push连同_dl_runtime_resole的这一小段代码实际上就是去加载bar的正确地址,并且修改GOT表。

1
2
3
push n
push moduleID
jump _dl_runtime_resole

.dynamic段

.dynamic段看上去像是属于这个so文件的“文件头”,可以通过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)

动态链接重定位

可以通过readelf -r xxx.so查看动态重定位表。
相比静态链接,动态连接多了一些重定位类型:

  1. R_386_JUMP_SLOT
    这个类型的条目,对应的重定位Offset指向的位置在.got.plt中,实际上指向了.got.plt表中对应函数条目的地址。
    例如,在动态加载时,我们查到printf的地址为X,我们就知道要把这个地址填到动态重定位表中Sym.Name为printf的条目中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的值,才成为最终结果。

有关动态链接器ld-linux

  1. ld-linux是静态链接的

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实验

实验设置

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

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 x80482d0
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

动态链接库

版本和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并不采用上面的命名方式。

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