為什麼程式碼必須停止寫死位址
從第 3 篇你已經知道,一個共享函式庫是磁碟上的一份副本,被許多執行中的程式同時對映進各自的位址空間裡。這個單一目標,在你一問「那麼這個函式庫究竟落在哪裡?」的瞬間,就帶來一個尖銳的後果。它不能落在某個固定位址,因為兩個函式庫可能都想要 0x400000,也因為 ASLR 會在每次執行刻意洗牌基底位址,好讓攻擊更難得手。所以載入器得以較晚才選定載入位址,而且同一個 .so 檔,在你的行程裡與在我的行程裡可能落在不同地方。因此,那個函式庫裡任何把絕對位址烤進去的指令,像「呼叫位於 0x4011a0 的函式」這種,只要函式庫被擺到別處,就立刻是錯的。
乾淨的答案是 位置無關程式碼(position-independent code,PIC):編譯出來的程式碼,沒有任何一條指令依賴它被載入到的絕對位址。你用 gcc -fPIC 來建置它,而從第 1 篇你已經見過它對應到可執行檔的自我重定位表親——位置無關可執行檔(PIE),它如今在多數 Linux 發行版上是預設值。讓 PIC 成為可能的把戲是:x86-64 能夠相對於指令指標來定址資料:一條指令可以說「位於 rip 目前所在處前方 0x2f3d 處的那個位元組」,而不是「位於 0x404050 的那個位元組」。因為從一條指令到同一份已載入映像中鄰近某處的偏移量,無論映像落在哪裡都永遠不變,rip 相對定址天生就是位置無關的。整個函式庫可以當成一塊剛體一起滑動,而每一個內部參考仍然指向正確的位置。
rip 相對定址解決的是同一份映像之內的參考,那裡的距離是一個固定的編譯期常數。它沒有解決那兩個困難的情況:呼叫一個位於不同共享函式庫裡的函式,它與此處的距離直到載入時才知道,而且每次執行都不同;以及參考一個可能定義在某個別的模組、甚至被第三個模組插隊覆蓋的全域變數。對這些,PIC 需要一層間接——一張小小的指標表,由載入器在真實位址已知之後填好一次。這樣的表有兩張,一張給資料、一張給函式,本篇接下來就是在講它們。
GOT:一張裝著真實位址的表,於載入時填好
資料那一半是 全域偏移表(global offset table,GOT)。把它想成一個樸素的陣列,由指標大小的槽位組成,住在每個已載入模組的一個可寫區段裡,模組需要觸及的每個外部符號各佔一槽。當編譯器產生 PIC、碰到一個對(比方說)全域 errno、或住在 libc 裡的符號 stdout 的參考時,它無法直接放出那個位址——它根本不知道。它改為放出 rip 相對的程式碼,說:「從我這個符號的 GOT 槽裡載入那個指標,然後用它。」GOT 槽本身位於距離程式碼固定的偏移處(好讓 rip 相對能搆到它),但它的內容在檔案裡留白,由載入器在 libc 被擺好之後寫入。
載入器怎麼知道該填哪些槽、又該填什麼?這正是第 2 篇那些重定位的用途。每個需要執行期位址的 GOT 槽,都帶著一筆命名其符號的重定位項;動態連結器在啟動時走過那串清單,依第 3 篇的符號解析順序查找每個符號,並把解析出的位址存進槽裡。所以 GOT 是三個早先觀念一次交會的地方:PIC 製造間接,重定位描述要修補什麼,而符號解析決定那個值。載入器做完之後,透過 GOT 讀一個全域變數,就只是多一次指標載入——你從槽裡取出它的位址,然後解參考。那一次間接,就是 PIC 為資料付出的常駐代價。
PLT:呼叫一個你還無法定位的函式
函式有自己專屬的表,程序連結表(procedure linkage table,PLT),乍看像是對 GOT 多餘的重複。它之所以是分開的,是因為一個我們待會就會遇到的最佳化:一個函式的真實位址可以在它真正第一次被呼叫時才被發現,而不必在啟動時一次全部查完。為了讓這成為可能,編譯器從不對外部函式發出直接呼叫。當你的 PIC 程式碼呼叫 printf() 時,組譯器改為發出一個對名為 printf@plt 的微小本地樁的呼叫,這個樁住在你自己模組裡的唯讀 PLT 中。你呼叫的每一個外部函式,都各分到一個這樣的樁。
每個 PLT 樁都黏著一個 GOT 槽。樁的工作很簡單:跳到它那個 GOT 槽裡目前所放的任何位址。所以當你呼叫 printf@plt 時,控制流到達一小段程式碼,它實際上做的是「jmp *(printf 的那個 GOT 槽)」。如果載入器已經把 printf 的真實位址存進那個槽,這次跳躍就直接落在 libc 的 printf() 裡,代價是穿過記憶體的一次間接跳躍——便宜,而且與資料 GOT 是一模一樣的機制,只是用來搆到程式碼。有趣的問題是:在還沒有人解析 printf 之前,那個 GOT 槽裡放著什麼,而答案正是下一節的核心。
your code printf@plt (PLT stub) GOT slot for printf
---------- -------------------- -------------------
call printf@plt --> jmp *GOT[printf] ----------> [ ?? ]
-- before first call: GOT[printf] points BACK into the PLT resolver stub
-- resolver runs once: dynamic linker looks up printf, writes its real
address into GOT[printf]
-- after first call: GOT[printf] = &printf in libc
call printf@plt --> jmp *GOT[printf] ----------> &printf (direct, fast)延遲繫結:第一次呼叫自己修補自己
一支大程式可能連結了上千個函式庫函式,卻在任何一次執行裡只呼叫其中寥寥幾個。在啟動時把每一個都解析掉,意味著緩慢的啟動,全花在查那些根本用不到的符號上。延遲繫結藉由按需解析來避開這點:每個函式在它第一次被呼叫時才解析,之後再也不必付費。這個機制是一段巧妙的自我修改資料。一開始,載入器並不把 printf 的真實位址填進 printf 的 GOT 槽。它改為把那個槽指回 PLT 開頭的一個小解析器樁——於是最最開始那次「jmp *GOT[printf]」跳的不是 libc,而是進到動態連結器的輔助常式裡。
- 你的程式碼呼叫 printf@plt。樁執行 jmp *GOT[printf]。在這最最開始的第一次呼叫,那個槽仍然指回 PLT 的解析器樁,所以跳躍落在那裡——順手帶上一個小索引,表示「這是 printf」。
- 解析器樁跳進動態連結器(常名為 _dl_runtime_resolve 的那個常式),把那個索引、以及一個指向你模組連結資訊的指標交給它。
- 動態連結器用一般的符號解析搜尋順序查找 printf,在 libc 裡找到它的真實位址,並把那個位址寫進 GOT[printf]——蓋掉那個指回解析器的指標。這個槽從此永遠正確。
- 解析器接著跳到 printf 本身,所以這第一次呼叫仍然正常完成——這趟繞路對你的程式而言是隱形的,只是一次性的成本。
- 之後每一次對 printf@plt 的呼叫都執行 jmp *GOT[printf],如今直接落在 libc 的 printf() 裡。對那個函式而言,解析器再也不會被碰到。
整個把戲就是這樣:GOT 槽一開始是一個通往解析器的跳板,並在第一次使用時被改寫成一個直接指向函式的指標。呼叫點本身從不改變——它永遠就只是穿過那個槽跳出去——所以修補完全發生在可變資料裡,而不在程式碼裡。你可以親眼看著它發生:在除錯器底下執行一支動態連結的程式,在某個函式的 GOT 項上設一個監看點,你就會看見它在第一次呼叫的那一瞬間,從解析器位址翻轉成 libc 位址。
它的代價,以及 RELRO 修補的那個安全漏洞
對價格標籤要誠實,因為這一切沒有一樣是免費的。x86-64 上的位置無關程式碼便宜,但不是零:幾個暫存器被綁住,有些參考要多付一次 GOT 載入,而且編譯器的自由度略減——在較舊的 32 位元 x86 上這曾經很痛,因為那裡沒有 rip 相對定址,所以 PIC 不得不專門撥出一整個暫存器當 GOT 指標,這是 32 位元 PIC 確實背著「慢」名聲的原因之一。在 x86-64 上,rip 相對這個把戲讓它幾乎可以忽略,這正是 PIE 得以成為預設值的原因。延遲繫結在每個函式的第一次呼叫上加了一次性的解析器成本,之後就沒有了;如果你寧可一次付清——為了可預測的延遲,或為了下面那個安全強化——你可以設環境變數 LD_BIND_NOW=1、或用 -z now 連結,載入器就改為在啟動時把每個 GOT 槽都解析好。
提早把一切都解析掉,不只關乎速度——它是一根真正的安全槓桿,而這裡就是它修補的漏洞。為了讓延遲繫結能運作,GOT 必須在程式執行的整段期間都保持可寫,因為解析器會按需修補槽位。但一張裝滿函式指標、又坐落在已知偏移處的可寫表,對攻擊者是一份禮物:一次落在 GOT 裡的越界寫入,就能把 printf 重導向去指著惡意程式碼,而你下一次完全無辜的 printf() 呼叫就直接跳進去。這是一個經典的控制流劫持目標。防禦是 RELRO——relocation read-only(重定位唯讀)。在完整 RELRO 之下(以 -z relro -z now 連結),載入器在啟動時就積極地把整個 GOT 解析完,然後請核心把那些分頁標記為唯讀,於是等到你的任何程式碼開始跑時,GOT 已經再也無法被竄改。