在1980s SunOS 將動態庫引入到 UNIX,後來又將 ELF(Executable and Linkable) 格式引入到了 UNIX。在現在看來,動態庫的出現主要解決了一系列的問題。內存問題,內核不需要為每個進程都加載一份動態庫,除數據區以外,所有進程共享一個動態庫。可擴展性,在程序不用重啟的情況下,動態的加載所需要的動態庫,可實現對程序的擴展。另外動態庫還解決了程序動態更新的問題,通過對動態庫版本的控制,可以完美的對程序接口進行更行升級。
在正式開始講解 Linux 動態庫工作原理的符號重定向之前,先給大家介紹下在本文中用到的幾個重要的工具:
•readelf
一個可以查看ELF格式文件的強大工具。
•objdump
可以將ELF格式的文件轉化成匯編指令。
•gdb
在本文分析問題的過程中,需要gdb進行調試來查看寄存器信息。
動態庫編譯鏈接
先貼出來本文要講解的 libincrease.so 的源碼
1 // increase.c
2 #include <unistd.h>
3 #include <stdio.h>
4 #include <stdlib.h>
5
6 int count = 7;
7
8 void increase()
9 {
10 count = count + 1;
11 }
任何程序,想要使用count或者increase,都必須知道這些變量和函數在內存中的地址。最終反應到匯編代碼裡面,需要訪問任何變量和函數,都需要使用mov指令,將某個內存單元的地址傳送到一個寄存器中。
由於動態庫是在程序運行過程中被載入到進程的地址空間的,在動態庫被編譯和鏈接期間,動態庫內所有的符號都是用的是相對地址。不僅如此,任何程序都可以使用任何數量的動態庫,且同一個動態庫在不同程序中被加載到進程空間的順序也不一樣。也就意味著編譯和鏈接階段是無法知道要使用的變量和函數在進程空間的地址。
為了解決程序運行過程中能夠准確的找到動態庫中的符號,在Linux中,有兩種方案:
1.加載過程中重定位
2.使用Position Independent Code(PIC)
重定位和PIC是通過在編譯動態庫的時候使用-shared或者-fPIC來控制的。本文主要講解動態庫加載過程中的重定位。
libincrease.so的編譯命令如下:
gcc -m32 -g -c increase.c -o increase.o
gcc -m32 -shared -o libincrease.so increase.o
為了方便對問題的分析,我這裡使用了-m32來強行編譯32位的程序。
我們先使用readelf -h來查看libincrease.so的ELF文件頭信息:
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x380
Start of program headers: 52 (bytes into file)
Start of section headers: 4984 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 7
Size of section headers: 40 (bytes)
Number of section headers: 33
Section header string table index: 30
根據ELF Specification的解釋。 ELF頭部有16個字節,其中第一個字節7f是固定的魔數,後面的 45,4c,46分別是'E', 'L', 'F'對應的ASIC碼值。後面三個字節依次標示當前的ELF支持的系統位數(32位/64位),大小端以及版本號。其中 Entry point address 0x380 表示進程開始執行的地方。
變量的重定位
使用命令:
readelf --segments libincrease.so
得到的動態庫程序頭部信息如下
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x00000000 0x00000000 0x00520 0x00520 R E 0x1000
LOAD 0x000f0c 0x00001f0c 0x00001f0c 0x00104 0x0010c RW 0x1000
DYNAMIC 0x000f20 0x00001f20 0x00001f20 0x000c8 0x000c8 RW 0x4
NOTE 0x000114 0x00000114 0x00000114 0x00024 0x00024 R 0x4
GNU_EH_FRAME 0x0004a4 0x000004a4 0x000004a4 0x0001c 0x0001c R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
GNU_RELRO 0x000f0c 0x00001f0c 0x00001f0c 0x000f4 0x000f4 R 0x1
Section to Segment mapping:
Segment Sections...
00 .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version
.gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .eh_frame_hdr .eh_frame
01 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
02 .dynamic
03 .note.gnu.build-id
04 .eh_frame_hdr
05
06 .ctors .dtors .jcr .dynamic .got
從這個頭部信息,我們可以看到有兩個LOAD,其中第一個LOAD被標記為RE,只讀可執行,說明是代碼段。第二個LOAD被標記為RW,可讀寫,說明是數據段。數據段從0x00001f0c(VirtAddr)開始,長度是0x00104(FileSiz)。
下面的Section to Segment mapping是上面的程序頭部每一個地址段對應的一個map,01對應於上面的第二個LOAD。從01可以看到.data以及.bss都在0x00001f0c開始的數據段內。
我們在使用命令:
readelf -r libincrease.so
看下動態重定位區的數據:
Relocation section '.rel.dyn' at offset 0x2dc contains 6 entries:
Offset Info Type Sym.Value Sym. Name
00002008 00000008 R_386_RELATIVE
00000440 00000701 R_386_32 0000200c count
00000448 00000701 R_386_32 0000200c count
00001fe8 00000106 R_386_GLOB_DAT 00000000 __cxa_finalize
00001fec 00000206 R_386_GLOB_DAT 00000000 __gmon_start__
00001ff0 00000306 R_386_GLOB_DAT 00000000 _Jv_RegisterClasses
.rel.dyn表示動態重定位區。動態庫在加載過程中的重定位就是使用.rel.dyn重定位數據。在這裡可以看到count出現了2次,是因為count在increase函數中,需要被使用2次。所以,在這個動態庫中,有兩個地方需要用到count的內存單元的地址。後面的Sym.value表示count這個符號在整個動態庫中的偏移量為0x200c。
通過以下命令
objdump -d -Mintel libincrease.so
dump出來的increase函數匯編代碼如下:
0000043c <increase>:
43c: 55 push ebp
43d: 89 e5 mov ebp,esp
43f: a1 00 00 00 00 mov eax,ds:0x0
444: 83 c0 01 add eax,0x1
447: a3 00 00 00 00 mov ds:0x0,eax
44c: 5d pop ebp
44d: c3 ret
44e: 90 nop
44f: 90 nop
通過這段匯編代碼,可以很明顯的函數mov eax,ds:0x0是在將count傳送到eax中去。但是此時count的地址是多少呢?
我們再回頭看看上面的動態重定位區的數據。第一個count對應的是在increase第一次出現的count。Type R_386_32,通過查看ELF specification,發現R_386_32操作的意思就是將符號的地址存放到Offset對應的地方去。那麼在這的意思就是,將count的真實地址存放到偏移量位0x440的地方去。我們在看看偏移量位0x440得地方有什麼。我們可以看到,increase匯編代碼中第一次取count的代碼
43f: a1 00 00 00 00 mov eax,ds:0x0
其中a1是mov指令的編碼,該編碼占一個字節,後面緊跟著是mov的操作數存放在0x440的地方。看到這裡,就明白了,動態重定位區的第一個count給出的信息是將count的真實地址存放到偏移量位0x440的地方,這樣就完成了第一個count的重定位。
為了驗證這個分析,我們來動態調試一番。為了獲取動態庫在內存中載入的起始地址,我們使用了dl_iterate_phdr。該函數可以在程序運行過程中獲取載入該進程的所有動態庫的信息。
1 #define _GNU_SOURCE
2 #include <link.h>
3 #include <unistd.h>
4 #include <stdio.h>
5 #include <stdlib.h>
6
7 static int header_handler(struct dl_phdr_info* info, size_t size, void* data)
8 {
9 printf("name=%s (%d segments) address=%p\n",
10 info->dlpi_name, info->dlpi_phnum, (void*)info->dlpi_addr);
11 for (int j = 0; j < info->dlpi_phnum; j++) {
12 printf("\t\t header %2d: address=%10p\n", j,
13 (void*) (info->dlpi_addr + info->dlpi_phdr[j].p_vaddr));
14 printf("\t\t\t type=%u, flags=0x%X\n",
15 info->dlpi_phdr[j].p_type, info->dlpi_phdr[j].p_flags);
16 }
17 printf("\n");
18 return 0;
19 }
20
21 extern void increase();
22
23 int main(int argc, char** argv)
24 {
25 dl_iterate_phdr(header_handler, NULL);
26 increase();
27
28 return 0;
29 }
編譯命令如下:
gcc -m32 -std=gnu99 -g main.c -L. -lincrease -o main
運行之前,需要將libincrease.so的路徑export到LD_LIBRARY_PATH當中才能讓程序正常運行。
使用gdb調試,得到的libincrease.so在進程地址空間信息如下:
name=libincrease.so (7 segments) address=0xf7fd6000
header 0: address=0xf7fd6000
type=1, flags=0x5
header 1: address=0xf7fd7f0c
type=1, flags=0x6
header 2: address=0xf7fd7f20
type=2, flags=0x6
header 3: address=0xf7fd6114
type=4, flags=0x4
header 4: address=0xf7fd64a4
type=1685382480, flags=0x4
header 5: address=0xf7fd6000
type=1685382481, flags=0x6
header 6: address=0xf7fd7f0c
type=1685382482, flags=0x4
我們可以得到的信息是libincrease.so從進程的地址空間0xf7fd6000開始。根據之前的理論,在動態庫加載過程中重定位的時候,將動態庫Offset位0x440處的內存單元內容替換成了count的真實地址了。而之前我們已經分析出,count變量在動態庫內部的偏移量是0x200c。所以,此時count在當前進程地址空間的地址應該是 0xf7fd6000 + 0x200c = 0xf7fd800c。不僅如此,在動態庫偏移量位0x440處的內容也應該是count的地址,即0xf7fd800c。所以,在0xf7fd6000 + 0x440 = 0xf7fd6440處應該是0xf7fd800c。
OK。接下來我就要在gdb中打印count的地址和內存地址0xf7fd6440處的內容了:
(gdb) print &count
$7 = (int *) 0xf7fd800c
(gdb) x /4xb 0xf7fd6440
0xf7fd6440 <increase+4>: 0x0c 0x80 0xfd 0xf7
Cool,奇跡出現了。跟我們與其的一模一樣!需要注意的是,在我打印0xf7fd6440處的內容的時候,我是用了x /4xb 0xf7fd6440,關於如何查看指定內存的gdb命令請參考Examining memory。
函數的重定位
上面分析了關於動態庫中變量的重定位。現在再來看看動態庫中的函數,在動態庫加載的過程中是如何完成重定位的。 為了驗證動態庫內部函數的重定位,我們需要對increase.c進行改造
1 #include <unistd.h>
2 #include <stdio.h>
3 #include <stdlib.h>
4
5 int count = 7;
6
7 void increase()
8 {
9 count = count + 1;
10 }
11
12 void doincrease(int n)
13 {
14 count += n;
15 increase();
16 }
objdump -d -Mintel libincrease.so
dump出來的doincrease函數的匯編代碼:
0000047e <doincrease>:
47e: 55 push ebp
47f: 89 e5 mov ebp,esp
481: a1 00 00 00 00 mov eax,ds:0x0
486: 03 45 08 add eax,DWORD PTR [ebp+0x8]
489: a3 00 00 00 00 mov ds:0x0,eax
48e: e8 fc ff ff ff call 48f <doincrease+0x11>
493: 5d pop ebp
494: c3 ret
495: 90 nop
從匯編代碼可以看出在48e: e8 fc ff ff ff call 48f 處,會調用increase函數。
我們還是需要去動態重定向區查看信息
Relocation section '.rel.dyn' at offset 0x2f4 contains 9 entries:
Offset Info Type Sym.Value Sym. Name
00002008 00000008 R_386_RELATIVE
00000470 00000701 R_386_32 0000200c count
00000478 00000701 R_386_32 0000200c count
00000482 00000701 R_386_32 0000200c count
0000048a 00000701 R_386_32 0000200c count
0000048f 00000602 R_386_PC32 0000046c increase
00001fe8 00000106 R_386_GLOB_DAT 00000000 __cxa_finalize
00001fec 00000206 R_386_GLOB_DAT 00000000 __gmon_start__
00001ff0 00000306 R_386_GLOB_DAT 00000000 _Jv_RegisterClasses
那麼我們需要弄清楚R_386_PC32,根據ELF Specification,R_386_PC32指的是將Offset內存單元的內容加上Sym.value重定位之後符號的真實地址再減去Offset重定位之後的r_offset,並將結果存放到r_offset對應的內存單元。
那麼對於我們分析函數increase調用的重定位,我們只需要知道libincrease.so在進程空間載入的地址即可,其他的都可以進行計算。 同樣,我們還是使用dl_iterate_phdr來獲取動態庫在進程地址空間的信息:
1 #define _GNU_SOURCE
2 #include <link.h>
3 #include <unistd.h>
4 #include <stdio.h>
5 #include <stdlib.h>
6
7 static int header_handler(struct dl_phdr_info* info, size_t size, void* data)
8 {
9 printf("name=%s (%d segments) address=%p\n",
10 info->dlpi_name, info->dlpi_phnum, (void*)info->dlpi_addr);
11 for (int j = 0; j < info->dlpi_phnum; j++) {
12 printf("\t\t header %2d: address=%10p\n", j,
13 (void*) (info->dlpi_addr + info->dlpi_phdr[j].p_vaddr));
14 printf("\t\t\t type=%u, flags=0x%X\n",
15 info->dlpi_phdr[j].p_type, info->dlpi_phdr[j].p_flags);
16 }
17 printf("\n");
18 return 0;
19 }
20
21 extern void doincrease(int n);
22
23 int main(int argc, char** argv)
24 {
25 dl_iterate_phdr(header_handler, NULL);
26 doincrease(6);
27
28 return 0;
29 }
編譯命令跟之前的一樣。使用gdb調試,查看libincrease.so在內存中載入的地址
name=libincrease.so (7 segments) address=0xf7fd6000
header 0: address=0xf7fd6000
type=1, flags=0x5
header 1: address=0xf7fd7f0c
type=1, flags=0x6
header 2: address=0xf7fd7f20
type=2, flags=0x6
header 3: address=0xf7fd6114
type=4, flags=0x4
header 4: address=0xf7fd64f4
type=1685382480, flags=0x4
header 5: address=0xf7fd6000
type=1685382481, flags=0x6
header 6: address=0xf7fd7f0c
type=1685382482, flags=0x4
我們可以看到libincrease.so載入地址是0xf7fd6000,根據rel.dyn中increase的sym.value值可以得出,在進程中increase函數的真實地址是0xf7fd6000 + 0x46c = 0xf7fd646c。
根據上面.rel.dyn的信息,對於increase的重定位,我們需要將Offset 0x48f處的內容加上increase重定位後的真實地址,再減去Offset重行為後的r_offset的內容,將結果寫回到r_offset標示的內存單元內。
通過上面objdump出來的doincrease中0x48f內存單元的內容是0xfffffffc。對於Offset 0x48f重定位之後r_offset的值是0xf7fd6000 + 0x48f = 0xf7fd648f。那麼.rel.dyn對於重定位increase函數的計算結果是0xf7fd646c + 0xfffffffc - 0xf7fd648f = 0xffffffd9。然後將0xffffffd9寫回到r_offset 0xf7fd648f處。那麼這一系列的重定向之後最終得到的結果應該是在進程地址空間0xf7fd648f內存單元的內容變為0xffffffd9。
OK。我們通過gdb打印結果如下:
(gdb) print increase
$2 = {void ()} 0xf7fd646c <increase>
(gdb) disas /r doincrease
Dump of assembler code for function doincrease:
0xf7fd647e <+0>: 55 push %ebp
0xf7fd647f <+1>: 89 e5 mov %esp,%ebp
0xf7fd6481 <+3>: a1 0c 80 fd f7 mov 0xf7fd800c,%eax
0xf7fd6486 <+8>: 03 45 08 add 0x8(%ebp),%eax
0xf7fd6489 <+11>: a3 0c 80 fd f7 mov %eax,0xf7fd800c
0xf7fd648e <+16>: e8 d9 ff ff ff call 0xf7fd646c <increase>
0xf7fd6493 <+21>: 5d pop %ebp
0xf7fd6494 <+22>: c3 ret
End of assembler dump.
通過gdb打印出increase在進程中的地址是0xf7fd646c。
disas /r doincrease
這條gdb命令是打印運行期間doincrease的匯編代碼。
我們發現在0xf7fd648f內存單元處的內容是0xffffffd9。是不是很cool。所有的結果跟我們之前計算的結果一樣。doincrease中call指令對應的code是e8,後面緊跟著是參數0xffffffd9,也即是我們剛剛計算出來的值,是一個相對地址。那麼真正開始准備跳轉到increase的時候,IP內的地址是0xf7fd6493,程序運行期間,找到increase的地址是0xf7fd6493 + 0xffffffd9 = 0xf7fd646c。回頭看看,0xf7fd646c處是什麼?是increase函數。
結束
到此為止,我們已經將動態庫加載期間,動態庫內的變量和函數的重定位都分析完畢。對於shared方式重定位技術,對於變量重定位過程中就是直接修改所有使用變量的地方,將其直接修改為變量的真實地址。對於函數重定位,就是將調用函數的地方,將其內容修改為當前地址到需要調用的函數的Offset。