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