上一篇我們用 ROP 呼叫了 libc 裡的 system("/bin/sh"),整個流程裡有一步是 puts(puts@got) 把 GOT 裡存的 Runtime Address Leak 出來。當時直接用了 elf.plt["puts"] 和 elf.got["puts"],但沒有解釋這兩個東西到底是什麼。
看這段程式:
#include <stdio.h>
int main() {
puts("Hello World!");
return 0;
}
puts() 被呼叫了,但它的程式碼根本不在我們的 ELF 裡面——它住在 libc。那 CPU 執行到這行的時候,到底怎麼找到 libc 的 puts()?
連結函式庫時大致分兩種做法。Static Linking 在編譯時直接把 Library Code 複製進 Binary,好處是不依賴外部,壞處是每支程式都帶一份 libc,Binary 會很大。Dynamic Linking 則是程式只記錄「我需要 puts()」,真正的實作留在 libc.so.6 裡,等到執行時才去找。現代 Linux 幾乎都用後者。
用 ldd 可以看到依賴哪些 Shared Library:
ldd ./a.out
linux-vdso.so.1
libc.so.6
/lib64/ld-linux-x86-64.so.2
既然 puts() 在 libc 裡,編譯器能不能直接產生 call 0x7ffff7e12340?
不行。前面介紹 ASLR 時已經知道,libc Base Address 每次執行都可能不同。今天是 0x7ffff7c00000,下一次變成 0x7ffff79a0000。編譯時根本不知道 puts() 最後會被載入到哪。
ELF 需要一個中間機制來處理這件事,這就是 PLT 和 GOT。
PLT(Procedure Linkage Table)是呼叫外部函式時的跳板。原始 C Code 寫的是 puts("Hello"),反組譯後看到的不是 call puts,而是 call puts@plt——先經過跳板,再由跳板找到真正的函式。
GOT(Global Offset Table)是一張存放 Runtime Address 的表。Dynamic Linker 找到 libc 裡真正的 puts() 之後,會把位址填進 GOT:
GOT
+--------------------------------------+
| puts@got -> 0x7ffff7e12340 |
| read@got -> 0x7ffff7ebc290 |
+--------------------------------------+
兩者合作的流程:
main()
| call puts@plt
v
puts@plt
| jmp [puts@got]
v
puts@got → libc puts()
一句話記:PLT 負責跳,GOT 負責記位址。
寫一支簡單程式:
#include <stdio.h>
int main() {
puts("Hello GOT!");
puts("Hello PLT!");
return 0;
}
存成 got.c,編譯時關掉 PIE 方便觀察位址:
gcc -O0 -fno-pie -no-pie -Wl,-z,lazy -Wl,-z,relro got.c -o got
不同 Distribution 跟 GCC / linker 預設可能不同,實際結果不一定完全一樣。
用 objdump 找 PLT:
objdump -d -M intel got | grep -A5 '<puts@plt>'
0000000000401030 <puts@plt>:
401030: ff 25 ... jmp QWORD PTR [rip+...]
401036: 68 00 00 00 00
40103b: e9 ...
注意那行 jmp QWORD PTR [rip+...],不是直接跳進 libc,而是先去查 GOT。
再用 objdump -R got 找 GOT:
OFFSET TYPE VALUE
00000000404000 R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5
0x404000 就是 puts() 對應的 GOT Entry。
GOT 的填入時機也有學問。Dynamic Linking 預設使用 Lazy Binding——函式第一次真的被呼叫時才解析位址。
一個程式可能 Link 了 printf、puts、malloc、free、read、write、open 一大堆函式,但執行時不一定全部用到。啟動時就全部解析一遍會浪費時間,所以用到才解析。
第一次呼叫 puts():puts@got 還不是真正的 libc 位址,會繞一圈經過 Dynamic Linker,找到 Symbol、算出 Runtime Address、填回 GOT,然後才跳到真正的函式。
第二次呼叫 puts():GOT 已經被更新,直接 puts@plt → puts@got → libc puts(),不再經過 Dynamic Linker。
兩次走的路徑不一樣,這是 Lazy Binding 的重點。
gdb ./got
b main
run
在第一次 puts() 前查看 GOT Entry(假設位址是 0x404000):
x/gx 0x404000
這時候裡面的值還不是 libc 的 puts()。用 nexti 走過第一次呼叫後再看一次:
x/gx 0x404000
會發現變成了 0x00007ffff7...... 開頭的位址——libc 裡 puts() 的真正位置。可以用 p puts 確認是否對得上。
不同 GDB 和 glibc 版本顯示可能不同,重點是觀察解析前後 GOT Entry 的變化。
GOT 裡存著 libc Function 的 Runtime Address。如果有辦法讀出 puts@got 的值,就拿到了 puts() 的 Runtime Address。昨天 Day 24 已經知道:
libc_base = puts_addr - libc.symbols["puts"]
system_addr = libc_base + libc.symbols["system"]
GOT 就是突破 ASLR 拿到 libc 位址的關鍵。至於到底怎麼把 GOT 的值 Leak 出來,是明天 Day 26 Information Leak 的內容。
剛接觸 Pwn 時很容易混淆,直接整理:
| 名稱 | 本質 | 用途 |
|---|---|---|
puts@plt |
Code(一段指令) | 外部函式呼叫的跳板 |
puts@got |
Data(一個指標) | 保存 Runtime Address |
libc puts |
真正的函式 | 實際執行 puts() |
call puts@plt 是在呼叫跳板,puts@got 是跳板查表的地方,表裡放的才是真正函式的位址。三者層層串接,不要混在一起。
除了 Leak,GOT 還有另一種經典玩法。
假設 puts@got 原本指向 libc 的 puts()。如果有漏洞讓我們把它改成 system() 的位址:
puts@plt → puts@got → system()
那程式下次執行 puts("/bin/sh") 時,實際上會跳到 system("/bin/sh")——直接拿 Shell。這就是 GOT Overwrite。
不過現代 Binary 有防護可以擋這招,最重要的就是 Day 22 提過的 RELRO。
RELRO(RELocation Read-Only)針對 GOT 這類 Relocation 區域的保護,checksec 會看到三種狀態:
| RELRO | GOT Overwrite |
|---|---|
| No RELRO | GOT 可寫,直接改 |
| Partial RELRO | .got.plt 為了 Lazy Binding 通常還是可寫 |
| Full RELRO | 啟動時完成所有解析,GOT 設為唯讀,改不了 |
所以 checksec 永遠是第一步——RELRO 等級直接決定 GOT Overwrite 能不能用。
puts@plt、write@plt 這些現成跳板puts@plt(puts@got) 印出 libc Address,算出 system() 在哪不用每次手動翻位址:
from pwn import *
elf = ELF("./got")
print(hex(elf.plt["puts"])) # puts@plt
print(hex(elf.got["puts"])) # puts@got
可能輸出:
0x401030
0x404000
寫 Exploit 時 elf.plt、elf.got、elf.symbols 會一直用到,背後對應的就是今天學的這些 ELF 結構。
回頭看 ret2libc,配合 GOT / PLT,完整的攻擊鏈長這樣:
Buffer Overflow → Control RIP → ROP
→ puts@plt(puts@got) → Leak libc Address
→ 算出 libc Base → 算出 system()
→ system("/bin/sh")
基礎 Pwn 題裡這個套路出現頻率非常高。
PLT 是外部函式呼叫的跳板,GOT 存放 Runtime Address。程式透過 call puts@plt 到 GOT 查表,再跳到 libc 裡真正的函式。搭配 Lazy Binding,第一次呼叫才解析,之後直通。
在 Pwn 裡,GOT 既是 Leak libc Address 的目標,也是 GOT Overwrite 的攻擊面。而 RELRO 等級決定 GOT 能不能改。
到目前為止,從 Stack Buffer Overflow 一路走過 Control RIP、ret2win、Shellcode、ROP、ret2libc 到今天的 GOT / PLT,但還有一個關鍵問題沒解決:ASLR 讓 libc 每次都在不同位置,我們到底怎麼拿到 Runtime Address?今天看到了突破口——GOT。
下一篇 Day 26,正式進入 Information Leak,看看怎麼從程式中洩漏記憶體資訊,一步一步突破 ASLR。