📄
字号:
[22] .bss NOBITS 0804a324 001324 000008 00 WA 0 0 4
[23] .stab PROGBITS 00000000 001324 004878 0c 24 0 4
[24] .stabstr STRTAB 00000000 005b9c 014cd4 00 0 0 1
[25] .comment PROGBITS 00000000 01a870 000165 00 0 0 1
[26] .debug_aranges PROGBITS 00000000 01a9d8 000078 00 0 0 8
[27] .debug_pubnames PROGBITS 00000000 01aa50 000025 00 0 0 1
[28] .debug_info PROGBITS 00000000 01aa75 000a98 00 0 0 1
[29] .debug_abbrev PROGBITS 00000000 01b50d 000138 00 0 0 1
[30] .debug_line PROGBITS 00000000 01b645 000284 00 0 0 1
[31] .debug_frame PROGBITS 00000000 01b8cc 000014 00 0 0 4
[32] .debug_str PROGBITS 00000000 01b8e0 0006be 01 MS 0 0 1
[33] .shstrtab STRTAB 00000000 01bf9e 00013a 00 0 0 1
[34] .symtab SYMTAB 00000000 01c678 000890 10 35 5c 4
[35] .strtab STRTAB 00000000 01cf08 0005db 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
这是按Section的名称列出的,其中跟动态连接有关的Section也出现在前面名为Dynamic的Segment中,只是在那里是按类型列出的。例如,前面类型为HASH的表项说与此有关的信息在0x8048128处,而这里则说有个名为.hash的Section,其起始地址为0x8048128。还有,前面类型为PLTGOT的表项说与此有关的信息在0x804a2c4处,这里则说有个名为.got的Section,其起始地址为0x804a2c4,不过Section表中提供的信息更加详细一些,有些信息则互相补充。在Section表中,只要类型为PROGBITS,就说明这个Section的内容都来自映像文件,反之类型为NOBITS就说明这个Section的内容并非来自映像文件。
有些Section名是读者本来就知道的,例如.text、.data、.bss;有些则从它们的名称就可猜测出来,例如.symtab是符号表、.rodata是只读数据、还有.comment和.debug_info等等。还有一些可能就不知道了,这里择其要者先作些简略的介绍:
(1).hash。为便于根据函数/变量名找到有关的符号表项,需要对函数/变量名进行hash计算,并根据计算值建立hash队列。
● .dynsym。需要加以动态连接的符号表,类似于内核模块中的INPORT符号表。这是动态连接符号表的数据结构部分,须与.dynstr联用。
● .dynstr。动态连接符号表的字符串部分,与.dynsym联用。
● .rel.dyn。用于动态连接的重定位信息。
● .rel.plt。一个结构数组,其中的每个元素都代表着GOP表中的一个表项GOTn(见下)。
● .init。在进入main()之前执行的代码在这个Section中。
● .plt。“过程连接表(Procedure Linking Table)”,见后。
● .fini。从main()返回之后执行的代码在这个Section中,最后会调用exit()。
● .ctors。表示“Constructor”,是一个函数指针数组,这些函数需要在程序初始化阶段(进入main()之前,在.init中)加以调用。
● .dtors。表示“Distructor”,也是一个函数指针数组,这些函数需要在程序扫尾阶段(从main()返回之后,在.fini中)加以调用。
● .got。“全局位移表(Global Offset Table)”,见后。
● .strtab。与符号表有关的字符串都集中在这个Section中。
其中我们最关心的是“过程连接表(Procedure Linking Table)”PLT和“全局位移表(Global Offset Table)”GOT。程序之间的动态连接就是通过这两个表实现的。
下面我们通过一个实例来说明程序之间的动态连接。目标映像/usr/local/bin/wine的main()函数中调用了一个库函数getenv(),这个函数在C语言共享库libc.so.6中。下面是main()经编译/连接以后的汇编代码:
08048ce0 <main>:
8048ce0: 55 push %ebp
8048ce1: 89 e5 mov %esp,%ebp
. . . . . .
8048cef: 68 20 91 04 08 push $0x8049120
8048cf4: e8 47 f9 ff ff call 8048640 <_init+0x58>
本来,这里call指令机器代码的后4个字节应该是目标函数getenv()的入口地址。可是,这个目标函数在共享库libc.so.6中,而这个共享库的装入地址是浮动的,要到装入了以后才能知道其地址。怎么办?一个不必很有天分的人就能想到的简单办法是:编译时先让这条call指令空着,但是创建一个带有字符串“getenv”的数据结构,并让这个数据结构中有个指针反过来指向这条call指令;而在动态连接时,则让“解释器”在共享库的导出符号表中寻找这个符号,找到后根据其装入后的位置计算出应该填入这条call指令的数值,再把结果填写到这里的call指令中、即地址为0x8048cf5的地方。当然,程序中调用getenv()的地方可能不止一个,所以在调用者的映像中需要把所有调用getenv()的地方都记下来。然而,不幸的是这样的地方可能成百上千,而类似于getenv()这样由共享库提供的函数也可能成百上千。更何况一个共享库可能还要用到别的共享库,从而形成一个多层次的共享库“图”。这样一来,动态连接的效率就大成问题了,显然这不是个好办法。
那么实际采用的办法是什么样的呢?这里的call指令采用的是相对寻址,调用的子程序入口地址为0x8048640,我们就循着这个地址看过去:
8048640: ff 25 dc a2 04 08 jmp *0x804a2dc
8048646: 68 18 00 00 00 push $0x18
804864b: e9 b0 ff ff ff jmp 8048600 <_init+0x18>
这就已经在wine映像的PLT表中了,这几条指令就构成getenv()在PLT中的表项,程序中凡是对getenv()的调用都先来到这里。当然,PLT表中有许多这样的表项,对应着许多需要通过动态连接引入的函数,凡是这样的表项都以PLTn表示之。所有的PLTn都是相似的,但是PLT表中的第一个表项、即PLT0、却是特殊的:
08048600 <.plt>:
8048600: ff 35 c8 a2 04 08 pushl 0x804a2c8
8048606: ff 25 cc a2 04 08 jmp *0x804a2cc
804860c: 00 00 add %al,(%eax)
804860e: 00 00 add %al,(%eax)
8048610: ff 25 d0 a2 04 08 jmp *0x804a2d0
8048616: 68 00 00 00 00 push $0x0
804861b: e9 e0 ff ff ff jmp 8048600 <_init+0x18>
8048620: ff 25 d4 a2 04 08 jmp *0x804a2d4
8048626: 68 08 00 00 00 push $0x8
804862b: e9 d0 ff ff ff jmp 8048600 <_init+0x18>
8048630: ff 25 d8 a2 04 08 jmp *0x804a2d8
8048636: 68 10 00 00 00 push $0x10
804863b: e9 c0 ff ff ff jmp 8048600 <_init+0x18>
8048640: ff 25 dc a2 04 08 jmp *0x804a2dc
8048646: 68 18 00 00 00 push $0x18
804864b: e9 b0 ff ff ff jmp 8048600 <_init+0x18>
. . . . . .
可以看出,除PLT0以外,所有的PLTn的形式都是一样的,而且最后的jmp指令都是以0x8048600、即PLT0为目标,所不同的只是第一条jmp指令的目标和push指令中的数据。PLT0则与之不同,但是包括PLT0在内的每个表项都占16个字节,所以整个PLT就像是个数组。其实PLT0只需要12个字节,但是为了大小划一而补了4个字节的0。
注意每个PLTn中的第一条jmp指令是间接寻址的。以getenv()的表项为例,是以地址0x804a2dc处的内容为目标地址进行跳转。这样,只要把getenv()装入用户空间后的入口地址填写在0x804a2dc处,就可以实现正确的跳转,即实现了与共享库中函数getenv()的动态连接。这样,对于共享库函数的每次调用,额外的消耗只是执行一条间接寻址的jmp指令所需的时间。另一方面,这是不涉及堆栈的跳转指令,堆栈的内容在跳转的过程中保持不变,所以当getenv()执行ret指令返回时就直接回到了调用它的地方,在这里是前面的main()中。
由此可见,解释器的任务就是事先把getenv()装入用户空间后的入口地址填写在0x804a2dc处。不仅是getenv(),共享库提供的库函数可能有很多,对于每个这样的库函数都得保存一个用于间接寻址跳转的指针。保存这些指针的地方就是GOT。与PLT相对应,每个PLTn在GOT中都有个相应的GOTn,但是每个GOTn只是一个函数指针。同样,GOT0也是特殊的,而且GOT0的大小也不一样,有12个字节,相当于三个GOTn那么大。在“解释器”ld-linux.so的代码中把GOT0的三个长字表示成got[0]、got[1]、和got[2],注意不要跟GOTn相混淆。显然、解释器负有正确设置所有GOTn的责任。
既然如此,每个PLTn中只要一条指令就行了,代码中为什么有三条呢?还有,PLT0和GOT0又是干什么用的呢?原来,那都是为实现“懒惰式”的动态连接、即“懒连接”而存在的。简而言之,“懒连接”就是解释器并不事先完成对共享库函数的动态连接、即不事先设置GOTn、而把对具体共享库函数的动态连接拖延到真正要用的时候才来进行,需要用哪一个函数就连接哪一个函数,绝不“积极主动”,以免劳而无功。
然而,要是不事先设置好GOTn的内容,PLTn中的(间接寻址)跳转指令会跳到什么地方去?这要看GOTn中的原始内容,这内容来自目标映像(对外进行库函数调用的映像)。
我们看wine映像所提供的GOT原始内容。这个映像的GOT起始地址为0x0804a2c4,跳过GOT0的12个字节,GOTn是从0x0804a2d0开始的:
Relocation section '.rel.plt' at offset 0x548 contains 20 entries:
Offset Info Type Sym.Value Sym. Name
0804a2d0 00000107 R_386_JUMP_SLOT 08048610 strchr
0804a2d4 00000207 R_386_JUMP_SLOT 08048620 getpid
0804a2d8 00000307 R_386_JUMP_SLOT 08048630 fprintf
0804a2dc 00000407 R_386_JUMP_SLOT 08048640 getenv
0804a2e0 00000507 R_386_JUMP_SLOT 08048650 pthread_create
. . . . . .
从这里地址为0x0804a2dc的这一表项看,似乎这个指针所指向的是0x08048640,这正是指令“jmp *0x804a2dc”所在的位置。设想如果CPU在尚未完成对getenv()的动态连接之前就调用了这个函数,从而在PLT中执行了指令“jmp *0x804a2dc”,那岂不就是执行了一条指向其自身的跳转指令?这可是一个最紧扣的死循环!
然而事实并非如此,这里的信息是经过readelf整理的,旨在让使用者知道这一表项跟PLT表中地址为0x08048640的表项相对应,并且相应的函数名为getenv。这是readel综合了好几方面的信息才形成的报告,不幸的是在有些情况下这就成了很误导的报告。而实际上存储在映像中这个位置上的数据却有所不同。为此,我们通过另一个工具od去观察这个位置上真实的、原始的内容。根据前面关于.got的信息,地址0x 0804a2c4在映像中的位移是0x0012c4,所以从0x0804a2d0开始的几个指针是:
0012d0 8616 0804 8626 0804 8636 0804 8646 0804
0012e0 8656 0804 8666 0804 8676 0804 8686 0804
可见,地址0x0804a2dc处的内容其实指向0x08048646。这个地址同样在getenv()的PLT表项中,但这是第二条指令“push $0x18”所在的地址。所以,即使在动态连接之前就试图调用getenv(),至少在这里是不会出问题的。
回过去看getenv()在PLT中的表项。在执行了“push $0x18”以后,下一条指令是“jmp 0x8048600”,这就是PLT0的起点。注意每个PLTn表项的最后一条指令都是相同的,都是跳转到PLT0的起点,所不同的只是压入堆栈的数值,所以这里0x18就代表着getenv()。另一方面,PLT中在getenv()之前的几个表项压入堆栈的数值分别为0x0、0x8、0x10,所以0x18表示这是PLT中的第4个函数。相应地,我们从.dynsym的内容也可以得到验证:
Symbol table '.dynsym' contains 25 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 08048610 359 FUNC GLOBAL DEFAULT UND strchr@GLIBC_2.0 (2)
2: 08048620 8 FUNC GLOBAL DEFAULT UND getpid@GLIBC_2.0 (2)
3: 08048630 23 FUNC GLOBAL DEFAULT UND fprintf@GLIBC_2.0 (2)
4: 08048640 229 FUNC GLOBAL DEFAULT UND getenv@GLIBC_2.0 (2)
5: 08048650 312 FUNC GLOBAL DEFAULT UND pthread_create@GLIBC_2.1 (3)
. . . . . .
再看PLT0,从getenv()的PLT表项跳转到PLT0以后,先把地址0x804a2c8处的内容压入堆栈,这就是got[1]的内容,是由解释器事先设置好了的,这是一个指向代表着本映像的数据结构的指针。然后又是间接寻址的跳转指令,这次使用的地址是0x804a2cc,即&got[2],其内容是个指针,也是由解释器事先设置好的,指向一个函数_dl_runtime_resolve(),这就是用来实现懒连接的函数。注意每次进入_dl_runtime_resolve()只完成一个共享库函数的懒连接,那就是把目标函数的实际地址填写到相应的GOTn中,并且跳转到这个函数。
这样,当目标程序对外调用某个共享库函数时,如果对该函数的动态连接业已完成,那么CPU通过相应的PLTn表项和GOTn表项进行间接寻址的跳转。而若是在尚未建立连接之前,那就临时实行“懒连接”。此时先后由PLTn和PLT0压入堆栈的两项数据分别代表着具体的(调用者)映像和需要调用的具体函数。
下面就是_dl_runtime_resolve()的事了。
_dl_runtime_resolve:\n\
pushl %eax # Preserve registers otherwise clobbered.\n\
pushl %ecx\n\
pushl %edx\n\
movl 16(%esp), %edx # Copy args pushed by PLT in register. Note\n\
movl 12(%esp), %eax # that `fixup' takes its parameters in regs.\n\
call fixup # Call resolver.\n\
popl %edx # Get register content back.\n\
popl %ecx\n\
xchgl %eax, (%esp) # Get %eax contents end store function address.\n\
ret $8 # Jump to function address.\n\
由于前面的三条push指令,这里的12(%esp)就是PLT0所压入的数据结构指针,也就是下面fixup()的第一个调用参数;而16(%esp)就是PLTn所压入的目标函数标识(位移)。函数fixup()一方面从共享库映像中找到目标函数的入口、并将其填写在GOTn中,使得下一次再调用同一函数时可以直接从PLTn通过间接寻址进入目标函数;一方面通过寄存器%eax返回这个函数指针。然后,指令“xchgl %eax, (%esp)”把这个指针交换到了堆栈上。这样一来,下面的“ret $8”指令就使CPU“返回”到了目标函数中,同时又从堆栈上清除了由PLT0和PLTn压入的两项数据。当CPU进入目标函数时,堆栈上的内容首先是调用点的返回地址,然后是对于目标函数的调用参数,就像目标函数直接受到调用时一样。
下面是fixup()的伪代码,有兴趣的读者可以在GLIBC-2.3/elf/dl-runtime.c中找到它的源代码。
fixup (struct link_map *map, unsigned reloc_offset)
{
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -