ELF動态連結
靜态連結通過将整個庫都編譯到可執行檔案的方式來生成可執行檔案,而動态連結則利用共享庫來實作可執行檔案對共享庫中函數的調用,在執行時将共享庫加載并綁定到該程序的位址空間中。
1.事前準備
由于要探究的是共享庫,是以我們需要實作一個共享庫檔案:
首先是頭檔案:
add.h:
#ifndef ADD_H
#define ADD_H
int add(int a,int b);
#endif
然後是實作檔案:
add.c:
#include "add.h"
int add(int a,int b)
{
return a+b;
}
最後編譯一波生成.so檔案:
gcc -shared -fPIC add.c -o libadd.so
這裡shared參數說明要生成一個共享庫。
PIC意為position independent code,意思是說生成的代碼中沒有絕對位址,全部是相對位址,這也是為了共享庫的通用性而加的。
這樣就生成了一個隻有函數的共享庫。
可以用readelf -h libadd.so檢視一下ELF頭:
ELF 頭:
.......
類型: DYN (共享目标檔案)
可以看到類型是共享目标檔案,使用readelf -l 檢視它的段的話也會發現沒有INTERP段,因為它不需要程式解釋器。
接下來編寫一個簡單的a+b程式調用一下這個共享庫,為了防止其他共享庫造成影響,這裡并沒有IO過程:
test.c:
#include "add.h"
int main(){
int a=1,b=2;
int c=add(a,b);
return 0;
}
然後需要進行編譯,這裡需要幹兩件事,第一件是強制程式到目前的目錄去找庫檔案,第二件事就是編譯,指令如下:
export LD_LIBRARY_PATH=.
gcc test.c -L. -l add
然後目前目錄就可以生成一個a.out。
當一個共享庫被加載到一個程序的位址空間中時,動态連結器會修改可執行檔案中的全局偏移表GOT,進而達到讓可執行檔案通路庫函數的目的,而由于GOT會被修改,是以它位于資料段中,也就是.got.plt節,如下所示:
0000000000400420 <.plt.got>:
400420: ff 25 d2 0b 20 00 jmpq *0x200bd2(%rip) # 600ff8 <_DYNAMIC+0x1d0>
400426: 66 90 xchg %ax,%ax
這個0x600ff8也處于資料段中。
2.輔助向量
這一節讀完之後也不是很懂這個輔助向量是幹嘛的,需要讀完PLT/GOT這一節,就能夠了解這個輔助向量的用處了。
通過系統調用sys_execve()将程式加載到記憶體中時,對應的可執行檔案會被映射到記憶體的位址空間,并為該程序的位址空間配置設定一個棧。這個棧會用特定的方式向動态連結器傳遞資訊。這種特定的對資訊的設定和安排即為輔助向量。棧底存放了如下資訊:
Auxilary |
---|
environ |
argv |
Stack |
輔助向量的項目滿足如下結構:
typedef struct{
uint64_t a_type;
union{
uint64_t a_val;
} a_un;
}Elf64_auxv_t;
a_type指定了輔助向量的條目類型,a_val為輔助向量的值。
輔助向量是由核心函數create_elf_tables()設定的,該核心函數在Linux的源碼/usr/src/linux/fs/binfmt_elf.c中
- sys_execve()
- 調用do_execve_common()
- 調用search_binary_handler()
- 調用load_elf_binary()
- 調用create_elf_tables()
程式被加載進記憶體,輔助向量被填充好以後,控制權就交給了動态連結器,它會解析要連結到程序位址空間的用于共享庫的符号和重定位。
使用ldd指令可以檢視一個可執行檔案所依賴的共享庫清單。
$ ldd a.out
linux-vdso.so.1 => (0x00007ffdb7354000)
libadd.so => ./libadd.so (0x00007f78f0f4a000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f78f0b80000)
/lib64/ld-linux-x86-64.so.2 (0x00007f78f114c000)
3.PLT/GOT
當一個程式調用共享庫中的函數時,需要到程式運作時才能解析這些函數調用。這裡我實驗的時候和書上的例子不太一樣。。大概是因為書上的是32位而我的是64位,不過表達的意思都差不多。
來看我們之前準備好的例子:
使用objdump -d a.out,看main函數中的内容:
4006a2: 89 d6 mov %edx,%esi
4006a4: 89 c7 mov %eax,%edi
4006a6: e8 b5 fe ff ff callq 400560 <[email protected]>
可以看到這裡調用了位址為0x400560的一個函數add,也就是我們庫中實作的函數,然後來看0x400560對應的内容:
0000000000400560 <[email protected]>:
400560: ff 25 b2 0a 20 00 jmpq *0x200ab2(%rip) # 601018 <_GLOBAL_OFFSET_TABLE_+0x18>
400566: 68 00 00 00 00 pushq $0x0
40056b: e9 e0 ff ff ff jmpq 400550 <_init+0x28>
可以看到這裡有一個跳轉到0x601018中的位址的指令,這個位址就是GOT條目,存儲着共享庫中函數add的實際位址。
`動态連結器使用預設的延遲連結方式時,不會在函數第一次調用時就對位址進行解析。延遲連結意味着動态連結器不會在程式加載時解析每一個函數,而是在調用時通過.plt和.got.plt節來對函數進行解析。可以通過修改LD_BIND_NOW環境變量将連結方式修改為嚴格加載,以便在程式加載的同時進行動态連結。但是延遲連結能夠提高性能。
這裡先看一下add函數的重定位條目:
$ readelf -r a.out
......
000000601018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 add + 0
......
可以看到,重定位的偏移位址為0x601018,跟add函數PLT的跳轉位址相同。動态連結器需要對add的位址進行解析,并把值存入add的GOT條目中,看一下測試程式的GOT==(這個0x18應該對應的就是0x601018,也就是說這個GOT應該為0x601000)==:
_GLOBAL_OFFSET_TABLE_+0x18>
400566: 68 00 00 00 00 pushq $0x0
40056b: e9 e0 ff ff ff jmpq 400550 <_init+0x28>
這個0x0實際上是第4個GOT條目,即GOT[3],共享庫中的位址并不是從GOT[0]開始的,而是從GOT[3]開始的,前三個條目有其他的作用:
- GOT[0] 存放了指向可執行檔案動态段的位址,動态連結器利用該位址提取動态連結相關的資訊。
- GOT[1] 存放link_map結構的位址,動态連結器利用該位址來對符号進行解析。
- GOT[2] 存放了指向動态連結器_dl_runtime_resolve()函數的位址,該函數用來解析共享函數的實際符号位址。
這裡如果把_GLOBAL_OFFSET_TABLE+0x18當做這個0x0(GOT[3])會好了解很多
它的最後一條指令是jmpq 0x400550,那麼我們來看一下這個位址的指令:
0000000000400550 <[email protected]>:
400550: ff 35 b2 0a 20 00 pushq 0x200ab2(%rip) # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
400556: ff 25 b4 0a 20 00 jmpq *0x200ab4(%rip) # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
40055c: 0f 1f 40 00 nopl 0x0(%rax)
由于64位系統一個機關為8個位元組,則0x10/8=2,第一條指令将GOT[1]的位址壓入棧中,jmpq 0x601010則跳轉到第3個GOT條目,即GOT[2],在GOT[2]中存放了動态連結器函數的位址,對函數add進行解析後,後續所有對PLT條目add的調用都會跳轉到add的代碼本身,而不是重新指向PLT。
這個GOT[1]的位址相當于_GLOBAL_OFFSET_TABLE+0x08,也就是GOT[3-2+1]=GOT[1],GOT[2]則為_GLOBAL_OFFSET_TABLE+0x10,也就是GOT[3-1+1]=GOT[2],其為程式解釋器的位址。這裡壓入的棧我覺得可以聯系到前面提到的輔助向量。
4.動态段
動态連結器需要在程式運作時引用段,動态段需要相關的程式頭。
動态段儲存了一個如下結構體組成的數組:
這裡有必要對下列的結構體成員類型進行一下解釋,以下資訊來自ELF手冊:
ElfN_Addr Unsigned program address, uintN_t
ElfN_Off Unsigned file offset, uintN_t
ElfN_Section Unsigned section index, uint16_t
ElfN_Versym Unsigned version symbol information, uint16_t
Elf_Byte unsigned char
ElfN_Half uint16_t
ElfN_Sword int32_t
ElfN_Word uint32_t
ElfN_Sxword int64_t
ElfN_Xword uint64_t
數組如下:
typedef struct {
Elf64_Sxword d_tag;
union {
Elf64_Xword d_val;
Elf64_Addr d_ptr;
} d_un;
} Elf64_Dyn;
d_tag字段儲存了類型的定義參數
- DT_NEEDED 儲存了所需的共享庫名的字元串偏移表
- DT_SYMTAB 動态表的位址,對應的節名.dynsym
- DT_HASH 符号散清單的位址,對應的節名.gnu,hash
也就是說,可以通過對這一段的解讀,找到.dynsym等節,這樣不用節頭隻用程式頭也可以找出這些節。于是在缺少節頭表的情況下也可以通過這一段重建部分節頭表。
d_val成員儲存了一個整型值,可以存放各種不同的資料,如條目大小。
p_ptr儲存了一個記憶體虛址,可以指向連結器需要的各種類型的位址,如d_tag DT_SYMTAB符号表的位址。
p_val與p_ptr位于一個聯合體,也就是說這個結構體的第二個成員有可能是一個位址也可能是一個數值。
動态連結器利用d_tag來定位動态段的不同部分,每一部分都通過d_tag儲存了指向某部分可執行檔案的引用,對應的d_prt給出了指向該符号表的虛址。
動态連結器映射到記憶體中時,首先會處理自身的重定位,因為連結器本身就是一個共享庫。接着會檢視可執行程式的動态段并查找DT_NEEDED參數,該參數儲存了指向所需要的共享庫的字元串或者路徑名。當一個共享庫被映射到記憶體之後,連結器會擷取到共享庫的動态段,并将共享庫的符号表添加到符号鍊中,符号鍊存儲了所有映射到記憶體中的共享庫的符号表。
連結器為每個共享庫生成一個link_map結構的條目,并将其存到一個連結清單中:
struct link_map{
ElfW(Addr) l_addr; //共享對象的基址
char *l_name; //對象的絕對檔案名
ElfW(Dyn) *l_ld; //共享對象的動态節
struct link_map *l_next,*l_prev;
}
這個link_map應該就是GOT[1]中存儲的内容
連結器建構完依賴清單之後,會挨個處理每個庫的重定位,同時會補充每個共享庫的GOT。延遲連結對共享庫的PLT/GOT仍然适用,是以,隻有當一個函數真正被調用時,才會進行GOT重定位。
為了了解這一段,我進行了一些小實驗。
4.1.可執行檔案
這裡假設連結器已經完成了自身的重定位,首先我們關注可執行檔案:
我們可以用readelf -d指令檢視動态段的項:
$readelf -d a.out
Dynamic section at offset 0xe18 contains 25 entries:
标記 類型 名稱/值
0x0000000000000001 (NEEDED) 共享庫:[libadd.so]
0x0000000000000001 (NEEDED) 共享庫:[libc.so.6]
...
0x0000000000000003 (PLTGOT) 0x601000
...
0x0000000000000000 (NULL) 0x0
這裡我們看到了PLTGOT偏移表的位址為0x601000,也與我們之前的猜想相同,我們同樣可以看到第一條就寫出了共享庫libadd.so,那麼這個值是如何得來的呢?
我們先檢視一下a.out程式頭的情況:
DYNAMIC 0x0000000000000e18 0x0000000000600e18 0x0000000000600e18
0x00000000000001e0 0x00000000000001e0 RW 8
首先我們可計算出Elf64_Dyn結構體的大小為8*2=16位元組,DYNAMIC中共有0x1e0個位元組,也就是0x1e0/16=30項,這與之前的25項似乎有點出入,不過可以看之前列印出來的最後一條是NULL,說明之後的項目不再有意義,也就是說一共隻有24項有實際含義,第25項表示結束,往後的都再無意義了。
接下來我們需要搞清楚這個libadd.so是如何得到的,為了搞清楚這個,我們來檢視一波從0xe18開始的前兩條的情況:
01 00 00 00 00 00 00 00 ................
00000E20 01 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 ................
00000E30 74 00 00 00 00 00 00 00
那麼第一個結構體的d_tag值為1,d_val的值為1,第二個結構體d_tag值也為1,d_val的值為0x74,接下來我們檢視一下源碼中的.dynstr節所在的位址:
$ readelf -S a.out
[ 6] .dynstr STRTAB 00000000004003f0 000003f0
00000000000000b4 0000000000000000 A 0 0 1
知道了是0x3f0這個位址之後,使用hexedit a.out去找這個位址對應的内容:
000003F0 00 6C 69 62 61 64 64 2E 73 6F 00 5F 49 54 4D 5F .libadd.so._ITM_
...
00000460 69 6E 69 00 6C 69 62 63 2E 73 6F 2E 36 00 5F 5F ini.libc.so.6.__
...
到這裡我們就能夠明白libadd.so和libc.so.6的來曆了,因為這個d_val代表的是偏移量,第一個結構體的偏移量是1,也就指向了這裡的libadd.so,第二個結構體的偏移量是0x74,0x3F0+0x74=0x464,也就指向了這個libc.so.6。
那麼現在連結器成功讀取了共享庫的名稱,并成功将其映射到記憶體中了,下一步就是擷取共享庫的動态段了。
4.2.共享庫檔案
首先我們觀察共享庫的動态段:
$readelf -l libadd.so
Dynamic section at offset 0xe48 contains 21 entries:
标記 類型 名稱/值
0x0000000000000005 (STRTAB) 0x368
0x0000000000000006 (SYMTAB) 0x230
可以知道符号表的起始位址為0x230,(通過對共享庫的節頭表各項位址的檢視,可以知道這裡的符号表指的是動态符号表)
接下來我們用readelf直接看一下符号表中的各項内容:
$ readelf -s libadd.so
Symbol table '.dynsym' contains 13 entries:
......
8: 0000000000201028 0 NOTYPE GLOBAL DEFAULT 22 _end
9: 0000000000000650 20 FUNC GLOBAL DEFAULT 11 add
10: 0000000000201020 0 NOTYPE GLOBAL DEFAULT 22 __bss_start
......
這裡第8項就是我們一直想要找的add函數的實際位址,這裡表達的意思是在偏移量650的位置,于是我們再用objdump檢視一下650是不是add函數的位置:
$ objdump -d libadd.so
.......
0000000000000650 <add>:
650: 55 push %rbp
651: 48 89 e5 mov %rsp,%rbp
.......
可以看出,這裡的确是真正的add函數的位置,這也是最終調用函數的位址,到這裡,可執行檔案能夠獲得真正的函數位址,也可以進一步調用這個函數了。
是以總結一下整個過程,就是可執行檔案加載時,首先把GOT之類的東西壓入輔助向量,然後将控制權交給動态連結器,它把真實位址從共享庫中找出來,然後替換GOT中的值,這樣可執行檔案調用共享庫函數的時候,就可以通路真實的函數入口了。