Linux逆向---ELF動態連結
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中的值,這樣可執行檔案呼叫共享庫函式的時候,就可以訪問真實的函式入口了。