位置无关代码(Position Independent Code, PIC)包含两种含义:数据位置无关以及代码位置无关。位置无关代码可以由编译器产生,gcc的选项“-fPIC”表示对代码进行相对寻址(即仅执行相对跳转和调用)。PIC只能使用相对寻址,即对数据和代码进行相对寻址,或者仅对其中一个类别进行相对寻址。PIC可以在任何内存地址上运行,无须任何修改。仅包含PIC的可执行文件不需要重新定位信息。
寻址通常用于两种类型的访问:数据的访问(如读、写等)和代码的执行(如不同部分跳转、调用等)。大多数处理器架构有两种寻址方式:
(1)绝对寻址:调用位于固定地址的代码或在读取固定地址的数据。绝对寻址的使用比较受限,因为在编译时必须知道所有的地址。例如,在调用外部库代码时,可能不知道操作系统将库装载到哪个内存地址;在对堆上的数据进行访问时,也不能预先知道操作系统在堆上分配的是哪个地址。
(2)相对寻址:相对寻址即相对当前命令寄存器进行寻址。例如,跳转到相对于当前命令寄存器某个偏移的命令、跳转到后面的第五条命令执行或者读取相对于当前命令某个偏移地址的数据等。相对寻址通常会使速度和内存同时产生额外的开销,因为处理器必须先根据命令寄存器和相对值计算出绝对地址,然后才能访问实际内存地址或实际命令,在速度上会产生一定的开销。同时,因为必须存储一个额外的指针(通常存储在寄存器中,寄存器速度虽然非常快,但其空间非常小),因此在内存上也会产生一定的开销。
如果程序使用绝对寻址,就需要对地址空间的布局进行设置,此时操作系统可能无法满足所有的设置。为了解决这个问题,大多数操作系统在二进制文件中使用了额外的元数据。元数据通常描述二进制文件中使用绝对寻址命令的位置,操作系统在运行二进制文件时使用元数据对二进制文件进行更改,以便修改后的设置适合当前情况。当操作系统装载二进制文件时,它会在必要时更改存储在这些命令中的绝对地址。ELF文件格式的重定位信息就是这些元数据的一个实例。
重定位信息部分包含了怎样修改地址信息的信息,一般包括:
●应用重定位操作的地点:一般以偏移量的形式给出。
●重定位的符号:在编译或运行程序时如果需要引用符号,则要对符号在内存中的实际地址进行替换。
●重定位的类型:一般跟处理器相关。
重定位信息可使用readelf-r filename进行查看,例如:
(1)目标文件中的重定位信息示例如下:
以第一行重定位信息为例,该重定位信息表述的含义是:偏移量0xc(0000000c)处存放的地址值应该被重新计算,具体计算的规则按类型R_386_PC32进行,针对的是符号setSummand。
(2)可执行文件中的重定位信息示例如下:
(1)数据位置无关原理。实现PIC的关键点在于:
①链接器在进行链接时知道代码和数据部分之间的偏移量。当链接器将多个目标文件链接在一起时,会收集这些文件的节,如将所有代码节都统一到一个大的代码节中,因此链接器既知道节的大小,也知道节的相对位置。例如,代码部分可能紧跟着数据部分,因此代码部分中的任何命令到数据部分开头的偏移量正好是代码部分的大小减去从代码部分开头的命令的偏移量。这两个偏移量对于链接器来说都是已知的。
代码装载如图2.8所示,代码段被装载到某个地址(链接时未知)时,如0xXXXX0000(X表示任何值),数据节就在它后面的偏移量0xXXXXF000处。若偏移量(offset)0xEF80处的代码节中某条命令想要引用数据节中的内容,则链接器知道相对偏移量(本例中为0xEF80,即0xXXXXF000-0xXXXX0080=0xXXXXEF80),并可以在命令中对其进行编码。
图2.8 代码装载
注意,既可以在代码节和数据节之间存在另一个节,也可以将数据节放在代码节之前,链接器知道所有节的大小,并决定将这些节放置在何处。
②在x86上实现命令指针(IP)的相对偏移。在将相对偏移量用于工作时,还需要一个绝对地址,即命令指针的值,因为相对地址是相对于命令指针的值而言的。有人可能会好奇,为什么这么麻烦,直接用EIP寄存器不就行了?其实64位的操作系统就是这样操作的,不过32位的操作系统不支持直接访问EIP寄存器,所以就多了一层间接的函数调用。
在x86上引用数据时(即在mov命令中)需要绝对地址,那么应当如何实现绝对地址呢?
在x86上没有获取命令指针值的命令,但可以使用一个简单的技巧来获取命令指针。下面用一些汇编伪代码进行说明:
上述伪代码的含义是:
①CPU执行call TMPLABEL时,会使得下一条命令(pop ebx)的地址被保存在堆栈上,并跳转到标签TMPLABEL处执行。
②因为TMPLABEL标签处的命令是pop ebx,所以接下来执行该命令,将栈顶的值弹出到ebx中。而这个值是命令pop ebx本身的地址,所以ebx就有效地包含了命令指针的值。
下面以32位的操作系统下的真实程序的代码进行说明:
Listing 2.11 PIC中使用全局变量的测试源码mlpic-dataonly.c
说明:
①在偏移量0x4f3处,将下一条命令的地址0x4f8放入寄存器eax。实现的关键是地址0x511处的函数__x86.get_pc_thunk.ax,其功能是将当前PC寄存器的值(即命令指针的值)取到寄存器eax中。在函数的入口处执行该函数前栈的状态如图2.9所示。
图2.9 在函数入口处执行函数__x86.get_pc_thunk.ax前栈的状态
通过执行命令:
可将栈顶esp中的内容取到寄存器eax中,即地址值0x4f8存放到了eax中。eax中有了当前命令指针的值,下面就可以对外部全局变量myglob进行位置无关数据的访问了。
②在偏移量0x4f8处,将一个命令计数器的常数偏移(0x1b08)加到eax上,此时eax中存放的是GOT的基地址。
③在偏移量0x4fd处,将存放在地址eax-0x14中的值(GOT中的一个表项)取到eax中,此时eax中存放的是全局变量myglob的地址。
④在偏移量0x503处,使用间接寻址将全局变量myglob的值取到edx中。
⑤将参数a和b的值和myglob的值相加并通过eax返回。
通过下面的命令可以核对计算是否正确。
上述命令首先在偏移量0x4f3处将下一条命令的地址0x4f8放入eax;然后将常数0x1b08加到eax(该常数是当前IP与数据段中的GOT的偏移),结果为0x4f8+0x1b08=0x2000;接着,为得到全局变量myglob在GOT中的表项,进行了偏移计算eax-0x14,其中,0x14是全局变量myglob在GOT中的偏移,因此myglob在GOT中的表项为0x2000-0x14=0x1fec,该表项是GOT的第二项(第一项的地址是0x0001fe8)。
有了上述的表项,就可以在x86上实现位置无关的数据访问了,这是通过GOT完成的。
(2)全局偏移表(Global Offset Table, GOT)。全局偏移表是一个地址表,位于数据节,可写。链接器在进行链接时将外部符号(如全局变量、外部函数)的实际地址填充在GOT中。GOT中存放的是全局变量和程序中使用的外部函数的地址。GOT中的表项可在程序运行时进行修改,因此每个表项也可称为可重定位项。
全局偏移表被ELF拆分为.got表和.got.plt表,其中.got表用来保存全局变量的引用地址,.got.plt表用来保存外部函数的引用地址。
(3)数据重定位。假设代码中的某条命令要引用一个变量,不是通过绝对地址直接引用它(这需要重新定位)的,而是通过GOT中的一个表项引用的,如图2.10所示。由于GOT位于数据段中的已知位置,因此引用是相对地址的,并且链接器已知这些地址。GOT中的表项按地址顺序依次包含变量的绝对地址。
在伪汇编命令中,将如下的绝对寻址命令:
替换为通过寄存器的位移寻址,以及额外的间接寻址,命令如下:
图2.10 通过GOT中的表项引用变量
通过GOT引用全局变量,可避免在代码节中进行重定位,但在数据节创建了一个重定位。这是为什么呢?因为要使上面描述的方案能工作,GOT仍然必须包含变量的绝对地址。从以上分析可知,数据节中的重定位要比代码节中的重定位容易得多,原因有两个(这直接解决了在代码装载时重定位的两个主要问题):
①在代码节中每次引用全局变量都需要进行一次重定位,而在GOT中则只需要为每个全局变量重定位一次,对全局变量的引用次数可能要比全局变量本身的数量多得多,因此这种方法更有效。
②数据节是可写的,并且不会在进程之间共享,因此在数据节中添加重定位不会造成任何困难。若将重定位从代码段中剥离,则可以使代码成为只读的,并可在进程之间共享。
延迟绑定技术是指只有在调用外部函数时才将其跟具体的内存地址进行绑定,否则不绑定。这样做的目的是节省资源,如果在程序开始运行时就链接共享库的所有函数就会浪费很多资源。如何用动态链接器实现绑定呢?绑定什么呢?这里主要用到got.plt表和.plt表,其中,.got.plt表用于存放外部函数调用的地址。
绑定的含义是修改.got.plt表,使得表中存放的是外部函数代码的真实内存地址,延迟的含义是将.plt表当成一个跳板,重定位指向.got.plt表中的真实内存地址。例如:
Listing 2.12 简单的hello程序
(1).got.plt表。外部函数的引用全部放在.got.plt表中,动态链接器能实时修改.got.plt表中的内容,我们主要研究的也就是这部分内容。不过值得注意的是,在i386架构下,除了每个外部函数都占用一个.got.plt表项,还为系统(动态链接器在程序启动时使用)保留了三个公共的.got.plt表项,每个表项32bit(4B),保存在前三个位置,分别是:
①got[0]:存放动态链接器装载ELF动态段(.dynamic段)的地址,.dynamic段保存了很多访问ELF其他部分的指针。
②got[1]:存放动态链接器管理ELF的link_map数据结构描述符地址,该数据结构中保存的是一个节点链表,每个节点对应着程序使用的动态库中的一个符号表。通过设置环境变量LD_PRELOAD,可以确保预装载的动态库在该链表的第一个节点。
③got[2]:存放动态链接器的_dl_runtime_resolve()函数地址,该函数用于确定外部符号的地址。
(2).plt表。.plt表是代码节的一部分,由一组条目组成(每个共享库函数的调用都对应一个表项),每个.plt表项是一小段可执行代码。当调用共享库中的函数时,编译器不会直接调用该函数,而是首先调用其对应的.plt表,如call printf@plt,然后由对应的.plt表项负责调用实际函数。这种机制有时被称为“蹦床”。每个.plt表项在.got.plt表中也有一个对应的表项,但仅当动态加载程序才解析.got.plt表中的表项,.got.plt表中的表项才包含函数的实际偏移量。
程序在装载.plt表中的表项时分为两种情况:
①初次装载时的情况:正如前面提到的,.plt表允许函数的延迟解析,当共享库函数被首次装载时,函数调用尚未解析。.got.plt表中存放的是.plt表的下一条命令,如0x080482e6;继续往下执行到动态链接器函数_dl_runtime_resolve,把.got.plt表中的表项对应的函数重定位为共享库中真实的地址。
②二次装载时的情况:.plt表指向的.got.plt表的地址是第一次重定位的被调用函数的实际地址。
运行结果如下:
(3)延迟绑定原理及示例如Listing 2.13所示。
Listing 2.13 延迟绑定测试用源码plt.c
上述代码在运行之前,.got.plt表和.plt表中的内容如下:
第一次调用puts()函数后的结果为:
动态库函数第一次被调用的过程如图2.11所示。
图2.11 动态库函数第一次被调用的过程
说明:
①在用户代码中调用puts()函数时,编译器将其转换为调用puts@plt,该表项是.plt表中的第 n 项。
②.plt表中的第一个表项是一个特殊表项,对解析器例程的进行调用,该例程位于动态加载程序本身。
③从.plt表的第二个表项开始,接下来的都是普通表项,这些普通表项结构相同,与需要解析的函数一一对应。普通表项主要由以下三部分组成:
●一条jmp命令,跳转到相应.got.plt表中的表项指定的位置;
●为解析器例程准备相关参数;
●调用位于.plt表中第一个表项中的解析器例程。
④在函数的实际地址被解析之前,.got.plt表的第 n 个表项只是包含.plt表中对应表项的jmp命令之后的命令地址。这就是为什么图中的箭头颜色不同——它不是实际的跳跃,只是一个指针。
第一次调用func()函数时会发生以下情况:
●.plt[ n ]被调用并跳转到.got.plt[ n ]中指向的地址。
●这个地址指向.plt[ n ]本身,为解析器例程准备参数。
●调用解析器例程。
●解析器例程解析puts()函数的实际地址,将其实际地址放入.got.plt[ n ]并调用puts@libc中的代码。
动态库函数在第一次调用之后,后续的函数调用过程如图2.12所示,跟第一次被调用有些不同。
图2.12 动态库函数后续被调用的过程
注意,.got.plt[ n ]现在指向puts@libc代码的实际入口地址,而不是返回.plt表,因此再次调用puts()时,.plt[ n ]被调用并跳转到.got.plt[ n ]中指向的地址,.got.plt[ n ]指向puts()函数,即将控制权转移到puts()函数。
也就是说,现在不需要经过解析器,就能跳转到puts@libc代码的实际地址。这种机制允许对函数进行延迟解析,对没有被调用的函数则完全不需要进行解析。另外,这种机制还可以使动态库的代码部分完全位置无关,因为唯一使用绝对地址的地方是.got.plt表,它位于数据节,将由动态装载程序重新定位。.plt表本身也是PIC,因此它可以位于只读代码节。
解析器例程只是装载程序中执行符号解析的一块低级代码,.plt表的每个表项都为它准备参数,以及合适的重定位表项,帮助它了解需要解析的符号以及要更新的.got.plt表的表项。