吳章金: 深度剖析 Linux共享庫的“位置無關”實現原理

640?wx_fmt=png

license: "cc-by-nc-nd-4.0"

背景簡介

>

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("hello\n");

    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=.LC0eipGLOBAL_OFFSET_TABLE

計算過程如下:

$ echo "obase=16;$((0x510-0x4cd-0x1B33))" | bc
-1AF0

反過來,運行時的計算公式爲:

>

.LC0 =GLOBAL_OFFSET_TABLE + .LC0@GOTOFF + eip

.got.plt = GLOBALOFFSETTABLE+.LC0@GOTOFF+eip.LC0=0x1B33+(1AF0)+eip.got.plt=GLOBAL_OFFSET_TABLE + 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 地址密切相關,受限於篇幅,暫時不做詳細展開。

(完)


更多精彩,盡在"Linux閱碼場",掃描下方二維碼關注

640?wx_fmt=png

你的隨手轉發或點個在看是對我們最大的支持!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章