全域偏移表(the global offset table)
/ GOT, said got /
與位置無關的程式碼拒絕把絕對位址烤進指令,但它仍必須觸及全域變數與函式,而它們的真正位址只有在一切載入後才知道。它需要一個單一、眾所周知的地方去查那些位址。全域偏移表正是如此:一張小小的指標表,模組用到的每個外部符號一個槽,由動態連結器在載入時填入。程式碼透過從 GOT 讀出某外部之物的位址來觸及它,而非直接知道它。
用簡單步驟說明流程。編譯器為模組參照的每個外部符號保留一個 GOT 槽。在程式碼裡,它不是「直接用 foo」,而是發出「載入 foo 的 GOT 槽裡的指標,然後用那個」。GOT 槽本身坐落在模組的可寫資料區段裡。載入時動態連結器算出 foo 實際落在記憶體何處,把那個真正位址寫進 foo 的 GOT 槽(一次載入期重定位)。從此以後,每次存取 foo 都是:讀 GOT 槽取得位址,然後解參考。因為程式碼只用對自己 GOT 的 rip 相對參照(GOT 相對於程式碼在固定偏移),程式碼頁保持與位置無關、唯讀、可共享,而每行程的 GOT 持有那些真正不同的位址。
它重要,是因為 GOT 是「不烤進位址」這份成本被支付與被收束之處:動態連結器不是在載入時修補數千條指令,而是修補一張精簡的表。這也是為什麼 GOT 是攻擊者垂涎的目標——覆寫一個 GOT 項目可以改寫一次函式呼叫的方向——這正是 RELRO 的動機,那是一個讓 GOT 在動態連結器填完後變唯讀的強化步驟。注意實務上的劃分:變數位址傾向放在 .got,而 PLT 為延遲繫結所用的函式指標槽放在 .got.plt。
// 要觸及外部變數 g,PIC 這樣做(用文字描述): addr = *(g 的 GOT 槽); // 動態連結器在載入時填好 value = *addr; // 組合語言: mov rax, [rip + g@GOTPCREL] ; rax = &g // mov eax, [rax] ; eax = g
GOT 持有每個外部符號的真正位址;程式碼讀那個槽(由動態連結器填好)而非直接知道位址。
因為一個可寫、能改寫呼叫方向的 GOT 項目是強力的攻擊目標,RELRO 可以在啟動後讓 GOT 變唯讀。GOT 是每行程的(它依載入位址而異),這就是為什麼即使程式碼頁被共享,它的記憶體頁卻不被共享。