程序連結表(the procedure linkage table)
/ PLT, said by its letters /
當你的程式呼叫共享函式庫裡的函式,譬如 printf(),這個呼叫必須觸及 printf 的真正位址——而那要到 C 函式庫在載入時被映射後才知道。你大可預先解析每個這樣的位址,但一個程式可能參照數百個函式庫函式,而在任一次執行裡只呼叫其中少數幾個。程序連結表是個巧妙的小小中轉區,讓每個函式庫呼叫能被便宜地解析,而且如果你願意,只在它真正第一次發生時解析。
PLT 是一個小小的樁常式陣列,程式呼叫的每個外部函式一個,住在可執行檔的程式碼裡。當你呼叫 printf() 時,你其實是呼叫 printf 的 PLT 樁。那個樁透過 printf 在 GOT(具體是 .got.plt)裡的槽做一次間接跳躍。在最最開始的第一次呼叫,那個 GOT 槽仍指回 PLT,指向一小段呼叫動態連結器解析器的程式碼:解析器找到真正的 printf、把它的位址寫進 GOT 槽、跳過去。之後每次呼叫,GOT 槽已持有 printf 的真正位址,所以 PLT 樁的間接跳躍直接跳到那裡、不涉及解析器。所以 PLT 加 GOT 一起把「呼叫一個尚未知的外部函式」變成「呼叫一個固定的本地樁,它查一張指標表」。
它重要,是因為這個 PLT 加 GOT 的配對是 ELF 系統上呼叫共享函式庫函式的標準機制,也是延遲繫結之所以可能的原因——只在每個函式第一次被呼叫時才解析它,而非在啟動時全部解析。誠實的取捨:間接讓每次呼叫多花幾條指令、延遲繫結的首次呼叫解析帶來一次性的小頓挫,而可寫的 .got.plt 是個強化上的顧慮。完整的 RELRO 會停用延遲繫結(在啟動時積極解析所有東西),使整個 GOT 能變唯讀,以一點啟動時間換取較小的攻擊面。
call printf@plt ; 你呼叫的是 PLT 樁,不是直接呼叫 printf ; 樁的動作: jmp [rip + printf@GOTPCREL] ; 第一次呼叫 -> 解析器;之後 -> 真正的 printf $ readelf -r app | grep printf # .got.plt 裡的 R_X86_64_JUMP_SLOT
對函式庫函式的呼叫經過它的 PLT 樁,樁透過 GOT 槽跳躍——第一次呼叫由動態連結器解析,之後就直接跳。
PLT 的存在是為了啟用延遲繫結;若你以完整 RELRO 建構(或設定 LD_BIND_NOW),所有東西在啟動時就被積極解析,之後 GOT 變唯讀,以啟動成本換取強化。