動態鏈接的PLT與GOT

  • 2020 年 12 月 1 日
  • 筆記

最近在研究緩衝區溢出攻擊的試驗,發現其中有一種方法叫做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 <[email protected]>

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

看看動態鏈接在將確定的符號地址放到GOT前,GOT里存放的是什麼地址:

(gdb) x 0x600920
0x600920 <[email protected]>:	0x004003ca
(gdb) disas  0x4003c4
Dump of assembler code for function printf@plt:
   0x00000000004003c4 <+0>:	jmpq   *0x200556(%rip)        # 0x600920 <[email protected]>
=> 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 <[email protected]>:	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 <[email protected]>
(gdb) x 0x600920
0x600920 <[email protected]>:	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

(完)

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