首先,你应该了解一下elf 目标文件三种形式:
l 可重定向文件:这种文件持有代码(code)和数据,需要与其它的目标文件link在一起,来生成一个可执行的文件或是一个共享库文件。换而言之,你可是把可重定向文件理解为:它是生成可执行文件和库的基础。
如果你如下方式编译源代码,就可得到这种文件:
$gcc -c test.c
这会产生一个test.o,它就是可重定向文件。
内核模块(如*.o或是*.ko)都是可重定向文件的形式。
l 可执行文件:此目标文件持有可执行的程序(program,这是可执行的二进制代码组成的),例如:你的mp3播放器,你的VCD软件播放器,甚至你的 txt的编辑器,这都是elf的可执行文件。
你编译一个程序,即可得到类似的文件:
$ gcc -o test test.c
在你确认“test”程序的可执行位是启用时(linux中文件的可执行位),你就可以执行它了。有个问题:shell 脚本是怎么执行的呢?Shell脚本不是一个elf可执行文件,它只是一个解释器。
l 共享库文件:此文件持有代码和数据,但是用在两种不同的用法。
1. Link editor可以把它+其它的可重定向文件+共享库文件一起处理,进而生成一个其它的目标文件。【这就是将静态库与其它的*.o编译在一起的方式】
2. Dynamic linker(动态连接器)把它、可执行文件、其它库结合在一起,进而生成一个process image(进程镜像)。
一句话:这些文件,就是你常见到的*.so文件。(一般在/usr/lib中)
还有其它的方式来确定elf文件的类型吗?当然有。在每一个elf文件中,有一个文件头,它中的字段表示了此文件的类型。假如你要创建一个二进制包,你可以使用readelf命令,来读出这个头。例如(命令结果会适当瘦身只显示相关的域信息):
$ readelf -h /bin/ls Type:EXEC (Executable file)
sn@ubuntu:~$readelf -h /bin/ls
ELFHeader:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABIVersion: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entrypoint address: 0x8049d60
Startof program headers: 52 (bytes into file)
Startof section headers: 95164 (bytes into file)
Flags: 0x0
Sizeof this header: 52 (bytes)
Sizeof program headers: 32 (bytes)
Numberof program headers: 9
Sizeof section headers: 40 (bytes)
Numberof section headers: 29
Sectionheader string table index: 28
$ readelf -h /usr/lib/crt1.o Type: REL (Relocatable file)
sn@ubuntu:~$ readelf -h/usr/lib/crt1.o
ELFHeader:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABIVersion: 0
Type: REL (Relocatable file)
Machine: Intel 80386
........
$ readelf -h /lib/libc-2.3.2.so Type: DYN (Shared object file)
sn@ubuntu:~$readelf -h /lib/libcap.so.2
ELFHeader:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABIVersion: 0
Type: DYN (Shared object file)
Machine: Intel 80386
......
“File”命令不适合查看目标文件信息,我不想多说这个,让我们关注readelf和objdump。现在我们就开始学习它们。
为了让我们更轻松地学习ELF,你可以使用以下简单的C程序:
/*test.c */ #include int global_data = 4; int global_data_2; int main(int argc, char **argv) { int local_data = 3; printf("HelloWorldn"); printf("global_data= %dn", global_data); printf("global_data_2= %dn", global_data_2); printf("local_data= %dn", local_data); return(0); }
并且编译它:
$gcc -o test test.c
刚生成的二进制就是我们要查看的目标。让我们从ElF的头开始吧:
$readelf -h test
ELFHeader:
Magic:7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class:ELF32 Data: 2's complement, little endian
Version:1 (current)
OS/ABI:UNIX - System V
ABIVersion: 0
Type:EXEC (Executable file)
Machine:Intel 80386
Version:0x1
Entrypoint address: 0x80482c0
Startof program headers: 52 (bytes into file)
Startof section headers: 2060 (bytes into file)
Flags:0x0
Sizeof this header: 52 (bytes)
Sizeof program headers: 32 (bytes)
Numberof program headers: 7
Sizeof section headers: 40 (bytes)
Numberof section headers: 28
Sectionheader string table index: 25
从这个头是告诉我们什么呢?
这个可执行文件是可以在Intel x86 32 bit的体系的机器上运行的(从“machine”和“class”字段)。
当执行时,程序将从虚地址0x080482c0(看“Entry point address”)开始运行。这个地址不是指向我们常见的main()函数地址的,但是它指向是一个名为__start的函数。你从未感觉到创建了它是吗?当然你没有,__start函数是被linker创建的,它的目标是初始你的程序。
这个程序还有28个节区(section)和7个段(segment)【最近读了一些有关ELF的文章,有的把section翻译成“段”,有也把segment翻译成“段”,大家在读文章时要注意】。
什么是节区(section)? Section是在目标文件中的一个区,它包括一些信息(这些信息对连接过程有用):程序的代码、程序的数据(变量、数组、字符串),可重定向的信息和其它。所以,在每一个区,几种信息组合在一起,这里有一个明显地含义:代码区只有代码,数据区只是初始化的或是没有初始化的数据,等等。节区头部分列表(Section Header Table,SHT)精确地告诉我们:ELF目标文件中有什么section。至少从“Number of section headers”字段中知道“test”目标文件有28个section.
如果section是一个二进制表示的,我们的linux内核不能用一种方式读懂它,linux内核准备几个VMA(Virtual Memory Area),它们包括虚拟地址连续的页面帧。在VMA的内部,一个或多个section被映射其中。在这个例子中每一个VMA都代表一个ELF的段(segment)。那内核是如何知道哪个section去往哪个segment呢?这是Program Header Table(PHT)的工作。
上图 ELF结构的两种不同示图。
让我们看一个Section在程序中的存在形式:
$ readelf -S test
Thereare 28 section headers, starting at offset 0x80c:
SectionHeaders:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[4] .dynsym DYNSYM 08048174 000174 000060 10 A 5 1 4
........
[11].plt PROGBITS 08048290 000290 000030 04 AX 0 0 4
[12].text PROGBITS 080482c0 0002c0 0001d0 00 AX 0 0 4
........
[20].got PROGBITS 080495d8 0005d8 000004 04 WA 0 0 4
[21].got.plt PROGBITS 080495dc 0005dc 000014 04 WA 0 0 4
........
[22].data PROGBITS 080495f0 0005f0 000010 00 WA 0 0 4
[23].bss NOBITS 08049600 000600 000008 00 WA 0 0 4
........
[26].symtab SYMTAB 00000000 000c6c 000480 10 27 2c 4
........
编译器把可执行代码保存到.text节区中。那.text节区被标记为可执行('X'在flag字段)。在这个节区,你可以看到我们main()函数的机器代码。
$ objdump -d -j.text test
-d选项告诉objdump分解机器代码。-j告诉objdump只关心那个特定的节区(在本例中,是.text)。以下是执行命令后的部分内容。
08048370 :.......
8048397: 83 ec 08sub $0x8,%esp
804839a: ff 35 fc95 04 08 pushl 0x80495fc
80483a0: 68 c1 8404 08 push $0x80484c1
80483a5: e8 06 ffff ff call 80482b0
80483aa: 83 c4 10add $0x10,%esp
80483ad: 83 ec 08sub $0x8,%esp
80483b0: ff 35 0496 04 08 pushl 0x8049604
80483b6: 68 d3 8404 08 push $0x80484d3
80483bb: e8 f0 feff ff call 80482b0 .......
.data节区保存所有的初始化的变量,这些变量不在栈中。“Initialized”是指这些变量被赋于初始值,如”global_data”。那”local_data”呢?“local_data”的值不在此节区中,它们生活在进程的栈里。
以下是用objdump查看.data节区:
$ objdump -d -j.data test
.....
080495fc <global_data>:
80495fc: 04 00 00 00 ......... 【此处作了修改。】
我们可推断出objdump可以很好地完成地址与符号之间的转译工作。不用在符号表中找,我们可以知道080495fc【作者原文是0x08049424】是global_data的地址。这里我们可以看到它的初始值为4。请注解linux创建的通用可执行文件。这里没有注释的符号表。Objdump很难解析这个地址。
那.bss呢?BSS(BlockStarted by Symbol)是一个映射【注意是映射,不是保存,这个节区在目标文件中的size为0,但在进程中,这个区是有实际空间的,也就是说初始化的变量在进程运行时被创建,在静态的程序中是没他们的空间】未初始化变量的节区,你可能会想“每个东东都应该有明确地初始值”。诚然,在linux中,所有的未初始化的变量都被设置为0。这也是为什么.bss只是一片0的原因。对于字符类型的变量,那就是null字符。知道这个事实,我们知道在运行时,global_data_2被迫成为0.
$objdump -d -j .bss test
Disassemblyof section .bss:
.....
08049604: 8049604: 00 00 00 00 .........
之前,我们提到了符号表。这个表可以找到符号名(不能是外部函数和变量)与地址的关联关系。使用-s,readelf可以解调这个符号表。
$readelf -s ./test
Symboltable '.dynsym' contains 6 entries:
Num:Value Size Type Bind Vis Ndx Name
.....
2:00000000 57 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.0 (2)
.....
Symboltable '.symtab' contains 72 entries:
Num:Value Size Type Bind Vis Ndx Name
.....
49:080495fc 4 OBJECT GLOBAL DEFAULT 22 global_data
.....
55:08048370 109 FUNC GLOBAL DEFAULT 12 main
.....
59:00000000 57 FUNC GLOBAL DEFAULT UND printf@@GLIBC_2.0
.....
61:08049604 4 OBJECT GLOBAL DEFAULT 23 global_data_2
.....
"value" 指示了符号对应的地址。例如:如果一个指令引用的地址(pushl 0x80495fc),此地址含义是global_data。对Printf()这个符号处理是不同的,因为这个符号是外部函数的符号。要知道printf是 定义在了glibc中,不是在test程序的内部,之后呢,我会解释我们的test程序是如何调用到printf的。
像我之前解释的方法,段(segment)是一个OS“看懂”我们程序的方法。让我们看看我们程序是如何变成段的吧:
$readelf -l test
here are 7 program headers, starting at offset 52Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
[00] PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
[01] INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
[02] LOAD 0x000000 0x08048000 0x08048000 0x004fc 0x004fc R E 0x1000
[03] LOAD 0x0004fc 0x080494fc 0x080494fc 0x00104 0x0010c RW 0x1000
[04] DYNAMIC 0x000510 0x08049510 0x08049510 0x000c8 0x000c8 RW 0x4
[05] NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
[06] STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag
06
注意:我自己给这些输出增加了[x]的行号。在实际的输出中是没有的。
这个映射很直观。例如段号2,这里有15个节区被映射到其中。.text节区就映射到此段。它的标志是R,E其含义分别是可读,可执行。W 就是可读的含义。
看 一下“VirtAddr“一列,我们能发现这是每一个段的虚拟首地址。看一个2号段,它的首地址是0x08048000。在这个节区,我们可发现这个地址 不是段在内存中的真实地址。你先忽略"PhyAddr",因为linux一直运行在保存模式(在Intel/AMD 32 bit 和64bit)所以这个虚拟地址是我们关心的。
段有很多类型,我们只关心两类:
很好奇看到程序段的真实的布局是吗?我们使用/proc/<pid>/maps 文件也可以得看到它。<pid>是一个我们想要查看的进程的ID。要行动之前,还有一个小问题,我们的test进程运行的太快了,在我们进入/proc这前,它就结束了。我使用gdb来解决此问题。你也可以在return之前调用 sleep()来搞定这个问题。
在另一个控制台中(或是模拟的终端如xterm):
$ gdb test
(gdb) b main
Breakpoint 1 at 0x8048376
(gdb) r
Breakpoint 1, 0x08048376 in main ()
在此保持(hold)住,打开另一个控制台,找到test的PID。如果你想图省事的话,就这样:
$ cat /proc/`pgrep test`/maps
你将看到如下的输出:(你的输出可能有点不同)
[1] 0039d000-003b2000 r-xp 00000000 16:41 1080084 /lib/ld-2.3.3.so
[2] 003b2000-003b3000 r--p 00014000 16:41 1080084 /lib/ld-2.3.3.so
[3] 003b3000-003b4000 rw-p 00015000 16:41 1080084 /lib/ld-2.3.3.so
[4] 003b6000-004cb000 r-xp 00000000 16:41 1080085 /lib/tls/libc-2.3.3.so
[5] 004cb000-004cd000 r--p 00115000 16:41 1080085 /lib/tls/libc-2.3.3.so
[6] 004cd000-004cf000 rw-p 00117000 16:41 1080085 /lib/tls/libc-2.3.3.so
[7] 004cf000-004d1000 rw-p 004cf000 00:00 0
[8] 08048000-08049000 r-xp 00000000 16:06 66970 /tmp/test
[9] 08049000-0804a000 rw-p 00000000 16:06 66970 /tmp/test
[10] b7fec000-b7fed000 rw-p b7fec000 00:00 0
[11] bffeb000-c0000000 rw-p bffeb000 00:00 0
[12] ffffe000-fffff000 ---p 00000000 00:00 0
注意:我自己给这些输出增加了[x]的行号。在实际的输出中是没有的。
【上图是译者的用例】
回到gdb,输入:
(gdb) q
于是,最后,我们看到了12个段(实际上是VMA)。重点关注第一个字段和最后一字段。第一字段显示了VMA的地址范围,最后一个字段显示了背后的文件。你在看到VMA的第8行与之前PHT的第2行的类似点了吗?不同之处是SHT说它自己于0x080484fc结束,但在8号段中我们看到它的结束地址是0x08049000。在VMA9号与段3号之间也有同样的现象。SHT显示3号段开始于0x080494fc。而VMA则显示开始于0x08049000。
有这么几个因素我们必须了解:
1.尽管VMA开始于不同的地址,与之关联的节区仍然被映射到精确的虚拟地址上了。
2.内核分配内存是以4KB的页为基本单位的,所以每一页的地址都是4KB的整数倍。如0x1000,0x2000等。对于VMA的9号,这个页的地址是0x08049000。或从技术角度讲,这个段的地址必须与页面的大小对齐。
最后,哪一个VMA是栈呢?VMA11就是。一般地,内核动态地分配几个页面,并映射到用户空间可能的最高的虚拟地址,这就是栈的区域了。简单地讲,每一个进程的地址空间被分成两部分(前提是32位的CPU):用户空间和内核空间。用户空间在0x00000000-0xc0000000 ,所以内核空间只能在0xc0000000以上了。
于是,分配给栈的地址是在0xc0000000边界附近的。结束地址是固定的,开始地址可以根据保存内容的多少而变化。
一个程序(它自己是可执行的)调用一个函数。它要做的很简单:只是调用一个过程(函数)。但是如果它调用了如printf()这样定义在glibc库中的函数会怎么样呢?
这里,我们不深入地讨论动态链接器是如何工作的,我重点讲一个在可执行体(或是可执行文件或是进程)中,调用机制是怎么玩的。有了这个前提,让我们继续。
当一个程序想到调用一个函数时,它得按以下的流程来做:
1.它得完成一个跳跃(jump),跳到在PLT(Procedure Linkage Tabe)中要调用的函数相关的条目。
2.在PLT中,还有一个跳跃,跳跃到在GOT(Global Offset Table)中的相关条目的地址。
3.如果这个函数是第一次被调用,则进入第4步,否则进入第5步。
4.相关的GOT入口包含了一个地址(指向PLT下一条指令的地址点)。程序将会跳到此地址,并且调用动态链接器,让它搞定函数的地址。如果函数地址找到了,这个地址被放入相关的GOT条目中,最后这个函数被执行。
于是,当再一次调用此函数时,GOT已经持有了它的地址,PLT就直接跳到这个地址上了。这个过程叫做懒绑定【到了真正用的时候,才完成绑定过程的机制叫懒绑定】;所有的外部符号直到它们第一次被真实地需要时,这些符号才被转译成地址。(在这个例子中,就是函数被调用时,函数符号才被转译成地址)。现在转到第6步。
5.跳到GOT提及的地址点。这个地址点就是函数的地址。不需要再经过动态链接器了。
6.执行完成函数之后,跳回到调用者的下一条指令。
一般地,查看可执行文件的内容的最好方式就是反解析它。可以这样:
$ objdump -d -j .text test
你就可以看到如下代码了:
.....08048370 :..... 804838f: e8 1c ff ff ff call 80482b0 【这是进入PLT的条目的地址。】
我们在0x80482b0处干什么了:
080482b0 : 【PLT表,每一个表项有16个字节,每个表项是一段汇编代码。】
80482b0: ff 25 ec 95 04 08 jmp *0x80495ec 【这个地址是GOT表的一个表项的地址,这个GOT表项对应PLT表项】
80482b6: 68 08 00 00 00 push $0x8
80482bb: e9 d0 ff ff ff jmp 8048290 <_init+0x18>
你看,在0x80482b0处是一个间接跳转*0x80495ec(*要在地址之前)。所以,看它跳哪去。我们得再看0x80482b0一下。猜想,这个地址要么在.GOT中,要么在.GOT.PLT中。回头看SHT,我们在.GOT.PLT中找到了它。我使用readelf完成十六进制的转置。
$ readelf -x 21 test
Hex dump of section '.got.plt':
0x080495dc 080482a6 00000000 00000000 08049510
................
0x080495ec 080482b6 ....
注意,第一列是虚拟地址,这个地址上的数据在第5列,不是第二列。
有了!我们的"080482b6"就在这里。换句话说,我们回到了PLT【回到了相应PLT表项中的第二条指令push $0x8】,在这里我们跳转到了另一个地址。这里的工作是由动态链接器在一开始就完成了的,所以我们把它略过了。假设动态链接器已经完成了这个工作,在GOT的一个条目中持有了printf函数的地址。
除了readelf和objdump,还有一个工具叫Beye。它是一个文件的查看器,能解析ELF结构。你可以从http://beye.sourceforge.net 得到源文件,并自己编译它。
一般,Beye 会在Linux的live CD中。
我个人比较喜欢Beye,因为它提供一个GUI 的显示。针对节区有导航,可以查看ElF头,列出符号表或是其它的任务,这些都只要你点几下键盘就搞定了。
例如:你能列出符号,并直接跳转到符号的地址。我们试着跳转到main函数。第一步,启动Beye.
$ beye test
先按F7后,再按Ctrl+A可查看到符号表。为了节省时间,再按下F7来打开"Find string"菜单。输入"main"按下回车。那个高亮的条目就是你要找的。轻松按下回车,Beye就跳转到main的地址了。不要忘了切换到汇编模式(按F2来选择),这样你能看到机器码的高级形式(汇编形式)。
上图是Beye列出的符号。
一般我们更希望看到虚拟地址,不是文件的偏移。切换到虚拟地址视图更好些。先按F6再按Ctrl+C,选择"Local"。你可以看到最左列是虚拟地址
总结
这个文章只是学习ELF结构的简介。使用readelf和objdump,你就可以开始上道了。如有需要,可以使用Beye工具,它能帮助你快速地探索内部二进制。把你所学的东东,用会,用熟,那你成这方面的大师了。
进一步阅读:
http://www.linuxjournal.com/article/1059
http://www.linuxjournal.com/article/1060
由Eric Youngdale编写的很不错的ELF介绍性文章。
http://en.wikipedia.org/wiki/Executable_and_Linkable_Format
ELF在Wikipedia上的说明,从那里,你能找到一些其它有用的文章。
http://en.wikipedia.org/wiki/Executable_and_Linkable_Format
这个文档完整地详细地说明了ELF的结构,读完本文之后再读此文,可以对ELF有一个全面的了解。