动态链接三:延时绑定

PLT

PLT是一种表结构,能够帮助我们触发符号地址的计算以及跳转到正确的符号地址上,PLT一方面可以帮助我们借用jmp指令可以实现更远范围的跳转(邓凡平P67),在程序中如果使用条件判断跳转的话,跳转到外部函数的跳转范围可能会不够。另一方面可以帮助我们实现延时绑定。结合2.3章的内容我们来分析一下位置有关码,位置无关码,plt和got的情况。

接着2.3中的反汇编代码来我们来看一下main是如何通过plt调用printf的绝对地址来实现延时绑定的:

Disassembly of section .plt:
000003a0 <.plt>:
 3a0: ff b3 04 00 00 00       pushl  0x4(%ebx)
 3a6: ff a3 08 00 00 00       jmp    *0x8(%ebx)
 3ac: 00 00                   add    %al,(%eax)
   ...
000003b0 <puts@plt>:
 3b0: ff a3 0c 00 00 00       3b0: ff a3 0c 00 00 00   jmp *0xc(%ebx)
 3b6: 68 00 00 00 00          push   $0x0
 3bb: e9 e0 ff ff ff          jmp    3a0 <.plt>

可以看出,相比于位置有关码,位置无关码的地址都是基于ebx来计算的,那么ebx这个寄存器里面存放着什么呢?根据main中的528行代码,eax寄存器中的值赋给了ebx,此时ebx中的值为0x1d8,表明ebx寄存器就是got的起始地址。

[22] .got              PROGBITS        00001fd8 000fd8 000028 04  WA 

这个时候我们再来通过readelf看一下.got中的地址,如图2.5所示:
图2.5 程序装载前got表中的内容

Puts 的plt段第一条指令:jmp *0xc(%ebx),我们之前分析过ebx就是got段的起始地址,0xc是偏移地址,一个地址占据4个字节(32位),也就是第四个位置(got[3])就是put在got表中的位置。所以在图2.5中可以看到画红线的地方存放着相应的地址信息。由于x86位小端模式,翻译成大端模式之后地址为0x3b6,这个3b6是什么东西?

这个时候我们在看到puts对应的plt段,可以发现,就是jmp *0xc(%ebx)的下一条指令:push $0x0,这条指令的地址就是0x3b6,说明在编译阶段,got[3]默认存放的时下一条指令。接着plt段跳转到了3a0,也就是plt[0]所在的地方。这个plt[0]先进行了pushl 0x4(%ebx),将got[1]的值压到栈中。接着跳转到got[2]的地方,这个got[2]中存放的内容就是如2.2章中所述,是Resolver。用来寻找printf的绝对地址。当Resolver找到这个绝对地址之后,就会在got[3]的这个地方写入printf的绝对地址。在延时绑定的情况下:当main函数第一次调用printf时,对应的plt跳转到got[3]中表示的地址,该地址存放的是下一条指令,然后会调用Resolver去找printf的绝对地址,当如果main中第二次调用了printf,依然会进入到plt段,执行jmp *0xc(%ebx),这个时候got中的值已经有了printf的绝对地址,将会直接跳转到绝对地址去执行。我们可以先来看一下got中在装载后的内容:
图2.6位置相关码在装载之后,可执行文件的got内容就会被更新
这里来解释一下,为什么gdb调试之后,我们的地址相比objdump产生了偏移。这是因为我们在objdump只是在反汇编了编译好的main这个可执行程序,还没有到装载阶段。如果我们在编译的时候就指定main是个位置有关码,在编译阶段就会确定函数在虚拟内存中的地址,而由于我们编译的时候用的是位置无关的参数(默认),所以只会显示位置无关的代码。当我们在gdb 运行了r之后,程序就会被装载到虚拟内存中,这个时候就会确定main函数的代码在虚拟内存中的位置,不同的机器会加上不同的偏移量。通过layout split可以观察各个寄存器的值。当call puts时ebx寄存器依然会指向我们的got段,最后的虚拟地址中的最后三位fd8也与我们在objdump中的地址对应的上,也可以在此论证寻找got的地址是找对的。

通过x/10xz,可以显示该地址以16进制显示接下来的10条内容,可以看出本来got段got[3]在编译阶段通过readelf指向的puts plt的下一条指令的位置的,现在已经变成了一个地址。通过gdb调试发现进入到这个地址后,就是直接执行了printf这个函数的绝对地址。

延时绑定

装载时重定位除了无法共用动态库导致内存占用比较大之外,对于对动态库依赖较大的情况,由于需要在装载时需要把依赖的动态库的代码绝对地址都要计算出来,在某些特定的场景下程序的加载时间就会很长,有些函数在本次运行过程中并不会调用。延时绑定除了解决耗时的问题,还解决了动态库共享的问题。

所谓延时绑定,就是程序在运行的时候再去找绝对地址。但在这里要提示一下,网上包括书中的例子(http://rickgray.me/2015/08/07/use-gdb-to-study-got-and-plt/),比如main中调用两次printf,第一次会使用Resolver去寻找,找到之后更新got中的地址,如果第二次调用printf,那么就会直接根据got中的地址,去调用printf的绝对地址,省略了寻找的过程,这个就是我们所说的延时绑定的过程。如果单纯的想复现这些,在我们的电脑上是无法复现的。为什么呢?因为在可执行文件中,如果采用默认编译的方式进行编译,对于可执行程序来说,采用的是装载时重定位的方式。

就2.4章节中的例子,gdb运行main,把断点打在main中,然后r运行一下,你会发现此时的printf还没有执行,但是此时got中的值已经更新了。这个就是我们所说的装载时就全部重定位结束了。但是要注意,这个情况是针对可执行程序

那么是不是意味着网上的例子是错误的呢?不是,当我们把main这个可执行文件变成位置有关码来调试的时候,我们来看一下可执行文件时如何实现延时绑定的:

函数部分如下,实现了两次打印:

#include <stdio.h>

int main(){
    printf("fno_pic\n");
    printf("fno_pic2\n");
    return 0;
}

接着采用位置有关码进行编译:
gcc -g -m32 -no-pie -fno-pic main.c -o main
然后通过readelf查看puts对应的got段的地址:
图2.7 puts在got中对应的位置

接着gdb调试,将断点打在main中,然后r运行,这个时候先查看一下此时0x0804a00c中的内容,接着运行完一次printf后,再查看0x0804a00c中的内容:
图2.7 位置无关码的延时绑定,在第一次printf运行完成之后,got中对应的地址中的值发生了改变
这个现象就是我们所说的延时绑定的过程,即运行时绑定。其原理我们可以在2.4章节中再回顾一下。

但是在现实情况中我们的使用场景往往是位置无关码,那么位置无关码和延时绑定有什么关系呢?为什么在所谓的可执行文件的调试中,程序刚刚加载完got就更新了呢?这个时候必须要重申一点,延时绑定的焦点应该集中在动态库。我们之前所有的尝试,都是对可执行文件的尝试。

位置无关码的延时绑定

在我们尝试的试验中,位置无关可执行文件都是通过装载时重定位,但动态库为延时绑定,至于为什么可执行文件是通过装载时绑定,从性能上来说,可执行文件并不像动态库一样可能需要被重复使用,也不存在内存占用的问题,所以并不需要延时绑定。至于如何实现的,可能的猜测是在dlopen中环境变量被设置成了RTLOAD_NOW(邓凡平P66)
针对这个问题,我们再来写一个例子验证一下:
Main函数中调用了test:

#include <stdio.h>

void test(int a){
    printf("test: %d\n", a);
    return;
}

接着采用位置无关的方式进行编译:
gcc -fPIC -m32 -g -shared test.c -o libtest.so
此时是将test编译生成了一个位置无关的动态库,main函数如下:

extern void test();
int main (){
    test(1);
    return 0;
}

接着对main函数进行编译:
gcc -g -m32 main.c -o main -L’pwd’ -ltest (pwd是文件所在路径,可以在自己的电脑上输入命令pwd然后直接替换一下)
编译完成之后,main函数还不能执行,需要导入动态库的路径:
export LD_LIBRARY_PATH=‘pwd’:$LD_LIBRARY_PATH (此处的pwd如上)
接下来我们先分析仪一下main函数中需要重定位的test信息,位于0x1fe8。

Relocation section '.rel.plt' at offset 0x40c contains 2 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00001fe8  00000607 R_386_JUMP_SLOT   00000000   test

再来看main函数中调用的test动态库中需要重定位printf的信息,位于0x2000c:

Relocation section '.rel.plt' at offset 0x35c contains 1 entry:
 Offset     Info    Type            Sym.Value  Sym. Name
0000200c  00000207 R_386_JUMP_SLOT   00000000   [printf@GLIBC_2.0](mailto:printf@GLIBC_2.0)

Main函数中关于test的got段,在编译后的内容如图2.9。编译后的可执行文件,test的got段存放的是test plt段的下一条指令。
图2.9 got中的初始值为test plt段为第二行代码
Gdb开始运行后,可执行文件中的main还没运行至test函数,got段中的内容已经发生了更新:
图2.10 位置无关码的可执行文件时通过装载时重定位的,并非延时绑定
而当函数进入到test函数后,当把断点打在test函数,通过ebx寄存器的值找到got段的地址,如图所示可以看到一开始printf对应地方存放的是printf plt段接下来第二条的内容,但是在运行了printf之后,再次显示got中的内容可以发现该处的地址内容已经发生了改变。
图2.11 动态库中的重定位时是延时绑定的
如图2.12,通过gdb单步调试的方式可以看到,动态链接在运行的过程中调用了ld来寻找printf函数的绝对地址。
图2.12 通过gdb si单步调试可以查看程序在运行时调用了ld进行动态链接

关于Android

Android的动态链接器采用的不是linux中的ld-linux.so,而是android自带的linker,linker并没有使用延时绑定的技巧,而是在装载时将所有引用的函数地址都解析出来了。因为Android的所有用户进程都是fork出来的,在zygote进程中将所有地址都解析出来后,fork处的子进程就可以直接使用了,如果再延时绑定反而更浪费时间,这是由Android的特点决定的。(深入解析Android5.0 P83)

为了验证,在bionic中的hello.c,编写函数只有printf的调用,反汇编的printf的plt段如下,可以看出其实现思路与linux中三步走的形式不一样,Android就是读取2230中把8字节的内容,然后跳转到该地址去运行了。

00000000000010c0 <printf@plt>:
    10c0:   00001e17            auipc   t3,0x1
    10c4:   170e3e03            ld  t3,368(t3) # 2230 <printf@LIBC>
    10c8:   000e0367            jalr    t1,t3
    10cc:   00000013            nop

通过gdb调试发现,2230中的内容就是函数got中printf对应的内容,在程序装载阶段就已经把got中的内容全部计算出来了,plt段也没有像linux一样,从got中调用linker之类的操作。
图2.12 Android中plt跳转到got后,直接读取该地址中的内容,不会再延时绑定

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值