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 记录

Demo 的系统和编译器是

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 和 Linux

权限

当你对某个 ELF 做写操作(例如 > 重定向写入、以写方式打开并 truncate、直接覆盖二进制)而该文件正被系统当作程序 text 部分使用(被某个进程执行或以可执行方式 mmap)时,内核通常会返回 ETXTBSY,拒绝写入以避免破坏运行中的程序代码。

主要是为了一致性与安全:

  • 如果在磁盘上修改正在执行的代码,已经映射到内存的进程可能会出现不可预测的行为或崩溃(映射和磁盘内容不同步)
  • 传统 Unix 会锁定 text 区以防写入,避免同时修改正在运行的程序

注意,我们是可以对这个文件进行重命名的,例如

1
2
# 编译到 bin.new
mv bin.new bin

原因是重命名后,会得到一个新的 inode,运行的进程继续持有老的 inode。

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 表的各个列

section 表被实现为 Elf32_Shdr 数组,其中的各个列的说明如下:

  1. Nr
  2. Name
    表示 section 的名字,它实际位于 .shstrtab 这个字符串表中,在 Elf32_Shdr 结构中实际记录的是一个偏移。
  3. Type
    专门讨论。
  4. Addr
    如果该段可以被加载,那么 Addr 是加载后在进程地址空间中的虚拟地址。
    如果该段不能被加载,则为 0。
  5. Off
    对应于 Addr,这里指的是相对于文件中的位置。
  6. Size
  7. ES(Section Entry Size)
    如果这个段中包含了一些固定大小的项目,ES 表示每个项目的大小。
    例如 .symtabES 就是 10,表示每个项是 10 字节。
  8. Flg
  9. Lk(Link)/Inf(Info)
    对于 RELA/REL 段,Lk 表示该段使用的符号表对应到 section 表中的 Nr,一般是 .symtab 或者 .dynsym 的 Nr。
    Inf 表示这个重定位信息为哪个段服务的。例如 .ref.text是为 .text 服务的,所以它的 Inf 就是 .text 的 Nr。
  10. Al(Section Address Alignment)

Section Headers 的 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
  6. HASH:符号表的哈希表
  7. DYNAMIC:动态链接信息
    .dynamic
  8. NOTE:
  9. NOBITS:
    .bss
  10. SHLIB
  11. DYNSYM:动态符号表
    .dynsym

示例

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
    容易看出:

    • 目标文件中有这两个段
    • 静态的可执行文件来中不存在这两个字段,因为已经没有什么需要重定位的了
    • 动态的可执行文件,实验也没发现有这样的段。我理解它们的作用被 .got.got.plt.plt 替代了。
  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 可以理解为 GOT 的一个子集。.got.got.plt 共同构成了 GOT 表.got.plt 表记录了每个函数装载后的实际地址
    • .plt中列出了所有的@plt为后缀的“函数”,这个段也是可执行的,用来实现 PLT 延时绑定的机制。

    可以通过 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 内存中时,呈现下面的布局。可以看到,栈是从高往低增长的,ES P是栈顶;堆是由低往高增长的,由 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[]

关于 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。这个操作数的地址就是 0x80482f2
call 的行为是将下一条地址 0x80482f6 压栈,然后跳转到被调用的函数 swap 的第一条命令 0x8048400 处。

0x8048400 这个地址是怎么算出来的呢?注意到 0x80482f6+0x010a=0x8048400,所以 call 命令的操作数 0x010a 是相对于 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 段头的 offset。也就是后面看到的 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 位的值是 0x00000f 也就是 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:相对寻址
      这里相对的是那个 call 命令的地址。
    2. R_386_32=1:绝对寻址
  4. Sym.Value

定义两个关键指标:

  1. refaddr
    refaddr 表示需要修改的 call 的操作数位置。即上面 call 命令的地址 0x80482f1 再加上 1。
  2. *refptr
    *refptr 表示 call 参数的最新值。

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

  1. 需要修改的重定位地址 refaddr 相对于 main 的段头的偏移量是 000000120x080482e0 + 0x120x80482f2
    这个 00000012,就是 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 两个段,一般来说只有这两个段会被加载到内存。第一个段包含了 .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
  2. Offset
    表示这个 Segment 在文件中的偏移。
  3. VirtAddr
    表示 Segment 的第一个字节在进程虚拟地址空间中的起始位置,应该就是 VMA。
    对于动态链接的共享库,可以在运行时通过 dl_iterate_phdr 函数来计算当前库,和库里面的每个 Segment 被加载到的真实地址。
  4. PhysAddr
    就是 LMA,一般和 VirtAddr 是一样的。
  5. FileSiz
    表示 Segment 在文件中占用的长度,它是可能为 0。
  6. MemSiz
    表示 Segment 在内存中占用的长度,它也可能为 0。
  7. Align
    表示对齐。

对于 LOAD 类型来说,内存大小 MemSiz 不可能小于文件大小 FileSiz,否则这个 Segment 都无法装载到虚拟空间里面。
但是有时候在内存中会分配多于文件大小的空间,例如 BSS 段所占用的空间。可以结合 Section 表的来观察,首先找到 .bss 的大小是 0x8,然后找到第二个 LOAD 段的 MemSiz 是 0x124,而 0x124-0x8=0x11c 就是 LOAD 段的 FileSiz。Linux 对这个机制的实现会导致包含 .data 的 LOAD 段的结束地址比期望要小。

在进程运行时,可以通过 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 的地址,然后对它加上偏移量来访问。对应汇编类似于

    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 中的重定位项。在有了延迟绑定(PLT)机制后,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,并且进程 AB 都使用了该 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

动态链接库依赖其他的库

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

但其中有一个陷阱,首先给出下面的代码:

  1. foo1.cpp 中定义了 foo,返回 1
  2. foo2.cpp 中也定义了 foo,返回 2
  3. libfoo.cpp 中定义了 test 函数,返回 foo()
  4. main.cpp 中调用 test 和 foo
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. 分别将 foo1.cpp 和 foo2.cpp 编译为 foo1 和 foo2,把它们分别打包为 libfoo1.a 和 libfoo2.a
  2. 把 libfoo.cpp 编译为动态库 libfoo.so,并链接 foo1,不链接 foo2
  3. 编译 main,按顺序链接 foo2 和 libfoo.so
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 这套逻辑了。

简单来说,第一次执行 jmp *(bar@GOT) 会跳到它的下一行,也就是 pushjump _dl_runtime_resolve。在 resolve 到了真的地址之后,再执行 jmp *(bar@GOT) 就会指向真正的地址了。

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

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

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

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

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

  1. R_386_JUMP_SLOT
    这个类型的条目,重定位表中 Offset 字段指向 .got.plt 中对应函数条目的地址。
    例如在动态加载时,查到 printf 的地址为 X,然后就去动态重定位表中找 Sym.Nameprintf 的条目,将 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

可以看出,printf 确实从 R_386_PLT32 变成了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

dlopen

实验

如何获得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技术的介绍