聊聊Linux動態鏈接中的PLT和GOT(1)——何謂PLT與GOT

在介紹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雛形圖,供各位參考。

PLT和GOT原理雛形

當然這個原理圖並不是Linux下的PLT/GOT真實過程,Linux下的PLT/GOT還有更多細節要考慮了。這個圖只是將這些躁聲全部消除,讓大家明確看到PLT/GOT是如何穿針引線的。
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章