在介紹PLT和GOT出場之前,先以一個簡單的例子引入兩個主角,各位請看以下代碼:
#include <stdio.h>
void print_banner()
{
printf("Welcome to World of PLT and GOT\n");
}
int main(void)
{
print_banner();
return 0;
}
編譯:
gcc -Wall -g -o test.o -c test.c -m32
鏈接:
gcc -o test test.o -m32
注意:現代Linux系統都是x86_64系統了,後面需要對中間文件test.o以及可執行文件test反編譯,分析彙編指令,因此在這裏使用-m32選項生成i386架構指令而非x86_64架構指令。
經編譯和鏈接階段之後,test可執行文件中print_banner函數的彙編指令會是怎樣的呢?我猜應該與下面的彙編類似:
080483cc <print_banner>:
80483cc: push %ebp
80483cd: mov %esp, %ebp
80483cf: sub $0x8, %esp
80483d2: sub $0xc, %esp
80483d5: push $0x80484a8
80483da: call **<printf函數的地址>**
80483df: add $0x10, %esp
80483e2: nop
80483e3: leave
80483e4: ret
print_banner函數內調用了printf函數,而printf函數位於glibc動態庫內,所以在編譯和鏈接階段,鏈接器無法知知道進程運行起來之後printf函數的加載地址。故上述的**<printf函數地址>** 一項是無法填充的,只有進程運運行後,printf函數的地址才能確定。
那麼問題來了:進程運行起來之後,glibc動態庫也裝載了,printf函數地址亦已確定,上述call指令如何修改(重定位)呢?
一個簡單的方法就是將指令中的**<printf函數地址>**修改printf函數的真正地址即可。
但這個方案面臨兩個問題:
現代操作系統不允許修改代碼段,只能修改數據段
如果print_banner函數是在一個動態庫(.so對象)內,修改了代碼段,那麼它就無法做到系統內所有進程共享同一個動態庫。
因此,printf函數地址只能回寫到數據段內,而不能回寫到代碼段上。
注意:剛纔談到的回寫,是指運行時修改,更專業的稱謂應該是運行時重定位,與之相對應的還有鏈接時重定位。
說到這裏,需要把編譯鏈接過程再展開一下。我們知道,每個編譯單元(通常是一個.c文件,比如前面例子中的test.c)都會經歷編譯和鏈接兩個階段。
編譯階段是將.c源代碼翻譯成彙編指令的中間文件,比如上述的test.c文件,經過編譯之後,生成test.o中間文件。print_banner函數的彙編指令如下(使用強調內容objdump -d test.o命令即可輸出):
00000000 <print_banner>:
0: 55 push %ebp
1: 89 e5 mov %esp, %ebp
3: 83 ec 08 sub $0x8, %esp
6: c7 04 24 00 00 00 00 movl $0x0, (%esp)
d: e8 fc ff ff ff call e <print_banner+0xe>
12: c9 leave
13: c3 ret
是否注意到call指令的操作數是fc ff ff ff,翻譯成16進制數是0xfffffffc(x86架構是小端的字節序),看成有符號是-4。這裏應該存放printf函數的地址,但由於編譯階段無法知道printf函數的地址,所以預先放一個-4在這裏,然後用重定位項來描述:這個地址在鏈接時要修正,它的修正值是根據printf地址(更確切的叫法應該是符號,鏈接器眼中只有符號,沒有所謂的函數和變量)來修正,它的修正方式按相對引用方式。
這個過程稱爲鏈接時重定位,與剛纔提到的運行時重定位工作原理完全一樣,只是修正時機不同。
鏈接階段是將一個或者多箇中間文件(.o文件)通過鏈接器將它們鏈接成一個可執行文件,鏈接階段主要完成以下事情:
各個中間文之間的同名section合併
對代碼段,數據段以及各符號進行地址分配
鏈接時重定位修正
除了重定位過程,其它動作是無法修改中間文件中函數體內指令的,而重定位過程也只能是修改指令中的操作數,換句話說,鏈接過程無法修改編譯過程生成的彙編指令。
那麼問題來了:編譯階段怎麼知道printf函數是在glibc運行庫的,而不是定義在其它.o中
答案往往令人失望:編譯器是無法知道的
那麼編譯器只能老老實實地生成調用printf的彙編指令,printf是在glibc動態庫定位,或者是在其它.o定義這兩種情況下,它都能工作。如果是在其它.o中定義了printf函數,那在鏈接階段,printf地址已經確定,可以直接重定位。如果printf定義在動態庫內(鏈接階段是可以知道printf在哪定義的,只是如果定義在動態庫內不知道它的地址而已),鏈接階段無法做重定位。
根據前面討論,運行時重定位是無法修改代碼段的,只能將printf重定位到數據段。那在編譯階段就已生成好的call指令,怎麼感知這個已重定位好的數據段內容呢?
答案是:鏈接器生成一段額外的小代碼片段,通過這段代碼支獲取printf函數地址,並完成對它的調用。
鏈接器生成額外的僞代碼如下:
.text
...
// 調用printf的call指令
call printf_stub
...
printf_stub:
mov rax, [printf函數的儲存地址] // 獲取printf重定位之後的地址
jmp rax // 跳過去執行printf函數
.data
...
printf函數的儲存地址:
這裏儲存printf函數重定位後的地址
鏈接階段發現printf定義在動態庫時,鏈接器生成一段小代碼print_stub,然後printf_stub地址取代原來的printf。因此轉化爲鏈接階段對printf_stub做鏈接重定位,而運行時纔對printf做運行時重定位。
動態鏈接姐妹花PLT與GOT
前面由一個簡單的例子說明動態鏈接需要考慮的各種因素,但實際總結起來說兩點:
需要存放外部函數的數據段
獲取數據段存放函數地址的一小段額外代碼
如果可執行文件中調用多個動態庫函數,那每個函數都需要這兩樣東西,這樣每樣東西就形成一個表,每個函數使用中的一項。
總不能每次都叫這個表那個表,於是得正名。存放函數地址的數據表,稱爲重局偏移表(GOT, Global Offset Table),而那個額外代碼段表,稱爲程序鏈接表(PLT,Procedure Link Table)。它們兩姐妹各司其職,聯合出手上演這一出運行時重定位好戲。
那麼PLT和GOT長得什麼樣子呢?前面已有一些說明,下面以一個例子和簡單的示意圖來說明PLT/GOT是如何運行的。
假設最開始的示例代碼test.c增加一個write_file函數,在該函數裏面調用glibc的write實現寫文件操作。根據前面討論的PLT和GOT原理,test在運行過程中,調用方(如print_banner和write_file)是如何通過PLT和GOT穿針引線之後,最終調用到glibc的printf和write函數的?
我簡單畫了PLT和GOT雛形圖,供各位參考。
當然這個原理圖並不是Linux下的PLT/GOT真實過程,Linux下的PLT/GOT還有更多細節要考慮了。這個圖只是將這些躁聲全部消除,讓大家明確看到PLT/GOT是如何穿針引線的。