Understanding ELF using readelf and objdump


译者说明:
文章的原文地址:
http://www.linuxforums.org/articles/understanding-elf-using-readelf-and-objdump_125.html
这是我第一次翻译技术类文章,可能会有一些错别字,尤其一些语句不通的地方。这可能是由于我用五笔打字,形成的笔误,请大家批评指正。
【】中的内容,是译者作的注,注意:xxxx。斜体字是作者的注。


通过readelf和objdump学习ELF

首先,你应该了解一下elf 目标文件三种形式:

可重定向文件:这种文件持有代码(code)和数据,需要与其它的目标文件link在一起,来生成一个可执行的文件或是一个共享库文件。换而言之,你可是把可重定向文件理解为:它是生成可执行文件和库的基础。

如果你如下方式编译源代码,就可得到这种文件:

$gcc -c test.c

这会产生一个test.o,它就是可重定向文件。

内核模块(如*.o或是*.ko)都是可重定向文件的形式。

可执行文件:此目标文件持有可执行的程序(program,这是可执行的二进制代码组成的),例如:你的mp3播放器,你的VCD软件播放器,甚至你的 txt的编辑器,这都是elf的可执行文件。

你编译一个程序,即可得到类似的文件:

$ gcc -o test test.c

在你确认“test”程序的可执行位是启用时(linux中文件的可执行位),你就可以执行它了。有个问题:shell 脚本是怎么执行的呢?Shell脚本不是一个elf可执行文件,它只是一个解释器。

共享库文件:此文件持有代码和数据,但是用在两种不同的用法。

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

A.查看ELF的头。

刚生成的二进制就是我们要查看的目标。让我们从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结构的两种不同示图。

B.查看Section Header Table(SHT)

让我们看一个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 A0 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的。

C.查看 program Header Table(PHT)

像我之前解释的方法,段(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)所以这个虚拟地址是我们关心的。

段有很多类型,我们只关心两类:

  • LOAD : 这种段的内容是从可执行文件中加载进来的。"offset"指示了内核应该从文件哪个位置开始读取。"FileSiz"告诉我们从该文件读多少字节。例如:2号段(segment)它的内容是从文件0到0x4fc的这段内容。为了迅速的执行,当有需要时,文件的内容才被读到内存中。【这里的LOAD是指映射到用户的虚拟空间,不是将数据复制到相应的物理页面。】
  • STACK: 这个段是栈的区域。有意思的是它的字段全是0,除了"Flg"和“Align"。不会是错了吧?不是的。决定栈的开始的地址,以及它的大小这都是内核的工作。请记住:在intel的CPU,栈是向下增长的(地址递减说明是在进栈)。

 很好奇看到程序段的真实的布局是吗?我们使用/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边界附近的。结束地址是固定的,开始地址可以根据保存内容的多少而变化。

D.一个函数是怎么一回事呢?

一个程序(它自己是可执行的)调用一个函数。它要做的很简单:只是调用一个过程(函数)。但是如果它调用了如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函数的地址。

E.其它的检测elf结构的工具

除了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有一个全面的了解。




  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值