動態鏈接
計算機程序鏈接時分兩種形式:靜態鏈接和動態鏈接。
靜態鏈接在鏈接時將所有目標文件中的代碼、數據等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 <[email protected]>: 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.h
和 test.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;
}