2017年3月2日 星期四

[翻譯] 共享函式庫裡的位址無關程式碼(PIC)

  • 原文標題:Position Independent Code (PIC) in shared libraries
  • 原文網址:http://eli.thegreenplace.net/2011/11/03/position-independent-code-pic-in-shared-libraries
  • 原文作者:Eli Bendersky
  • 原文發表時間:2011 年 11 月 03 日
  • 譯註:
    • 在本文的翻譯中,section 翻譯為區段,segment 翻譯為節區,這兩個名詞的翻譯方法時有交換,我只是挑一種我喜歡的。
    • 本文圖片均取自原文網頁。

↓↓↓↓↓↓ 正文開始 ↓↓↓↓↓↓

於一篇先前的文章中,我已經敘述過當要載入共享函式庫到行程(process)的位址空間時,所需要的特殊處理,大致上,在連結器(linker)生成共享函式庫的時候,它並不能預先得知函式庫會被載入到什麼位置,而這對函式庫內的資料與程式碼參照(reference)造成了問題,它們需要以某種方式指向正確的記憶體位置。

處理 Linux ELF 共享函式庫的這個問題有兩個主要的方式:

  1. 載入期重定位(load-time relocatio)
  2. 位址無關程式碼(position independent code,PIC)

載入期重定位已經講過了,在這裡,我要解釋第二種方式——PIC。

我原本打算在這篇文章中同時聚焦在 x86 和 x64(又叫 x86-64)上,但當它的內容越來越長,我認定這不可行,因此,它將只會解釋在 x86 上 PIC 是如何運作的。特地挑這個比較舊的架構,是因為它不是在考量到 PIC 的情況下設計的(不像 x64),所以在其上實作 PIC 要用點伎倆。一篇將來(希望能更短一點)的文章將以此為基礎解釋 PIC 如何在 x64 上實作。

載入期重定位的一些問題

如同前篇文章中看到的,載入期重定位是一個相當直觀的方法,而且確實可行;可是,現今 PIC 卻更加的普遍,通常是建立共享函式庫的建議方法,為什麼會這樣呢?

載入期重定位有兩個問題:它需要時間去進行,而且它會讓函式庫的文字區段(text section)1 變得不可共享。

第一,效率問題。假如共享函式庫連結了載入期重定位項目,當應用程式載入時,就要花時間實際進行這些重定位,你可能認為這消耗不會太大——畢竟,載入器(loader)不需要掃瞄整個文字區段——只要查看那些重定位項目就好。但是如果軟體的一個複雜的部份在啟動時載入了多個大的共享函式庫,而且每個共享函式庫都要在一開始就套用各自的載入期重定位,這些消耗就會累加起來,在應用程式的啟動階段形成可見的延遲。

第二,文字區段的不可共享問題,某種程度來說更加嚴重。起初使用共享函式庫的其中一個要點是為了節省 RAM,而某些共享函式庫會被多個應用程式使用,假如共享函式庫的文字區段(程式碼所在處)能只被載入記憶體一次(然後映射到許多行程的虛擬記憶體),就可以省下可觀的 RAM;然而,這件事對載入期重定位卻是不可行的,因為在使用這項技術的時候,文字區段會在載入期施加重定位時被修改,因此,對於每個載入這個共享函式庫的應用程式來說,都必須將函式庫整個再一次放在 RAM 裡〔1〕,不同應用程式並不能真正的共享。

還有,具有一個可寫的文字區段(它必須保持可寫,以便動態載入器進行重定位2)暴露了一個安全風險,會導致攻陷應用程式變得更容易。

而我們將在本文中看到,PIC 大幅地減輕了這些問題3

PIC——簡介

PIC 背後的想法很單純——對程式碼中的全域資料與函式參照都加一層間接導向。藉由巧妙的利用連結與載入過程的某些產物,就有可能讓共享函式庫的文字區段真正的位址無關,這意指文字區段可以簡單地映射到不同的記憶體位址,而不需要改變任何一個位元,接下來幾節我會詳細地解釋這個成果是如何達成的。

關鍵思路 #1——文字與資料區段之間的偏移值

其中一個 PIC 所依賴的關鍵思路是文字與資料區段之間的偏移值,早在連結期連結器就已經知道了。當連結器把數個目的檔(object file)合併在一起的時候,它會收集它們的區段(舉例來說,所有的文字區段會被統合成單一一個大的文字區段),因此,連結器對於各個區段的大小和它們之間的相對位址都很清楚。

例如:文字區段後面也許會緊接著資料區段,那麼從文字區段中任何一個給定指令到資料區段開頭之 間的偏移值,就會是文字區段的大小減去從文字區段開頭到該指令的偏移值——而這些量值對連結器而言都是已知的。

在上圖中,程式碼區段被載入到某個位址(連結期未知)0xXXXX0000(字母 X 代表「不在意」),而緊接在後的資料區段在偏移位址 0xXXXXF000。然後,假如有個在程式碼區段偏移值 0x80 的指令要參照到資料區段的東西,連結器就會知道它們的相對偏移值(此例中是 0xEF80),並且編碼到該指令裡面。

注意,即使有另一個區段被放在程式碼與資料區段之間,或是資料區段被放在程式碼區段之前,都不用在意,因為連結器知道所有區段的大小,並決定把它們放在哪裡,所以這個思路都是成立的。

關鍵思路 #2——讓相對於 IP 的偏移值在 x86 上運作起來

上面所說的,只有在實際上可以用相對偏移值來運作時才有用;可是 x86 上的資料參照(也就是指令 mov)需要用絕對位址,那麼,該怎麼辦呢?

如果擁有相對位址,卻需要絕對位址,那這之間所缺少的就是指令指標(instruction pointer,IP)的值(因為就定義上來說,相對位址是指相對於該指令的所在處)。在 x86 上沒有指令能夠獲得指令指標的值,但是我們可以用個小伎倆來取得,這裡是一些用來證明的組合語言虛擬碼(pseudo-code):


    call TMPLABEL
TMPLABEL:
    pop ebx

這會造成的效果是:

  1. CPU 執行 call TMPLABEL,導致它把下個指令(pop ebx)的位址存到對堆疊上,並跳到該標籤(label)。
  2. 因為標籤所在處的指令是 pop ebx,所以它會接著被執行,它從堆疊取出一個值到 ebx,不過這個值就是該指令自己的位址,所以 ebx 現在等同於含有指令指標的值了。

全域偏移表(global offset table,GOT)

有了這個,終於可以開始在 x86 上實作位址無關資料定址了,這要藉著「全域偏移表」,縮寫是 GOT,來完成。

GOT 單純只是一張位址表,坐落在資料區段,假設某個程式碼區段的指令要參照到一個變數,它並非以絕對位址直接參照到變數(這會需要做重定位),而是參照到 GOT 中的一筆項目,由於 GOT 位在資料區段的一個已知地點,因此這個參照是相對的,並且對連結器來說是已知的。而接著由 GOT 包含變數的絕對位址:

以組語虛擬碼的形式4,我們要取代掉絕對定址的指令:


; 把變數的值放到 edx
mov edx, [ADDR_OF_VAR]

配合相對於暫存器的位移定址(displacement addressing),加上額外的一個間接導向:


; 1. 用某種方式把 GOT 位址放到 ebx。
lea ebx, ADDR_OF_GOT

; 2. 假設 ADDR_OF_VAR 存在 GOT 內偏移值 0x10 的地方,
;    所以這會把 ADDR_OF_VAR 放進 edx。
mov edx, DWORD PTR [ebx + 0x10]

; 3. 最後,存取變數並把值放到 edx。
mov edx, DWORD PTR [edx]

這樣一來,藉由重導變數參照到 GOT,我們已然擺脫了程式碼區段的一筆重定位,然而卻也在資料區段產生了一筆重定位,為什麼呢?因為在上述架構中,GOT 運作時仍然包含了該變數的絕對位址。那麼,我們又得到了什麼呢?

其實有很多,一個在資料區段的重定位能比在程式碼區段更加不會造成問題,有兩個原因(都直接針對文章一開始提到的關於載入期重定位的兩個主要問題):

  1. 在程式碼區段的重定位對每個變數參照都有需求,而在 GOT 則對每個變數都只需要一次,大致上對變數的參照會比變數還多很多,所以這種方式更有效率。
  2. 資料區段是可寫的,所以本來無論如何就都不會在行程間共享,因此對其增添重定位不構成損害;然而,把重定位從程式碼區段移走,則讓其成為唯讀並可在行程間共享。

透過 GOT 來做資料參照的 PIC——範例

我現在要展示一個完整的範例來證實 PIC 的機制:


int myglob = 42;

int ml_func(int a, int b)
{
    return myglob + a + b;
}

這段程式碼會編進一個名為 libmlpic_dataonly.so 的共享函式庫裡面(適當地使用旗標 -fpic-shared)。

來看看它的反組譯結果,聚焦在函式 ml_func 上:


0000043c <ml_func>:
 43c:   55                      push   ebp
 43d:   89 e5                   mov    ebp,esp
 43f:   e8 16 00 00 00          call   45a <__i686.get_pc_thunk.cx>
 444:   81 c1 b0 1b 00 00       add    ecx,0x1bb0
 44a:   8b 81 f0 ff ff ff       mov    eax,DWORD PTR [ecx-0x10]
 450:   8b 00                   mov    eax,DWORD PTR [eax]
 452:   03 45 08                add    eax,DWORD PTR [ebp+0x8]
 455:   03 45 0c                add    eax,DWORD PTR [ebp+0xc]
 458:   5d                      pop    ebp
 459:   c3                      ret

0000045a <__i686.get_pc_thunk.cx>:
 45a:   8b 0c 24                mov    ecx,DWORD PTR [esp]
 45d:   c3                      ret

我會用指令的位址(反組譯結果最左邊的數字)來指稱指令,這個位址是相對於共享函式庫載入位址的偏移值。

  • 43f,藉由上面〈關鍵思路 #2〉一節所講的技術,下一個指令的位址被放進 ecx
  • 444,一個從該指令到 GOT 放置處的偏移值常數被加進 ecx,所以現在 ecx 相當於 GOT 的基底指標。
  • 44a,一個值取自 [ecx - 0x10],為一筆 GOT 項目,並放到 eax,這就是 myglob 的位址。
  • 450 的指令結束後,myglob 的值被放進 eax
  • 之後參數 ab 加到 myglob,然後值會回傳(留在 eax 當中)。

我們也可以用 readelf -S 檢索該共享函式庫來看看 GOT 區段放在哪:


Section Headers:
  [Nr] Name     Type            Addr     Off    Size   ES Flg Lk Inf Al
  <刪剪>
  [19] .got     PROGBITS        00001fe4 000fe4 000010 04  WA  0   0  4
  [20] .got.plt PROGBITS        00001ff4 000ff4 000014 04  WA  0   0  4
  <刪剪>

讓我們來做點數學,確認編譯器尋找 myglob時做的運算。就像前面提過的,對 __i686.get_pc_thunk.cx 的呼叫會把下個指令的位址放進 ecx,該位址是 0x444〔2〕,然後,下個指令把 0x1bb0 加上去,在 ecx 中的結果會變成 0x1ff4。最後,為了真正獲得持有 myglob 位址的 GOT 項目,使用了位移定址——[ecx - 0x10],因此該項目是在 0x1fe4,根據區段標頭,該位址也是 GOT 的第一筆項目。

至於為什麼這裡還有另一個名字以 .got 開頭的區段,將會在本文之後再做解釋〔3〕。注意,編譯器選擇將 ecx 指向 GOT,並使用負的偏移值來獲取其項目,這沒有關係,只要數學運算能得出結果就行了,而直到現在它也確實做到了。

然而,我們還漏了些什麼,myglob 的位址實際上是怎麼跑到 GOT 在 0x1fe4 的空位的?回想一下我有提過一筆重定位,那就來把它找出來吧:


> readelf -r libmlpic_dataonly.so

Relocation section '.rel.dyn' at offset 0x2dc contains 5 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00002008  00000008 R_386_RELATIVE
00001fe4  00000406 R_386_GLOB_DAT    0000200c   myglob
<刪剪>

注意到 myglob 那筆重定位,指向位址 0x1fe4,正如預期,而該重定位是 R_386_GLOB_DAT 型態的,這會單純告訴動態載入器:「把該符號的實際值(也就是位址)放到那個偏移值的地方」,這樣一來,所有東西都能正確運作了。還剩下的事情是確認當函式庫被載入時,會是怎麼一個樣子,我們要做到這件事,可以藉由寫一個「驅動用(driver)」程式連結 libmlpic_dataonly.so,並且呼叫 ml_func,之後再用 GDB 來運行它。


> gdb driver
[...] 略過輸出
(gdb) set environment LD_LIBRARY_PATH=.
(gdb) break ml_func
[...]
(gdb) run
Starting program: [...]pic_tests/driver

Breakpoint 1, ml_func (a=1, b=1) at ml_reloc_dataonly.c:5
5         return myglob + a + b;
(gdb) set disassembly-flavor intel
(gdb) disas ml_func
Dump of assembler code for function ml_func:
   0x0013143c <+0>:   push   ebp
   0x0013143d <+1>:   mov    ebp,esp
   0x0013143f <+3>:   call   0x13145a <__i686.get_pc_thunk.cx>
   0x00131444 <+8>:   add    ecx,0x1bb0
=> 0x0013144a <+14>:  mov    eax,DWORD PTR [ecx-0x10]
   0x00131450 <+20>:  mov    eax,DWORD PTR [eax]
   0x00131452 <+22>:  add    eax,DWORD PTR [ebp+0x8]
   0x00131455 <+25>:  add    eax,DWORD PTR [ebp+0xc]
   0x00131458 <+28>:  pop    ebp
   0x00131459 <+29>:  ret
End of assembler dump.
(gdb) i registers
eax            0x1    1
ecx            0x132ff4       1257460
[...] 略過輸出

除錯器進入了 ml_func,並停在 IP 0x0013144a〔4〕,可以到 ecx 裡的值是 0x132ff4(這是該指令位址加上 0x1bb0,如同先前解釋的),注意,在這個點,執行期,這些都是絕對位址——共享函式庫已經載入到行程的位址空間。

那麼,myglob 的 GOT 項目在 [ecx - 0x10],就讓我們確認一下那裡有什麼:


(gdb) x 0x132fe4
0x132fe4:     0x0013300c

因此,我們預期 0x0013300c 會是 myglob 的位址,驗證一下吧:


(gdb) p &myglob
$1 = (int *) 0x13300c

果然沒錯,就是這樣!

PIC 中的函式呼叫

好啦,所以這就是位址無關程式碼裡資料定址怎麼運作的,但函式呼叫呢?理論上,相同的方式也能用在函式呼叫身上,也就是 call 並不實際包含要呼叫函式的位址,而是包含一筆已知的 GOT 項目,並且在載入期間填寫該項目。

但是 PIC 中的函式呼叫並不是這麼運作的,實際的運作過程有一點複雜,在解釋它怎麼做的之前,要講下這麼一個機制的動機。

基於惰性繫結(lazy binding)的最佳化

當共享函式庫參照到某個函式時,那個函式的位址直到載入階段之前都是未知的,解析(resolve)這個位址的過程叫做繫結(binding,也譯作「綁定」),而這是動態載入器把共享函式庫載入到行程的記憶體空間時,要做的一些事,繫結的過程並不容易,因為載入器需要實際在特殊表格〔5〕查找函式符號。

因此,解析各個函式會花費時間,雖然不多,不過它是會增多的,因為一般而言函式庫裡的函式數量,要比全域變數數量多上許多。還有,這些重定位中的大部分都是徒勞的,因為程式一次典型的執行中,只有一部份的函式會真的被呼叫到(想想處理錯誤及特殊情況的函式,這都是一般而言根本不會被呼叫的)。

所以,為了加速這個流程,一個巧妙的惰性繫結方案被構想出來了,「惰性(lazy)」在電腦程式設計是一個通用名詞,用於一類最佳化方法,將工作延遲到真正需要之前的最後一刻,只要程式特定某次執行不會用到工作的結果,就會企圖避免做這個工作。關於惰性的好例子有寫入時複製(copy-on-write)惰性求值(lazy evaluation)

惰性繫結方案的達到是靠再增加另一層的間接導向——PLT。

程序連結表(procedure linkage table,PLT)

PLT 是執行檔文字節區的一部份,由一組項目組成(每個共享函式庫中會呼叫的外部函式5 都對應一個),每個 PLT 項目都是一小塊可執行程式碼,程式碼並不會直接呼叫函式,而是呼叫 PLT 中的一個項目,而它會小心地呼叫實際的函式。這種安排有時被叫做「跳板(trampoline)」。每個 PLT 項目也會有一筆對應的項目在 GOT,該項目含有函式真正到函式的偏移值,不過只有當動態載入器解析它之後才會有。我知道這令人困惑,但還是希望一旦我用接下來的幾個段落與圖示解釋之後,能變得清楚一點。

如同前一節提到的,PLT 允許函式的惰性解析(lazy resolution),在共享函式庫一開始載入時,函式呼叫還沒有被解析:

解說:

  • 在程式碼中,函式 func 被呼叫,編譯器將其轉換成對 func@plt 的呼叫,這是 PLT 裡個第 N 個項目。
  • PLT 的組成由特殊的第一個項目,在接著一堆結構相同、且各自相對應一個需解析函式的項目。
  • 除了第一個之外,每個 PLT 項目由這些部分組成:
    • 一個跳躍指令到由相應 GOT 項目指定的位址
    • 準備要給「解析器」程序的引數
    • 呼叫解析器程序,它坐落在 PLT 的第一個項目
  • 第一個 PLT 項目是對解析器程序的呼叫,該程序位在動態載器本身裡面〔6〕,這個程序會解析函式的實際位址,其他關於它的動作稍後再說。
  • 在函式的實際位址被解析出來之前,第 N 筆 GOT 項目僅僅是指向跳躍指令的後面,這也是圖中箭頭顏色不一樣的原因——這並非實際的跳躍指令,只是指標。

在第一次呼叫 func 時,發生的事情是:

  • PLT[n] 被呼叫,並跳到 GOT[n] 所指向的位址6
  • 這個位址指向 PLT[n] 本身裡面,指到要為解析器引數做準備處。
  • 解析器被呼叫。
  • 解析器進行對 func 實際位址的解析,把它的實際位址放到 GOT[n] 並呼叫 func

在第一次呼叫後,整張圖看起來會有點不同:

注意 GOT[n] 現在指向真正的 func〔7〕,而不是指回 PLT,所以,當函式被再次呼叫:

  • PLT[n] 被呼叫,並跳到 GOT[n] 所指向的位址。
  • GOT[n] 指向 func,所以便轉移控制到 func

換句話說,現在 func 會真的被呼叫,不再通過解析器,代價是一個額外的跳躍指令。這就是所有發生的事了,真的。這個機制允許函式的惰性解析,而實際是沒被呼叫的函式就根本不需要做解析了。

它同時留下了完全位址無關的程式碼/文字區段,因為唯一會用到絕對位址的地方是 GOT,GOT 落在資料區段而且會由動態載入器做重定位;甚至,由於 PLT 本身也是 PIC 的,所以它也能存在唯讀的文字區段內。

我沒有在解析器上探索太多細節,不過它對我們的目標真的不太重要,解析器單純只是一段進行符號解析的低階程式碼罷了,在每個 PLT 項目中準備的引數,配上合適的重定位項目,幫助它了解需要做解析的符號和需要做更新的 GOT 項目。

透過 PLT 與 GOT 來做函式呼叫的 PIC——範例

再一次的,為了用實際的實證來加強難學的理論,這有個完整的案例演示使用上述機制的函式呼叫解析方式,這次我會講得快一點。

這裡是共享函式庫的程式碼:


int myglob = 42;

int ml_util_func(int a)
{
    return a + 1;
}

int ml_func(int a, int b)
{
    int c = b + ml_util_func(a);
    myglob += c;
    return b + myglob;
}

這段程式碼會編成 libmlpic.so,而專注點是從 ml_funcml_util_func 的呼叫,先來反組譯 ml_func


00000477 <ml_func>:
 477:   55                      push   ebp
 478:   89 e5                   mov    ebp,esp
 47a:   53                      push   ebx
 47b:   83 ec 24                sub    esp,0x24
 47e:   e8 e4 ff ff ff          call   467 <__i686.get_pc_thunk.bx>
 483:   81 c3 71 1b 00 00       add    ebx,0x1b71
 489:   8b 45 08                mov    eax,DWORD PTR [ebp+0x8]
 48c:   89 04 24                mov    DWORD PTR [esp],eax
 48f:   e8 0c ff ff ff          call   3a0 <ml_util_func@plt>
 <... 刪剪其他程式碼>

我們感興趣的是對 ml_util_func@plt 的呼叫,也要注意到 GOT 的位置被放在 ebx。而這邊是 ml_util_func@plt 看起來的樣子(它位在一個叫 .plt 的可執行區段)


000003a0 <ml_util_func@plt>:
 3a0:   ff a3 14 00 00 00       jmp    DWORD PTR [ebx+0x14]
 3a6:   68 10 00 00 00          push   0x10
 3ab:   e9 c0 ff ff ff          jmp    370 <_init+0x30>

回想一下每個 PLT 項目由三個部分組成:

  • 一個跳躍指令到 GOT 指定的位址(在這裡是跳到 [ebx+0x14]
  • 準備要給解析器的引數
  • 呼叫解析器

解析器(PLT 項目 0)落在位址 0x370,不過在此處我們對其不感興趣,比較有趣的是看看 GOT 含有什麼,為此,我們必須先算點數學。

ml_func 裡,「取得 IP」的伎倆是在位址 0x483 完成的,而它又加了 0x1b71,所以 GOT 的基底位置是 0x1ff4,我們可以用 readelf 偷看一下 GOT 的內容了〔8〕


> readelf -x .got.plt libmlpic.so

Hex dump of section '.got.plt':
  0x00001ff4 241f0000 00000000 00000000 86030000 $...............
  0x00002004 96030000 a6030000                   ........

ml_util_func@plt 所用的 GOT 項目是在偏移值 +0x14,或者說 0x2008,從上面看來,在該位置的字組是 0x3a6,而這是 ml_util_func@pltpush 指令的位址。

為了幫助動態載入器做事,也增加了一筆重定位項目,指定 GOT 裡要給 ml_util_func 的位置:


> readelf -r libmlpic.so
[...] 刪剪輸出

Relocation section '.rel.plt' at offset 0x328 contains 3 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00002000  00000107 R_386_JUMP_SLOT   00000000   __cxa_finalize
00002004  00000207 R_386_JUMP_SLOT   00000000   __gmon_start__
00002008  00000707 R_386_JUMP_SLOT   0000046c   ml_util_func

最後一行代表動態載入器應該把符號 ml_util_func 的值(位址)放進 0x2008(回想一下,這就是配合這個函式的 GOT 項目)。

在第一次呼叫之後,看到這筆 GOT 項目的修改真的發生會很有意思,所以再次使用 GDB 來查驗一下。


> gdb driver
[...] 略過輸出
(gdb) set environment LD_LIBRARY_PATH=.
(gdb) break ml_func
Breakpoint 1 at 0x80483c0
(gdb) run
Starting program: /pic_tests/driver

Breakpoint 1, ml_func (a=1, b=1) at ml_main.c:10
10        int c = b + ml_util_func(a);
(gdb)

我們現在位於第一次呼叫 ml_util_func 之前,回憶一下,這裡的程式碼會讓 ebx 指向 GOT,來看看裡面是什麼:


(gdb) i registers ebx
ebx            0x132ff4

而我們所要的項目的偏移值在 [ebx+0x14]


(gdb) x/w 0x133008
0x133008:     0x001313a6

很好,以 0x3a6 結尾,看起來是對的,現在,讓我們單步執行(step)直到呼叫 ml_util_func 後再來確認一下:


(gdb) step
ml_util_func (a=1) at ml_main.c:5
5         return a + 1;
(gdb) x/w 0x133008
0x133008:     0x0013146c

0x133008 的值被改過了,因此,0x0013146c 應該會是 ml_util_func 的真正位址,由動態載入器放在那裡的:


(gdb) p &ml_util_func
$1 = (int (*)(int)) 0x13146c <ml_util_func>

果然正如預期。

控制重定位由載入器完成的條件與時機

這是一個好位置,來提一下由動態載入器進行的惰性符號解析過程,是可以被某些環境變數(以及在連結共享函式庫時,給 ld 的相應旗標)調整的,有時候這對於特殊效能需求或除錯是很有用的。

當環境變數 LD_BIND_NOW 被定義時,會告訴動態載入器永遠要在啟動階段,就進行所有符號的解析,而非惰性地進行。藉由設定這個環境變數,並以 GDB 重新執行之前的範例,你就可以輕易地用行動來驗證這件事,你會看到配合 ml_util_func 的 GOT 項目已經包含了它真正的位址,甚至是在對函式做第一次呼叫之前。

相對地,環境變數 LD_BIND_NOT 告訴動態載入器完全不要更新 GOT 項目,每次呼叫外部函式都會透過動態載入器,並重新做解析。

動態載入器還可以被其他旗標調整,我鼓勵你們去看一下 man ld.so——它包含了一些有趣的資訊。

PIC 的代價

本文一開始定位了載入期重定位的問題,以及 PIC 方案是如何處理它們的,然而,PIC 也並非毫無問題,一個直接明顯的代價是在 PIC 中,所有對外部資料與程式碼的參照都需要額外的間接導向,每個對全域變數的參照、每個對函式的呼叫,那都會是額外的記憶體負擔,這個問題有多大,實務上依賴在編譯器、CPU 架構和特定的應用程式上。

另一個比較不明顯的代價,是因實作 PIC 時所需,而增多的暫存器用量,為了避免太頻繁定位 GOT,對編譯器而言,產生將 GOT 位址保留在暫存器(通常是 ebx)的程式碼是很合理的。但為了 GOT 的緣故,就束縛住了一整個暫存器,儘管在 RISC 架構不是個大問題,因為那種架構傾向於擁有大量的通用暫存器(general purposes register),但是這對類似 x86 的架構,只擁有少量暫存器,會導致效能問題。PIC 不但意味著少了一個暫存器,還多了一個間接導向的消耗,因此都會導致現在必須做更多的記憶體參照。

結語

本文解釋了什麼是位址無關程式碼,以及它如何協助產生有可共享的唯讀文字區段的共享函式庫,在 PIC 跟替代方案(載入期重定位)之間有著一些權衡,而最終結果實際依賴於大量因素,例如程式將於運行的 CPU 架構。

雖然是這麼說,但 PIC 正變得越來越普遍,某些非 Intel 架構,例如 SPARC64,會強制共享函式庫使用只用 PIC 的程式碼,還有許多其他架構(例如 ARM)包含了相對於 IP 的定址模式,來讓 PIC 更有效率。這兩者對於 x86 的接替者,x64 而言,都已經採用,我會在以後的文章中探討 x64 上的 PIC。

然而,本文所專注的,並非效能的考量或架構的抉擇,我的目的是解釋,當 PIC 被使用時,是如何運作的,如果解釋有不夠清楚的地方——請利用評論讓我知曉,我會試著提供更多資訊。


〔1〕
除非所有應用程式都把這個函式庫載入到完全相同的虛擬記憶體位址,但這在 Linux 上通常不會發生。

(譯註:事實上,由於 Linux 採用的寫入時複製(copy-on-write)技術,即使載入到相同的位址,一樣會在 RAM 裡產生分開的複本。)
〔2〕
0x444(以及所有其他在計算中提及的位址)是相對於共享函式庫載入位址的,而載入位址直到執行檔實際在執行期載入函式庫前都是未知的。注意它為何對程式碼不造成影響,是因為這裡的計算只操弄*相對*位址。
〔3〕
敏銳的讀者可能會想究竟為什麼 .got 會是一個分離的區段,我不是剛剛才秀在圖中說它位於資料區段嗎?實際上,這的確沒錯。我並不想探討所謂 ELF 區段與節區之間的差別,因為這偏離主題太遠了。簡單來說,一個函式庫可以定義任何數量的「資料」區段,並映射進一個可讀可寫節區,這其實不重要,只要 ELF 檔案能正確組織起來就好了。但分離資料節區成為不同的邏輯區段能提供模組化並讓連結器的工作變簡單。
〔4〕
注意到 gdb 略過了 ecx 被賦值的部分,那是因為這部分某種程度上被認為是函式的 prolog(當然,真正的原因是 gcc 建構除錯資訊的方式),在一個函式內部會有數個對全域資料與函式的參照,用一個暫存器指向 GOT 可以為所有的參照服務。

(譯註:如果想要不略過 prolog,之前設中斷點(breakpoint)時可以用 break *ml_func 這種指定函式位址的方式。而如果想了解 GDB 分析 prolog 時的設計理念,可以參考 GDB Wiki 的〈Prologue Analysis〉一文;而想知道 GCC 在建構除錯資訊時的內容,則可以參考原作者的〈How debuggers work: Part 3 - Debugging information〉一文。)
〔5〕
共享函式庫的 ELF 目的檔實際上為此附帶著特殊雜湊表區段。

(譯註:在現代 Linux 上,這個區段會叫做 .hash.gnu.hash。)
〔6〕
Linux 上的動態載入器就只是另一個會被所有運行中行程載入到位址空間的共享函式庫。
〔7〕
我把 `func` 放在一個分開的程式碼區段,儘管理論上跟做出呼叫 `func` 的地方放在在同一個區段(也就是同一個共享函式庫)也行。這篇文章的〈加分題〉裡面有為什麼對同一個共享函式庫的外部函式做呼叫也需要 PIC(或是重定位)的資訊。
〔8〕
回想一下,我在資料參照的範例裡,承諾過會解釋為什麼似乎在目的檔裡有兩個 GOT 區段:.got.got.plt。現在應該很明顯了,這只是為了方便把為了全域資料的 GOT 項目跟為了 PLT 的 GOT 項目分割開來,這也是為什麼當 GOT 偏移值在函式中計算出來時,會指向接在 .got 後面的 .got.plt,這樣子,負數的偏移值會到 .got,而正數的偏移值會到 .got.plt,這樣的安排並非強制性的,這兩個部分也可以放進單一的 .got 區段。

(譯註:其實為什麼分成兩塊,與名為「relro(relocation read-only)」的安全技術有關,在當時的郵件列表(mailing list)中有詳細的資訊。至於 GOT 基底指標的計算,是因為根據 ELF 規格的內容,呼叫 PLT 函式時,ebx 保存的值必須指向一些與 PLT 機制有關的特殊值(可以參考譯註 6),而這些值會與對應於 PLT 的項目放在一起,構成了 .got.plt,所以才有現在的結果。當然了,在使用資料參照時,並不受此限制影響,但反正這樣的基底指標也能用,又何必自找麻煩呢?)

↑↑↑↑↑↑ 正文結束 ↑↑↑↑↑↑

本文以 StackEdit 撰寫。

譯註:


  1. 雖然在一般的情況下,文字區段(text section)與文字節區(text segment)通常是通用的;然而在 ELF 中,.text 區段和文字節區卻各自有著特定的定義,因此不能混用。儘管在本文中不影響理解,但事實上原文中是有些混用的,而翻譯時尊重原作者,保留原文用法。
  2. 這一點應該是原文作者搞錯了,現今 Linux 上的動態載入器 ld-linux.so是由 Glibc 所提供,ld-linux.so 只會在進行重定位的當下,調用系統呼叫 mprotect 暫時讓特定節區變得可寫,而不會讓它們一直保持可寫。在 Glibc 2.25 中,動態載入器執行重定位的相關函式 _dl_relocate_object 中,可以找到以下程式碼(縮排有排版過,並略過所有不影響理解的內容):
    void _dl_relocate_object (struct link_map *l, struct r_scope_elem *scope[], int reloc_mode, int consider_profiling) { /* (略) */ if (__glibc_unlikely (l->l_info[DT_TEXTREL] != NULL)) { /* Bletch. We must make read-only segments writable long enough to relocate them. */ for (ph = l->l_phdr; ph < &l->l_phdr[l->l_phnum]; ++ph) if (ph->p_type == PT_LOAD && (ph->p_flags & PF_W) == 0) { /* (略) */ if (__mprotect (newp->start, newp->len, PROT_READ|PROT_WRITE) < 0) { errstring = N_("cannot make segment writable for relocation"); call_error: _dl_signal_error (errno, l->l_name, NULL, errstring); } /* (略) */ } } { /* Do the actual relocation of the object's GOT and other data. */ /* (略) */ ELF_DYNAMIC_RELOCATE (l, lazy, consider_profiling, skip_ifunc); /* (略) */ } /* (略) */ /* Undo the segment protection changes. */ while (__builtin_expect (textrels != NULL, 0)) { if (__mprotect (textrels->start, textrels->len, textrels->prot) < 0) { errstring = N_("cannot restore segment prot after reloc"); goto call_error; } /* (略) */ textrels = textrels->next;
    } /* (略) */ }

    可以看出重定位過程可以分成三部分:修改可讀可寫、套用重定位、還原讀寫屬性,雖然仍有可能被攻擊者抓住可寫的時機,但事實上風險遠不及原文作者所述。
  3. 本文使用的是 Intel 格式的組合語言語法
  4. 所謂的外部函式(external function),指的是能有做輸出(export)的函式,能在其他執行檔中引用。在 Linux 對 C 的實作中,預設所有一般函式都是外部函式。
  5. 事實上,與 PLT 類似,GOT 的前幾項也有特殊用途,在 x86 上,GOT 的前三項有著特殊用途(與 PLT 機制有關),因此除了這些項目,實際的對應方式,會是 PLT[1] 對應 GOT[3]PLT[2] 對應 GOT[4]、…以此類推。

沒有留言 :

張貼留言