譯者說明:
文章的原文地址:
http://www.linuxforums.org/articles/understanding-elf-using-readelf-and-objdump_125.html
這是我第一次翻譯技術類文章,可能會有一些錯別字,尤其一些語句不通的地方。這可能是由於我用五筆打字,形成的筆誤,請大家批評指正。
【】中的內容,是譯者作的注,注意:xxxx。斜體字是作者的注。
通過readelf和objdump學習ELF
首先,你應該瞭解一下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 */#includeint 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 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的。
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有一個全面的瞭解。