★★ 前言
本篇文章以linux为平台为例,演示ELF动态解析符号的过程。
不正之处,还请斧正。
通常,ELF解析符号方式称为lazy MODE装载的。这种装载技术是ELF平台上
默认的方式。在不同的体系平台在实现这种机制也是不同的。但是i386和SPARC
在大部分上是相同的。
动态连接器(rtld)提供符号的动态连接,装载共享objects和解析标号的引用。
通常是ld.so,它可以是一个共享object也可以是个可执行的文件。
★★ 符号表(symbol table)
每个object要想使它对其他的ELF文件可用,就要用到符号表(symbol table)中
symbol entry.事实上,一个symbol entry 是个symbol结构,它描述了这个
symbol的名字和该symbol的value.symbol name被编码作为dynamic string
table的索引(index). The value of a symbol是在ELF OBJECT文件内该
symbol的地址。该地址通常需要被重新定位(加上该object装载到内存的基地址
(base load address)). 从而构成该symbol在内存中的绝对地址。
一个符号表入口有如下的格式:
typedef struct
{
Elf32_Word st_name; /* Symbol name (string tbl index) */
Elf32_Addr st_value; /* Symbol value */
Elf32_Word st_size; /* Symbol size */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* No defined meaning, 0 */
Elf32_Section st_shndx; /* Section index */
} Elf32_Sym;
可执行文件他们知道运行时刻他们的地址,所以他们内部的引用符号在编译时候就已
经被重定位了。
★★ GOT(global offset table)
GOT是一个数组,存在ELF image的数据段中,他们是一些指向objects的指针(通常
是数据objects).动态连接器将重新修改那些编译时还没有确定下来地址的符号的
GOT入口。所以说GOT在i386动态连接中扮演着重要的角色。
★★ PLT(procedure linkage table)
PLT是一个这样的结构,它的entries包含了一些代码片段用来传输控制到外部的过程。
在i386体系下,PLT和他的代码片段entries有如下格式:
PLT0:
push GOT[1] ; word of identifying information
jmp GOT[2] ; pointer to rtld function nop
...
PLTn:
jmp GOT[x + n] ; GOT offset of symbol address
push n ; relocation offset of symbol
jmp PLT0 ; call the rtld
PLTn + 1
jmp GOT[x +n +1]; GOT offset of symbol address
push n +1 ; relocation offset of symbol
jmp PLT0 ; call the rtld
当传输控制到一个外部的函数时,它传输执行到PLT 中跟该symbol相关的那个entry
(是在编译时候连接器安装的)。在PLT entry中第一条指令将jump到一个存储在GOT
中的一个指针地址;假如符号还没有被解析,该GOT中存放着的是该PLT entry中的
下一条指令地址。该指令push一个在重定位表中的偏移量到stack,然后下一条指令
传输控制到PLT[0]入口。该PLT[0]包含了调用RTLD解析符号的函数代码。该
解析符号函数地址由程序装载器已经插入到GOT[2]中了。
动态连接器将展开stack并且获取需要解析符号在重定位表地址信息。重定位入口、
符号表和字符串表共同决定着PLT entry引用的那个符号和在进程内存中符号应该
存放的地址。假如可能的话,该符号将被解析出来,它的地址将被存放在被该
PLT entry使用的GOT entry中。下一次该符号被请求时,与之对应的GOT已经包
含了该符号的地址了。所以,所有后来的调用将直接通过GOT传输控制。动态连接器
只解析第一次被二进制文件所引用的符号;这种引用方式就是我们上面所说的
lazy MODE。
★★ 哈希表和链(hash table and chain)
除了符号表(symbol table),GOT(global offset table),PLT(procedure
linkage table),字符串表(string table),ELF objects还可以包含一个
hash table和chain(用来使动态连接器解析符号更加容易)。hash table和chain
通常被用来迅速判定在符号表中哪个entry可能符合所请求的符号名。hash table(总
是伴随着chain的)被作为整型数组存放。在hash表中,一半位置是留给那些buckets的,
另一半是留给在chain中的元素(element)的. hash table直接反映了symbol table
的元素数目和他们的次序。
动态连接器结构提供了所有动态连接的执行是以透明方式访问动态连接器.
然而,明确访问也是可用的。动态连接(装载共享objects和解析符号),
可以通过直接访问RTLD的那些函数来完成:dlopen() , dlsym() and
dlclose() .这些函数被包含在动态连接器本身中。为了访问那些函数,
连接时需要把动态连接函数库(libdl)连接进去。该库包含了一些stub函数
允许编译时候连接器解析那些函数的引用;然而那些stub函数只简单的返回0。
因为事实上函数驻留在动态连接器中,假如从静态连接的ELF文件中调用
那些函数,共享object的装载将会失败。
对于执行动态连接器所必须的是:hash table,hash table元素的数目,
chain,dynamic string table和dynamic symbol talbe。满足了
这些条件,下面算法适用任何symbol的地址计算:
1. hn = elf_hash(sym_name) % nbuckets;
2. for (ndx = hash[ hn ]; ndx; ndx = chain[ ndx ]) {
3. symbol = sym_tab + ndx;
4. if (strcmp(sym_name, str_tab + symbol->st_name) == 0)
5. return (load_addr + symbol->st_value); }
hash号是elf_hash()的返回值,在ELF规范的第4部分有定义,以hash table中元素
个数取模。该号被用来做hash table的下表索引,求得hash值,找出与之匹配的符号
名的chain的索引(line 3)。使用该索引,符号从符号表中获得(line 3).比较获得
的符号名和请求的符号名是否相同(line 5).使用这个算法,就可以简单解析任何符号了。
★★ 演示
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello, world/n");
return 0;
}
Relocation section '.rel.plt' at offset 0x278 contains 4 entries:
Offset Info Type Symbol's Value Symbol's Name
0804947c 00107 R_386_JUMP_SLOT 080482d8 __register_frame_info
08049480 00207 R_386_JUMP_SLOT 080482e8 __deregister_frame_info
08049484 00307 R_386_JUMP_SLOT 080482f8 __libc_start_main
08049488 00407 R_386_JUMP_SLOT 08048308 printf
只有R_386_JUMP_SLOT的才会出现在GOT中
Symbol table '.dynsym' contains 7 entries:
Num: Value Size Type Bind Ot Ndx Name
0: 0 0 NOTYPE LOCAL 0 UND
1: 80482d8 116 FUNC WEAK 0 UND __register_frame_info@GLIBC_2.0 (2)
2: 80482e8 162 FUNC WEAK 0 UND __deregister_frame_info@GLIBC_2.0 (
2)
3: 80482f8 261 FUNC GLOBAL 0 UND __libc_start_main@GLIBC_2.0 (2)
4: 8048308 41 FUNC GLOBAL 0 UND printf@GLIBC_2.0 (2)
5: 804843c 4 OBJECT GLOBAL 0 14 _IO_stdin_used
6: 0 0 NOTYPE WEAK 0 UND __gmon_start__
[alert7@redhat]$ gcc -o test test.c
[alert7@redhat]$ ./test
Hello, world
[alert7@redhat]$ objdump -x test
...
Dynamic Section:
NEEDED libc.so.6
INIT 0x8048298
FINI 0x804841c
HASH 0x8048128
STRTAB 0x80481c8
SYMTAB 0x8048158
STRSZ 0x70
SYMENT 0x10
DEBUG 0x0
PLTGOT 0x8049470
PLTRELSZ 0x20
PLTREL 0x11
JMPREL 0x8048278
REL 0x8048270
RELSZ 0x8
RELENT 0x8
VERNEED 0x8048250
VERNEEDNUM 0x1
VERSYM 0x8048242
...
7 .rel.got 00000008 08048270 08048270 00000270 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
8 .rel.plt 00000020 08048278 08048278 00000278 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
9 .init 0000002f 08048298 08048298 00000298 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
10 .plt 00000050 080482c8 080482c8 000002c8 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
11 .text 000000fc 08048320 08048320 00000320 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
12 .fini 0000001a 0804841c 0804841c 0000041c 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
13 .rodata 00000016 08048438 08048438 00000438 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
14 .data 0000000c 08049450 08049450 00000450 2**2
CONTENTS, ALLOC, LOAD, DATA
15 .eh_frame 00000004 0804945c 0804945c 0000045c 2**2
CONTENTS, ALLOC, LOAD, DATA
16 .ctors 00000008 08049460 08049460 00000460 2**2
CONTENTS, ALLOC, LOAD, DATA
17 .dtors 00000008 08049468 08049468 00000468 2**2
CONTENTS, ALLOC, LOAD, DATA
18 .got 00000020 08049470 08049470 00000470 2**2
CONTENTS, ALLOC, LOAD, DATA
19 .dynamic 000000a0 08049490 08049490 00000490 2**2
CONTENTS, ALLOC, LOAD, DATA
...
[alert7@redhat]$ gdb -q test
(gdb) disass main
Dump of assembler code for function main:
0x80483d0 <main>: push %ebp
0x80483d1 <main+1>: mov %esp,%ebp
0x80483d3 <main+3>: push $0x8048440
0x80483d8 <main+8>: call 0x8048308 <printf>
0x80483dd <main+13>: add $0x4,%esp
0x80483e0 <main+16>: xor %eax,%eax
0x80483e2 <main+18>: jmp 0x80483e4 <main+20>
0x80483e4 <main+20>: leave
0x80483e5 <main+21>: ret
...
0x80483ef <main+31>: nop
End of assembler dump.
(gdb) b * 0x80483d8
Breakpoint 1 at 0x80483d8
(gdb) r
Starting program: /home/alert7/test
Breakpoint 1, 0x80483d8 in main ()
(gdb) disass 0x8048308 ① ⑴
Dump of assembler code for function printf:
/****************************************/ //PLT4:
0x8048308 <printf>: jmp *0x8049488 //jmp GOT[6]
//此时,GOT[6]中存在的是0x804830e
0x804830e <printf+6>: push $0x18 //$0x18为printf重定位入口在JMPREL section中的偏移量
0x8048313 <printf+11>: jmp 0x80482c8 <_init+48> //jmp PLT0
//PLT0处存放着调用RTLD函数的指令
//当函数返回时候,把GOT[6]修改为真正的
//printf函数地址,然后直接跳到printf函数
//执行。
该部分为PLT的一部分
/****************************************/
End of assembler dump.
(gdb) x 0x8049488
0x8049488 <_GLOBAL_OFFSET_TABLE_+24>: 0x0804830e
080482c8 <.plt>: ② //PLT0:
80482c8: ff 35 74 94 04 08 pushl 0x8049474 //pushl GOT[1]地址
//GOT[1]是一个鉴别信息,是link_map类型的一个指针
80482ce: ff 25 78 94 04 08 jmp *0x8049478 //JMP GOT[2]
//跳到动态连接器解析函数执行
80482d4: 00 00 add %al,(%eax)
80482d6: 00 00 add %al,(%eax)
80482d8: ff 25 7c 94 04 08 jmp *0x804947c //PLT1:
80482de: 68 00 00 00 00 push $0x0
80482e3: e9 e0 ff ff ff jmp 80482c8 <_init+0x30>
80482e8: ff 25 80 94 04 08 jmp *0x8049480 //PLT2:
80482ee: 68 08 00 00 00 push $0x8
80482f3: e9 d0 ff ff ff jmp 80482c8 <_init+0x30>
80482f8: ff 25 84 94 04 08 jmp *0x8049484 //PLT3:
80482fe: 68 10 00 00 00 push $0x10
8048303: e9 c0 ff ff ff jmp 80482c8 <_init+0x30>
8048308: ff 25 88 94 04 08 jmp *0x8049488 //PLT4:
804830e: 68 18 00 00 00 push $0x18
8048313: e9 b0 ff ff ff jmp 80482c8 <_init+0x30>
(gdb) b * 0x80482c8
Breakpoint 2 at 0x80482c8
(gdb) c
Continuing.
Breakpoint 2, 0x80482c8 in _init ()
(gdb) x/8x 0x8049470
0x8049470 <_GLOBAL_OFFSET_TABLE_>: 0x08049490 0x40013ed0 0x4000a960 0x400fa550
0x8049480 <_GLOBAL_OFFSET_TABLE_+16>: 0x080482ee 0x400328cc 0x0804830e 0x00000000
(gdb) x/50x 0x40013ed0 ( * link_map类型)
0x40013ed0: 0x00000000 0x40010c27 0x08049490 0x400143e0
0x40013ee0: 0x00000000 0x40014100 0x00000000 0x08049490
0x40013ef0: 0x080494e0 0x080494d8 0x080494a8 0x080494b0
0x40013f00: 0x080494b8 0x00000000 0x00000000 0x00000000
0x40013f10: 0x080494c0 0x080494c8 0x08049498 0x080494a0
0x40013f20: 0x00000000 0x00000000 0x00000000 0x080494f8
0x40013f30: 0x08049500 0x08049508 0x080494e8 0x080494d0
0x40013f40: 0x00000000 0x080494f0 0x00000000 0x00000000
0x40013f50: 0x00000000 0x00000000 0x00000000 0x00000000
0x40013f60: 0x00000000 0x00000000 0x00000000 0x00000000
(gdb) disass 0x4000a960 ③
Dump of assembler code for function _dl_runtime_resolve:
0x4000a960 <_dl_runtime_resolve>: push %eax
0x4000a961 <_dl_runtime_resolve+1>: push %ecx
0x4000a962 <_dl_runtime_resolve+2>: push %edx
0x4000a963 <_dl_runtime_resolve+3>: mov 0x10(%esp,1),%edx
0x4000a967 <_dl_runtime_resolve+7>: mov 0xc(%esp,1),%eax
0x4000a96b <_dl_runtime_resolve+11>: call 0x4000a740 <fixup>
//调用真正的解析函数fixup(),修正GOT[6],使它指向真正的printf函数地址
0x4000a970 <_dl_runtime_resolve+16>: pop %edx
0x4000a971 <_dl_runtime_resolve+17>: pop %ecx
0x4000a972 <_dl_runtime_resolve+18>: xchg %eax,(%esp,1)
0x4000a975 <_dl_runtime_resolve+21>: ret $0x8 //跳到printf函数地址执行
0x4000a978 <_dl_runtime_resolve+24>: nop
0x4000a979 <_dl_runtime_resolve+25>: lea 0x0(%esi,1),%esi
End of assembler dump.
(gdb) b * 0x4000a972
Breakpoint 4 at 0x4000a972: file dl-runtime.c, line 182.
(gdb) c
Continuing.
Breakpoint 4, 0x4000a972 in _dl_runtime_resolve () at dl-runtime.c:182
182 in dl-runtime.c
(gdb) i reg $eax $esp
eax 0x4006804c 1074167884
esp 0xbffffb64 -1073743004
(gdb) b *0x4000a975
Breakpoint 5 at 0x4000a975: file dl-runtime.c, line 182.
(gdb) c
Continuing.
Breakpoint 5, 0x4000a975 in _dl_runtime_resolve () at dl-runtime.c:182
182 in dl-runtime.c
(gdb) si
printf (format=0x1 <Address 0x1 out of bounds>) at printf.c:26
26 printf.c: No such file or directory.
(gdb) disass ④ ⑵
Dump of assembler code for function printf:
0x4006804c <printf>: push %ebp
0x4006804d <printf+1>: mov %esp,%ebp
0x4006804f <printf+3>: push %ebx
0x40068050 <printf+4>: call 0x40068055 <printf+9>
0x40068055 <printf+9>: pop %ebx
0x40068056 <printf+10>: add $0xa2197,%ebx
0x4006805c <printf+16>: lea 0xc(%ebp),%eax
0x4006805f <printf+19>: push %eax
0x40068060 <printf+20>: pushl 0x8(%ebp)
0x40068063 <printf+23>: mov 0x81c(%ebx),%eax
0x40068069 <printf+29>: pushl (%eax)
0x4006806b <printf+31>: call 0x400325b4
0x40068070 <printf+36>: mov 0xfffffffc(%ebp),%ebx
0x40068073 <printf+39>: leave
0x40068074 <printf+40>: ret
End of assembler dump.
(gdb) x/8x 0x8049470
0x8049470 <_GLOBAL_OFFSET_TABLE_>: 0x08049490 0x40013ed0 0x4000a960 0x400fa550
0x8049480 <_GLOBAL_OFFSET_TABLE_+16>: 0x080482ee 0x400328cc 0x4006804c 0x00000000
GOT[6]已经被修正为0x4006804c了
第一次调用printf()的时候需要经过①->②->③->④
以后调用printf()的时候就不需要这么复杂了,只要经过⑴->⑵就可以了
我们来看看到底是如何修正GOT[6]的,也是就说如何找到要修正的地址的
(以前我在这点理解上发生了一些比较大的误解,误导各位的地方还请包涵:) )
1:
进入PLT4的时候 push $0x18 ,该$0x18为printf重定位入口在JMPREL section中的偏移量
2:
printf重定位地址为JMPREL+$0x18 /* Elf32_Rel * reloc = JMPREL + reloc_offset; */
(gdb) x/8x 0x8048278+0x18
0x8048290: 0x08049488 0x00000407 0x53e58955 0x000000e8
0x80482a0 <_init+8>: 0xc3815b00 0x000011cf 0x001cbb83 0x74000000
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
也就是说printf重定位printf_retloc.r_offset=0x08049488;
printf_retloc.r_info=0x00000407;
再看看0x08049488是什么地方
(gdb) x 0x08049488
0x8049488 <_GLOBAL_OFFSET_TABLE_+24>: 0x4006804c
也就是GOT[6]
3:
void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);
对一个可执行文件 或一个共享目标而言,rel_addr就等于reloc->r_offset
所以rel_addr=0x08049488=GOT[6];
4:
*reloc_addr = value;
修正了rel_addr也就是GOT[6]
至于value是如何计算的,请参考下面的源代码
同时r_info又关联着一个符号
Elf32_Sym * sym = &SYMTAB[ ELF32_R_SYM (reloc->r_info) ];
sym=0x8048158+0x00000407;
typedef struct {
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
Elf32_Half st_shndx;
} Elf32_Sym;
(gdb) x/10x 0x8048158+0x00000407
0x804855f: 0x00003a00 0x00008000 0x00000000 0x00006900
0x804856f: 0x00008000 0x00000000 0x00008300 0x00008000
0x804857f: 0x00000000 0x0000b700
link_map结构说明如下:
/* Structure describing a loaded shared object. The `l_next' and `l_prev'
members form a chain of all the shared objects loaded at startup.
These data structures exist in space used by the run-time dynamic linker;
modifying them may have disastrous results.
This data structure might change in future, if necessary. User-level
programs must avoid defining objects of this type. */
★★ glibc中动态解析符号的源代码(glibc 2.1.3的实现)
.text
.globl _dl_runtime_resolve
.type _dl_runtime_resolve, @function
.align 16
_dl_runtime_resolve:
pushl %eax # Preserve registers otherwise clobbered.
pushl %ecx
pushl %edx
movl 16(%esp), %edx # Copy args pushed by PLT in register. Note
movl 12(%esp), %eax # that `fixup' takes its parameters in regs.
call fixup # Call resolver.
popl %edx # Get register content back.
popl %ecx
xchgl %eax, (%esp) # Get %eax contents end store function address.
ret $8 # Jump to function address.
static ElfW(Addr) __attribute__ ((unused))
fixup (
# ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS
ELF_MACHINE_RUNTIME_FIXUP_ARGS,
# endif
struct link_map *l, ElfW(Word) reloc_offset)
{
const ElfW(Sym) *const symtab
= (const void *) l->l_info[DT_SYMTAB]->d_un.d_ptr;
const char *strtab = (const void *) l->l_info[DT_STRTAB]->d_un.d_ptr;
const PLTREL *const reloc /*计算函数重定位人口*/
= (const void *) (l->l_info[DT_JMPREL]->d_un.d_ptr + reloc_offset);
/*l->l_info[DT_JMPREL]->d_un.d_ptr 为JMPREL section的地址*/
const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];/*计算函数symtab入口*/
void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);/*重定向符号的绝对地址*/
ElfW(Addr) value;
/* The use of `alloca' here looks ridiculous but it helps. The goal is
to prevent the function from being inlined and thus optimized out.
There is no official way to do this so we use this trick. gcc never
inlines functions which use `alloca'. */
alloca (sizeof (int));
/* Sanity check that we're really looking at a PLT relocation. */
assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT);/*健壮性检查*/
/* Look up the target symbol. */
switch (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)
{
default:
{
const ElfW(Half) *vernum =
(const void *) l->l_info[VERSYMIDX (DT_VERSYM)]->d_un.d_ptr;
ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)];
const struct r_found_version *version = &l->l_versions[ndx];
if (version->hash != 0)
{
value = _dl_lookup_versioned_symbol(strtab + sym->st_name,
&sym, l->l_scope, l->l_name,
version, ELF_MACHINE_JMP_SLOT);
break;
}
}
case 0:
value = _dl_lookup_symbol (strtab + sym->st_name, &sym, l->l_scope,
l->l_name, ELF_MACHINE_JMP_SLOT);
}
/*此时value为object装载的基地址*/
/* Currently value contains the base load address of the object
that defines sym. Now add in the symbol offset. */
value = (sym ? value + sym->st_value : 0);/*函数的绝对地址*/
/* And now perhaps the relocation addend. */
value = elf_machine_plt_value (l, reloc, value);/*可能还需要一下重定位*/
/* Finally, fix up the plt itself. */
elf_machine_fixup_plt (l, reloc, rel_addr, value);/*修正rel_addr,一般来说是GOT[N]*/
return value;
}
static inline Elf32_Addr
elf_machine_plt_value (struct link_map *map, const Elf32_Rela *reloc,
Elf32_Addr value)
{
return value + reloc->r_addend;
}
/* Fixup a PLT entry to bounce directly to the function at VALUE. */
static inline void
elf_machine_fixup_plt (struct link_map *map, const Elf32_Rel *reloc,
Elf32_Addr *reloc_addr, Elf32_Addr value)
{
*reloc_addr = value;
}
参考资料:
1.glibc 2.1.3 src
2.<<ELF文件格式>>
3.<<Cheating the ELF Subversive Dynamic Linking to Libraries>> write by the grugq
4.Linux动态链接技术
http://www.linuxforum.net/forum/showflat.php?Cat=&Board=Kstudy&Number=102793&page=1&view=collapsed&sb=5&o=31&part=
5.p58-0x04 by Nergal <nergal@owl.openwall.com>
Linux支持动态连接库,不仅节省了磁盘、内存空间,而且可以提高程序运行效率[1]。不过引入动态连接库也可能会带来很多问题,例如动态连接库的调试[4]、升级更新[5]和潜在的安全威胁[6][7]。这里主要讨论符号的动态链接过程,即程序在执行过程中,对其中包含的一些未确定地址的符号进行重定位的过程[3][8]。
本篇主要参考资料[3]和[8],前者侧重实践,后者侧重原理,把两者结合起来就方便理解程序的动态链接过程了。另外,动态连接库的创建、使用以及调用动态连接库的部分参考了资料[1][2]。
下面先来看看几个基本概念,接着就介绍动态连接库的创建、隐式和显示调用,最后介绍符号的动态链接细节。
1、基本概念
1.1 ELF
ELF是Linux支持的一种程序文件格式,本身包含重定位、执行、共享(动态连接库)三种类型。(man elf)
代码:
Code:
[Ctrl+A Select All]
演示:
$ gcc -c test.c #通过-c生成可重定位文件test.o,这里不会进行链接 |
虽然ELF文件本身就支持三种不同的类型,不过它有一个统一的结构。这个结构是:
文件头部(ELF Header)
程序头部表(Program Header Table)
节区1(Section1)
节区2(Section2)
节区3(Section3)
...
节区头部表(Section Header Table)
无论是文件头部、程序头部表、节区头部表,还是节区,它们都对应着C语言里头的一些结构体(elf.h中定义)。文件头部主要描述ELF文件的类型,大小,运行平台,以及和程序头部表和节区头部表相关的信息。节区头部表则用于可重定位文件,以便描述各个节区的信息,这些信息包括节区的名字、类型、大小等。程序头部表则用于描述可执行文件或者动态连接库,以便系统加载和执行它们。而节区主要存放各种特定类型的信息,比如程序的正文区(代码)、数据区(初始化和未初始化的数据)、调试信息、以及用于动态链接的一些节区,比如解释器(.interp)节区将指定程序动态装载/连接器ld-linux.so的位置,而过程链接表(plt)、全局偏移表(got)、重定位表则用于辅助动态链接过程。
1.2 符号
对于可执行文件除了编译器引入的一些符号外,主要就是用户自定义的全局变量,函数等,而对于可重定位文件仅仅包含用户自定义的一些符号。
$ gcc -c test.c #生成可重定位文件test.o |
1.3 重定位:"是将符号引用与符号定义进行连接的过程"[8]
从上面的演示可以看出,重定位文件test.o中的符号地址都是没有确定的,而经过“静态"链接(gcc默认调用ld进行链接)以后有两个符号地址已经确定了,这样一个确定符号地址的过程实际上就是链接的实质。链接过后,对符号的引用变成了对地址(定义符号时确定该地址)的引用,这样程序运行时就可通过访问内存地址而访问特定的数据。
我们也注意到符号printf在可重定位文件和可执行文件中的地址都没有确定,这意味着该符号是一个外部符号,可能定义在动态连接库中,在程序运行时需要通过动态链接器(ld-linux.so)进行重定位,即动态链接。
通过这个演示可以看出printf确实在glibc中有定义。
$ nm /lib/libc.so.6 | grep "/ printf$" |
1.4 动态链接
动态链接就是在程序运行时对符号进行重定位,确定符号对应的内存地址的过程。
Linux下符号的动态链接默认采用Lazy Mode方式[3],也就是说在程序运行过程中用到该符号时才去解析它的地址。这样一种符号解析方式有一个好处:只解析那些用到的符号,而对那些不用的符号则永远不用解析,从而提高程序的执行效率。
不过这种默认是可以通过设置LD_BIND_NOW为非空来打破的(下面会通过实例来分析这个变量的作用),也就是说如果设置了这个变量,动态链接器将在程序加载后和符号被使用之前就对这些符号的地址进行解析。
1.5 动态连接库
上面提到重定位的过程就是对符号引用和符号地址进行链接的过程,而动态链接过程涉及到的符号引用和符号定义分别对应可执行文件和动态连接库,在可执行文件中可能引用了某些动态连接库中定义的符号,这类符号通常是函数。
为了让动态链接器能够进行符号的重定位,必须把动态连接库的相关信息写入到可执行文件当中,这些信息是什么呢?
$ readelf -d test | grep NEEDED |
ELF文件有一个特别的节区,.dynamic,它存放了和动态链接相关的很多信息,例如动态链接器通过它找到该文件使用的动态连接库。不过,该信息并未包含动态连接库libc.so.6的绝对路径,那动态链接器去哪里查找相应的库呢?
通过LD_LIBRARY_PATH参数,它类似shell解释器中用于查找可执行文件的PATH环境变量,也是通过冒号分开指定了各个存放库函数的路径。该变量实际上也可以通过/etc/ld.so.conf文件来指定,一行对应一个路径名。为了提高查找和加载动态连接库的效率,系统启动后会通过ldconfig工具创建一个库的缓存/etc/ld.so.cache。如果用户通过/etc/ld.so.conf加入了新的库搜索路径或者是把新库加到某个原有的库目录下,最好是执行一下ldconf以便刷新缓存。
需要补充的是,因为动态连接库本身还可能引用其他的库,那么一个可执行文件的动态符号链接过程可能涉及到多个库,通过read -d可以打印出该文件直接依赖的库,而通过ldd命令则可以打印出所有依赖或者间接依赖的库。
$ ldd test |
lib.so.6通过read -d就可以看到的,是直接依赖的库;而linux-gate.so.1在文件系统中并没有对应的库文件,它是一个虚拟的动态连接库,对应进程内存映像的内核部分,更多细节请参考资料[11];而/lib/ld-linux.so.2正好是动态链接器,系统需要用它来进行符号重定位。那ldd是怎么知道/lib/ld-linux.so就是该文件的动态链接器呢?
那是因为ELF文件通过专门的节区指定了动态链接器,这个节区就是.interp。
$ readelf -x .interp test |
可以看到这个节区刚好有字符串/lib/ld-linux.so.2,即ld-linux.so的绝对路径。
我们发现,与libc.so不同的是,ld-linux.so的路径是绝对路径,而libc.so仅仅包含了文件名。原因是:程序被执行时,ld-linux.so将最先被装载到内存中,没有其他程序知道去哪里查找ld-linux.so,所以它的路径必须是绝对的;当ld-linux.so被装载以后,由它来去装载可执行文件和相关的共享库,它将根据PATH变量和LD_LIBRARY_PATH变量去磁盘上查找它们,因此可执行文件和共享库都可以不指定绝对路径。
下面着重介绍动态连接器本身。
1.6 动态连接器(dynamic linker/loader)
Linux下elf文件的动态链接器是ld-linux.so,即/lib/ld-linux.so.2。从名字来看和静态连接器ld(gcc默认使用的连接器,见参考资料[10])类似。通过man ld-linux可以获取与动态链接器相关的资料,包括各种相关的环境变量和文件都有详细的说明。
对于环境变量,除了上面提到过的LD_LIBRARY_PATH和LD_BIND_NOW变量外,还有其他几个重要参数,比如LD_PRELOAD用于指定预装载一些库,以便替换其他库中的函数,从而做一些安全方面的处理[6][9][12],而环境变量LD_DEBUG可以用来进行动态链接的相关调试。
对于文件,除了上面提到的ld.so.conf和ld.so.cache外,还有一个文件/etc/ld.so.preload用于指定需要预装载的库。
从上一小节中发现有一个专门的节区.interp存放有动态链接器,但是这个节区为什么叫做.interp(interpeter)呢?因为当shell解释器或者其他父进程通过exec启动我们的程序时,系统会先为ld-linux创建内存映像,然后把控制权交给ld-linux,之后ld-linux负责为可执行程序提供运行环境,负责解释程序的运行,因此ld-linux也叫做dynamic loader(或intepreter)(关于程序的加载过程请参考资料[13])
那么在exec()之后和程序指令运行之前的过程是怎样的呢?ld-linux.so主要为程序本身创建了内存映像(以下内容摘自资料[8]),大体过程如下:
1) 将可执行文件的内存段添加到进程映像中;
2) 把共享目标内存段添加到进程映像中;
3) 为可执行文件和它的共享目标(动态连接库)执行重定位操作;
4) 关闭用来读入可执行文件的文件描述符,如果动态链接程序收到过这样的文件描述符的话;
5) 将控制转交给程序,使得程序好像从exec()直接得到控制
关于第1)步,在ELF文件的文件头中就指定了该文件的入口地址,程序的代码和数据部分会相继map到对应的内存中。而关于可执行文件本身的路径,如果指定了PATH环境变量,ld-linux会到PATH指定的相关目录下查找。
$ readelf -h test | grep Entry |
对于第2)步,上一节提到的.dynamic节区指定了可执行文件依赖的库名,ld-linux(在这里叫做动态装载器或程序解释器比较合适)再从LD_LIBRARY_PATH指定的路径中找到相关的库文件或者直接从/etc/ld.so.cache库缓冲中加载相关库到内存中。(关于进程的内存映像,推荐参考资料[14])
对于第3)步,在前面已提到,如果设置了LD_BIND_NOW环境变量,这个动作就会在此时发生,否则将会采用lazy mode方式,即当某个符号被使用时才会进行符号的重定位。不过无论在什么时候发生这个动作,重定位的过程大体是一样的(在后面将主要介绍该过程)。
对于第4)步,这个主要是释放文件描述符。
对于第5)步,动态链接器把程序控制权交还给程序。
现在关心的主要是第3步,即如何进行符号的重定位?下面来探求这个过程。期间会逐步讨论到和动态链接密切相关的三个数据结构,它们分别是ELF文件的过程链接表、全局偏移表和重定位表,这三个表都是ELF文件的节区。
1.7 过程链接表(plt)
从上面的演示发现,还有一个printf符号的地址没有确定,它应该在动态连接库libc.so中定义,需要进行动态链接。这里假设采用lazy mode方式,即执行到printf所在位置时才去解析该符号的地址。
假设当前已经执行到了printf所在位置,即call printf,我们通过objdump反编译test程序的正文段看看。
$ objdump -d -s -j .text test | grep printf |
发现,该地址指向了plt(即过程链接表)即地址80482a0处。下面查看该地址处的内容。
$ objdump -D test | grep "80482a0" | grep -v call |
发现80482a0地址对应的是一条跳转指令,跳转到0x804958c地址指向的地址。到底0x804958c地址本身在什么地方呢?我们能否从.dynamic节区(该节区存放了和动态链接相关的数据)获取相关的信息呢?
$ readelf -d test |
发现0x8049578地址和0x804958c地址比较近,通过资料[8]查到前者正好是.got.plt(即过程链接表)对应的全局偏移表的入口地址。难道0x804958c正好位于.got.plt节区中?
1.8 全局偏移表(got)
现在进入全局偏移表看看,
$ readelf -x .got.plt test |
从上述结果可以看出0x804958c地址(即0x08049588+4)处存放的是a6820408,考虑到我的实验平台是i386,字节顺序是little-endian的,所以实际数值应该是080482a6,也就是说*(0x804958c)的值是080482a6,这个地址刚好是过程链接表的最后一项call 80482a0中80482a0地址往后偏移6个字节,容易猜到该地址应该就是jmp指令的后一条地址。
$ objdump -d -d -s -j .plt test | grep "080482a0 :" -A 3 |
80482a6地址恰巧是一条push指令,随后是一条jmp指令(暂且不管push指令入栈的内容有什么意义),执行完push指令之后,就会跳转到8048270地址处,下面看看8048270地址处到底有哪些指令。
$ objdump -d -d -s -j .plt test | grep -v "jmp 8048270 <_init+0x18>" | grep "08048270" -A 2 |
同样是一条入栈指令跟着一条跳转指令。不过这两个地址0x804957c和0x8049580是连续的,而且都很熟悉,刚好都在.got.plt表里头(从上面我们已经知道.got.plt的入口是0x08049578)。这样的话,我们得确认这两个地址到底有什么内容。
$ readelf -x .got.plt test |
不过,遗憾的是通过readelf查看到的这两个地址信息都是0,它们到底是什么呢?
现在只能求助参考资料[8],该资料的“3.8.5 过程链接表”部分在介绍过程链接表和全局偏移表相互合作解析符号的过程中的三步涉及到了这两个地址和前面没有说明的push $0x10指令。
1) 在程序第一次创建内存映像时,动态链接器为全局偏移表的第二(0x804957c)和第三项(0x8049580)设置特殊值。
2) 原步骤5。在跳转到08048270 <__gmon_start__@plt-0x10>,即过程链接表的第一项之前,有一条压入栈指令,即push $0x10,0x10是相对于重定位表起始地址的一个偏移地址,这个偏移地址到底有什么用呢?它应该是提供给动态链接器的什么信息吧?后面再说明。
3) 原步骤6。跳转到过程链接表的第一项之后,压入了全局偏移表中的第二项(即0x804957c处),“为动态链接器提供了识别信息的机会”(具体是什么呢?后面会简单提到,但这个并不是很重要),然后跳转到全局偏移表的第三项(0x8049580,这一项比较重要),把控制权交给动态连接器。
从这三步发现程序运行时地址0x8049580处存放的应该是动态连接器的入口地址,而重定位表0x10位置处和0x804957c处应该为动态连接器提供了解析符号需要的某些信息。
在继续之前先总结一下过程链接表和全局偏移表。上面的操作过程仅仅从“局部”看过了这两个表,但是并没有宏观地看里头的内容。下面将宏观的分析一下, 对于过程链接表:
$ objdump -d -d -s -j .plt test |
除了该表中的第一项外,其他各项实际上是类似的。而最后一项080482a0 和第一项我们都分析过,因此不难理解其他几项的作用。过程链接表没有办法单独工作,因为它和全局偏移表是关联的,所以在说明它的作用之前,先从总体上来看一下全局偏移表。
$ readelf -x .got.plt test |
比较全局偏移表中0x08049584处开始的数据和过程链接表第二项开始的连续三项中push指定所在的地址,不难发现,它们是对应的。而0x0804958c即push 0x10对应的地址我们刚才提到过(下一节会进一步分析),其他几项的作用类似,都是跳回到过程链接表的push指令处,随后就跳转到过程链接表的第一项,以便解析相应的符号(实际上过程链接表的第一个表项是进入动态链接器,而之前的连续两个指令则传送了需要解析的符号等信息)。另外0x08049578和0x08049580处分别存放有传递给动态连接库的相关信息和动态链接器本身的入口地址。但是还有一个地址0x08049578,这个地址刚好是.dynamic的入口地址,该节区存放了和动态链接过程相关的信息,资料[8]提到这个表项实际上保留给动态链接器自己使用的,以便在不依赖其他程序的情况下对自己进行初始化,所以下面将不再关注该表项。
$ objdump -D test | grep 080494ac |
1.9 重定位表
这里主要接着上面的push 0x10指令来分析。通过资料[8]发现重定位表包含如何修改其他节区的信息,以便动态链接器对某些节区内的符号地址进行重定位(修改为新的地址)。那到底重定位表项提供了什么样的信息呢?
每一个重定位项有三部分内容,我们重点关注前两部分。
第一部分是r_offset,这里考虑的是可执行文件,因此根据资料发现,它的取值是被重定位影响(可以说改变或修改)到的存储单元的虚拟地址。
第二部分是r_info,此成员给出要进行重定位的符号表索引(重定位表项引用到的符号表),以及将实施的重定位类型(如何进行符号的重定位)。(Type)。
先来看看重定位表的具体内容,
$ readelf -r test |
仅仅关注和过程链接表相关的.rel.plt部分,0x10刚好是1*16+0*1,即16字节,作为重定位表的偏移,刚好对应该表的第三行。发现这个结果中竟然包含了和printf符号相关的各种信息。不过重定位表中没有直接指定符号printf,而是根据r_info部分从动态符号表中计算出来的,注意观察上述结果中的Info一列的1,2,4和下面结果的Num列的对应关系。
$ readelf -s test | grep ".dynsym" -A 6 |
也就是说在执行过程链接表中的第一项的跳转指令("jmp *0x8049580")调用动态链接器以后,动态连接器因为有了push 0x10,从而可以通过该重定位表项中的r_info找到对应符号(printf)在符号表(.dynsym)中的相关信息。
除此之外,符号表中还有Offset(r_offset)以及Type这两个重要信息,前者表示该重定位操作后可能影响的地址0804958c,这个地址刚好是got表项的最后一项,原来存放的是push 0x10指令的地址。这意味着,该地址处的内容将被修改,而如何修改呢?根据Type类型R_386_JUMP_SLOT,通过资料[8]查找到该类型对应的说明如下(原资料有误,下面做了修改):
链接编辑器创建这种重定位类型主要是为了支持动态链接。其偏移地址成员给出过程链接表项的位置。动态链接器修改全局偏移表项的内容,把控制传输给指定符号的地址。 |
这说明,动态连接器将根据该类型对全局偏移表中的最有一项,即0804958c地址处的内容进行修改,修改为符号的实际地址,即printf函数在动态连接库的内存映像中的地址。
到这里,动态链接的宏观过程似乎已经了然于心,不过一些细节还是不太清楚。
下面先介绍动态连接库的创建,隐式调用和显示调用,接着进一步澄清上面还不太清楚的细节,即全局偏移表中第二项到底传递给了动态连接器什么信息?第三项是否就是动态连接器的地址?并讨论通过设置LD_BIND_NOW而不采用默认的lazy mode进行动态链接和采用lazy mode动态链接的区别?
2、动态连接库的创建和调用
在介绍动态符号链接的更多细节之前,先来了解一下动态连接库的创建和两种使用方法,进而引出符号解析的后台细节。
首先来创建一个简单动态连接库。
代码:
Code:
[Ctrl+A Select All]
Code:
[Ctrl+A Select All]
演示:
$ gcc -c myprintf.c |
得到三个文件libmyprintf.so,libmyprintf.so.0,libmyprintf.so.0.0,这些库暂且存放在当前目录下。这里有一个问题值得关注,那就是为什么要创建两个符号链接呢?为了在不影响兼容性的前提下升级库[5]。
现在写一段代码来使用该库,调用其中的myprintf函数,这里是隐式使用该库:在代码中并没有直接使用该库,而是通过调用myprintf隐式地使用了该库,在编译引用该库的可执行文件时需要通过-l参数指定该库的名字。
Code:
[Ctrl+A Select All]
演示:
$ gcc -o test test.c -lmyprintf -L./ -I./ |
LD_LIBRARY_PATH环境变量使得库可以放到某些指定的路径下面,而无须在调用程序中显式的指定该库的绝对路径,这样避免了把程序限制在某些绝对路径下,方便库的移动。
虽然显式调用有不便,但是能够避免隐式调用搜索路径的时间消耗,提高效率,除此之外,显式调用为我们提供了一组函数调用,让符号的重定位过程一览无遗。
Code:
[Ctrl+A Select All]
演示:
$ gcc -o test1 test1.c -ldl |
这种情况下,无须包含头文件。从这个代码中很容易看出符号重定位的过程:
1、首先通过dlopen找到依赖库,并加载到内存中,再返回该库的handle,通过dlopen我们可以指定RTLD_LAZY采用lazy mode动态链接模式,如果采用RTLD_NOW则和隐式调用时设置LD_BIN_NOW类似。
2、找到该库以后就是对某个符号进行重定位,这里是确定myprintf函数的地址。
3、找到函数地址以后就可以直接调用该函数了。
关于dlopen,dlsym等后台工作细节建议参考资料[15]。
隐式调用的动态符号链接过程和上面类似。下面通过一些实例来确定之前没有明确的两个内容:即全局偏移表中的第二项和第三项,并进一步讨论lazy mode和非lazy mode的区别。
3、动态链接过程
因为通过ELF文件,我们就可以确定全局偏移表的位置,因此为了确定全局偏移表位置的第三项和第四项的内容,有两种办法:
1、通过gdb调试。
2、直接在函数内部打印。
因为资料[3]详细介绍了第一种方法,这里现试着通过第二种方法来确定这两个地址的值。
Code:
[Ctrl+A Select All]
在写好上面的代码后就需要确定全局偏移表的地址,然后把该地址设置为代码中的宏GOT。
$ make got |
把地址0x8049614替换到上述代码中,然后重新编译运行,查看结果。
$ make got |
通过两次运行,发现全局偏移表中的这两项是变化的,并且printf的地址对应的new_addr也是变化的,说明libc和ld-linux这两个库启动以后对应的虚拟地址并不确定。因此,无法直接跟踪到那个地址处的内容,还得借助调试工具,以便确认它们。
下面重新编译got,加上-g参数以便调试,并通过调试确认got2,got3,以及调用printf前后printf地址的重定位情况。
$ gcc -g -o got got.c |
通过演示发现一个问题(1)(2),即本来调用的是printf,为什么会进行puts的重定位呢?通过gcc -S参数编译生成汇编代码后发现,gcc把printf替换成了puts,因此不难理解程序运行过程为什么对puts进行了重定位。
从演示中不难发现,当符号被使用到时才进行重定位。因为通过调试发现在执行printf之后,GOT表项的最后一项才被修改为printf(确切的说是puts)的地址。这就是所谓的lazy mode动态符号链接方式。
除此之外,我们容易发现GOT表第三项确实是ld-linux.so中的某个函数地址,并且发现在执行printf语句之前,先进入了ld-linux.so的_dl_runtime_resolve函数,而且在它返回之后,GOT表的最后一项才变为printf(puts)的地址。
本来打算通过第一个断点确认第二次调用printf时不再需要进行动态符号链接的,不过因为gcc把第一个替换成了puts,所以这里没有办法继续调试。如果想确认这个,你可以通过写两个一样的printf语句看看。实际上第一次链接以后,GOT表的第三项已经修改了,当下次再进入过程链接表,并执行“jmp *(全局偏移表中某一个地址)”指令时,*(全局偏移表中某一个地址)已经被修改为了对应符号的实际地址,这样jmp语句会自动跳转到符号的地址处运行,执行具体的函数代码,因此无须再进行重定位。
到现在GOT表中只剩下第二项还没有被确认,通过资料[3]我们发现,该项指向一个link_map类型的数据,是一个鉴别信息,具体作用对我们来说并不是很重要,如果想了解,请参考资料[16]。
下面通过设置LD_BIND_NOW再运行一下got程序并查看结果,比较它与默认的动态链接方式(lazy mode)的异同。
$ LD_BIND_NOW=1 ./got #设置LD_BIND_NOW环境变量的运行结果 |
通过比较容易发现,在非lazy mode(设置LD_BIND_NOW后)下,程序运行之前符号的地址就已经被确定,即调用printf之前GOT表的最后一项已经被确定为了printf函数对应的地址,即0xb7e61a20,因此在程序运行之后,GOT表的第二项和第三项就保持为0,因为此时不再需要它们进行符号的重定位了。通过这样一个比较,就更容易理解lazy mode的特点了:在用到的时候才解析。
到这里,符号动态链接的细节基本上就已经清楚了。
参考资料:
[1] LINUX系统中动态链接库的创建与使用
http://www.ccw.com.cn/htm/app/linux/develop/01_8_6_2.asp
[2] LINUX动态链接库高级应用
http://www.vchome.net/tech/dll/dll9.htm
[3] ELF动态解析符号过程(修订版)
http://elfhack.whitecell.org/mydocs/ELF_symbol_resolve_process1.txt
[4] 如何在 Linux 下调试动态链接库
http://unix-cd.com/unixcd12/article_5065.html
[5] Dissecting shared libraries
http://www.ibm.com/developerworks/linux/library/l-shlibs.html
[6] 关于Linux和Unix动态连接库的安全
http://fanqiang.chinaunix.net/safe/system/2007-02-01/4870.shtml
[7] Linux系统下解析Elf文件DT_RPATH后门
http://article.pchome.net/content-323084.html
[8] ELF 文件格式分析
http://162.105.203.48/web/gaikuang/submission/TN05.ELF.Format.Summary.pdf
[9] C语言程序缓冲区注入分析(第二部分:缓冲区溢出和注入实例)
http://oss.lzu.edu.cn/blog/blog.php?do_showone/tid_1540.html
[10] GCC编译的背后(第二部分:汇编和链接)
http://oss.lzu.edu.cn/blog/blog.php?do_showone/tid_1546.html
[11] What is Linux-gate.so.1
http://www.trilithium.com/johan/2005/08/linux-gate/
http://isomerica.net/archives/2007/05/28/what-is-linux-gateso1-and-why-is-it-missing-on-x86-64/
http://www.linux010.cn/program/Linux-gateso1-DeHanYi-pcee6103.htm
[12] Linux下缓冲区溢出攻击的原理及对策
http://www.ibm.com/developerworks/cn/linux/l-overflow/index.html
[13] Linux命令行上程序执行的那一刹那
http://oss.lzu.edu.cn/blog/blog.php?do_showone/tid_1543.html
[14] C语言程序缓冲区注入分析(第一部分:进程的内存映像)
http://oss.lzu.edu.cn/blog/blog.php?do_showone/tid_1539.html
[15] Intel平台下Linux中ELF文件动态链接的加载、解析及实例分析
http://www.ibm.com/developerworks/cn/linux/l-elf/part1/index.html
http://www.ibm.com/developerworks/cn/linux/l-elf/part2/index.html
[16] ELF file format and ABI
http://www.x86.org/ftp/manuals/tools/elf.pdf
http://www.muppetlabs.com/~breadbox/software/ELF.txt