高级ROP ret2dl_runtime 之通杀详解(本文首发于先知社区)
前言:花了好几天研究这几个类型题,发觉了个通用规律,原来越高级的题目利用起来越容易,因为根本不用画太多时间改exp,直接改几个变量就可以直接打成功。。。所以想写篇文章记录下,以前怕高级rop,理解原理并利用后发觉简单了
ret2dllruntime 原理
先推荐几个地址学习下
虽然以上不是我的博客,不过这些个大佬帮助了我,给个链接也是应该的。
ctf-wiki那个题目我感觉不够经典,还多了个write函数,单一难以泄露才是最经典的题目,所以我选了这个题目来做示例
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
1 | ssize_t vuln() |
很明显的栈溢出
可没有多余的函数可以泄露,这对于我这千年通过leak进行rop的玩家很不友好,因为这道题我做过,虽然当时没做出也没研究,不过以前欠过的债迟早要还的,在国赛就在遇到了它,国赛的时候我找到了别人的exp,0ctf-2018的题目一把梭了。在赛后好好研究了一下这个题目,发觉这类题型就是改改exp就可以通杀,发觉很舒服做这种题。好了,话不多说,开始正文:
没有leak,如何做呢,ctf-wiki高级ROP了解一波
原理
要想弄懂这个 ROP 利用技巧,需要首先理解 ELF 文件的基本结构,以及动态链接的基本过程,请参考 executable 中 elf 对应的介绍。这里我只给出相应的利用方式。
我们知道在 linux 中是利用_dl_runtime_resolve(link_map_obj, reloc_index) 来对动态链接的函数进行重定位的。那么如果我们可以控制相应的参数以及其对应地址的内容是不是就可以控制解析的函数了呢?答案是肯定的。具体利用方式如下
控制程序执行 dl_resolve 函数
给定 Link_map 以及 index 两个参数。
当然我们可以直接给定 plt0 对应的汇编代码,这时,我们就只需要一个 index 就足够了。
控制 index 的大小,以便于指向自己所控制的区域,从而伪造一个指定的重定位表项。
伪造重定位表项,使得重定位表项所指的符号也在自己可以控制的范围内。
伪造符号内容,使得符号对应的名称也在自己可以控制的范围内。
此外,这个攻击成功的很必要的条件
dl_resolve 函数不会检查对应的符号是否越界,它只会根据我们所给定的数据来执行。
dl_resolve 函数最后的解析根本上依赖于所给定的字符串。
注意:
符号版本信息
最好使得 ndx = VERSYM[(reloc->r_info) >> 8] 的值为 0,以便于防止找不到的情况。
重定位表项
r_offset 必须是可写的,因为当解析完函数后,必须把相应函数的地址填入到对应的地址。
相信各位看官在看到这里的时候跟我一样懵,因为我也不了解具体原理当时,看着这段话不知道什么意思,所以我就先去了解elf的基本结构以及动态链接的基本过程(PS:我也没完整看完,枯燥乏味,通过调试一点点理解过程的)
这里先说下动态延迟绑定机制:
就是一开始把所有的函数都链接实际是一种浪费,因此采用延迟绑定技术,核心是第一次用的时候进行绑定,没有用到不进行绑定,这样用来加快程序的运行速度
所以第一次调用的这个函数的时候,程序会去查表,然后利用_dl_runtime_resolve将正确的地址写入got.plt表里,下次查询的时候就直接跳到正确的地址处
先看下调试部分吧
1 | ► 0x8048390 <read@plt> jmp dword ptr [read@got.plt] <0x804a00c> |
这是我在read@plt处下断,
- 你看他第一次调用的时候,read@got.plt里存的是下一条指令的地址,也就是0x8048396,
- 然后将read函数在表里的偏移push进去,这里push的是0,
- 然后跳到plt0里,将linkmap push进去,然后跳到_dl_runtime_resolve进行解析,解析后的地址将会写入到第一次的read@got.plt表里,然后将程序的控制权交给解析出来的地址指向的函数
而我们的攻击方式就是伪造所谓的表,然后将我们伪造表的偏移当参数传入,这样的话,他就会解析到我们想需要的函数了
这只是通俗易懂的说法,实际伪造这个表起来不是那么简单,除非你理解了整个过程
我将ctf-wiki上的内容摘抄过来了,帮助你们理解,他是进行了完整的解释,我感觉太长了,不过我理解过后看的话,看懂了。。。
elf部分的关键点(来自ctf-wiki)
动态链接器和程序按照如下方式解析过程链接表和全局偏移表的符号引用。
- 当第一次建立程序的内存镜像时,动态链接器将全局偏移表的第二个和第三个项设置为特殊的值,下面的步骤会仔细解释这些数值。
- 如果过程链接表是位置独立的话,那么 GOT 表的地址必须在 ebx 寄存器中。每一个进程镜像中的共享目标文件都有独立的 PLT 表,并且程序只在同一个目标文件将控制流交给 PLT 表项。因此,调用函数负责在调用 PLT 表项之前,将全局偏移表的基地址设置为寄存器中。
- 这里举个例子,假设程序调用了 name1,它将控制权交给了 lable .PLT1。
- 那么,第一条指令将会跳转到全局偏移表中 name1 的地址。初始时,全局偏移表中包含 PLT 中下一条 pushl 指令的地址,并不是 name1 的实际地址。
- 因此,程序将一个重定向偏移(reloc_index)压到栈上。重定位偏移是 32 位的,并且是非负的数值。此外,重定位表项的类型为 R_386_JMP_SLOT,并且它将会说明在之前 jmp 指令中使用的全局偏移表项在 GOT 表中的偏移。重定位表项也包含了一个符号表索引,因此告诉动态链接器什么符号目前正在被引用。在这个例子中,就是 name1 了。
- 在压入重定位偏移后,程序会跳转到 .PLT0,这是过程链接表的第一个表项。pushl 指令将 GOT 表的第二个表项 (got_plus_4 或者 4(%ebx),当前 ELF 对象的信息) 压到栈上,然后给动态链接器一个识别信息。此后,程序会跳转到第三个全局偏移表项 (got_plus_8 或者 8(%ebx),指向动态装载器中_dl_runtime_resolve 函数的指针) 处,这将会将程序流交给动态链接器。
- 当动态链接器接收到控制权后,他将会进行出栈操作,查看重定位表项,找到对应的符号的值,将 name1 的地址存储在全局偏移表项中,然后将控制权交给目的地址。
- 过程链接表执行之后,程序的控制权将会直接交给 name1 函数,而且此后再也不会调用动态链接器来解析这个函数。也就是说,在 .PLT1 处的 jmp 指令将会直接跳转到 name1 处,而不是再次执行 pushl 指令。
在 Linux 的设计中,第一个之后的 PLT 条目进行了如下的函数调用
_dl_runtime_resolve(link_map_obj, reloc_index)
这里以 32 位为例(64 位类似),具体的过程如下
- 根据 reloc_index 计算相应的重定位表项:Elf32_Rel *reloc = JMPREL + index
- 根据得到的重定位表项的 r_info 得到对应的符号在符号表中的索引:(reloc->r_info)>>8
- 继而得到对应的符号:Elf32_Sym *sym = &SYMTAB[((reloc->r_info)>>8)]
- 判断符号的类型是否为 R_386_JMP_SLOT:assert (((reloc->r_info)&0xff) == 0x7 )
- if ((ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)
- if (sym->st_other) & 3 == 0 )
- 判断该符号是否已经解析过了,如果解析过,就不会再去执行 lookup 操作。
- 得到符号的版本,如果 ndx 为 0 的话,会直接使用 local symbol。
- uint16_t ndx = VERSYM[(reloc->r_info) >> 8]
- r_found_version *version = &l->l_version[ndx]
- 根据 name 来寻找相应函数在库中的地址。
- name = STRTAB + sym->st_name
解析
刚刚我说了攻击方式,接下来我们就要伪造偏移跟表了
简单来说,ret2dllruntime这个攻击方式他要利用三个表:
- .rel.plt
- .dynsym
- .dynstr
- 他先从.rel.plt表里找到某个函数在dynsym里的偏移
- 然后从.dynsym符号表里找寻该函数在.dynstr表里的偏移
- 在从.dynstr表里找到具体的函数对应的字符串,然后将这个字符串解析成函数
所以如果我们可以找到这个表,改掉这个表里的字符串,按理说也是可以进行调用成功的
贴张图,这是整体结构部分图
第一个表是.rel.plt也就是ELF REL Relocation Table
这个表里有个结构体,存储了写入位置和具体偏移量
1 | LOAD:0804831C ; ELF REL Relocation Table |
- 前面是写入的位置,而107代表的是偏移为1的导入函数,07代表的是导入函数的意思,所以你在exp里会看到<<8位或者>>8位这种操作,就是去掉07或者增加07
1 | typedef struct{ |
- 他实际是个结构体,每个都由r_offset和r_info组成,r_info存的也是偏移,是在dynsym表里的偏移,例如read,他在这里是107h就是偏移为1的导入函数,从ELF Symbol Table里找
第二个表是.dynsym也就是ELF Symbol Table
1 | LOAD:080481DC ; ELF Symbol Table |
在这个表里查到也就是第一个函数,没毛病,其实这个表每个项也是一个结构体
1 | typedef struct |
我们所以要伪造的还有st_name,让他去我们的.dynstr表里查找,查找到我们需要的
第三个表就是.dynstr了,也就是ELF String Table
1 | LOAD:0804827C ; ELF String Table |
这个没啥好解释的就是一串字符串,找到这个后,_dl_lookup就会拿这个字符串去查找对应的函数,然后将函数地址取回来写入got.plt表,最后将程序控制权交给该函数
注意:这里的都是相对偏移地址,没有绝对地址,切记切记,不然等会构造exp的时候你会一脸懵逼的
exp详解
我将exp分段进行讲述吧,从exp里调试或许能更清晰的解释这个过程
1 | #!/usr/bin/env python |
这段只是进行栈的迁移,这个部分的知识自行到ctf-wiki补充,或者找些题目练下
1 | plt0 = elf.get_section_by_name('.plt').header.sh_addr |
这是初始化取那三个表地址,plt0是我刚解释过的带linkmap然后jmp到_dl_runtime_resolve的,没有他我们无法进行解析
我将上述代码分为两个部分,一部分为取地址初始化,第二部分才为构造,开头先取各个表的地址,至于为什么要.header.sh_addr这里是因为e这是elf的section header部分,他表里有个字段叫sh_addr
1 | typedef struct { |
成员 | 说明
-|-|-
sh_name |节名称,是节区头字符串表节区中(Section Header String Table Section)的索引,因此该字段实际是一个数值。在字符串表中的具体内容是以 NULL 结尾的字符串。
sh_type |根据节的内容和语义进行分类,具体的类型下面会介绍。
sh_flags |每一比特代表不同的标志,描述节是否可写,可执行,需要分配内存等属性。
sh_addr |如果节区将出现在进程的内存映像中,此成员给出节区的第一个字节应该在进程镜像中的位置。否则,此字段为 0。
sh_offset |给出节区的第一个字节与文件开始处之间的偏移。SHT_NOBITS 类型的节区不占用文件的空间,因此其 sh_offset|成员给出的是概念性的偏移。
sh_size |此成员给出节区的字节大小。除非节区的类型是 SHT_NOBITS ,否则该节占用文件中的 sh_size 字节。类型为SHT_NOBITS 的节区长度可能非零,不过却不占用文件中的空间。
sh_link |此成员给出节区头部表索引链接,其具体的解释依赖于节区类型。
sh_info |此成员给出附加信息,其解释依赖于节区类型。
sh_addralign |某些节区的地址需要对齐。例如,如果一个节区有一个 doubleword 类型的变量,那么系统必须保证整个节区按双字对齐。也就是说,$sh_addr % sh_addralign$=0。目前它仅允许为 0,以及 2 的正整数幂数。 0 和 1 表示没有对齐约束。
sh_entsize|某些节区中存在具有固定大小的表项的表,如符号表。对于这类节区,该成员给出每个表项的字节大小。反之,此成员取值为0。
sh_addr就是取这个地址,取进程镜像中的地址
接下来是重点了
- 如果这部分不理解,你就。。。
- 其实还是可以做这道题的,因为这是原理部分内容,完全可以复制黏贴一把梭,不用理解
- 不过做题图个啥,不就是懂这个原理嘛,接下来仔细解释下如何构造
第一部分
1 | fake_sym_addr = base_stage + 32 |
接下来就是真正的构造部分了,我先构造dynsym内容的地址,我将base_stage + 32作为system函数的偏移地址,也就是说,我知道了dynstr的system地址了,但我这随便取的base_stage + 32 有可能相对于dynsym不是个标准地址 什么叫标准地址,他的每个结构体都是16个字节,也就是说他的地址都是对齐的,我可能相对于他不是刚好一个对齐的地址,所以我这里需要对齐一下,利用我对齐上面部分的代码就可以进行对齐了。解释下:
假设内存布局是这样的
0x8048a00 11111111 22222222 33333333 44444444 dynsym起始位置
0x8048a10 11111111 22222222 33333333 44444444
0x8048a20 11111111 22222222 33333333 44444444
0x8048a30 11111111 22222222 33333333 44444444
0x8048a40 11111111 22222222 33333333 44444444
0x8048a50 11111111 22222222 33333333 44444444
0x8048a60 11111111 22222222 33333333 44444444
0x8048a70 11111111 22222222 33333333 44444444
0x8048a80 11111111 22222222 33333333 44444444
我base_stage + 32可能在这4个部分的任意位置,但这样是不行的,他的结构体只能从开头开始,所以我需要取他的这段开头的地址
- 假设我在第3部分,第一个3的位置,那我base_stage + 32就是0x8048a88
- 利用上面那个计算方式就是0x10 - ((0x8048a88 - 0x8048a00) & 0xf) = 0x10 - 0x8 = 0x8
- 故我的地址在加上align后就变成0x8048a90刚好是对齐了
第二部分
1 | index_dynsym = (fake_sym_addr - dynsym)/0x10 |
- 然后利用这个对齐后的地址开始构造,我可以求出他在.rel.plt表中的偏移,别忘了,我当初说过的,这是相对偏移,所以我们要求r_info也是相对偏移,
- 先通过( fake_sym_addr - dynsym(基地址) )/0x10,求出偏移
- 然后再在这个地址后面添加上07标识,表示这个函数是导入函数,所以就变成了,左移8位就是增加一个字节,两位二进制位, |7相当于加7
- 然后我们需要一个地址进行写入,以后调用跳到这个表的函数就会直接去到函数的真实地址了,不过这里我们只需调用一次,不需要二次调用,所以地址可以随便写,当然,要可写的地址。。。我取了setvbuf的got表,然后将他做成个结构体
- flat([])就是将里面的全变成字符
第三部分
1 | st_name = fake_sym_addr + 0x10 - dynstr |
- 构造dynsym表里的结构体,如果你不记得他具体结构是什么,往上翻一下,我们需要伪造的只有第一项跟第四项,其余为0,第一项为st_name,也就是符号的具体偏移位置,第四项标识为导入函数
- 这里我将fake_sym_addr + 0x10作为’system\x00’的地址,然后求出相对偏移,然后将他构造成一个结构体
第四部分
1 | index_offset = base_stage + 24 - rel_plt |
这部分是最后的了,这个偏移就是拿来寻找.rel.plt表的
构造完后,我们需要构造ROP链了
ROP链的构造
说实话,我好几次看这个ROP链,我都被绕晕了,搞了好几次才完全理解,忘了结构体的原因,建议不要跟博主一样这样命名,结构体最后加个fake_sys_struct这样的,方便看
第一部分
1 | rop = ROP('./pwn') |
- 初始化ROP链和参数
第二部分
1
2rop.raw(plt0)
rop.raw(index_offset) - 先plt0,我已经说过了,调用那部分地址,才能利用_dl_runtime_resolve
- 然后传入偏移,32位是用栈传参的,也就是这样
- 如果是64位,这里还得调整下,先利用pop将参数弄到寄存器里,在调用plt0
第三部分
1 | rop.raw('bbbb') |
- bbbb为返回地址
- base-stage + 82 为函数参数,这个函数是我们最后将程序控制权交给他的函数,本题里也就是system函数
这里具体为什么是这里,你可以从gdb调试看出来,他里面1
2
3
4
5
6
7
8
9
10
11
12
13
14gdb-peda$ disassemble _dl_runtime_resolve
Dump of assembler code for function _dl_runtime_resolve:
=> 0xf7f7e6c0 <+0>: push eax
0xf7f7e6c1 <+1>: push ecx
0xf7f7e6c2 <+2>: push edx
0xf7f7e6c3 <+3>: mov edx,DWORD PTR [esp+0x10]
0xf7f7e6c7 <+7>: mov eax,DWORD PTR [esp+0xc]
0xf7f7e6cb <+11>: call 0xf7f78ac0 <_dl_fixup>
0xf7f7e6d0 <+16>: pop edx
0xf7f7e6d1 <+17>: mov ecx,DWORD PTR [esp]
0xf7f7e6d4 <+20>: mov DWORD PTR [esp],eax
0xf7f7e6d7 <+23>: mov eax,DWORD PTR [esp+0x4]
0xf7f7e6db <+27>: ret 0xc
End of assembler dump. - 从上图可以看出,他直接将栈迁移到了system函数那里,看到这里不由得佩服前人们,研究出了这些攻击方法,然后后面又提高了栈, ret 0xc平衡堆栈过后就刚好对应上了
看参数 - 这里arg[0]就是返回地址,
- arg[1]就是参数了
- 符合了原来的说法,调用完dl_runtime_resolve后将程序控制权交给解析出来的函数。。我先把后面的过程讲了,我在绕回来讲表吧
第四部分
1 | rop.raw('bbbb') |
- 进行填充,使位置达到base_stage + 24
第五部分
1 | rop.raw(fake_sys_rel) |
- 填入.rel.plt里的一个结构体,用于解析函数
第六部分
1 | rop.raw(align * 'a') |
- 填充对齐部分
第七部分
1
rop.raw(fake_sys)
- 这里填入的是一个结构体,大小为0x10,fake_sys->st_name后去找我们的dynstr,这里st_name构造的就是这里地址在加0x10,所以这个结构体过后就是system字符串地址了
第八部分
1 | rop.raw('system\x00') |
第九部分
1 | rop.raw('a'*(80 - len(rop.chain()))) |
- 这里打印出来是82,rop链的自动对齐,所以接下来是参数内容/bin/sh
第十部分
1
2
3
4rop.raw(sh+'\x00')
rop.raw('a'*(100 - len(rop.chain())))
io.sendline(rop.chain())
io.interactive()
完整构造就这样完成了,接下来直接打就能成功了。
先贴上完整exp
1 | #!/usr/bin/env python |
终于写完了这道题。。。不过好像跟我标题好像不太符合啊,通杀,如何通杀。。。
通杀
- 其实这种类型题中间的构造部分完全可以不理,也就是rop链构造和表得到构造部分,你可以直接复制黏贴中间部分拿去打别的题目,也是能成功的,我测试了xctf2015的那道题,也就是ctf-wiki例题,以及iscc2019的题目都是一个套路
- 其实还有集成工具利用,叫roputils,这个也是一个库,专门用于对付ret2dllruntime
- 理解过后,这种题你会发觉很简单,因为利用方式单一,根本没有啥骚姿势学习了,都是一样的套路了
接下来贴下roputils的利用方法,我根本没改什么,就是ctf-wiki的工具使用方法,改几个参数就行,我将需要改的参数提放到前面了
1 | #!/usr/bin/env python |
是不是发觉精简好多,几乎不用写啥,我感觉这种题就是这样,原理难理解点,解题很简单,以后比赛遇到这种题,就拿这个exp改下offset和程序名,一波梭,有时候需要手动迁移下栈而已
总结:
- 以后遇到高级ROP这种题就一把梭了
- 妈妈在也不用担心我遇到栈的这种问题了
- 我只分析了32位程序的这种题,64位题目的结构和大小也改了,不用利用工具也可以方便的搞定,具体自行尝试了
本文作者:NoOne
本文地址: https://noonegroup.xyz/posts/a606db69/
版权声明:转载请注明出处!