67.1 位置无关代码
在分析Linux共享库的时候(.so)的时候,可能会经常看到类似下面的代码:
Listing 67.1: libc-2.17.so x86
.text:0012D5E3 __x86_get_pc_thunk_bx proc near ; CODE XREF: sub_17350+3
.text:0012D5E3 ; sub_173CC+4 ...
.text:0012D5E3 mov ebx, [esp+0]
.text:0012D5E6 retn
.text:0012D5E6 __x86_get_pc_thunk_bx endp
...
.text:000576C0 sub_576C0 proc near ; CODE XREF: tmpfile+73
...
.text:000576C0 push ebp
.text:000576C1 mov ecx, large gs:0
.text:000576C8 push edi
.text:000576C9 push esi
.text:000576CA push ebx
.text:000576CB call __x86_get_pc_thunk_bx
.text:000576D0 add ebx, 157930h
.text:000576D6 sub esp, 9Ch
...
.text:000579F0 lea eax, (a__gen_tempname - 1AF000h)[ebx] ; "__gen_tempname"
.text:000579F6 mov [esp+0ACh+var_A0], eax
.text:000579FA lea eax, (a__SysdepsPosix - 1AF000h)[ebx] ; "../sysdeps/posix/tempname.c"
.text:00057A00 mov [esp+0ACh+var_A8], eax
.text:00057A04 lea eax, (aInvalidKindIn_ - 1AF000h)[ebx] ; "! \"invalid KIND in __gen_tempname\""
.text:00057A0A mov [esp+0ACh+var_A4], 14Ah
.text:00057A12 mov [esp+0ACh+var_AC], eax
.text:00057A15 call __assert_fail
在每个函数开始处,所有指向字符串的指针都需要通过EBX和一些常量值来修正地址。这就是所谓的PIC(位置无关代码),它的目的是让这段代码即使随机地放在内存中某个位置都能正确地执行。这也是为什么不能使用绝对地址的原因。
PIC(位置无关代码)对于早期的操作系统和现在那些没有虚拟内存支持的嵌入式系统来说至关重要(所有进程都放在同一个连续的内存块)。此外,它还用于*NIX系统的共享库。这样共享库只需要加载一次到内存之后就可以让所有需要的进程使用,而且这些进程可以把同一个共享库映射到各自不同的内存地址上。这也是为什么共享库不使用绝对地址也能够正常地工作。
让我们做一个简单的实验:
#include <stdio.h>
int global_variable=123;
int f1(int var)
{
int rt=global_variable+var;
printf ("returning %d\n", rt);
return rt;
};
用GCC 4.7.3编译它并用IDA查看.so文件的反汇编代码:
gcc -fPIC -shared -O3 -o 1.so 1.c
.text:00000440 public __x86_get_pc_thunk_bx
.text:00000440 __x86_get_pc_thunk_bx proc near ; CODE XREF: _init_proc+4
.text:00000440 ; deregister_tm_clones+4 ...
.text:00000440 mov ebx, [esp+0]
.text:00000443 retn
.text:00000443 __x86_get_pc_thunk_bx endp
.text:00000570 public f1
.text:00000570 f1 proc near
.text:00000570
.text:00000570 var_1C = dword ptr -1Ch
.text:00000570 var_18 = dword ptr -18h
.text:00000570 var_14 = dword ptr -14h
.text:00000570 var_8 = dword ptr -8
.text:00000570 var_4 = dword ptr -4
.text:00000570 arg_0 = dword ptr 4
.text:00000570
.text:00000570 sub esp, 1Ch
.text:00000573 mov [esp+1Ch+var_8], ebx
.text:00000577 call __x86_get_pc_thunk_bx
.text:0000057C add ebx, 1A84h
.text:00000582 mov [esp+1Ch+var_4], esi
.text:00000586 mov eax, ds:(global_variable_ptr - 2000h)[ebx]
.text:0000058C mov esi, [eax]
.text:0000058E lea eax, (aReturningD - 2000h)[ebx] ; "returning %d\n"
.text:00000594 add esi, [esp+1Ch+arg_0]
.text:00000598 mov [esp+1Ch+var_18], eax
.text:0000059C mov [esp+1Ch+var_1C], 1
.text:000005A3 mov [esp+1Ch+var_14], esi
.text:000005A7 call ___printf_chk
.text:000005AC mov eax, esi
.text:000005AE mov ebx, [esp+1Ch+var_8]
.text:000005B2 mov esi, [esp+1Ch+var_4]
.text:000005B6 add esp, 1Ch
.text:000005B9 retn
.text:000005B9 f1 endp
如上所示:每个函数执行时都会矫正“returning %d”和global_variable的地址。__x86_get_pc_thunk_bx()函数通过EBX返回一个指向自身的指针(返回的是0x57C)。这是一种获取程序计数器(EIP)的简单方法。0x1A84常量是这个函数开始处到(Global Offset Table Procedure Linkage Table(GOT PLT))它们之间的距离差。IDA会把这些偏移处理成更容易理解后再显示出来,所以实际上的代码是:
.text:00000577 call __x86_get_pc_thunk_bx
.text:0000057C add ebx, 1A84h
.text:00000582 mov [esp+1Ch+var_4], esi
.text:00000586 mov eax, [ebx-0Ch]
.text:0000058C mov esi, [eax]
.text:0000058E lea eax, [ebx-1A30h]
这里的EBX指向了GOT PLT section。当计算global_variable(存储在GOT)的地址时须减去0x0C偏移量。当计算“returning %d”字符串的地址时须减去0x1A30偏移量。
顺便说一下,AMD64的指令支持使用RIP用于相对寻址,这使得它可以产生出更简洁的PIC代码。
让我们用相同的GCC编译器编译相同的C代码,但使用x64平台。
IDA会简化了反汇编代码,造成我们无法看到使用RIP相对寻址的细节,所以我在这里使用了objdump来查看反汇编代码:
0000000000000720 <f1>:
720: 48 8b 05 b9 08 20 00 mov rax,QWORD PTR [rip+0x2008b9] # 200fe0 <_DYNAMIC+0x1d0>
727: 53 push rbx
728: 89 fb mov ebx,edi
72a: 48 8d 35 20 00 00 00 lea rsi,[rip+0x20] #751 <_fini+0x9>
731: bf 01 00 00 00 mov edi,0x1
736: 03 18 add ebx,DWORD PTR [rax]
738: 31 c0 xor eax,eax
73a: 89 da mov edx,ebx
73c: e8 df fe ff ff call 620 <__printf_chk@plt>
741: 89 d8 mov eax,ebx
743: 5b pop rbx
744: c3 ret
0x2008b9是0x720处指令地址到global_variable地址的差,0x20是0x72a处指令地址到“returning %d”字符串地址的差。
你可能会看到,频繁重新计算地址会导致执行效率变差(虽然在x64会更好)。所以如果你比较关心性能的话最好还是使用静态链接。
67.1.1 Windows
Windows的DLL并没有使用PIC机制。如果Windows加载器需加载DLL到另外一个基地址,它需要把DLL在内存中的“重定位段”(在固定的位置)里所有地址都调整为正确的。这意味着多个Windows进程不能在不同进程内存块的不同地址共享一份DLL,因为每个实例加载在内存后只固定在这些地址工作。