ELF文件装载链接过程及hook原理
ELF文件格式解析
可執行和可鏈接格式(Executable and Linkable Format,縮寫為ELF),常被稱為ELF格式,在計算機科學中,是一種用于執行檔、目的檔、共享庫和核心轉儲的標準文件格式。
ELF文件主要有四種類型:
- 可重定位文件(Relocatable File) 包含適合于與其他目標文件鏈接來創建可執行文件或者共享目標文件的代碼和數據。
- 可執行文件(Executable File) 包含適合于執行的一個程序,此文件規定了 exec() 如何創建一個程序的進程映像。
- 共享目標文件(Shared Object File) 包含可在兩種上下文中鏈接的代碼和數據。首先鏈接編輯器可以將它和其它可重定位文件和共享目標文件一起處理,生成另外一個目標文件。其次,動態鏈接器(Dynamic Linker)可能將它與某個可執行文件以及其它共享目標一起組合,創建進程映像。
以一個簡單的目標文件為例:
| 1234567891011121314151617 |
| 1 | gcc -c SimpleSection.c |
ELF文件結構
鏈接視圖和執行視圖
ELF文件在磁盤中和被加載到內存中并不是完全一樣的,ELF文件提供了兩種視圖來反映這兩種情況:鏈接視圖和執行視圖。顧名思義,鏈接視圖就是在鏈接時用到的視圖,而執行視圖則是在執行時用到的視圖。
程序頭部表(Program Header Table),如果存在的話,告訴系統如何創建進程映像。
節區頭部表(Section Header Table)包含了描述文件節區的信息,比如大小,偏移等。
ELF文件頭(ELF Header)
定義了ELF魔數、硬件平臺等、
入口地址、程序頭入口和長度、
段表的位置和長度及段的數量、
段表字符串表(.shstrtab)所在的段在段表中的下標。
可以在”/usr/include/elf.h”中找到它的定義(Elf32_Ehdr)。
ELF各個字段的說明:
段表(Section Header Table)
描述了各個段的信息,比如每個段的段名、段的長度、在文件中的偏移、讀寫權限及段的其它屬性。
段表的結構是一個以Elf32_Shdr結構體(段描述符)為元素的數組。
每個Elf32_Shdr結構體對應一個段。
使用readelf工具查看ELF文件的段:
段描述符(Elf32_Shdr)的各個成員及含義:
段的類型(sh_type)
對于編譯器和鏈接器,主要決定段的屬性的是段的類型(sh_type)和段的標志位(shflags)。段的類型相關常量以SHT開頭,列舉如下表。
段的標志位(shflag)表示該節在進程虛擬地址空間中的屬性,比如是否可寫,是否可執行等。相關常量以SHF開頭,如下表:
段的鏈接信息(sh_link、sh_info) 如果節的類型是和鏈接相關的,比如重定位表、符號表等,那么sh_link和sh_info兩個成員包含的意義如下。對于其他段,這兩個成員沒有意義。
代碼段(.text)
使用objdump工具查看代碼段的內容,”-d”參數將所有包含指令的段反匯編。
數據段(.data)和只讀數據段(.rodata)
.data段保存的是那些已經初始化了的全局靜態變量和局部靜態變量。前面SimpleSection.c代碼里面一共有兩個這樣的變量,都是int類型的,一共剛好8字節。
在SimpleSection.c里在調用”printf”的時候,用到了一個字符串常量”%d\n”,它是一種只讀數據,所以被放到了”.rodata”段。
BSS段(.bss)
.bss段存放的未初始化的全局變量和局部靜態變量。.bss段不占磁盤空間。
字符串表(.strtab)
在ELF文件中,會用到很多字符串,比如節名,變量名等。所以ELF將所有的字符串集中放到一個表里,每一個字符串以’\0’分隔,然后使用字符串在表中的偏移來引用字符串。
比如下面這樣:
那么偏移與他們對用的字符串如下表:
這樣在ELF中引用字符串只需要給出一個數組下標即可。字符串表在ELF也以段的形式保存,常見的段名為”.strtab”或”.shstrtab”。這兩個字符串表分別為字符串表(String Table)和段表字符串表(Header String Table),字符串表保存的是普通的字符串,而段表字符串表用來保存段表中用到的字符串,比如段名。
符號表(.symtab)
在鏈接的過程中需要把多個不同的目標文件合并在一起,不同的目標文件相互之間會引用變量和函數。在鏈接過程中,我們將函數和變量統稱為符號,函數名和變量名就是符號名。
每一個目標文件中都有一個相應的符號表(System Table),這個表里紀錄了目標文件所用到的所有符號。每個定義的符號都有一個相應的值,叫做符號值(Symbol Value),對于變量和函數,符號值就是它們的地址。
符號表是一個Elf32_Sym(32位)的數組,每個Elf32_Sym對應一個符號。這個數組的第一個元素,也就是下標為0的元素為無效的”未定義”符號。
他們的定義如下:
符號類型和綁定信息(st_info)
該成員的低4位標識符號的類型(Symbol Type),高28位標識符號綁定信息(Symbol Binding),如下表所示。
符號所在段(st_shndx)
如果符號定義在本目標文件中,那么這個成員表示符號所在段在段表中的下表,但是如果符號不是定義在本目標文件中,或者對于有些特殊符號,sh_shndx的值有些特殊。如下:
符號值(st_value)
每個符號都有一個對應的值。主要分下面幾種情況:
- 如果符號不是”COMMON”類型的(即st_shndx不為SHN_COMMON),則st_value表示該符號在段中的偏移,即符號所對應的函數或變量位于由st_shndx指定的段,偏移st_value的位置。比如SimpleSection中的”func1”,”main”和”global_init_var”。
- 在目標文件中,如果符號是”COMMON”類型(即st_shndx為SHN_COMMON),則st_value表示該符號的對齊屬性。比如SimleSection中的”global_uninit_var”。
- 在可執行文件中,st_value表示符號的虛擬地址。
下圖為使用readelf工具來查看ELF文件的符號:
比如,Num13行指的是符號表中的第13個元素,符號名為main,它是函數類型,定義在第一個段(即.text段)的第001b偏移處,大小為64字節。
重定位表(.rel.text)
SimpleSection.o中有一個叫”.rel.text”的段,它的類型(sh_type)為”SHT_REL”,也就是說它是一個重定位表。鏈接器在處理目標文件時,需要對目標文件中的某些部位進行重定位,即代碼段和數據中中那些絕對地址引用的位置。對于每個需要重定位的代碼段或數據段,都會有一個相應的重定位表。比如”.rel.text”就是針對”.text”的重定位表,”.rel.data”就是針對”.data”的重定位表。
靜態鏈接
這節以下面兩個文件為例
| 123456 | /* a.c */extern int shared;int main(){ int a = 100;swap(&a,&shared);} |
| 12345 | /* b.c */int shared = 1;void swap(int* a, int* b){*a ^= *b ^= *a ^= *b;} |
當我們有兩個目標文件時,如何將他們鏈接起來形成一個可執行文件?
對于多個輸入目標文件,鏈接器如何將它們的各個段合并到輸出文件?輸出文件中的空間如何更配給輸入文件?
下圖為現在鏈接器采用的空間分配策略。
整個鏈接過程分兩步:
- 第一步 空間與地址分配 掃描所有的輸入目標文件,并且獲得它們的各個段的長度、屬性和位置,并且將輸入目標文件中的符號表中所有的符號定義和符號引用收集起來,統一放到一個全局符號表中。
- 第二步 符號解析與重定位 使用第一步中收集到的信息,讀取輸入文件中段的數據、重定位信息,并且進行符號解析與重定位、調整代碼中的地址等
使用ld鏈接器將”a.o”和”b.o”鏈接起來:
| 1 | $ld a.o b.o -e main -o ab |
查看鏈接前后各個段的屬性
VMA表示虛擬地址,LMA表示加載地址,正常情況下這兩個值應該一樣。
整個鏈接過程前后,目標文件各段的分配、程序虛擬地址:
在Linux下,ELF可執行未見默認從地址0x08048000開始分配。
符號解析與重定位
編譯器在將”a.c”編譯成指令時,它如何訪問”shared”變量?如何調用”swap”函數?
重定位表(Relocation Tabel)專門用來保存與重定位相關的信息,鏈接器根據它知道哪些指令時要被調整的,以及如何調整。
對于32位的Intel x86系列處理器來說,重定位表的結構是一個Elf_32Rel結構的數組,每個數組元素對應一個重定位入口。定義如下:
可以使用objdump來查看目標文件的重定位表:
將”a.o”的代碼段反匯編可以看到,此時編譯器并不知道“shared”的地址,暫時把地址0看做”shared”的地址。
0xE8是一條近址相對位移調用指令,后面4個字節就是被調用函數的相對于調用指令的下一條指令的偏移量。
此處”swap”函數的地址是0x2b-4=0x27,可以看出0xfffffffc也是一個臨時地址。
指令修正方式
指令修復的結果
可執行文件的裝載與進程
程序執行時所需要的指令和數據必需在內存中才能夠正常運行。
頁映射將內存和所有磁盤中的數據和指令按照“頁(Page)”為單位劃分成若干個頁,以后所有的裝載和操作的單位就是頁。
進程的建立需要做下面三件事情:
- 創建一個獨立的虛擬地址空間
- 讀取可執行文件頭,并且建立虛擬空間與可執行文件的映射關系。
- 將CPU的指令寄存器設置成可執行文件的入口地址,啟動運行。
對于第2步,當操作系統捕獲到缺頁錯誤時,它應該知道程序當前所需的頁在可執行文件中的哪一個位置。
這種映射關系是保存在操作系統內部的一個數據結構VMA。
例如下圖中,操作系統創建進程后,會在進程相應的數據結構中設置有一個.text段的VMA:它在虛擬空間中的地址為0x08048000~0x08049000,它對應ELF文件中偏移為0的.text,它的屬性為只讀,還有一些其他的屬性。
頁錯誤
在上面的例子中,程序的入口地址為0x08048000,當CPU開始打算執行這個地址的指令時,發現頁面0x08048000~0x08049000(虛擬地址)是個空頁面,于是它就認為這是一個頁錯誤。CPU將控制權交給操作系統,操作系統將查詢虛擬空間與可執行文件的映射關系表,找到空頁面所在的VMA,計算相應的頁面在可執行文件中的偏移,然后在物理內存中分配一個物理頁面,將進程中該虛擬頁與分配的物理頁之間建立映射關系,然后把控制權再還給進程,進程從剛才頁錯誤的位置重新開始執行。
鏈接視圖和執行視圖
以下面的程序為例。
| 1234567891011121314 | /**使用靜態鏈接的方式將其編譯成可執行文件:$gcc -static SectionMapping.c -o SectionMapping.elf**/while11000return0 |
下面的elf文件被重新劃分成了三個部分,有一些段被歸入可讀可執行的,他們被統一映射到一個CODE VMA;另一部分段是可讀可寫的,它們被映射到了DATA VMA;還有一些段在程序執行時沒有用,所以不需要映射。
ELF與Linux進程虛擬空間映射關系(一個常見進程的虛擬空間)如下圖。
程序頭表(Program Header Table)
用來保存“Segment”的信息,描述了ELF文件該如何被操作系統映射到虛擬空間。因為ELF目標文件不需要被裝載,所以它沒有程序頭表,而ELF的可執行文件和共享庫文件都有。
使用readelf查看程序頭表。
跟段表結構一樣,程序頭表也是一個結構體數組,其結構體用Elf32_Phdr表示。
下表是Elf32_Phdr結構的各個成員的基本含義。
堆和棧
VMA除了被用來映射可執行文件中的各個”segment”以外,操作系統通過使用VMA來對進程的地址空間進行管理,包括堆和棧。
在Linux下,可以通過查看”/proc”來查看進程的虛擬空間分布:
我們可以看到進程中有5個VMA,只有前兩個是映射到可執行文件中的兩個Segment。另外三個段的文件所在設備主設備號及文件節點號都是0,則表示他們沒有映射到文件中,這種VMA叫做匿名虛擬內存區域。另外有一個很特殊的VMA叫“vdso”,它的地址已經位于內核空間了(即大于0xC0000000的地址),事實上它是一個內核的模塊,進程可以通過訪問這個VMA來跟內核進行一些通信。
操作系統通過給進程空間劃分出一個個VMA來管理進程的虛擬空間;基本原則是將相同權限屬性的、有相同映像文件的映射成一個VMA。
動態鏈接
以下面的代碼為例
| 1234567891011121314151617181920212223242526 | /* Lib.h */ |
將Lib.c編譯成一個共享對象文件:
| 1 | $gcc -fPIC -shared -o Lib.so Lib.c |
分別編譯鏈接Program1.c和Program2.c:
| 1 | $gcc -o Program1 Program1.c ./Lib.so |
| 1 | $gcc -o Program2 Program2.c ./Lib.so |
查看進程的虛擬地址空間分布:
上圖中的ld-2.6.so實際上是Linux下的動態鏈接器,它與普通共享對象一樣被映射到了進程的地址空間,在系統開始運行program1之前,首先會把控制權交給動態鏈接器,由它完成所有的動態鏈接工作以后再把控制權交給program1,然后開始執行。
通過readelf查看Lib.so的裝載屬性:
與普通程序不同的是,動態鏈接模塊的裝載地址是從地址0x00000000開始的,這個地址是無效的,共享對象的最終裝載地址在編譯時時不確定的,而是在裝載時,裝載器根據當前地址空間的空前情況,動態分配一塊足夠大小的虛擬地址空間給相應的共享對象。
地址無關代碼(PIC)
裝載時重定位是解決動態模塊中有絕對地址引用的方法之一,但是它有一個很大的缺點是指令部分無法在多個進程之間共享,這樣就失去了動態鏈接節省內存的一大優勢。我們還需要有一種更好的方法解決共享對象指令中對絕對地址的重定位問題。其實我們的目的很簡單,希望程序模塊中共享的指令部分在裝載時不需要因為裝載地址的改變而改變,所以實現的基本思想就是把指令中那些需要被修改的部分分離出來,跟數據部分放在一起,這樣指令部分就可以保持不變,而數據部分可以在每個進程中擁有一個副本。
模塊中各種類型的地址引用方式如下圖:
全局偏移表(GOT)
用于模塊間數據訪問,在數據段里建立一個指向外部模塊變量的指針數組。當代碼需要引用該全局變量時,可以通過GOT中相對用的項間接引用,它的基本機制如下圖。
當指令中需要訪問變量b時,程序會先找到GOT,然后根據GOT中變量所對應的項找到變量的目標地址。每個變量都對應一個4字節的地址,鏈接器在裝載模塊的時候會查找每個變量所在的地址,然后填充GOT中的各個項,以確保每個指針所指向的地址正確。由于GOT本身是放在數據段的,所以它可以在模塊裝載時被修改,并且每個進程都可以有獨立的副本,相互不受影響。
延遲綁定(PLT)
動態鏈接下對于全局和靜態的數據訪問都要進行復雜的GOT定位,然后間接尋址;對于模塊間的調用也要先定位GOT,然后再進行間接跳轉。程序開始執行時,動態鏈接器都要進行一次鏈接工作,會尋找并裝載所需的共享對象,然后進行符號查找地址重定位等工作,如此一來,程序的運行速度必定會減慢。
延遲綁定的實現
函數第一次被用到時才進行綁定(符號查找、重定位等),如果沒有用到則不進行綁定。
GOT 位于 .got.plt section 中,而 PLT 位于 .plt section中。
GOT 保存了程序中所要調用的函數的地址,運行一開時其表項為空,會在運行時實時的更新表項。一個符號調用在第一次時會解析出絕對地址更新到 GOT 中,第二次調用時就直接找到 GOT 表項所存儲的函數地址直接調用了。
printf()函數的調用過程如下圖
GDB調試分析延遲綁定機制
為了加深理解可以用GDB動態調試,Examine下斷點前后GOT表的內存的變化。
動態加載器解析結束,可以看到got表項正確指向了libc動態庫中printf的地址
動態鏈接的相關結構
.interp段
在動態鏈接的ELF可執行文件中,有一個專門的段叫做”.interp”段。里面保存的是一個字符串,記錄所需動態鏈接器的路徑。
從下圖可以看出,Android用的動態鏈接器是linker
.dynamic段
這個段里保存了動態鏈接器所需要的基本信息,比如依賴哪些共享對象、動態鏈接符號表的位置、動態鏈接重定位表的位置、共享對象初始化代碼的地址等。
.dynamic段里保存的信息有點像ELF文件頭。
.dynamic段的結構是由Elf32_Dyn組成的數組。
Elf32_Dyn結構由一個類型值加上一個附加的數值或指針,對于不同的類型,后面附加的數值或者指針有著不同的含義。
動態符號表(.dynsym)
為了表示動態鏈接模塊之間的符號導入導出關系,ELF專門有一個叫做動態符號表的段用來保存這些信息。
與”.symtab”不同的是,”.dynsym”只保存了與動態鏈接相關的符號,對于那些模塊內部的符號,比如模塊私有變量則不保存。很多時候動態鏈接模塊同時擁有”.dynsym”和”.symtab”兩個表,”.symtab”中往往保存了所有符號,包括”.dynsym”中的符號。
動態符號字符串表(.dynstr)
在動態鏈接時用于保存符號名的字符串表。
符號哈希表(.hash)
由于動態鏈接下,需要在程序運行時查找符號,為了加快符號的查找過程,往往還有輔助的符號好戲表。
用readelf查看elf文件的動態符號表及它的哈希表。
動態鏈接重定位表
在動態鏈接中,導入符號的地址在運行時才確定,所以需要在運行時將這些導入符號的引用修正,即需要重定位。
“.rel.dyn”段對數據引用的修正,它所修正的位置位于”.got”以及數據段;
“.rel.plt”段對函數引用修正,它所修正的位置位于”.got.plt”。
用readelf來查看一個動態鏈接的文件的重定位表:
R_386_JUMP_SLOT和R_386_GLOB_DAT這兩個類型的重定位入口表示,被修正的位置只需要直接填入符號地址即可。
比如,printf這個重定位入口,它的類型為R_386_JUMP_SLOT,它的偏移為0x000015d8,它位于”.got.plt”中,下圖為其結構。
當鏈接器需要進行重定位時,它先查找”printf”的地址,“printf”位于libc.so中。假設鏈接器在全局符號表里面找到”printf”的地址為0x08801234,那么鏈接器就會將這個地址填入到”.got.plt”中偏移為0x000015d8的位置中去,從而實現了地址的重定位。
R_386_GLOB_DAT是對”.got”的重定位,它跟R_386_JUMP_SLOT的做法一樣。
android arm架構的一種hook實現方案
具體實現來自Andrey Petrov的blog.
| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970 |
用法:
| 1 | hook_call("libandroid_runtime.so", "connect", &my_connect); |
1.調用dlopen拿到so的句柄,得到soinfo,它包含了符號表、重定位表、plt表等信息。
2.查找需要hook的函數的符號,得到它在符號表中的索引。具體實現是soinfo_elf_lookup函數。
bucket數組包含nbucket個項目,chain數組包含nchain個項目,下標都是從0開始。bucket和chain中都保存了符號表的索引。chain表項和符號表存在對應。符號表項的數目應該和nchain相等,所以符號表的索引也可以用來選取chain表項。哈希函數能夠接受符號名并返回一個可以用來計算bucket的索引。如果哈希函數針對某個名字返回了數值x,則bucket[x%nbucket]給出了一個索引y,該索引可用于符號表,也可用于chain表。如果該符號表項不是所需要的,那么chain[y]則給出了具有相同哈希值的下一個符號表項。我們可以沿著chain鏈一直搜索,直到所選中的符號表項包含了所需要的符號,或者chain項中包含值STN_UNDEF。
3.遍歷plt表,直到匹配第2步中找到的符號索引。
如果是JUMP_SLOT類型(函數調用),替換為新的符號地址(函數指針)。
程序中調用mprotect的作用是:
修改一段指定內存區域的保護屬性。
函數原型為:
int mprotect(const void *start, size_t len, int prot);
mprotect()函數把自start開始的、長度為len的內存區的保護屬性修改為prot指定的值。
需要指出的是,指定的內存區間必須包含整個內存頁(4K)。區間開始的地址start必須是一個內存頁的起始地址,并且區間長度len必須是頁大小的整數倍。
參考
- 《程序員的自我修養》
- 《深入理解計算機系統》
- Andrey Petrov’s blog
- Redirecting functions in shared ELF libraries
總結
以上是生活随笔為你收集整理的ELF文件装载链接过程及hook原理的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Android Linker学习笔记
- 下一篇: android linker 浅析