利用思路

ret2libc这种攻击方式主要是针对动态链接(Dynamic linking)编译的程序,因为正常情况下是无法在程序中找到像system()execve() 这种系统级函数。因为程序运行时会调用libc.so程序被装载时,动态链接器会将程序所需的所有动态链接库加载至进程空间,libc.so就是其中最基本的一个),libc.so是linux下C语言库中的运行库glibc的动态链接版,并且libc.so中包含了大量的可以利用的函数,如system()execve() 等系统级函数,我们可以通过找到这些函数在内存中的地址覆盖掉返回地址来获得当前进程的控制权。通常情况下,我们会选择执行system(“/bin/sh”) 来打开shell,因此我们就有两个问题:

  1. 找到system()函数的地址
  2. 在内存中找到”/bin/sh”这个字符串的地址

动态链接(Dynamic linking)

动态链接是指在程序装载时通过动态链接器将程序所需的所有动态链接库(Dynamic linking library) 装载至进程空间中(程序按照模块拆分成各个相对独立的部分),当程序运行时才将他们链接在一起形成一个完整程序的过程。由于静态链接太过于浪费内存和磁盘空间,并且现在的软件开发都是模块化开发,不同的模块都是有不同厂家开发,在静态链接的情况下,一旦其中某一模块发生改变就会导致整个软件都需要重新编译,而通过动态链接的方式就推迟这个链接过程到了程序运行时进行,因此有几大优点:

1、节省内存、磁盘空间

例如磁盘中有两个程序,p1、p2,且他们两个都包含lib.o这个模块,在静态链接的情况下他们在链接输出可执行文件时都会包含lib.o这个模块,这就造成了磁盘空间的浪费,当这两个程序运行时,内存中同样也就包含了这两个相同的模块,这也就使得内存空间被浪费。当系统中包含大量类似lib.o这种被多个程序共享的模块时,也就会造成很大空间的浪费。在动态链接的情况下,运行p1,当系统发现需要用到lib.o,就会接着加载lib.o。这时我们运行p2,就不需要重新加载lib.o,因为此时lib.o已经在内存中了,系统仅需要将两者链接起来,此时内存中就只有一个lib.o,节省了内存空间

2、程序更新更简单

比如程序p1所使用的lib.o是由第三方提供的,等到第三方更新、或者为lib.o打补丁的时候,p1就需要拿到第三方最新更新的lib.o,重新链接后再将其发布给用户。程序依赖的模块越多,就越发显得不方便,毕竟都是从网络上获取新资源。在动态链接的情况下,第三方更新lib.o后,理论上只需要覆盖掉原有的lib.o,就不必重新链接整个程序,在程序下一次运行时,新版本的目标文件就会自动装载到内存并且链接起来,就完成了升级的目标

3、增强程序扩展性和兼容性

动态链接的程序在运行时可以动态地选择加载各种模块,也就是我们常常使用的插件。软件的开发商开发某个产品时会按照一定的规则制定好程序的接口,其他开发者就可以通过这种接口来编写符合要求的动态链接文件,以此来实现程序功能的扩展。增强兼容性是表现在动态链接的程序对不同平台的依赖差异性降低,比如对某个函数的实现机制不同,如果写静态链接的程序会在不同平台发布不同的版本,而在动态链接的情况下,只要不同的平台都能提供一个动态链接库包含该函数且接口相同,就只需用一个版本了
总而言之,动态链接的程序在运行时会根据自己所以来的动态链接库,通过动态链接器将他们加载至内存中,并在此时将他们链接成一个完整的程序。linux系统中,ELF动态链接文件被称为动态共享对象(Dynamic Shared Objects),简称共享对象,一般都是以“.so”为扩展名的文件;在windows系统中就是常常软件报错缺少xxx.dll文件

GOT(Global offset Table)

共享对象在被装载时,如何确定其在内存中的地址呢?因此,要使共享对象能在任意地址装载就需要利用到装载时重定位的想法,即在链接时对所有的绝对地址的引用不做重定位而将这一步推迟到装载时再完成,一旦装载模块确定,系统就对所有的绝对地址引用进行重定位。但是,指令部分就无法在多个进程之间共享了,这又产生了一个新技术地址无关代码(PIC,Position-independent Code)该技术基本思想就是将指令中需要被修改的部分分离出来放在数据部分,这样就能保证指令部分不变且数据部分又可以在进程空间中保留一个副本,也就避免了不能节省空间的情况,那么重新定位后的程序怎么进行数据访问和函数调用呢?
编写两个模块,一个是程序自身的代码模块,另一个是共享对象模块

1
2
3
4
5
6
7
//got_extern.c
#include <stdio.h>
int b;
void test()
{
printf("test\n");
}

编译成32位共享对象文件:

1
gcc got_extern.c -fPIC -shared -m32 -o got_extern.so

[!info]
-fPIC选项是生成地址无关代码,gcc中还有另一个-fpic选项,差别是fPIC生成的代码较大但是跨平台性较强;而fpic生成的代码较小,且生成速度更快但是在不同平台中会有限制,一般都会采用fPIC选项
-shared选项是生成共享对象文件
-m32选项是编译成32位程序
-o选项是定义输出文件名称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//got.c
#include <stdio.h>
static int a;
extern int b;
extern void test();
int fun()
{
    a = 1;
    b = 2;
}
int main(int argc, char const *argv[])
{
    fun();
    test();
    printf("hey!");
    return 0;
}

和共享模块一同编译:

1
gcc got.c ./got_extern.so -m32 -o got

接着用objdump查看反汇编代码objdump -D -Mintel got

1、模块内部调用

main()函数中调用fun()函数,指令为:

1
11dd:       e8 bb ff ff ff          call   119d <fun>

fun()函数所在地址位0x0000119d,机器码e8代表call指令,那为什么后面是bb ff ff ff而不是9d 11 00 00呢?
因为e8后的这四个字节代表着目的地址相对于当前指令的下一条指令地址的偏移,即0x11dd + 0x5 + (-69) = 0x119d,0xffffffbb是-69的补码形式,这样做就可以使程序无论被装载到哪里都会正常执行

2、模块内部数据访问

ELF文件是由很多很多的段(segment) 所组成的,常见的如.text(代码段)、.data(数据段,存放已经初始化的全局变量或静态变量)、.bss段(数据段,存放为初始化的全局变量)等,这样就能做到数据与指令分离互不干扰。在同一个模块中,一般前面的内存区域存放着代码后面的区域存放着数据(这里指的是.data段)。那么指令是如何访问远在.data段中的数据呢?
观察fun()函数中给静态变量a赋值的语句:

1
2
3
4
11a0:       e8 63 00 00 00          call   1208 <__x86.get_pc_thunk.ax>
11a5: 05 2b 2e 00 00 add eax,0x2e2b
11aa: c7 80 3c 00 00 00 01 mov DWORD PTR [eax+0x3c],0x1
11b1: 00 00 00

可以看出,它先调用了__x86.get_pc_thunk.ax()函数

1
2
3
00001208 <__x86.get_pc_thunk.ax>:
1208: 8b 04 24 mov eax,DWORD PTR [esp]
120b: c3 ret

这个函数的作用就是把返回地址的值放到eax寄存器中,也就是把0x11a5保存到eax中,然后再加上0x2e2b,最后再加上0x3c,即0x000011a5 + 0x2e2b + 0x3c = 0x400c,这个值就是相对于模块加载基址的值,通过这样就能够访问到模块内部的数据。我们通过objdump -h ./got查看段布局,然后计算不难发现访问地址位于.bss段

3、模块间数据访问

变量b被定义在其他模块中,其地址需要在程序装载时才能够确定。利用到前面的代码地址无关的思想,把地址相关的部分放入数据段中,然而这里的变量b的地址与其自身所在的模块装载的地址有关。为了解决这个问题,ELF中在数据段里面建立了一个指向这些变量的指针数组,也就是我们常说的GOT表(Global offset Table,全局偏移表),它的功能就是当代码需要引用全局变量时,可以通过GOT表间接引用
查看反汇编代码中是如何访问变量b的:

1
2
3
4
5
6
11a0:       e8 63 00 00 00          call   1208 <__x86.get_pc_thunk.ax>
11a5: 05 2b 2e 00 00 add eax,0x2e2b
11aa: c7 80 3c 00 00 00 01 mov DWORD PTR [eax+0x3c],0x1
11b1: 00 00 00
11b4: 8b 80 1c 00 00 00 mov eax,DWORD PTR [eax+0x1c]
11ba: c7 00 02 00 00 00 mov DWORD PTR [eax],0x2

计算变量b在GOT表中的位置,0x11a5 + 0x2e2b + 0x1c = 0x3fec,查看GOT表的位置
命令objdump -h got,查看ELF文件中的节头内容

1
2
21 .got          00000030  00003fd0  00003fd0  00002fd0  2**2
CONTENTS, ALLOC, LOAD, DATA

这里可以看到.got在文件中的偏移是0x00003fd0,现在来看在动态链接时需要重定位的项,使用objdump -R got命令

1
00003fec R_386_GLOB_DAT    b@Base

可以看到变量b的地址需要重定位,位于0x00003fec,在GOT表中的偏移就是28,也就是第八项(每四个字节为一项),这个值正好对应之前通过指令计算出来的偏移值

4、模块间函数调用

模块间函数调用用到了延迟绑定,都是函数名@plt的形式

1
11e2:       e8 69 fe ff ff          call   1050 <test@plt>

延迟绑定(Lazy Binding) && PLT(Procedure Linkage Table)

因为动态链接的程序是在运行时需要对全局和静态数据访问进行GOT定位,然后间接寻址。同样,对于模块间的调用也需要GOT定位,再间接跳转,这么做就一定会影响到程序的运行速度。而且程序在运行时很大一部分函数都可能用不到,于是ELF采用了当函数第一次使用时才进行绑定的做法,也就是我们所说的延迟绑定。ELF文件实现延迟绑定是通过PLT原先GOT中存放着全局变量和函数调用,现在将其拆成两个部分.got和.got.plt,用.got存放全局变量引用,而.got.plt存放函数调用引用。查看test@plt代码,用objdump -Mintel -d -j .plt got命令

[!info]
-Mintel选项指定intel汇编语法
-d选项展示可执行文件节的汇编形式
-j选项后面跟上节名,指定节

1
2
3
4
00001050 <test@plt>:
1050: ff a3 14 00 00 00 jmp DWORD PTR [ebx+0x14]
1056: 68 10 00 00 00 push 0x10
105b: e9 c0 ff ff ff jmp 1020 <_init+0x20>

查看main()函数中调用test@plt的反汇编码

1
2
3
4
11d2:       e8 c9 fe ff ff          call   10a0 <__x86.get_pc_thunk.bx>
11d7: 81 c3 f9 2d 00 00 add ebx,0x2df9
11dd: e8 bb ff ff ff call 119d <fun>
11e2: e8 69 fe ff ff call 1050 <test@plt>

这里的<__x86.get_pc_thunk.bx>函数和之前的<__x86.get_pc_thunk.ax>功能一样,得出ebx = 0x3fd0 = 0x11d7 + 0x2df9,ebx + 0x14 = 0x3fe4,这个地址在.got.plt节中。也就是说当程序需要调用到其他模块中的函数时,例如fun(),就去访问保存在.got.plt中的fun@plt。这里有两种情况,第一种就是第一次使用这个函数,这个地方就存放着第二条指令的地址,也就是相当于什么都不做。用objdump -d -s got -j .got.plt命令查看节中的内容

[!info]
-s 参数显示指定节的所有内容

但是这里我的程序没有独立的.got.plt节,所以就使用了objdump -s -j .got got命令查看整个got节

1
2
3
4
Contents of section .got:
3fd0 d03e0000 00000000 00000000 36100000 .>..........6...
3fe0 46100000 56100000 00000000 00000000 F...V...........
3ff0 00000000 00000000 c3110000 00000000 ................

3fe4处存放着56100000,小端序即为0x00001056,这个位置刚好对应着push 0x10这条指令,这个值是test这个符号在.rel.plt节中的下标。继续jmp指令跳转到.plt处

1
2
3
4
00001020 <__libc_start_main@plt-0x10>:
1020: ff b3 04 00 00 00 push DWORD PTR [ebx+0x4]
1026: ff a3 08 00 00 00 jmp DWORD PTR [ebx+0x8]
102c: 00 00 add BYTE PTR [eax],al

push DWORD PTR [ebx+0x4]指令是将当前模块ID压栈,也就是got.c模块,接着jmp DWORD PTR [ebx+0x8],这个指令就是跳转到动态链接器中的_dl_runtime_resolve函数中去。这个函数的作用就是在另外的模块中查找需要的函数,就是这里的在got_extern.so模块中的test函数。然后_dl_runtime_resolve函数会将test()函数的真正地址填入到test@got中去也就是.got.plt节中。那么第二种情况就是,当第二次调用test()@plt函数时,就会通过第一条指令跳转到真正的函数地址。整个过程就是所说的通过plt来实现延迟绑定。程序调用外部函数的整个过程就是,第一次访问test@plt函数时,动态链接器就会去动态共享模块中查找test函数的真实地址,然后将真实地址保存到test@got中(.got.plt);第二次访问test@plt时,就直接跳转到test@got中去