深入淺出 PLT/GOT Hook與原理實踐

zxzhang發表於2023-12-01

動態連結

計算機程式連結時分兩種形式:靜態連結和動態連結。
靜態連結在連結時將所有目標檔案中的程式碼、資料等Section都組裝到可執行檔案當中,並將程式碼中使用到的外部符號(函式、變數)都進行了重定位。因此在執行時不需要依賴其他外部模組即可執行,並且可以獲得更快的啟動時間和執行速度。然而靜態連結的方式缺點也很明顯:

  • 模組更新困難。如果依賴的外部函式有著嚴重的bug,那麼不得不與修復的外部模組重新連結生成新的可執行檔案。
  • 對磁碟和記憶體的浪費非常嚴重。每一個可執行檔案如果都包含C語言靜態庫,那麼對於 /usr/bin 目錄下上千個可執行檔案,最後造成的浪費是不可想象的。
    動態連結將程式模組相互分割開來,而不再將它們靜態的連結在一起,等到程式要執行時才連結。儘管相比於靜態連結由於需要在執行時進行連結帶來了一定的效能損耗,但其能夠有效的節省記憶體以及動態更新,因此有著廣大的應用。

延遲繫結(PLT/GOT 表)

在動態連結下,程式模組之間包含大量的符號引用,所以在程式開始執行前,動態連結會耗費不少時間用於解決模組間的符號引用的查詢和重定位。在一個程式中,並非所有的邏輯都會走到,可能直到程式執行完成,很多符號(全域性變數或函式)都並未被執行(比如一些錯誤分支)。因此如果在程式執行前將所有的外部符號都進行連結,無疑是一種效能浪費,同時也極大地拖累了啟動速度。所以 ELF 採用了一種延遲繫結(Lazy Binding)的做法,思想也比較簡單,當外部符號第一次使用時進行繫結(符號查詢、重定位等),第二次使用時直接使用第一次符號繫結的結果。

站在編譯器的角度來看,由於編譯器在連結階段無法得知外部符號的地址,因此其在編譯時發現有對外部符號的引用,將生成一小段程式碼,用於外部符號的重定位。
ELF 使用 PLT(Procedure Linkage Table 過程連結表) 和 GOT(Global Offset Table 全域性偏移表) 來實現延遲繫結:

  • PLT表:編譯器生成的用於獲取資料段中外部符號地址的一小段程式碼組成的表格。它使得程式碼可以方便地訪問共享的符號。對於每一個引用的共享符號,PLT 表都會有一個對應的條目。這些條目用於管理和重定位動態連結的符號。
  • GOT表:存放外部符號地址的資料段。

為什麼需要 PLT/GOT 表

在不熟悉現代作業系統對於記憶體的訪問控制許可權的情況下,我們可能會有疑惑:

  • 只透過 PLT 表無法實現延遲繫結嗎?
  • PLT 表重定位拿到外部符號的地址後,再次訪問時跳轉到對應的地址不行嗎?
    這裡主要有兩個原因。

程式碼段訪問許可權的限制

一般來說,程式碼段:可讀、可執行;資料段:可讀、可寫。PLT 表項進行重定位後,要使得下次訪問外部符號時直接跳轉到重定位後的地址,需要對程式碼段進行修改。然而程式碼段是沒有寫許可權的。既然程式碼段沒有寫許可權而資料段是可寫的,那麼在程式碼段中引用的外部符號,可以在資料段中增加一個跳板:讓程式碼段先引用資料段中的內容,然後在重定位時,把外部符號重定位的地址填寫到資料段中對應的位置。這個過程正好對應 PLT/GOT 表的用途。
以下為一個基本示意圖,實際的 PLT/GOT 流程更為複雜。

+------------------+     +-------------------+     +-----------------+     +---------------------+
|                  |     |                   |     |                 |     |                     |
|  printf_func     |  +--+-> printf@plt      |     |  printf@got     | +---+--> f73835f0<printf> |
|                  |  |  |                   |     |                 | |   |                     |
|  call printf@plt +--+  |   jmp *printf@got-+-----+-> 0xf7e835f0----+-+   |                     |
|                  |     |                   |     |                 |     |                     |
+------------------+     +-------------------+     +-----------------+     +---------------------+
   可執行檔案                    PLT 表                    GOT 表                  glibc中的printf

共享記憶體的考慮

即使可以對程式碼段進行修改,由於 PLT 程式碼片段是在一個共享物件內,因為程式碼段被修改了,就無法實現所有程式共享同一個共享物件。而動態庫的主要優點之一是多個程式共享同一個共享物件的程式碼段,擁有資料段的獨立副本,從而節省記憶體空間。為了解決這個問題,PLT/GOT 表的使用變得必要。透過在資料段中增加一個全域性偏移表(GOT),在程式執行時進行動態重定位,從而實現多個程式共享同一個共享物件的程式碼段,而資料段仍然保持獨立副本。

PLT/GOT 表工作原理

概述

當程式要呼叫一個外部函式時,它會首先跳轉到 PLT 表中的對應條目。當 PLT 表中的條目被呼叫時,它會首先檢查 GOT 表中是否已經存在該函式的地址。如果存在,PLT 將直接跳轉到該地址;否則 PLT 將呼叫動態連結器 (dynamic linker)尋找該函式的地址,並將該地址填充到 GOT 表中。
當函式的地址被填充到 GOT 表中後,下一次呼叫該函式時,PLT 將直接跳轉到該地址,而不需要再次呼叫動態連結器。這個過程中,GOT 表充當了一個快取,可以避免重複呼叫動態連結器,從而提高程式的執行效率。
為了更好地理解 PLT/GOT 的工作原理,下面是一個示意圖:

  第一次對外部符號進行呼叫            第二次對同一外部符號進行呼叫
┌──────────────────────┐          ┌───────────────────┐
│     External Func    │          │ External Func Addr│
└──────────────────────┘          └───────────────────┘
             │                              │
             ▼                              ▼
┌──────────────────────┐          ┌───────────────────┐
│       PLT Stub       │          │      PLT Stub     │
└──────────────────────┘          └───────────────────┘
             │                              │
             ▼                              ▼
┌──────────────────────┐          ┌───────────────────┐
│    GOT Entry Addr    │          │   GOT Entry Addr  │
└──────────────────────┘          └───────────────────┘
             │                              │
             ▼                              ▼
┌──────────────────────┐          ┌───────────────────┐
│  Dynamic Linker Call │          │ External Func Addr│
└──────────────────────┘          └───────────────────┘
             │                              │
             ▼                              ▼
┌──────────────────────┐          ┌───────────────────┐
│   Update GOT Entry   │          │      Call Func    │
└──────────────────────┘          └───────────────────┘
             │           
             ▼                              
┌──────────────────────┐ 
│  External Func Addr  │    
└──────────────────────┘          
             │                              
             ▼                              
┌──────────────────────┐ 
│       Call Func      │
└──────────────────────┘         

實際上 ELF 將 GOT 拆分成了兩個表: .got 和 .got.plt 。其中:

  • .got 用來儲存全域性外部變數的引用地址
  • .got.plt 用來儲存外部函式引用的地址。
    我們這裡闡述的預設都是指的外部函式呼叫的流程。

工作流程

由延遲繫結的基本思想可以知道,第二次使用共享符號時不會再次進行重定位。那麼 GOT 表是如何判斷是否是第一次訪問的共享符號的呢?
一種常規的思想就是共享符號對應的 GOT 表項設定一個特殊的初始值,由於重定位後會更新共享符號的地址,因此判斷 GOT 表項中是否是這個初始值,是的話即為第一次訪問。那 ELF 檔案中實際是如何處理的呢?
對於一個 PLT 表項,其包含三條指令,格式如下:

addr xxx@plt
    jmp    *(xxx@got)
    push   offset
    jmp    *(_dl_runtime_resolve)

指令一

指令一跳轉到一個地址,這個地址的值從對應的 GOT 表項中讀取。這條 GOT 表項初始儲存的是 PLT 表項第二條指令的地址。因此實際相當於直接順序執行第二條指令。當重定位後 GOT 表項中儲存的地址會被更新為外部符號的實際地址。因此後續訪問這個外部符號時,指令一將直接跳轉到對應的外部符號地址。透過這種巧妙的方式在延遲初始化的時候避免了每次都進行重定位。

+-------------------------------+    +-----------+       +--------------+
|                         1     |    |           |       |              |
| addr puts@plt   +-------------+----> puts@got  |       |  <printf>    |
|                 |             |    |           |       |              |
|    jpm *(puts@got)            |    |           |       |              |
|                        2      |    |           |   5   |              |
|    push offset<------------------+----+        +------>|              |
|        |3                     |    |           |       |              |
|        v                      |    +--+--------+       +--------------+
|    jmp *(__dl_runtime_resolve)|       |
|                            |  |  4    |
|                            +--+-------+
|                               | update addr
|                               |
+-------------------------------+

指令二

指令二會壓入一個運算元,這個運算元實際是外部符號的識別符號id,動態連結器透過它來區分要解析哪個外部符號以及解析完後需要更新哪個 GOT 表項的資料。這個運算元通常是這個函式在 .rel.plt 的下標或地址,透過 readelf -r elf_file 可以檢視 .rel.plt 資訊。

指令三

指令三會跳轉到一個地址,這個地址是動態連結做符號解析和重定位的公共入口。因為所有的外部函式都需要經歷這一步驟,因此被提煉為公共函式,而非每個 PLT 表項都有一份重複指令。實際上這個公共入口指向 _dl_runtime_resolve,其完成符號解析和重定向工作後,將外部函式的真實地址填到對應的 GOT 表項中。

案例分析

這裡以 32 位 ELF 可執行檔案進行分析。

#include <stdio.h>

int main(){
    printf("Hello World\n");
    printf("Hello World Again\n");

    return 0;
}
# 編譯
gcc -Wall -g -o test.o -c test.c -m32

# 連結,新增 -z lazy,這樣在 gdb 除錯時才可以看到延遲繫結的過程
gcc -o test test.o -m32 -z lazy

檢視 test 可執行檔案的彙編程式碼 objdump -d test,其輸出的部分結果如下
PLT 表的彙編如下:

Disassembly of section .plt:

00001030 <__libc_start_main@plt-0x10>:
    1030:	ff b3 04 00 00 00    	push   0x4(%ebx)
    1036:	ff a3 08 00 00 00    	jmp    *0x8(%ebx)
    103c:	00 00                	add    %al,(%eax)
	...

00001040 <__libc_start_main@plt>:
    1040:	ff a3 0c 00 00 00    	jmp    *0xc(%ebx)
    1046:	68 00 00 00 00       	push   $0x0
    104b:	e9 e0 ff ff ff       	jmp    1030 <_init+0x30>

00001050 <puts@plt>:
    1050:	ff a3 10 00 00 00    	jmp    *0x10(%ebx)
    1056:	68 08 00 00 00       	push   $0x8
    105b:	e9 d0 ff ff ff       	jmp    1030 <_init+0x30>

Disassembly of section .plt.got:

00001060 <__cxa_finalize@plt>:
    1060:	ff a3 18 00 00 00    	jmp    *0x18(%ebx)
    1066:	66 90                	xchg   %ax,%ax

程式碼段 main 部分的彙編如下:

0000119d <main>:
    119d:	8d 4c 24 04          	lea    0x4(%esp),%ecx
    11a1:	83 e4 f0             	and    $0xfffffff0,%esp
    11a4:	ff 71 fc             	push   -0x4(%ecx)
    11a7:	55                   	push   %ebp
    11a8:	89 e5                	mov    %esp,%ebp
    11aa:	53                   	push   %ebx
    11ab:	51                   	push   %ecx
    11ac:	e8 ef fe ff ff       	call   10a0 <__x86.get_pc_thunk.bx>
    11b1:	81 c3 4f 2e 00 00    	add    $0x2e4f,%ebx
    11b7:	83 ec 0c             	sub    $0xc,%esp
    11ba:	8d 83 08 e0 ff ff    	lea    -0x1ff8(%ebx),%eax
    11c0:	50                   	push   %eax
    11c1:	e8 8a fe ff ff       	call   1050 <puts@plt>
    11c6:	83 c4 10             	add    $0x10,%esp
    11c9:	83 ec 0c             	sub    $0xc,%esp
    11cc:	8d 83 14 e0 ff ff    	lea    -0x1fec(%ebx),%eax
    11d2:	50                   	push   %eax
    11d3:	e8 78 fe ff ff       	call   1050 <puts@plt>
    11d8:	83 c4 10             	add    $0x10,%esp
    11db:	b8 00 00 00 00       	mov    $0x0,%eax
    11e0:	8d 65 f8             	lea    -0x8(%ebp),%esp
    11e3:	59                   	pop    %ecx
    11e4:	5b                   	pop    %ebx
    11e5:	5d                   	pop    %ebp
    11e6:	8d 61 fc             	lea    -0x4(%ecx),%esp
    11e9:	c3                   	ret    

檢視 test 可執行檔案的重定位資訊 readelf -r test,其輸入部分如下:

Relocation section '.rel.dyn' at offset 0x384 contains 8 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00003ef4  00000008 R_386_RELATIVE   
00003ef8  00000008 R_386_RELATIVE   
00003ff8  00000008 R_386_RELATIVE   
00004018  00000008 R_386_RELATIVE   
00003fec  00000206 R_386_GLOB_DAT    00000000   _ITM_deregisterTM[...]
00003ff0  00000306 R_386_GLOB_DAT    00000000   __cxa_finalize@GLIBC_2.1.3
00003ff4  00000506 R_386_GLOB_DAT    00000000   __gmon_start__
00003ffc  00000606 R_386_GLOB_DAT    00000000   _ITM_registerTMCl[...]

Relocation section '.rel.plt' at offset 0x3c4 contains 2 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
0000400c  00000107 R_386_JUMP_SLOT   00000000   __libc_start_main@GLIBC_2.34
00004010  00000407 R_386_JUMP_SLOT   00000000   puts@GLIBC_2.0

透過 gdb 除錯,在執行 printf("Hello World\n") 時可以看到其呼叫了 call 0x56556050 <puts@plt>

─── Output/messages ─────────────────────────────────────────────────────────────────────────────────────────────
0x565561c1	4	    printf("Hello World\n");
─── Assembly ────────────────────────────────────────────────────────────────────────────────────────────────────
 0x565561ac  main+15 call   0x565560a0 <__x86.get_pc_thunk.bx>
 0x565561b1  main+20 add    $0x2e4f,%ebx
!0x565561b7  main+26 sub    $0xc,%esp
 0x565561ba  main+29 lea    -0x1ff8(%ebx),%eax
 0x565561c0  main+35 push   %eax
 0x565561c1  main+36 call   0x56556050 <puts@plt>
 0x565561c6  main+41 add    $0x10,%esp
 0x565561c9  main+44 sub    $0xc,%esp
 0x565561cc  main+47 lea    -0x1fec(%ebx),%eax
 0x565561d2  main+53 push   %eax
─── Breakpoints ─────────────────────────────────────────────────────────────────────────────────────────────────
[1] break at 0x565561b7 in test.c:4 for main hit 1 time
─── Expressions ──────────────────────────────────────────────────────────────────────────────────────────────────
─── History ──────────────────────────────────────────────────────────────────────────────────────────────────────
─── Memory ───────────────────────────────────────────────────────────────────────────────────────────────────────
─── Registers ────────────────────────────────────────────────────────────────────────────────────────────────────
eax 0x56557008          ecx 0xffffcf00             edx 0xffffcf20               ebx 0x56559000          
esp 0xffffced0          ebp 0xffffcee8         esi 0xffffcfb4
edi 0xf7ffcb80          eip 0x565561c1          eflags [ PF AF SF IF ]           cs 0x00000023           
ss 0x0000002b           ds 0x0000002b          es 0x0000002b                    
fs 0x00000000           gs 0x00000063
─── Source ───────────────────────────────────────────────────────────────────────────────────────────────────────
~
~
 1  #include <stdio.h>
 2  
 3  int main(){
!4      printf("Hello World\n");
 5      printf("Hello World Again\n");
 6  
 7      return 0;
 8  }
─── Stack ──────────────────────────────────────────────────────────────────────────────────────────────────────────
[0] from 0x565561c1 in main+36 at test.c:4
─── Threads ────────────────────────────────────────────────────────────────────────────────────────────────────────
[1] id 5467 name test from 0x565561c1 in main+36 at test.c:4

disassemble 0x56556050 檢視一下 puts@plt 中的內容,其包含三條指令。

>>> disassemble 0x56556050
Dump of assembler code for function puts@plt:
   0x56556050 <+0>:	jmp    *0x10(%ebx)
   0x56556056 <+6>:	push   $0x8
   0x5655605b <+11>:jmp    0x56556030
End of assembler dump.

指令一執行了 jmp *0x10(%ebx),其表示跳轉到一個地址,地址值為儲存在 ebx 暫存器中的值加上 0x10。
透過 info registers 檢視暫存器的值為 0x56559000 加上 0x10 後最終地址為 0x56559010。透過 x 0x56559010 檢視這個地址的內容為 0x56556056,這個地址也即 puts@plt 中第二條指令的位置。

>>> info registers
eax            0x56557008          1448439816
ecx            0xffffcf00          -12544
edx            0xffffcf20          -12512
ebx            0x56559000          1448448000
esp            0xffffced0          0xffffced0
ebp            0xffffcee8          0xffffcee8
esi            0xffffcfb4          -12364
edi            0xf7ffcb80          -134231168
eip            0x565561c1          0x565561c1 <main+36>
eflags         0x296               [ PF AF SF IF ]
cs             0x23                35
ss             0x2b                43
ds             0x2b                43
es             0x2b                43
fs             0x0                 0
gs             0x63                99
>>> x 0x56559010
0x56559010 <puts@got.plt>:	0x56556056

指令二壓入 printf 的識別符號。

指令三跳轉到一個地址為 0x56556030,這個地址為動態連結器做符號解析和重定位的入口。其與 puts@plt 地址 0x56556050 相差 0x20 ,而這個數值正好等於彙編程式碼中 00001030 <__libc_start_main@plt-0x10>:00001050 <puts@plt>: 的差值。
對於 _dl_runtime_resolve 的執行過程我們不去探究,其在符號解析和重定位結束後會根據指令二壓入的運算元識別符號更新 GOT 表項的地址。

>>> x /5i 0x56556030
=> 0x56556030:	push   0x4(%ebx)
   0x56556036:	jmp    *0x8(%ebx)
   0x5655603c:	add    %al,(%eax)
   0x5655603e:	add    %al,(%eax)
   0x56556040 <__libc_start_main@plt>:	jmp    *0xc(%ebx)

基於 PLT/GOT 機制進行 hook

測試程式

建立一個共享庫 libtest.so,由 test.htest.c 組成。

# 編譯生成 libtest.so
gcc test.h test.c -fPIC -shared -o libtest.so
// test.h
#ifndef TEST_H
#define TEST_H 1

#ifdef __cplusplus
extern "C" {
#endif

void say_hello();

#ifdef __cplusplus
}
#endif

#endif
// test.c
#include <stdlib.h>
#include <stdio.h>

void say_hello()
{
    char *buf = malloc(1024);
    if(NULL != buf)
    {
        snprintf(buf, 1024, "%s", "hello\n");
        printf("%s", buf);
    }
}

建立一個測試程式 main,其呼叫了 libtest.so 中的函式。

# 編譯生成執行檔案
gcc main.c -L. -ltest -o main

# 新增 libtest.so 路徑,使so可被動態連結
export LD_LIBRARY_PATH=/path/to/libtest.so

# 檢視是否動態連結成功
ldd main
# linux-vdso.so.1 (0x00007fff596fc000)
# libtest.so => ./libtest.so (0x00007f1d9f61c000)
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1d9f200000)
# /lib64/ld-linux-x86-64.so.2 (0x00007f1d9f628000)
// main
#include <test.h>

int main()
{
    say_hello();
    return 0;
}

執行的目標為對 libtest.so 共享庫的 malloc 函式進行 hook 操作,替換成我們自定義的一個 my_malloc 實現。
由上述 PLT/GOT 表工作原理 可知, libtest.so 呼叫 malloc 時會進行重定向操作找到 malloc 的地址進行呼叫。因此我們只需要更改 got 表中 malloc 的地址指向,指向我們實現的 my_malloc 地址即可實現 hook。

基地址

基於基址的符號偏移地址可以直接透過 readelf -r elf_file 命令檢視 .rel.plt 中的資訊確定。
我的執行環境的 libtest.so 中的 malloc 偏移地址為 0x4028

~/Documents/ProgramDesign/test_hook> readelf -r libtest.so                                                          

Relocation section '.rela.dyn' at offset 0x4a8 contains 7 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003e10  000000000008 R_X86_64_RELATIVE                    1150
000000003e18  000000000008 R_X86_64_RELATIVE                    1110
000000004030  000000000008 R_X86_64_RELATIVE                    4030
000000003fe0  000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTM[...] + 0
000000003fe8  000400000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003ff0  000600000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCl[...] + 0
000000003ff8  000700000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

Relocation section '.rela.plt' at offset 0x550 contains 3 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000004018  000200000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000004020  000300000007 R_X86_64_JUMP_SLO 0000000000000000 snprintf@GLIBC_2.2.5 + 0
000000004028  000500000007 R_X86_64_JUMP_SLO 0000000000000000 malloc@GLIBC_2.2.5 + 0
#include <inttypes.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/mman.h>
#include "test.h"

#define PAGE_SIZE getpagesize()
#define PAGE_MASK (~(PAGE_SIZE-1))
#define PAGE_START(addr) ((addr) & PAGE_MASK)
#define PAGE_END(addr)   (PAGE_START(addr) + PAGE_SIZE)

void *my_malloc(size_t size)
{
    printf("%zu bytes memory are allocated by libtest.so\n", size);
    return malloc(size);
}

void hook()
{
    char       line[512];
    FILE      *fp;
    uintptr_t  base_addr = 0;
    uintptr_t  addr;

    //find base address of libtest.so
    if(NULL == (fp = fopen("/proc/self/maps", "r"))) return;
    while(fgets(line, sizeof(line), fp))
    {
        if(NULL != strstr(line, "libtest.so") &&
           sscanf(line, "%"PRIxPTR"-%*lx %*4s 00000000", &base_addr) == 1)
            break;
    }
    fclose(fp);
    if(0 == base_addr) return;

    //the absolute address
    addr = base_addr + 0x4028;
    
    //add write permission
    mprotect((void *)PAGE_START(addr), PAGE_SIZE, PROT_READ | PROT_WRITE);

    //replace the function address
    *(void **)addr = my_malloc;

    //clear instruction cache
    __builtin___clear_cache((void *)PAGE_START(addr), (void *)PAGE_END(addr));
}

int main()
{
    hook();
    
    say_hello();
    return 0;
}

相關文章