為了解決空間浪費和更新困難問題最簡單的辦法就是把程式的模組相互分割開來,形成獨立的檔案,而不是將它們靜態連結在一起。
簡單的說:不對那些組成程式的目標檔案進行連結,等到程式要執行時候才進行連結。
把連結這個過程推遲到了執行時再進行
,這就是動態連結(Dynamic Linking)
的基本思想。
LibTest.h LibTest.c
#ifndef LIBTEST_H
#define LIBTEST_H
void foobar(int i);
#endif
#include "LibTest.h"
#include <stdio.h>
void foobar(int i)
{
printf("Printing from Lib.so %d\n",i);
}
編譯成.so(動態連結庫)
gcc -fPIC -shared LibTest.c -o libtest.so
#-fPIC是與地址無關選項
#-shared 是編譯成so 動態聯機
#-o 輸出,so檔名必須以lib開頭
Program1.c
#include "LibTest.h"
int main(int argc,char *argv[])
{
foobar(1);
return 0;
}
Program2.c
#include "Lib.h"
int main(int argc,char *argv[])
{
foobar(2);
return 0;
}
分別編譯Program1 和Program2動態呼叫libtest.so
gcc Program1.c -L. -ltest -o Program1
gcc Program2.c -L. -ltest -o Program2
export LD_LIBRARY_PATH=/home/pwn/testdemo:$LD_LIBRARY_PATH
#上面命令的意思分別是
#-L. 代表的是so在本地當前目錄查詢
#-ltest 動態呼叫so有一套自己的命名規則,一般必須是lib帶頭,然後才是so名字.所以-l後面跟的是lib之後的so名,忽略字尾。
#export LD_LIBRARY_PATH代表的是把動態連結目錄加入環境變數,預設是/usr/lib下
~/testdemo » ldd Program1
linux-vdso.so.1 (0x00007ffd25b6e000)
libtest.so => /home/pwn/testdemo/libtest.so (0x00007f3ae466f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3ae446a000)
/lib64/ld-linux-x86-64.so.2 (0x00007f3ae467b000)
#ldd命令可以用來檢視當前程式所呼叫的動態so。
執行結果:
~/testdemo » ./Program1
Printing from Lib.so 1
~/testdemo » ./Program2
Printing from Lib.so 2
GOT表的全稱是Global Offset Table(全域性偏移表)
可以把它理解成為了動態連結,把所有的符號偏移量或(絕對地址)都放入到了一個表裡這就是GOT表
下面我們來做個實驗加深下理解。
/*a.c原始碼*/
extern int shared; //外部符號,跨模組
int main()
{
int a = 100;
swap(&a,&shared);//外部符號,呼叫外部模組的swap函數
}
/*b.c原始碼*/
extern int shared = 1;
void swap(int *a,int *b)
{
*a ^= *b ^= *a ^= *b;
}
#編譯成.so
gcc -fPIC -shared b.c -o libb.so
export LD_LIBRARY_PATH=/home/pwn/got:$LD_LIBRARY_PATH
#編譯a可執行程式
gcc a.c -L. -lb -o a
從下圖中可以看到shared變數的存取和之前我沒靜態連結篇的存取方式是一模一樣的,用的是當rip+偏移這種間接定址的方式來存取三方模組的全域性變數,而函數swap
在這裡則變成了swap@plt
。
利用斷點跟入swap@plt函數
,然後跟到了plt表
,後面會將plt表的用途,可以看到有個jmp是間接跳轉,加上偏移後剛好就是got表的位置,對應的是存放swap函數的絕對地址。
#include <stdio.h>
void fun()
{
system("id");
}
int main()
{
//下面演示:Printf("id") 變成shell命令
printf("id");
return 0;
}
最後成功劫持,將printf劫持成了system函數,輸出了當前id
首先, 我們要知道, GOT和PLT只是一種重定向
的實現方式. 所以為了理解他們的作用, 就要先知道什麼是重定向, 以及我們為什麼需要重定向.
重定向我在靜態連結文章中已經介紹過,就是編譯成.o檔案時候,那些外部符號變數和函數無法確定時候,預留的填充值,比如用0填充,然後等待連結時候才真實的被寫入。
之前介紹的是靜態連結的情況,那麼動態連結時候會怎麼樣呢?一遍實戰一遍學習。
#include <stdio.h>
void print_banner()
{
printf("Welcome to World of PLT and GOT\n");
}
int main(int argc,char *argv[])
{
print_banner();
return 0;
}
#編譯分別生成.o 和可執行程式
gcc -c plt.c -o plt.o -m32
gcc -o plt plt.c -m32
編譯後產生了.o和plt可執行程式,我們先用objdump來看看plt.o的組合原始碼,命令是objdump -M intel -dw plt.o
可以看到call printf的這個地址是填0的,因為這時候編譯器並不知道printf的函數真實地址,printf函數是需要程式被裝載後才能確定地址
,那麼動態連結器為什麼不在程式執行起來後,裝載起來後
再把真實的printf地址
填進去呢?因為這個call printf
的語句是在.text
程式碼段的,執行起來後程式碼段是無法被修改的,只能修改.data
資料段。
????????那怎麼搞啊,都不能修改程式碼段,那搞什麼。
只能羨慕大佬麼的技巧,大佬麼總是那麼騷,還是有辦法搞的,動態連結器生成了一段額外的小程式碼判斷,通過這段程式碼獲取printf函數地址,並完成對它的呼叫。
用來存放這小片段程式碼的地方就是PLT表,下面是虛擬碼片段。
.text
....
//呼叫printf的call指令
call printf_plt
....
printf_plt:
mov rax,[printf函數的儲存地址] //GOT表中
jmp rax //跳過去執行printf函數
.got.plt
.....
printf下標
這裡儲存了printf函數重定位後的真實地址
連結階段發現printf
定義在動態庫 glibc
時,連結器生成一段小程式碼
print@plt
,然後printf@plt
地址取代原來的printf
。因此轉化為連結階段對printf@plt
做連結重定位,而執行時才對printf
做執行時重定位,具體呼叫流程圖,可以參考如下:
好的,接下來我們繼續用上面的例子,詳細對的PLT表進行分析,首先我們用命令objdump -M intel -dw plt
檢視每個段的資料,有組合則反組合。
000003a0 <.plt>:
3a0: ff b3 04 00 00 00 push DWORD PTR [ebx+0x4]
3a6: ff a3 08 00 00 00 jmp DWORD PTR [ebx+0x8]
3ac: 00 00 add BYTE PTR [eax],al
...
000003b0 <puts@plt>:
3b0: ff a3 0c 00 00 00 jmp DWORD PTR [ebx+0xc]
3b6: 68 00 00 00 00 push 0x0
3bb: e9 e0 ff ff ff jmp 3a0 <.plt>
...
0000051d <print_banner>:
51d: 55 push ebp
51e: 89 e5 mov ebp,esp
...
53a: e8 71 fe ff ff call 3b0 <puts@plt>
...
547: c3 ret
+0000 0x56556fd8 e0 1e 00 00 00 00 00 00 00 00 00 00 90 cd e4 f7 │....│....│....│....│
+0010 0x56556fe8 b0 de df f7 00 00 00 00 80 58 e1 f7 00 00 00 00
OK,我們對上面的程式碼進行分析,收我們關注printf_banner
函數呼叫的printf
,這裡因為編譯優化的緣故printf變成了puts
,在53a
這裡可以看到呼叫了
puts@plt
,puts@plt這裡有3句組合程式碼,分別是jmp到ebx+0xC值的地址,然後又push0,又jmp到0x3a0,因為這裡我們不知道ebx是什麼值,所以需要動態偵錯來一步步詳細的觀察下,用命令pwndbg,gdb plt
,start
,b printf_banner
c
,然後單步到call puts@plt
。
從上圖中可以發現,ebx的值是0x56556fd8
,這其實是got表裝載到記憶體後的地址,我們可以用readelf -SW plt
檢視檔案中got表的偏移。
可以發現偏移正好是fd8
,虛擬記憶體的起始地址加上fd8就是0x56556fd8
。
那麼如何檢視程式載入的起始地址呢?可以藉助強大的pwndbg中的vmmap
命令來檢視記憶體分佈。
正好是(起始地址)0x56555000
+偏移(0xfd5
)=0x56556fd8
。
OK現在迴歸正題,我們已經知道puts@plt
中的jmp是要跳轉到GOT表中偏移0xC的位置,那麼這個位置存放的是什麼值呢?
聰明的你已經猜到了,他其實就是puts
函數的真實地址,但是!
為了不影響程式執行的速度,因為我們程式一執行就把所有符號地址都確定,然後都填入got表,那一但我們呼叫到非常的動態庫時候,效能肯定會受影響的。所以,採用了延遲繫結機制。
000003b0 <puts@plt>:
3b0: ff a3 0c 00 00 00 jmp DWORD PTR [ebx+0xc]
3b6: 68 00 00 00 00 push 0x0
3bb: e9 e0 ff ff ff jmp 3a0 <.plt>
延遲繫結機制原理
我們先來看看,這個got表偏移+0xC位置,在檔案位置中的值是多少,可以看到他的值是0x3B6
,你可以仔細看看puts@plt
函數,jmp後下一句組合地址是多少?
00001ee0
00000000
00000000
000003B6 [ebx+0xC]
剛好是0x3b6
,對應的組合語句是push 0,接著又跳到了jmp 0x3a0 <.plt>,跳到了plt表。
3b6: 68 00 00 00 00 push 0x0
plt表中的組合如下:
這幾句組合程式碼會呼叫核心的_dl_runtime_resolve()
函數,把puts函數在動態庫中的真實地址
放入到got表
中。
000003a0 <.plt>:
3a0: ff b3 04 00 00 00 push DWORD PTR [ebx+0x4]
3a6: ff a3 08 00 00 00 jmp DWORD PTR [ebx+0x8]
3ac: 00 00 add BYTE PTR [eax],al
所以延遲繫結機制的原理:就是第一次在呼叫函數時候,才把真實的地址放入got表(進行繫結),之後再呼叫這函數則直接jmp到真實地址。
最後,在其他大佬部落格上偷了張詳細的函數呼叫plt表延遲繫結的流程圖。
參考文獻:
https://yjy123123.github.io/2021/12/06/延遲繫結過程分析/
https://evilpan.com/2018/04/09/about-got-plt/ 非常完整詳細的講解部落格
《程式設計師的自我修養 連結、裝載與庫》 這本書,真的是神書,全部仔細看完肯定有幫助。
本文來自部落格園,作者:VxerLee,轉載請註明原文連結:https://www.cnblogs.com/VxerLee/p/16378703.html 專注逆向、網路安全 ——VxerLee