i386 Linux下Elf动态链接分析

Ian Lance Taylor正在写连载文章介绍linkers。   
一直想了解dynamic linking的过程,于是正好就学习了一下。   
program loader、program linker和dynamic linker的具体工作过程暂不在讨论范围。   
首先,随便写个小程序然后用objdump看一下:

$ objdump -d main.o  
00000000 :   
0: 8d 4c 24 04 lea 0x4(%esp),%ecx   
4: 83 e4 f0 and $0xfffffff0,%esp   
7: ff 71 fc pushl 0xfffffffc(%ecx)   
a: 55 push %ebp   
b: 89 e5 mov %esp,%ebp   
d: 51 push %ecx   
e: 83 ec 14 sub $0x14,%esp   
11: e8 fc ff ff ff call 12   
16: 89 04 24 mov %eax,(%esp)   
19: e8 fc ff ff ff call 1a   
1e: e8 fc ff ff ff call 1f   
23: 89 44 24 04 mov %eax,0x4(%esp)   
27: c7 04 24 00 00 00 00 movl $0x0,(%esp)   
2e: e8 fc ff ff ff call 2f   
33: 83 c4 14 add $0x14,%esp   
36: 59 pop %ecx   
37: 5d pop %ebp   
38: 8d 61 fc lea 0xfffffffc(%ecx),%esp   
3b: c3 ret

我们看到main call了一些函数,但地址都是12,1a这样的数字。这些数字表示的是本.text section中的offset,需要被linker patch。这些relocations信息可以用readelf -r查看: 
$ readelf -r main.o  
Relocation section '.rel.text' at offset 0x388 contains 5 entries:   
Offset Info Type Sym.Value Sym. Name   
00000012 00000902 R_386_PC32 00000000 foo   
0000001a 00000a02 R_386_PC32 00000000 printf   
0000001f 00000b02 R_386_PC32 00000000 bar   
0000002a 00000501 R_386_32 00000000 .rodata   
0000002f 00000a02 R_386_PC32 00000000 printf

这些函数地址在link时会被patch成真正的地址(静态联入),或者在plt中的地址(动态联入)。我们readelf -r a.out还能看到打印出如下信息,这些信息我们在后面还会看到有所呼应: 
$ readelf -r a.out  
Relocation section '.rel.dyn' at offset 0x34c contains 1 entries:   
Offset Info Type Sym.Value Sym. Name   
08049744 00000206 R_386_GLOB_DAT 00000000 __gmon_start__   
Relocation section '.rel.plt' at offset 0x354 contains 5 entries:   
Offset Info Type Sym.Value Sym. Name   
08049754 00000107 R_386_JUMP_SLOT 00000000 bar   
08049758 00000207 R_386_JUMP_SLOT 00000000 __gmon_start__   
0804975c 00000407 R_386_JUMP_SLOT 00000000 __libc_start_main   
08049760 00000507 R_386_JUMP_SLOT 00000000 foo   
08049764 00000607 R_386_JUMP_SLOT 00000000 printf

以动态连接方式产生的可执行文件会在.interp这个section中写入dynamic loader的路径。例如: 
wh5a@power3 /tmp/dyn $ readelf -S a.out|grep interp  
[ 1] .interp PROGBITS 08048134 000134 000013 00 A 0 0 1   
wh5a@power3 /tmp/dyn $ gdb -q a.out   
(gdb) x/s 0x8048134   
0x8048134: "/lib/ld-linux.so.2"

这样的话在程序被载入时,dynamic loader将会被调用以便装入程序依赖的动态库。 
下面来看foo函数是如何被调用的: 
0x080484e5 : call 0x80483d4 
我们前面提到从main中call的函数地址都需要被patch,这个过程被static linker做了第一步,使得call指令指向plt中。第二步的patch缺省时将会在动态时按需进行,也就是lazy symbol binding。这一行为可以通过环境变量LD_BIND_NOW=1进行修改,这样在debugging时会有些帮助。plt的基地址可以使用readelf -S看出来,got也是很重要的信息,在此一并列出: 
[11] .plt PROGBITS 08048394 000394 000060 04 AX 0 0 4  
[21] .got PROGBITS 08049744 000744 000004 04 WA 0 0 4   
[22] .got.plt PROGBITS 08049748 000748 000020 04 WA 0 0 4

plt每个entry是16 bytes,got每个entry是4 bytes。 
PLT0的信息比较特殊(这里把具体的地址列出以便理解): 
push GOT[1] ; 0x804974c  
jmp GOT[2] ; *0x8049750   
0x00000000 ; padding

这之后的plt表项对应于每个动态函数。它们的顺序与readelf -r列出的顺序相同。我们知道foo是第4个函数,也就是PLT[4],通过计算地址知道是0x80483d4,确实就是main函数call它的地址。 
其中内容为: 
PLT4:  
080483d4 :   
80483d4: ff 25 60 97 04 08 jmp *0x8049760 ; GOT[6]   
80483da: 68 18 00 00 00 push $0x18 ; foo's relocation offset   
80483df: e9 b0 ff ff ff jmp 8048394 ; PLT0

PLT entry的第一条指令跳转到GOT中,也就是说GOT起到了又一层indirection的作用。GOT的每个表项被初始化为指向到PLT entry的第二条指令。 
这里PLT4对应于GOT6(还记得readelf -r显示的信息么?找到0x8049760了吗?)是因为GOT[0..2]都有特殊的作用(GOT[0]似乎指向.dynamic section,存放的是给dynamic loader有用的一些信息 [5])。那么这个GOT的基址又是怎么来的呢?它对应于.got.plt这个section。.got section存放的应该是global variables,还有待继续研究。 
初始状态下,foo尚未resolve,所以GOT尚未被dynamic linker patch。这样一来,GOT6使得0x80483da这条指令被执行。这条指令的作用是将foo所对应的offset压栈。接下来跳到PLT0继续执行。 
PLT0首先将GOT1中的内容(指向一个link_map结构)压栈,然后跳到GOT2继续执行。GOT[2]指向_dl_runtime_resolve函数。这个函数是由dynamic linker提供的,通过查看proc文件系统的maps文件也可以看出GOT[2]确实指向的是/lib/ld-2.6.so的地址空间。这个函数是很简单的一段汇编,用来建立必要的堆栈环境以便让_dl_fixup(源码在glibc/elf/dl-runtime.c)来完成真正的工作。前面我们压入了两个参数,一个是GOT[1]的内容,也就是一个link_map的地址,另一个是待解决symbol的offset,在这里是0x18。这之后的详细工作过程参见源码及[2]。这个函数最终将会patch GOT[6],使得下次PLT4(也就是foo)再次被调用时可以直接取到foo的真正地址。接下来,_dl_fixup返回foo的真正地址给_dl_runtime_resolve,它将返回值放到栈顶,xchg %eax,(%esp),然后直接一个ret就跳到了foo了。 
最后再总结一下.got.plt的作用: 
.got.plt (0x8049748)  
0x0804966c GOT[0], .dynamic   
0x00ba3650 GOT[1], the link map   
0x00b9a2b0 GOT[2], always jump here to resolve symbols. /lib/ld-linux.so.2 is loaded here.   
...   
0x080483da GOT[6], not resolved yet, so points right back to the instruction after the jmp


References:   
[1] Linkers part 4
[2] ELF动态解析符号过程(修订版)
[3] How to hijack the Global Offset Table with pointers for root shells
[4] The ELF Object File Format: Introduction
[5] The ELF Object File Format by Dissection
[6] Before main() 分析

前面分析的是从应用程序调用动态库的情况。动态库本身是怎么完成符号解析的呢?  
根据Ian Lance Taylor的说法,最好是以PIC(-fpic)的方式编译shared lib,不这样也可以,但会增加dynamic linker做重定位的负担。以PIC方式编译的lib可以大量减少必要的relocation info,但调用non-static functions和访问global/static variables的时候都需要通过plt/got间接进行 (All problems in computer science can be solved by another level of indirection.)   
如果libfoo.so是以PIC模式编译的,并调用了一个外部函数bar,则bar会出现在.rel.plt section中(readelf -r);而如果不是以PIC模式编译,则bar将出现在.rel.dyn section中。如果是后者的话,dynamic linker会在load libfoo.so的时候利用该section提供的偏移量信息,直接将其中引用bar的地方patch上,这样一来也就意味着指令本身被修改了(dynamic linker之后是不是应该重新将指令改为只读?),因此也就丧失了可被多个进程共享的特性。而如果是PIC模式,则会有一次间接的过程,我们现在分析的就是这一过程。与函数调用不同,我们发现全局变量不论是以哪种方式编译,它们的重定位信息都被置于.rel.dyn中,我想这是因为数据的访问不像控制转移一样可以借助几层跳转来完成,因此也无法进行lazy binding而必须在load时做完。   
PIC模式与非PIC模式最大的不同就是前者不直接patch指令,而是patch GOT。所有的指令都访问GOT从而来达到position independence,与前文中动态解析库函数的idea非常类似。可是既然是地址无关的,怎么知道GOT的位置呢?关键在于每一个shared lib都带有自己的GOT,而且整个lib是作为一个整体被load到内存,因此GOT的基址与每条指令的相对偏移总是确定的。这样一来,一条指令在访问GOT的时候,只要算出自己当前的IP地址,再加上这个被静态确定下来的偏移量,就可以定位到自己要访问的symbol的GOT entry了。   
计算当前IP地址的函数一般是__i686.get_pc_thunk.bx,它会附带在每个PIC module中,因此它与调用它的函数的相对偏移也是固定下来的。它非常简单:

mov (%esp),%ebx  
ret

这样就把它的返回地址,也就是caller function的IP地址给放到了ebx中。有时还能见到__i686.get_pc_thunk.cx,会写入ecx,这是因为ebx是callee saved reg,而ecx是caller saved,因此如果一个函数要调用别的函数则最好使用ebx,否则最好使用其它寄存器。接下来的指令(0x00113458)使用自己的IP加上一个固定的偏移便得到了本lib的GOT地址。这一值通常会一直缓存在寄存器(ebx)中。 
(gdb) disassemble  
Dump of assembler code for function foo:   
0x0011344c : push %ebp   
0x0011344d : mov %esp,%ebp   
0x0011344f : push %ebx   
0x00113450 : sub $0x4,%esp   
0x00113453 : call 0x113447 <__i686.get_pc_thunk.bx>   
0x00113458 : add $0x1180,%ebx   
0x0011345e : mov 0xfffffff0(%ebx),%eax ; a negative number because we are accessing .got from .got.plt   
0x00113464 : movb $0x32,(%eax)   
0x00113467 : call 0x113320   
0x0011346c : lea 0xffffef08(%ebx),%eax   
...

0x00113458这条指令的$0x1180是如何得来的呢? 
我们运行readelf -r foo.o得到: 
Relocation section '.rel.text' at offset 0x484 contains 7 entries:  
Offset Info Type Sym.Value Sym. Name   
00000008 00000b02 R_386_PC32 00000000 __i686.get_pc_thunk.bx   
0000000e 00000c0a R_386_GOTPC 00000000 _GLOBAL_OFFSET_TABLE_   
...

说明linker应该在.text section的0xe偏移出patch上_GLOBAL_OFFSET_TABLE_的真实地址。而0xe对应的恰恰就是foo.o的.text section的那条add指令的操作数: 
$ objdump -d foo.o  
Disassembly of section .text:   
00000000 :   
0: 55 push %ebp   
1: 89 e5 mov %esp,%ebp   
3: 53 push %ebx   
4: 83 ec 04 sub $0x4,%esp   
7: e8 fc ff ff ff call 8 ; 0x8: __i686.get_pc_thunk.bx   
c: 81 c3 02 00 00 00 add $0x2,%ebx ; 0xe: _GLOBAL_OFFSET_TABLE_   
...

我们再看一下_GLOBAL_OFFSET_TABLE_这个symbol的值在libfoo.so中是多少 
$ nm libfoo.so |grep _GLOBAL_OFFSET_TABLE_  
000015d8 a _GLOBAL_OFFSET_TABLE_   
$ readelf -S libfoo.so|grep 15d8   
[20] .got.plt PROGBITS 000015d8 0005d8 00001c 04 WA 0 0 4

正是.got.plt的地址!因此可以看出来,所有访问got的指令都留了个空,告诉linker在决定了got的地址时(其实也就是偏移量而非绝对地址),把got相对于该指令的偏移量填进来。 
注意计算了半天得到got的地址只是为了能访问全局变量,因为需要绝对地址(0x0011345e)。而调用plt中的函数只需要一个相对地址就够了,因此不需要通过ebx来间接访问(0x00113467)。 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值