吳章金: 深度剖析 Linux共享庫的「位置無關」實現原理
- 2019 年 11 月 11 日
- 筆記
背景簡介
本文再來談談共享庫的運行時位置無關(PIC)是如何做到的。
>
PIC = position independent code -fpic Generate position-independent code (PIC) suitable for use in a shared library
共享庫有一個很重要的特徵,就是可以被多個可執行文件共享,以達到節省磁盤和內存空間的目標:
- 共享意味着不僅磁盤上只有一份拷貝,加載到內存以後也只有一份拷貝,那麼代碼部分在運行時也不能被修改,否則就得有多個拷貝存在
- 同時意味着,需要能夠靈活映射在不同的虛擬地址空間,以便適應不同程序,避免地址衝突
這兩點要求共享庫的代碼和數據都是位置無關的,接下來先看看什麼是「位置無關」。
什麼是位置無關
同樣以 hello.c 為例:
#include <stdio.h> int main(void) { printf("hellon"); return 0; }
以普通的方式來編譯並反彙編一個可執行文件看看:
$ gcc -m32 -o hello hello.c $ objdump -d hello | grep -B1 "call.*puts@plt>" 8048416: 68 b0 84 04 08 push $0x80484b0 804841b: e8 c0 fe ff ff call 80482e0 <puts@plt>
可以看到上面傳遞給 puts
(printf)的字符串地址是「寫死的」,在編譯時就是確定的,這意味着 Load Address 也必須是固定的:
$ readelf -l hello | grep LOAD | head -1 LOAD 0x000000 0x08048000 0x08048000 0x005b0 0x005b0 R E 0x1000
上面可以看到 Load Address 為 0x8048000。
如果 Load Address 改變,數據地址就指向別的內容了,這就是「位置有關」。
共享庫的話,必須摒棄這種「寫死的」地址,要做到「位置無關」(註:prelink 是特殊需求,暫且不表)。
如何做到位置無關(Part1)
位置無關,意味着運行時可以靈活調整 Load Address,當 Load Address 在運行時發生改變後,代碼還能被執行到,數據也能被正確訪問。
那麼代碼和數據都變成跟 Load Address 相關的,不能再是絕對地址,而需要採用某個相對 Load Address 的地址。
動態鏈接器會負責找到可執行文件的共享庫並裝載它們,所以動態鏈接器是知道這個 Load Address 的,那麼函數符號其實是很容易確定的,來看看不帶 -fpic
時編譯生成一個共享庫:
- 查看
main
函數的初始地址
$ gcc -m32 -shared -o libhello.so hello.c $ objdump -d libhello.so | grep -A 2 "main>:" 000004a9 <main>: 4a9: 8d 4c 24 04 lea 0x4(%esp),%ecx 4ad: 83 e4 f0 and $0xfffffff0,%esp
- 查看「裝載地址」,編譯後初始化為 0
$ readelf -l libhello.so | grep LOAD | head -1 LOAD 0x000000 0x00000000 0x00000000 0x0057c 0x0057c R E 0x1000
- 確認
main
在文件中的偏移
$ readelf --dyn-syms libhello.so | grep m Symbol table '.dynsym' contains 12 entries: Num: Value Size Type Bind Vis Ndx Name 4: 00000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 9: 000004a9 46 FUNC GLOBAL DEFAULT 11 main $ hexdump -C -s $((0x4a9)) -n 10 libhello.so 000004a9 8d 4c 24 04 83 e4 f0 ff 71 fc |.L$.....q.| 000004b3
可以看到,對於 main
而言,無論把共享庫裝載到哪裡,動態鏈接器總能根據 Load Address 以及 .dynsym
中的偏移把 main
的運行時地址算出來(見 glibc:_dl_fixup
)。
但是,這個時候(不用 -fpic
的話),數據地址也是「寫死的」:
$ objdump -d libhello.so | grep -B1 "call.*main" 4bd: 68 ec 04 00 00 push $0x4ec 4c2: e8 fc ff ff ff call 4c3 <main+0x1a>
作為對比,來看看加上 -fpic
的效果:
$ gcc -m32 -shared -fpic -o libhello.so hello.c $ objdump -dr libhello.so | grep -B6 "call.*puts@plt>" 4c8: e8 28 00 00 00 call 4f5 <__x86.get_pc_thunk.ax> 4cd: 05 33 1b 00 00 add $0x1b33,%eax 4d2: 83 ec 0c sub $0xc,%esp 4d5: 8d 90 10 e5 ff ff lea -0x1af0(%eax),%edx 4db: 52 push %edx 4dc: 89 c3 mov %eax,%ebx 4de: e8 bd fe ff ff call 3a0 <puts@plt>
可以看到,用上 -fpic
以後,傳遞給 puts 的數據地址(push %edx
)已經是通過動態計算的,那是怎麼算的呢?
上面有個內聯進來的函數很關鍵:
$ objdump -dr libhello.so | grep -A3 "__x86.get_pc_thunk.ax>:" 000004f5 <__x86.get_pc_thunk.ax>: 4f5: 8b 04 24 mov (%esp),%eax 4f8: c3 ret
這個函數賊簡單,從棧頂取了一個數據就跳回去了,取的數據是什麼呢?這就要了解調用它的 call
指令了。
call
指令會把下一條指令的 eip
壓棧然後 jump 到目標地址:
call backward ==> push eip; jmp backward
所以,數據地址是運行時計算的,跟運行時的 「eip」 給關聯上了。
不難猜測,如果知道當前指令的位置,又提前保存了數據離當前位置的偏移,那麼數據地址是可以直接計算的,只是上面那一段代碼還是略微複雜了,因為有一堆 「Magic Number」。
不管怎麼樣,先來模擬計算一下,假設裝載到的地址就是 0x0,那麼執行到 add
指令時存到 eax 的 eip,恰好是 call
返回後下一條指令的地址,即 0x4cd:
4c8: e8 28 00 00 00 call 4f5 <__x86.get_pc_thunk.ax> 4cd: 05 33 1b 00 00 add $0x1b33,%eax 4d5: 8d 90 10 e5 ff ff lea -0x1af0(%eax),%edx
根據上述指令,那麼 %edx
計算出來就是 0x510:
$ echo "obase=16;$((0x4cd+0x1b33-0x1af0))" | bc 510
再去取數據:
$ hexdump -C -s $((0x510)) -n 10 libhello.so 00000510 68 65 6c 6c 6f 00 00 00 01 1b |hello.....| 0000051a
果然是字符串的地址,所以,相對偏移其實被拆分成了兩部分:0x1b33
和 -0x1af0
。兩個 "Magic Number" 一加就出來了。
所以,小結一下,「位置無關」 是通過運行時動態獲取 「eip」 並加上一個編譯時記錄好的偏移計算出來的,這樣的話,無論加載到什麼位置,都能訪問到數據。
如何做到位置無關(Part2)
這對 「Magic Number」 還是需要再看一看,既然是編譯時確定的,看看彙編狀態是怎麼回事:
$ gcc -m32 -shared -fpic -S hello.c $ cat hello.s | grep -v .cfi ... .LC0: .string "hello" .text .globl main .type main, @function main: .LFB0: leal 4(%esp), %ecx andl $-16, %esp pushl -4(%ecx) pushl %ebp movl %esp, %ebp pushl %ebx pushl %ecx call __x86.get_pc_thunk.ax addl $_GLOBAL_OFFSET_TABLE_, %eax subl $12, %esp leal .LC0@GOTOFF(%eax), %edx pushl %edx movl %eax, %ebx call puts@PLT ...
從 i386 的 archABI 不難找到這塊的定義(P61~P62),name@GOTOFF(%eax)
直接表示 name 符號相對 %eax 保存的 GOT 的偏移地址。
首先,編譯時要計算 $_GLOBAL_OFFSET_TABLE
和 .LC0@GOTOFF
。
$_GLOBAL_OFFSET_TABLE_
為 GOT 相對 eip
的偏移,可計算為:
>
$_GLOBAL_OFFSET_TABLE_ = .got.plt – eip
計算過程如下:
$ readelf -S libhello.so | grep .got.plt [21] .got.plt PROGBITS 00002000 001000 000010 04 WA 0 0 4 $ echo "obase=16;$((0x2000-0x4cd))" | bc 1B33
接着,計算 .LC0@GOTOFF
:
>
.LC0 – eip = GLOBAL_OFFSET_TABLE + .LC0@GOTOFF .LC0@GOTOFF = .LC0 – eip –GLOBALOFFSETTABLE+.LC0@GOTOFF.LC0@GOTOFF=.LC0−eip−GLOBAL_OFFSET_TABLE
計算過程如下:
$ echo "obase=16;$((0x510-0x4cd-0x1B33))" | bc -1AF0
反過來,運行時的計算公式為:
> >
.LC0 =GLOBAL_OFFSET_TABLE + .LC0@GOTOFF + eip .LC0 = 0x1B33 + (-1AF0) + eip .got.plt = GLOBALOFFSETTABLE+.LC0@GOTOFF+eip.LC0=0x1B33+(−1AF0)+eip.got.plt=GLOBAL_OFFSET_TABLE + eip .got.plt = 0x1B33 + eip
實際上,只有 .got.plt 的地址,即 ebx
需要 $_GLOBAL_OFFSET_TABLE_
來計算,這個是用來做動態地址重定位的,暫且不表。
.LC0
的地址,完全可以換一種方式,直接用 .LC0
到 eip 的偏移即可,彙編代碼改造完如下:
call __x86.get_pc_thunk.ax .eip: # 計算 eip + (.LC0 - .eip) 剛好指向內存中的數據 "hello" 所在位置 movl %eax, %ebx leal (.LC0 - .eip)(%eax), %edx # 計算 .got.plt 地址,_GLOBAL_OFFSET_TABLE_ 是相對 eip 的偏移,所以必須加上這個 offset:. - .eip addl $_GLOBAL_OFFSET_TABLE_ + [. - .eip], %ebx subl $12, %esp pushl %edx call puts@PLT
驗證結果:
$ gcc -m32 -g -shared -fpic -o libhello.so hello.s $ gcc -m32 -g -o hello.noc -L./ -lhello $ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./ ./hello.noc hello
小結
本文詳細介紹了 Linux 下 C 語言共享庫「位置無關」(PIC)的核心實現原理:即用 EIP 相對地址來取代絕對地址。
「位置無關」 代碼會帶來很大的內存使用靈活性,也會帶來一定的安全性,因為「位置無關」以後就可以帶來加載地址的隨機性,給代碼注入帶來一定的難度。
由於有上述好處,各大平台的 gcc 都開始默認打開可執行文件的 -pie -fpie
了,因為 gcc 編譯時開啟了:--enable-default-pie
。這也可能導致一些「衰退」,大家可以根據需要關閉它:-no-pie
,-fno-pie
。
當然,共享庫的實現精髓不止於此,最核心的還是函數符號地址的動態解析過程,而這些則跟上面的 .got.plt
地址密切相關,受限於篇幅,暫時不做詳細展開。
(完)