PWN之Return-to-dl-resolve攻击详解
原理说明
return-to-dl-resolve是一种绕过NX和ASLR限制的ROP方法,在带有PARTIAL RELRO保护中可以使用。
带有重定向保护的程序的ELF中会带有got表和plt表,这两个表都是用来做重定向的。利用重定向方法调用函数就相当于在二进制文件中留下了一个个坑,预留给外部变量和函数。在编译期我们通常只知道外部符号的类型 (变量类型和函数原型),而不需要知道具体的值(变量值和函数实现). 而这些预留的”坑”,会在用到之前(链接期间或者运行期间)填上。在链接期间填上主要通过工具链中的连接器, 比如GNU链接器ld; 在运行期间填上则通过动态连接器, 或者说解释器(interpreter)来实现。
函数和变量作为符号被存在可执行文件中,各种符号在一起构成了符号表,ELF内有两种类型的符号表:常规符号表.symtab,.strtab和动态的.dynsym,.dynstr。利用readelf -S就可以查看。
利用以下程序来进行后续实验(来自很经典的2015-XDCTF-pwn200)
1 | |
使用以下命令编译,生成可执行文件
$ gcc -o test -m32 -fno-stack-protector -no-pie test.c
需要关闭栈溢出保护和PIE,否则无法进行
首先利用readelf查看段地址:readelf -S test

可以看到有TYPE为REL的两个项,.rel.plt(用于函数重定位) 和 .rel.dyn(用于变量重定位) 。其内部信息可以用readelf -r test来查看

下面从main函数入手,看看执行的glibc的write函数过程都发生了什么(利用gdb-peda)

以write函数为例,可以看见调用的时候实际上到了0x8049070,由上面的段列表比对可以看到,目标在.plt段内,先跳到了plt表。继续跟踪

该函数跳到了0x804c01c,位于.got.plt内,其内容为

回到了0x8049076,实际上是上上面那张图的push 0x20内,接着那张图的往下走,jump到了0x8049020,位于plt[0]。
plt[0]处的指令为

由第一张图知道,0x804c000是GOT表,这些指令先是push了GOT[1],再跳转了GOT[2]
先到这里停一停,我们发现她寻找的路径为 plt->.got.plt->plt->got,下面先解释一下这些表起什么作用。
.got
GOT, 即Global Offset Table, 全局偏移表。这是链接器在执行链接时实际上要填充的部分, 保存了所有外部符号的地址信息。在初始时GOT没有信息,链接的时候通过linux的_dl_runtime_resolve(link_map,reloc_offset)来对动态链接的函数进行重定位。
在i386架构下, 除了每个函数占用一个GOT表项外,GOT表项还保留了 3个公共表项, 每项32位(4字节), 保存在前三个位置, 分别是:
- GOT[0]: ELF的
.dynamic段的装载地址 - GOT[1]: ELF的
link_map数据结构描述符的地址 - GOT[2]:
_dl_runtime_resolve函数的地址
.plt
PLT, 即Procedure Linkage Table, 进程链接表。这个表里包含了一些代码, 用来
- 调用链接器来解析某个外部函数的地址, 并填充到
.got.plt中, 然后跳转到该函数 - 直接在
.got.plt中查找并跳转到对应外部函数(如果已经填充过) - plt表中,PLT[0]储存的信息能用来跳转到动态链接器中(具体代码已在前面分析,push
link_map的地址,跳转到_dl_runtime_resolve),PLT[1] 是系统启动函数(__libc_start_main), 其余每个条目都负责调用一个具体的函数。
.got.plt
相当于.plt的全局偏移表, 其内容有两种情况
- 如果在之前查找过该符号, 内容为外部函数的具体地址
- 如果没查找过, 则内容为跳转回
.plt的代码, 并执行查找
了解完这些以后,我们再来对前面的过程进行梳理:
首先我们想调用write,call到了PLT表,PLT先假设填充过,在.got.plt里面找,而.got.plt还没有填充过实际的地址,于是对应位置是一条跳转回PLT表call的下一句执行查找的代码(push 0x20 call ...)。call的目标在GOT表内,上面分析到程序先push了GOT[1],然后jump到了GOT[2]。而在GOT表的介绍中我们知道,其实就是push了link_map的地址,然后调用了_dl_runtime_resolve(link_map,reloc_offset)。那么offset哪里来的?就是之前push的0x20 !
接下来分析,_dl_runtime_resolve 位于glibc/sysdeps/i386/dl-trampoline.S
1 | |
其作用有2:
- 解析函数地址并填入
.got.plt - 跳转到目标函数执行
我们注意到,具体查找过程中是call到了_dl_fixup(11行)里,源代码位于glibc/elf/dl-runtime.c,部分含义如下
1 | |
第一句,计算重定位入口,_dl_fixup的两个参数就是_dl_runtime_resolve的参数。查到的reloc是一个表项
1 | |
第二句,利用reloc的r_info找到.dynsym段内的连接信息,根据定义
1 | |
查到的sym是如下的结构体:
1 | |
第三句,检查type是不是7(类型是否等于R_386_JUMP_SLOT)
第四句,通过strtab+sym->st_name找到符号表字符串,并返回在glibc的地址
第五句,返回实际函数的地址。
为了进一步理解其中发生了什么,我们可以简单模拟一下查找的过程。
首先在第二张图里面我们可以知道write的r_info是0x607, type=7无误,且索引值为6
在第一张图里知道.dynsym基地址0x804820c,加上6的偏移就是0x804820c+0x10*6 得到:

(.dynsym以\x00作为开始和结尾,中间每个字符串也以\x00间隔,因此会有中间两个0x0000,很重要,伪造的时候不要忘记)
就是说st_name是0x0000042,由Elf32_Sym的注释可知这也是在.dynstr(0x80482ac)的偏移值,我们查看一下0x80482ac+0x42

就是write的名字,接下来送到_dl_lookup_symbol_x去找真正的函数,但这部分过程我们已经不关心了。
因此,攻击思路为拦截write函数第一次链接的过程,即在main中call到plt[0]开始查找的过程
1、利用栈溢出控制eip为plt[0]地址,伪造一个_dl_runtime_resolve的reloc_offset参数
2、控制reloc_offset参数使得_dl_fixup查找到的reloc位于可控地址内
3、伪造reloc的内容,使得sym在可控地址内
4、伪造sym,使sym->st_name找到的符号表字符串在可控地址内
5、伪造sym->st_name对应的字符串为任意库函数,如system,实现攻击。
过程
在本次攻击中因为需要伪造很多数据结构,因此我们需要先进行栈迁移,将栈迁移到.bss段,然后利用.bss段内的栈来伪造上述所有内容,实现攻击。因此,我们的操作分为栈迁移和伪造两步。
步骤0:栈迁移及其原理
栈迁移是CTF中比较常用的套路。其本质上是通过ebp指针来修改栈帧位置和大小。通过将ebp伪造成.bss段的地址来实现。其主要由leave;ret;这个gadget来实现。
leave的本质是:mov esp ebp; pop ebp; ret是:pop eip ;
(以下图片来自http://blog.tianzheng.cool/?p=484)
假设有一个程序有栈溢出漏洞,堆栈是这样的:

在程序call之后,本质上是进行了
1 | |
mov执行完以后:

再来是pop ebp;此时ebp内的值就是esp处的fake_ebp1_addr,esp在pop后下移。

然后进行ret,将eip设置为esp现在所指的read_plt。在read_plt里放了glibc的read函数的地址,系统开始执行新的read函数。read函数的参数为栈内leave ret下面的0,fake_ebp1,0x100 代表向fake_ebp1读100字节。
写入的内容不是乱写的,就是我们的payload2,为了实现栈迁移,我们需要将.bss段fake_ebp1位置内写入fake_ebp2的地址,其他地方随意构造我们需要的数据,这部分我们都能利用

read函数执行完以后回到左侧read_plt下面的leave_ret,会将一开始的过程再执行一遍:
首先是mov esp,ebp;

pop ebp

这句话将ebp放到了fake_ebp2处,此时esp在system_plt上,此后在执行ret,我们构造的函数在.bss就被执行了,栈迁移也就实现了。
步骤1:栈迁移+截获write函数plt解析
首先利用第一次栈溢出,控制eip的位置到read函数,来进行栈迁移,同时准备接受写入在新栈的payload2。
先用gdb-peda定位栈溢出的位置:
pattern_create 120

输入r,gdb开始运行,将生成的pattern当作输入输入进去。

程序崩溃,发现eip值为:0x41384141
pattern_offset 0x41384141
即可得出移除偏移在112处。

此外,通过ROPgadget,我们也可以很清楚的定位到需要的return gadget。


1 | |
和“栈迁移原理”一部分介绍的一样,我们先通过read构造在.bss段的栈,其内容由payload2决定,在这里我们直接传入了write_plt的地址,会直接调用write函数并取指定buffer内容输出,结果如下:

步骤2:截获reloc_offset
刚才我们是知道了write函数的具体调用地址,然后直接传进去了,实际上由原理说明部分讲的那样,当程序不知道write链接到那儿的时候,是要进行动态连接的,如何跳转到动态链接过程?
在“原理说明”的plt表介绍时曾说,PLT[0]储存的信息能用来跳转到动态链接器。因此我们在上面的write_plt的地方传入PLT[0],并把write函数的offset压在后面,这样应该可以根据我们前面所说的那样,调用起动态连接过程,填充write的PLT表,并跳转到write执行。
write的offset是多少?上面已经说到了,是push进去的0x20。

1 | |
结果仍然是打印出/bin/sh

步骤3:伪造reloc_offset,从而伪造reloc
这里的reloc是指在_dl_fixup源码里面的第一句
1 | |
reloc_offset是相对于.rel.plt段的偏移,我们要更改这个偏移,让reloc找到我们.bss段内伪造的值。
把reloc的伪造值放入payload2 += p32(len(cmd))这一句后面,通过计算,位于base_stage+28的位置。
因此传入的reloc_offset是(base_stage + 28) - rel_plt
接下来要思考reloc填充一个假的什么值,前面已经说过reloc的格式是
1 | |
.got节保存了全局变量偏移表,.got.plt节保存了全局函数偏移表。我们通常说的got表指的是.got.plt。.got.plt对应着Elf32_Rel结构中r_offset的值。可以在pwntools通过elf.got拿到,就是在图中的0x0804c01c。
组装一下,假的reloc就是p32(write_got) + p32(r_info),其中r_info就是我们在途中看到的0x607。

1 | |
执行后,和上面的结果一样,输出了/bin/sh。

步骤4:伪造reloc的r_offset,从而伪造sym
继续看_dl_fixup源码这一句:
1 | |
我们首先要将fake_sym放到我们的payload中,再放之前先要注意到,dynsym里的Elf32_Sym结构体都是0x10字节大小,因此我们要先对即将注入的位置进行对齐。fake_sym正常会放在base_stage+36的位置,但不满足对其要求,对齐是0x10 - ((fake_sym_addr - dynsym) & 0xf)字节。故真正的fake_sym地址要加上这部分。先在payload2的fake_reloc后补一些A,再写入假的sym。
为了定位到这个假的sym,要修改之前已经控制的r_info(sym通过reloc->r_info获取在dynsym的偏移)。我们已知了我们注入假的sym的地址和dynsym地址,偏移为index_dynsym=(fake_sym_addr - dynsym) / 0x10(对齐)。实际找的时候,ELF32_R_INFO(sym, type)的算法是(((sym)<<8)+(unsigned char)(type)),也就是说我们的r_info=(index_dynsym << 8) 0x7(或上0x7是因为_dl_fixup里面有个assert,要让type=7)。
定位到假的sym以后,我们就要考虑sym填什么了,根据如下定义:
1 | |
前面我们在最后分析的时候,看到的sym是这样的:

所以我们暂时不改变它,照样写回去,这里的0x42就是st_name在dynstr的offset,0x12就是type。在这里我们只关注name和type,其他的用什么补齐不重要。
所以我们有了下面的代码:
1 | |
最终,也是成功打印出了/bin/sh,证明我们伪造正确。

步骤5:伪造st_name,从而伪造函数符号
前面提到st_name是在.dynstr内部的offset,因此我们可以通过继续伪造这个offset来让连接期间查找函数符号字符串的时候查到我们的.bss段。
为了满足fake_sym的对齐,我们要在fake_sym_addr+0x10 再减去.dynstr段的基地址,这样就能够得到我们想要的偏移。而这个偏移就是st_name,其他不变,然后我们在对应位置写入字符串“write”,并用/x00分割(原理说明里提到过,.dynstr段里面是通过/x00来区分字符串边界的)
于是我们有了下面的代码:
1 | |
结果如下:

步骤6:伪造dynstr查到的值,链接进system
到这一步我们要干什么就很明显了:把上面的程序write改成system即可。这样_dl_runtime_resolve就会把system链接进来,cmd会作为buffer参数传递给它。
函数名字改了,参数也得改掉,system的参数就是一个buffer地址,只有一个参数,因此我们要修改一下参数部分,详见代码里的注释
于是我们最终有:
1 | |
最终,我们拿到了一个shell。

参考
ret2dlresolve http://pwn4.fun/2016/11/09/Return-to-dl-resolve/
深入了解GOT,PLT和动态链接 https://www.cnblogs.com/pannengzhi/p/2018-04-09-about-got-plt.html
PWN从入门到放弃(12)——栈溢出之栈迁移 http://blog.tianzheng.cool/?p=484
[原创]高级栈溢出之ret2dlresolve详解(x86&x64),附源码分析 https://bbs.pediy.com/thread-266769.htm