iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

上一篇我們用 ROP 呼叫了 libc 裡的 system("/bin/sh"),整個流程裡有一步是 puts(puts@got) 把 GOT 裡存的 Runtime Address Leak 出來。當時直接用了 elf.plt["puts"] 和 elf.got["puts"],但沒有解釋這兩個東西到底是什麼。


0x00 函式從哪來?

看這段程式:

#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

0x01 為什麼不能寫死位址?

既然 puts() 在 libc 裡,編譯器能不能直接產生 call 0x7ffff7e12340?

不行。前面介紹 ASLR 時已經知道,libc Base Address 每次執行都可能不同。今天是 0x7ffff7c00000,下一次變成 0x7ffff79a0000。編譯時根本不知道 puts() 最後會被載入到哪。

ELF 需要一個中間機制來處理這件事,這就是 PLT 和 GOT。


0x02 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 負責記位址。


0x03 動手觀察

寫一支簡單程式:

#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。


0x04 Lazy Binding

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 的重點。


0x05 用 GDB 看 GOT 變化

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 的變化。


0x06 為什麼 GOT / PLT 對 Pwn 很重要?

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 的內容。


0x07 puts@plt 和 puts@got 不要搞混

剛接觸 Pwn 時很容易混淆,直接整理:

名稱 本質 用途
puts@plt Code(一段指令) 外部函式呼叫的跳板
puts@got Data(一個指標) 保存 Runtime Address
libc puts 真正的函式 實際執行 puts()

call puts@plt 是在呼叫跳板,puts@got 是跳板查表的地方,表裡放的才是真正函式的位址。三者層層串接,不要混在一起。


0x08 GOT Overwrite

除了 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。


0x09 RELRO 與 GOT 的關係

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 能不能用。


0x0A GOT / PLT 在 Exploit 中的三種用途

  1. 呼叫 PLT Function:ROP Chain 裡直接用 puts@plt、write@plt 這些現成跳板
  2. Leak GOT:用 puts@plt(puts@got) 印出 libc Address,算出 system() 在哪
  3. GOT Overwrite:在 GOT 可寫且有任意寫入漏洞時,把某個 GOT Entry 改成目標函式的位址

0x0B 用 pwntools 找 GOT / PLT

不用每次手動翻位址:

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 結構。


0x0C 完整攻擊思路

回頭看 ret2libc,配合 GOT / PLT,完整的攻擊鏈長這樣:

Buffer Overflow → Control RIP → ROP
    → puts@plt(puts@got) → Leak libc Address
    → 算出 libc Base → 算出 system()
    → system("/bin/sh")

基礎 Pwn 題裡這個套路出現頻率非常高。


0x0D 小結

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。


上一篇
Day24|ret2libc
下一篇
Day26|Information Leak
系列文
從零開始の Binary CTF:30 天系統化學習 Rev & Pwn 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言