動態連結的PLT與GOT

張雅宸發表於2020-12-01

最近在研究緩衝區溢位攻擊的試驗,發現其中有一種方法叫做ret2plt。plt?這個詞好熟悉,在彙編程式碼裡經常見到,和plt經常一起出現的還有一個叫got的東西,但是對這兩個概念一直很模糊,趁著這個機會研究一下。

可以先說一下結論 : plt和got是動態連結中用來重定位的。

GOT

我們知道,一般我們的程式碼都需要引用外部檔案的函式或者變數,比如#include<stdio.h>裡的printf,但是由於我們程式碼中用到的共享物件是執行時載入進來的,在虛擬地址空間的位置並不確定,所以程式碼裡call <addr of printf>addr of printf不確定,只有等執行時共享物件被載入到程式的虛擬地址空間裡時,才能最終確定printf的地址,再進行重定位地址

看一個最簡單的例子:

#include <stdio.h>

int main(){

    printf("Hello World");

    return 0;
}

用GDB除錯一下(關於GDB除錯彙編可以參考之前寫的GDB 單步除錯彙編 ):

(gdb) ni
0x000000000040054e in main ()
=> 0x000000000040054e <main+14>:	e8 71 fe ff ff	callq  0x4003c4 <printf@plt>

可以看出,call <addr of printf>callq 0x4003c4代替,而這個0x4003c4並不是真正的printf函式的地址。

可能有人已經想到了,為什麼不能直接在printf函式地址確定後,直接將call <addr of printf>修改為call <real addr of printf>,像靜態連結那樣呢(靜態連結是在連結階段進行重定位,直接修改的程式碼段)?有兩個原因:

  • 現代作業系統不允許修改程式碼段,只能修改資料段。
  • 如果上面的程式碼片段是在一個共享物件內,修改了程式碼段,那麼它就無法做到系統內所有程式共享同一個共享物件,因為程式碼段被修改了。而動態庫的主要一個優點就是多個程式共享同一個共享物件的程式碼段,節省記憶體空間,但是程式擁有資料段的獨立副本。

所以,我們很容易的想到,既然不能修改程式碼段,能修改資料段,我們可以在共享物件載入完成後,將真實的符號地址放到資料段中,程式碼中直接讀取資料段內的地址就行,這裡開闢的空間就叫做GOT(圖有點挫)。

image

  • 為每一個需要重定位的符號建立一個GOT表項。
  • 當動態連結器裝載共享物件時查詢每一個需要重定位符號的變數地址,填充GOT。
  • 當指令需要訪問變數或者函式的地址時,從對應的GOT表項中讀出地址,再訪問即可。對應的指令可能是callq *(addr in GOT)或者movq offset(%rip) %rax(%rax就是全域性變數的地址,可以用(%rax)解引用)。

但是這樣有一個問題,一個動態庫可能有成百上千個符號,但是我們引入該動態庫可能只會使用其中某幾個符號,像上面那種方式就會造成不使用的符號也會進行重定位,造成不必要的效率損失。我們知道,動態連結比靜態連結慢1% ~ 5%,其中一個原因就是動態連結需要在執行時查詢地址進行重定位。

所以ELF採用了延遲繫結的技術,當函式第一次被用到時才進行繫結。實現方式就是使用plt。

PLT

我們可以先自己獨立思考如何實現延遲繫結。

  • 上文描述的是動態連結器主動將確定好的符號地址放到GOT中,延遲繫結需要我們自己主動告訴一個模組:我現在需要該符號的確定地址。假設該模組叫做_dl_runtime_resolve()
  • 我們需要告訴_dl_runtime_resolve()需要尋找的符號,也就是函式引數。可以放到棧中或者暫存器傳遞。
  • _dl_runtime_resolve()尋找完符號的特定地址後,放到暫存器上,比如%rax,供呼叫者使用。

所以初步的實現步驟是:

callq plt_printf    <printf@plt>
......
......

plt_printf:
    pushq   %rbp            ## allocate stack frame     
    movq    %rsp,%rbp
    pushq iden_of_printf        ## 告訴_dl_runtime_resolve()找printf函式地址,即_dl_runtime_resolve()的引數>
    callq _dl_runtime_resolve()
    callq %rax     ## %rax存放printf真實地址
    leaveq    ## deallocate stack frame
    retq    

上面的步驟可以實現通過一段小程式碼(plt)實現延遲繫結,但是存在一個問題:每一次呼叫printf的時候都需要走一遍這個步驟,然而printf的地址一旦確定就不會變了,所以我們需要一個快取機制,將查詢好的printf地址快取起來。

PLT與GOT

上面說過_dl_runtime_resolve會將確定好的符合地址放到GOT中,那麼在需要延遲載入的情況下,GOT裡存放什麼地址?上面說過需要我們需要將確定好的符號地址快取起來,那麼ELF是如何通過PLT與GOT的配合做到延遲載入的?我們直接看一個真實的例子就行:

#include <stdio.h>

int main(){

    printf("Hello World");

    printf("Hello World Again");

    return 0;
}

gdb除錯一下:

One 呼叫printf的plt

第一次呼叫printf,會呼叫printf對應的plt程式碼片段,與上面我們自己分析實現延遲載入的步驟一樣:

(gdb) ni
0x000000000040054e in main ()
=> 0x000000000040054e <main+14>:	e8 71 fe ff ff	callq  0x4003c4 <printf@plt>

Two 調到printf對應的GOT裡儲存的地址

進到<printf@plt>看看:

(gdb) si
0x00000000004003c4 in printf@plt ()
=> 0x00000000004003c4 <printf@plt+0>:	ff 25 56 05 20 00	jmpq   *0x200556(%rip)        # 0x600920 <printf@got.plt>

這裡跳到了printf對應的GOT裡儲存的地址。(elf對got做了細分:got存放全域性變數引用的地址,got.plt存放函式引用的地址

看看動態連結在將確定的符號地址放到GOT前,GOT裡存放的是什麼地址:

(gdb) x 0x600920
0x600920 <printf@got.plt>:	0x004003ca
(gdb) disas  0x4003c4
Dump of assembler code for function printf@plt:
   0x00000000004003c4 <+0>:	jmpq   *0x200556(%rip)        # 0x600920 <printf@got.plt>
=> 0x00000000004003ca <+6>:	pushq  $0x0
   0x00000000004003cf <+11>:	jmpq   0x4003b4
End of assembler dump.

有意思的是jmp到了下一條指令的地址。其實這個時候我們已經可以猜出來了:延遲載入之前,got.plt裡存放的是下一條指令地址,延遲載入之後,got.plt裡存放的就是真實的符號地址,就可以直接jmp到printf函式裡了。

Three 將printf對應的標識壓到棧中,並跳到plt[0]

(gdb) ni
0x00000000004003ca in printf@plt ()
=> 0x00000000004003ca <printf@plt+6>:	68 00 00 00 00	pushq  $0x0
(gdb) ni
0x00000000004003cf in printf@plt ()
=> 0x00000000004003cf <printf@plt+11>:	e9 e0 ff ff ff	jmpq   0x4003b4
(gdb) si
0x00000000004003b4 in ?? ()      ## 這裡應該是plt[0],但是gdb不知道為什麼沒有顯示出來
=> 0x00000000004003b4:	ff 35 56 05 20 00	pushq  0x200556(%rip)        # 0x600910 <_GLOBAL_OFFSET_TABLE_+8>

Four 在plt[0]中呼叫_dl_runtime_resolve查詢符合真實地址

說明這個是什麼地址??0x600910

(gdb)
0x00000000004003b4 in ?? ()
=> 0x00000000004003b4:	ff 35 56 05 20 00	pushq  0x200556(%rip)        # 0x600910 <_GLOBAL_OFFSET_TABLE_+8>
(gdb)
0x00000000004003ba in ?? ()
=> 0x00000000004003ba:	ff 25 58 05 20 00	jmpq   *0x200558(%rip)        # 0x600918 <_GLOBAL_OFFSET_TABLE_+16>
(gdb)
_dl_runtime_resolve () at ../sysdeps/x86_64/dl-trampoline.S:34
34		subq $56,%rsp
=> 0x00007ffff7deef30 <_dl_runtime_resolve+0>:	48 83 ec 38	sub    $0x38,%rsp

我們不用管_dl_runtime_resolve是怎麼處理的,直接看_dl_runtime_resolve處理完成後printf對應的GOT的值:

(gdb)
56		jmp *%r11		# Jump to function address.
=> 0x00007ffff7deef8e <_dl_runtime_resolve+94>:	41 ff e3	jmpq   *%r11
   0x00007ffff7deef91:	66 66 66 66 66 66 2e 0f 1f 84 00 00 00 00 00	data32 data32 data32 data32 data32 nopw %cs:0x0(%rax,%rax,1)
(gdb)
0x00007ffff7a7b5d0 in printf () from /lib64/libc.so.6
=> 0x00007ffff7a7b5d0 <printf+0>:	48 81 ec d8 00 00 00	sub    $0xd8,%rsp
(gdb)
......
......
(gdb) x 0x600920
0x600920 <printf@got.plt>:	0xf7a7b5d0

與之前猜測的一樣,printf對應的GOT表專案前已經存放了printf真實的虛擬地址。那麼在下次呼叫時就避免再重定位,直接跳到printf地址了。

Five 第二次呼叫printf

(gdb) si
0x00000000004003c4 in printf@plt ()
=> 0x00000000004003c4 <printf@plt+0>:	ff 25 56 05 20 00	jmpq   *0x200556(%rip)        # 0x600920 <printf@got.plt>
(gdb) x 0x600920
0x600920 <printf@got.plt>:	0xf7a7b5d0
(gdb) si
0x00007ffff7a7b5d0 in printf () from /lib64/libc.so.6
=> 0x00007ffff7a7b5d0 <printf+0>:	48 81 ec d8 00 00 00	sub    $0xd8,%rsp

直接跳到printf的虛擬地址。

下面這張圖可以總結上面的五步過程:

image

(完)

朋友們可以關注下我的公眾號,獲得最及時的更新:

相關文章