原作者:Eli Bendersky
http://eli.thegreenplace.net/2011/11/03/position-independent-code-pic-in-shared-libraries/
在之前的文章里我已经描述过在将共享库载入程序地址空间时需要特殊的处理。简而言之,在链接器创建共享库时,它不能预先知道这个库将在哪里载入。这给在库里访问数据与代码带来了麻烦,应该使得这些访问指向正确的内存位置。
在Linux ELF共享库里解决这个问题有两个主要途径:
1. 载入时重定位
2. 位置无关代码(PIC)
载入时重定位已经说过了。这里,我想解释第二个方法——PIC。
一开始我计划在本文里同时关注x86与x64(即x86-64),但随着文章越来越长,我发现这是不现实的。因此我将仅解释PIC在x86上如何工作,选择这个更老的架构是因为(不像x64)它设计时没有考虑PIC,因此实现PIC有一点棘手。将来的一篇文章(希望篇幅可以大大缩短)将在这篇的基础上解释如何在x64上实现PIC。
载入时重定位的一些问题
正如我们在之前文章里看到的,载入时重定位是一个相当简单的方法,并且奏效。不过时至今日PIC要流行得多,是构建共享库的推荐方法。为什么会这样?
载入时重定位有几个问题:它需要时间执行,并且它使得库的代码节不可共享。
首先,性能问题。如果以载入时重定位项链接一个共享库,在应用程序载入时,需要花费一些时间执行这些重定位。你可能会认为这个代价不会太大——毕竟,载入器不需要扫描整个代码节——它仅需考虑重定位项。但如果一段复杂的软件在启动时载入多个大的共享库,而每个共享库必须首先应用它自己的载入时重定位,这些代价会累积,导致该应用程序启动时间可观的延迟。
其次,更为严重的不可共享代码节的问题。首先共享库存在的要点之一是节省RAM。一些通用的共享库被多个应用程序所使用。如果共享库的代码节(代码所在)可以只载入内存一次(然后映射到许多进程的虚拟内存),数目可观的RAM可以被节省下来。但对载入时重定位这是不可能的,因为使用这个技术时,需要在载入时修改代码节来应用重定位。因此,对于载入这个共享库的每个应用程序,它将被再次整个地放入RAM[1]。不同的应用程序不能真正地共享它。
另外,拥有一个可写的代码节(它必须保持可写,以允许动态载入器执行重定位)形成了一个安全风险,使得攻击应用程序更容易。
正如我们将在本文中看到的,PIC极大地缓解了这些问题。
PIC——介绍
PIC背后的思想是简单的——对代码中访问的所有全局数据与函数添加一层额外的抽象。通过巧妙地利用链接与载入过程中的某些工件,使得共享库的代码节真正位置无关是可能的,从这个意义来说它可以不做任何改变而容易地映射到不同的内存地址。在下几节我将详细解释如何实现这一壮举。
关键的洞察#1——代码与数据节间的偏移
PIC所依赖的关键的洞察之一是代码与数据节间的偏移,在链接时刻为链接器所知。当链接器将几个目标文件合并起来时,它收集它们的节(例如,所有的代码节合并为一个大的代码节)。因此,链接器知道节的大小与它们的相对位置。
例如,数据节可能紧随代码节,因此代码节中任一给定指令到数据节起始的偏移是该代码节的大小减去代码节起始到该指令的偏移——这两个数据链接器都是知道的。
在上图中,代码节被载入到某个地址(链接时刻未知)0xXXXX000(X表示无关紧要),数据节紧随其后,在0xXXXXF000。那么,如果在代码节0x80偏移处的指令想访问数据节里的内容,链接器知道相对偏移(这个情形里是0xEF80)并将它写入该指令。
注意到是否有另一个节插在代码节及数据节之间,或者代码节跟在代码节后,是无关紧要的。因为链接器知道所有节的大小并决定何处放置它们,这个洞察成立。
关键的洞察#2——使得IP相对偏移在x86上工作
只有我们让相对偏移工作,上面的讨论才有用。但在x86上访问数据(即在mov指令里)要求绝对地址。因此,我们该怎么办?
如果我们有一个相对地址而需要的是一个绝对地址,所缺少的是指令指针的值(因为根据定义,相对地址是相对于指令位置的)。在x86上没有指令可以获取指令指针的值,但我们可以使用一个简单的技巧办到。下面是展示这个技巧的汇编伪代码:
call TMPLABEL
TMPLABEL:
pop ebx
这里发生的是:
1. CPU执行call TMPLABEL,这使得它将下一条指令(popebx)的地址保存到栈上并跳到这个标记。
2. 因为标记处的指令是pop ebx,它下一步得到执行。它从栈里向ebx弹出一个值。不过这个值就是指令本身的地址。因此ebx现在实际上包含了指令指针的值。
全局偏移表(GOT)
有鉴于此,我们最终可以达成x86上访问位置无关代码的实现。它依靠一个“全局偏移表”或简称GOT来完成。
GOT只是一个地址表,位于数据节里。假设在代码节里某条指令想访问一个变量。指令不是通过绝对地址直接访问它(这将要求一个重定位),而是访问GOT里的一个项。因为GOT在数据节的一个已知位置,这个访问是相对的且链接器已知。而GOT项将包含该变量的绝对地址:
在伪汇编代码里,我们替换了一条绝对取址指令:
; Place the value of the variable in edx
mov edx, [ADDR_OF_VAR]
; 1. Somehow get the address of the GOT into ebx
lea ebx, ADDR_OF_GOT
; 2. Suppose ADDR_OF_VAR is stored at offset 0x10
; in the GOT. Then thiswill place ADDR_OF_VAR
; into edx.
mov edx, DWORD PTR [ebx + 0x10]
; 3. Finally, access the variable and place its
; value into edx.
mov edx, DWORD PTR [edx]
这样,在代码里通过GOT重定向变量的访问,我们去掉了一个重定位。不过我们还是要在数据节里创建一个重定位。为什么?因为要让上面描述的场景工作,GOT仍然必须包含变量的绝对地址。那么我们得到了什么好处?
答案是很多。因为两个原因(它们直接解决了文章开头描述的载入时重定位代码的两个主要问题),在数据节里的重定位比代码节里的重定位问题要少得多,
1. 每次变量访问都要求代码节里的重定位,而在GOT里对每个变量我们只需要重定位一次。对变量的访问数极可能远多于变量数,因此这更高效。
2. 数据节是可写的且不在进程间共享,因此向它添加重定位没有害处。而将重定位移出代码节使得代码节变成只读且在进程间共享。
带有通过GOT的数据访问的PIC——一个例子
现在我将出示一个展示了PIC机制的完整例子:
int myglob = 42;
intml_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,下一条指令的地址放入了ecx,通过上面关键的洞察#2所描述的技术。
· 在444,从该指令到GOT所在位置的一个已知的常量偏移加上ecx。因此现在ecx用作GOT的基址指针。
· 在44a,从[ecx – 0x10]获取一个值放入eax,它是一个GOT项。这是myglob的地址。
· 在450执行间接取址,myglob的值被放入eax。
· 随后参数a和b加到myglob,并返回这个值(通过把它保存在eax)。
我们可以以readelf –S查询该共享库来看GOT节放在哪里:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
<snip>
[19] .got PROGBITS 00001fe4 000fe4 000010 04 WA 0 0 4
[20] .got.pltPROGBITS 00001ff4 000ff4 00001404 WA 0 0 4
<snip>
让我们来检验编译器找出myglob所完成的计算。正如我上面提到的,对__i686.get_pc_thunk.cx的调用将下一条指令的地址放入ecx。那个地址是0x444[2]。下一条指令将它加上0x1bb0,在ecx中的结果将是0x1ff4。最后,为了实际获取保存myglob地址的GOT项,使用位移地址——[ecx– 0x10],因此这个项在0x1fe4,它是依据节头的GOT的第一个项。
为什么有另一个名字以.got开头的节将在后面解释[3]。注意编译器选择将ecx指向GOT末尾,然后使用负的偏移来获取项。这没问题,只要能算出来。而目前为止是可以的。
不过我们还是漏了一些东西。Myglob的地址如何真正地进入到0x1fe4处的GOT槽?记得我提到过一个重定位,因此让我们找找看:
> readelf -r libmlpic_dataonly.so
Relocation section '.rel.dyn' at offset 0x2dc contains 5entries:
Offset Info Type Sym.Value Sym. Name
00002008 00000008R_386_RELATIVE
00001fe4 00000406R_386_GLOB_DAT 0000200c myglob
<snip>
注意myglob的重定位节正如预期那样,指向地址0x1fe4。该重定位具有类型R_386_GLOB_DAT,它只是告诉动态载入器——“将这个符号的实际值(即它的地址)放入那个偏移”。因此所有东西都工作得很好。剩下的就是检查在载入这个库时,它实际看起来是怎么样的。为此,我们可以通过编写一个简单的,链接了libmlpic_dataonly.so并调用ml_func的“driver”可执行文件,并通过GDB运行它。
> gdb driver
[...] skipping output
(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
[...] skipping output
调试器进入了ml_func,在IP0x0013144a处停下[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里函数调用的工作方式。实际发生的要更复杂一点。在我解释如何做之前,要说一下这样机制的动机。
延迟绑定优化
当一个共享库访问某个函数时,该函数的真实地址直到载入时刻才会知道。解析这个地址称为绑定(binding),它是动态载入器在载入共享库时完成的。这个绑定过程不简单,因为载入器必须在特殊的表里查找函数符号[5]。
因此,解析每个函数要花时间。不是很多,但它是累计的,因为库里函数的数量通常远多于全局变量的数量。另外,大多数这些解析是无用功,因为通常程序只会调用一小部分函数(想一想各种错误处理函数及特殊的条件,它们通常不会被调用)。
这样,为了加速这个过程,发明了一个聪明的延迟(lazy)绑定方案。“延迟”是计算机编程里一族优化的通用名,其工作被推迟直到它被真正需要的最后一刻,目的是如果在程序的一次特殊运行中不需要其结果,就可以避免执行之。延迟的好例子有写时拷贝以及延迟求值。
这个延迟绑定方案通过添加另一层的间接性——PLT来实现。
程序链接表(PLT)
PLT是可执行文件代码节的部分,包含了一组入口(每个共享库调用的外部函数一个)。每个PLT项是一小段可执行代码。不是直接调用函数,代码调用PLT里的一个项,它负责调用真正的函数。这个安排有时称为“弹簧垫(trampoline)”。每个PLT项还在GOT中有一个对应的,仅在动态载入器解析它时,包含了函数实际偏移的项。我知道这令人困惑,但希望在下面几段和图中解释了细节后,这会清楚起来。
就像前面章节提到的,PLT允许函数的延迟解析。在共享库第一次载入时,函数调用还没解析:
解释:
· 在代码里,函数func被调用。编译器把它翻译为对func@plt的调用,它是PLT里的第N个项。
· PLT第一个项是特殊的,后跟一堆结构相同的项,每个需要解析的函数一个。
· 除了第一个,每个PLT项包含这些部分:
o 对在对应GOT项里指定位置的跳转
o 为“解析者”例程准备参数
o 调用解析者例程,它位于PLT的第一项。
· 第一个PLT项称为解析者例程,它本身位于动态载入器里[6]。这个例程解析函数的实际地址。稍后会有更多的讨论。
· 在函数的实际地址被解析出来之前,GOT的第N项只是指向jmp后的位置。这就是为什么在图中这个箭头的颜色不同——它不是一个实际的跳转,只是一个指针。
在第一次调用func时会发生这些事情:
· 调用PLT[n],并跳转到由GOT[n]指向的地址。
· 这个地址本身指向PLT[n],为解析者准备参数。
· 调用解析者。
· 解析者解析func的实际地址,把它的实际地址放入GOT[n],然后调用func。
在第一次调用后,图看起来有点不一样:
注意GOT[n]现在指向实际的func[7],而不是指回PLT。因此,当func被再次调用时:
· 调用PLT[n],并跳转到GOT[n]指向的地址。
· GOT[n]指向func,因此这就将控制权转给func。
换而言之,现在func将被实际调用,无需通过解析者,代价就是一次额外的跳转。真的,这就是所有的一切。这个机制允许函数的延迟解析,对于完全没有被调用的函数根本不解析。
它还使得库的代码/数据节完全位置无关,因为唯一使用绝对地址的地方是GOT,GOT位于代码节并且由动态载入器重定位。甚至PLT本身也是PIC的,因此它可以存在于只读代码节里。
我没有进入解析者的太多细节,但对我们这里的目标它并不重要。解析者只是载入器里执行符号解析的一段低级代码。在每个PLT项里为它准备参数,连同一个合适的重定位项,辅助它了解需要重定位的符号及要更新的GOT项。
通过PLT及GOT调用函数的PIC——一个例子
再次的,以一个实际的演示强化努力学习的理论,下面是使用上面描述机制解析函数调用的完整例子。这次我会稍微加快一点速度。
下面是共享库的代码:
int myglob = 42;
intml_util_func(int a)
{
return a + 1;
}
intml_func(int a, int b)
{
int c = b +ml_util_func(a);
myglob += c;
return b + myglob;
}
这个代码将被编译进libmlpic.so,关注点在从ml_func对ml_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>
<... snip morecode>
有趣的部分是对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 241f000000000000 00000000 86030000 $...............
0x00002004 96030000a6030000 ........
ml_util_func@plt着眼的GOT项在偏移+0x14,即0x2008。由上面,该位置上的内存字是0x3a6,它是ml_util_func@plt里push指令的地址。
为了帮助动态载入器完成它的工作,也添加了一个重定位项指定在GOT何处对ml_util_func进行重定位:
> readelf -r libmlpic.so
[...] snip output
Relocation section '.rel.plt' at offset 0x328 contains 3entries:
Offset Info Type Sym.Value Sym. Name
00002000 00000107R_386_JUMP_SLOT 00000000 __cxa_finalize
00002004 00000207R_386_JUMP_SLOT 00000000 __gmon_start__
00002008 00000707R_386_JUMP_SLOT 0000046c ml_util_func
最后一行表示动态载入器应该将符号ml_util+func的值(地址)放入0x2008(回忆这是这个函数的GOT项)。
看这个GOT项在第一个调用后发生的实际修改应该是有趣的。让我们再次使用GDB。
> gdb driver
[...] skipping output
(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看起来没问题。现在,前进到对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,让编译器生成将其地址保存在一个寄存器(通常是ebx)的代码是合理的。
但这因为GOT的缘故束缚了一整个寄存器。尽管对于倾向于拥有大量通用寄存器的RISC架构这不是大问题,对像x86这样只有少量寄存器的架构这构成了一个性能问题。PIC意味着要少一个通用寄存器,它增加了间接的代价,因为现在要进行更多的内存访问。
结论
本文解释了什么是位置无关代码,以及它如何以可共享的只读代码节辅助创建共享库。在选择PIC及其替代(载入时重定位)时有一些取舍,最终的结果取决于许多因素,比如运行该程序的CPU架构。
也就是说,PIC正变得越来越流行。一些非intel架构,像SPARC64对共享库强制PIC代码,而许多其他架构(比如ARM)包括了IP相对取址模型来使得PIC更高效。对x86的后继者x64架构,这两点也成立。在将来的文章里我将讨论x64上的PIC。
不过本文的关注点不是性能考虑或架构决定。我的目的是解释,假定使用了PIC,它如何工作。如果这个解释不够清晰——请在评论里让我知道,我将提供更多信息。
[1] 除非所有的应用程序将这个共享库载入相同的虚拟内存地址。但在Linux上通常不会这么做。
[2] 0x444(与其他在这个计算里提到的地址)是相对于共享库的载入地址,它是未知的,直到一个可执行文件在运行时实际载入它。在这个代码里这无关紧要,因为它仅应付相对地址。
[3] 精明的读者可能想知道为什么.got是一个完全独立的节。我不是在图里显示它在数据节吗?在实践中,它是。这里我深入ELF节与段的差异,因为这会离题太远。简要言之,一个库可以定义任意数量的“数据”节并映射到一个可读写段。只要ELF文件组织正确,这不重要。将数据段分离到不同的逻辑节提供了模块化,并使得链接器的工作变得简单。
[4]注意gdb跳过了向ecx赋值的部分。这是因为它差不多被视为函数的prolog(真正的原因当然是gcc组织调试信息的方式)。在函数里对全局数据与函数进行了几次访问,一个指向GOT的寄存器就可以服务所有这些访问。
[5] ELF共享库对象带有用于这个目的的特殊的哈希表节。 |
[6] 在Linux上的动态载入器只是另一个载入所有运行进程地址空间的共享库。
[7] 我将func放入一个独立的代码节,虽然理论上这可以与调用func代码在同一个节(即在同一个共享库)。这篇文章里“extra credit(额外的学分)一节解释了为什么在同一个共享库里调用一个外部函数还需要PIC(或重定位)。
[8] 回忆在数据访问例子里我承诺解释为什么在目标文件里有两个GOT节:.got与.got.plt。现在应该明显了,这只是为了将全局数据要求的GOT项与PLT要求的GOT项方便地分开。这也是为什么当在函数里计算GOT偏移时,它指向紧跟.got的.got.plt。这样,负偏移引向.got,而正偏移引向.got.plt。尽管方便,这样的安排不是强制的。这两部分都可以放在应该.got节里。