原理
堆溢出漏洞的形成,实际上就是写入数据的大小超出的堆块本身的大小,从而覆盖并修改到内存地址相邻的其他堆块的结构信息,最终导致任意内存地址申请,然后导致任意地址写入,最终达到任意命令执行的效果。
前置理论知识
0.单链表与循环双链表单链表结构,这是一种数据结构,由多个结点通过指针链接构成,首先来看单个结点的构成:
结点中需要有一块区域存储下一个结点的内存地址,也就是有一个指针指向下一个结点,然后结点之间就能实现串联 单链表概念图:
通过头指针来索引到一个链表,因为头指针就是第一个结点的内存地址,然后每次通过结点中存储的下一个结点的内存地址来找到下一个结点,当找到最后一个结点的时候,发现存储下一个节点内存地址的位置为空,表示现在这个节点就是终端节点,也就是最后一个节点。通过设置节点中存储的下一个节点的内存地址可以实现节点的增加和删除,单链表有两种插入节点方式,分别是头插法和尾插法,头插法是将新增的节点作为头节点,尾插法是将新增的节点作为终端节点。节点之间可以是内存地址相邻的也可以是不相邻的,通过这种结构可以实现的效果就是,能将多块内存当做一整块逻辑连续的内存来使用。循环双链表,单链表是是只记录下一个节点的内存地址的,而双链表是同时记录上一个和下一个节点的内存地址的,从而实现向前向后遍历,看结构图:
那么循环链表就是因为,在尾节点的下一个节点的内存地址是记录了头节点(第一个节点)的内存地址,如果从头节点一直向后遍历到尾节点,再下一个就又回到了头节点,实现一个循环的效果,看概念图:
1.堆,堆和栈都可以作为数据的缓冲区,但它们之间的结构不同,管理方式也不同,在C语言中通过malloc
函数来申请一个堆块。(0)申请堆块,程序初始状态下,会先申请一大片内存(称为:top_chunk
),当程序调用malloc
申请堆块时,如果没有其他大小合适的空闲堆块,那么堆管理器就会从top_chunk
中切割出相应大小的堆块分配给用户,如果用户申请的内存大于top_chunk
的大小,也就是top_chunk
不够分配时会主动向系统申请一块更大的内存空间,之后再分配给用户。(1)释放堆块,当一个程序申请了一个堆块,之后不需要了,释放堆块的时候,释放的内存并不会直接返还给系统,而是由堆管理器进行管理,这些释放掉的内存,会在之后申请堆块的时候得以重复利用,提高效率。(2)堆结构,堆有两种状态,一种是已分配,也就是正在使用的堆块,结构如下:
第一行表示堆块头部,prev_size
是用来记录内存相邻的上一个堆块的大小,size是记录自身堆块的大小,而AMP
是3个标志位,复用了size值的最低三个二进制位,也就是只有0和1两个状态,
A代表NON_MAIN_ARENA
,记录当前堆块是否属于主线程,1表示不属于,0表示属于;
M代表IS_MAPPED
,记录当前堆块是否有mmap分配的;
P表示PREV_INUSE
,记录前一个堆块是否被分配,1为已分配,0为空闲状态,当P为0的时候,通过查找prev_size记录的值获取上一个堆块的大小,方便空闲堆块之间的合并,content代表堆块的内容区,这里就是用来存放数据的。
具体是怎么复用这三个二进制位的呢,原因是堆的size对齐机制,当你申请一个0x19大小的堆块时,你实际会获得0x20大小的堆块,因为堆大小需要对齐,但会首先满足用户的需求,最后获得的堆块大小肯定是大于等于申请的堆大小,所以堆的大小永远是0x10的整数倍,但要大于等于0x20(因为堆块头起码要占用0x10大小,如果堆块的内容区为0,那就没意义了),将0x10
转换成二进制来看是10000,由于堆块大小是0x10
的整数倍,所以堆大小最后的四个二进制位都是固定为0的,比如0x20的二进制值是100000,0x1230的二进制值是1001000110000
,最后四个进制位永远用不到,所以可以将其复用,使用size
位置的值时会直接忽略最低三个二进制位使用,以免对堆块大小判断错误。 接下来将堆块的空闲状态,当程序释放堆块的时候,堆管理器会根据堆大小来对不同堆块进行分类,并带有不同的管理机制,但这里我只介绍与主题相关的fastbin
、unsorted bin
类型的空闲堆块,fastbin
结构图
可以看见,fastbin
相对于分配状态的堆块,在堆块头部多了两个字段fd和bk,fd位置是在fastbin
中用来记录下一个堆块的内存地址,bk位置在fastbin中没有作用,也就是说fastbin的单链表结构来进行管理的,通过头指针以及节点中记录下一个节点的内存地址来遍历整个链表,通过修改fastbin中的设置fd字段来实现堆块之间的链接。如果堆块的大小在0x20-0x80字节之间,释放的时候就会自动归类进入fastbin
链表,fastbin在字面意思就可以看出这种类型就是为了快速进行分配的,所以属于fastbin的chunk的PREV_INUSE位总是设置为1,即使相邻堆块存在空闲状态的堆块也不会进行合并操作,避免更多的操作提高速度。unsorted bin结构图:
可以看出,unsorted bin
的结构与fastbin
的结构相同,但是unsorted bin
是双向循环链表结构的,也就是说它的bk位置是有作用的,fd记录前一个堆块的内存地址,bk记录后一个堆块的内存地址,而尾节点的后一个会记录头节点,达到一个循环效果,可以实现正向和反向的遍历。
Unsorted bin
和fastbin
的作用有点不一样,unsorted从字面意思翻译就是未分类的 ,这是一个相当于临时存储的地方,如果释放堆块时系统没有进行归类那么都会先加入到unsorted bin中,所以unsorted bin中可以存在任意大小的堆块。既然只是临时存放的地方,那么之后还会有一些机制来进行自动归类,如果unsorted bin中不止一个堆块就会进行整理,遍历unsorted bin的时候如果大小合适(精确值,比如申请0x70就正好有0x70大小的堆块)则直接返回给用户,否则就进行整理,根据大小放入fastbin
、smallbin
、largebin
当中,然后再继续进行遍历查找下一个unsorted bin堆块。如果unsorted bin
只有一个堆块的情况下,用户申请了一个堆块在unosrted bin中查找的时候,如果申请的堆块size小于等于这个堆块的大小就将unsorted bin进行分割,割出刚刚好的大小分配给用户,剩余的块变成新的unsorted bin堆块加入,但是剩余块大小要大于0x10,因为堆块头部占0x10字节,这种堆块只有堆块头部没有内容区根本无法使用,明显是不合理的,如果申请的是0x70大小,unsorted bin堆块是0x80大小,相差0x10字节就不会进行分割了而是都返回给用户。
unosrted bin的作用是可以快速的重新利用最近释放的堆块,不用更多的额外操作查找合适的堆块,因为如果立刻进行归类到时候程序又立刻申请了一个相同大小的堆块,归类行为就是多此一举,快速复用提高效率。一般来说大于0x80字节大小且不与top_chunk相邻的堆块的堆块会先进入unsorted bin,也就是说大于fastbin大小的堆块,如果与top_chunk相邻就直接合并进top_chunk了。当程序申请一个堆块时会首先查找fastbin,如果没有就在unsorted bin查找,达到快速复用内存的目的。另外还要提到一个unsorted bin的特性,由于unsorted bin是双向循环链表,当unsorted bin只有一个堆块的时候,它的fd、bk指针会指向main_arena附近,main_arena就是管理所有堆块的结构体的位置,所以会指向一个main_arena中管理unosrted_bin的地方。2.glibc,glibc
是GNU发布的libc库,也就是C运行库,glibc是Linux中最底层的api,几乎所有其他的运行库都要依赖于glibc,因为其足够底层。Glibc 是提供系统调用和基本函数的 C 库,比如open
, malloc
, printf
等等,调用的这些系统函数都是由glibc提供的,glibc的作用就是为了提供一些函数可以给任何程序公共使用,如果没有glibc,程序就要从零造轮子实现各种底层功能,提高了编写程序的难度,同时如果系统中有大量程序在内存中运行着相似的函数就会导致严重的内存空间浪费,所以glibc就是为了解决这些问题,glibc由系统加载到内存中,供所有程序共享内存空间使用。3.HOOK,中文译为钩子,hook是一种在程序中用来拦截函数之间调用、事件、消息代码的技术,可以达到一个拦截、增加、修改的效果,比如说hook一个API,我可以设置直接返回,不让程序调用这个API,也可以增加一些操作再执行API,或者修改执行API的位置,跳过一些代码部分。
4.one_gadget,这是一种利用技巧同时也是一种工具的名字,一般情况下的缓冲区溢出利用都是通过调用system(‘/bin/sh’)
来调用终端进行任意命令执行,但是one_gadget可以从glibc找出来一些利用execve的系统调用来执行/bin/sh的代码片段,也就是说控制程序跳转到这个位置就能get_shell,但是需要满足一些前提条件。接下来查找一下2.23版本的glibc共享库文件作为示例:
橙色框的位置表示这个gadget的地址,但不是直接的内存地址,他是相对于libc基地址的相对偏移,抽象来说明一下,如图:
假如系统将glibc装载到这一片内存区域,那么以这个例子来说,假如puts函数代码的长度是0x100
,那么libc基地址+0x100就是printf函数,可以根据相对偏移寻址直接定位到函数或者代码片段,glibc中的函数都是提前编译好然后在系统空间中连续加载进内存的,因此在装载的时候他里面的结构是固定的,所以可以通过基地址+偏移的方式来寻址,这也相当于指针通过偏移寻址的概念,但还有一个问题,系统默认是开启地址随机化保护的,具体来说就是程序运行的时候,glibc的地址是随机不固定的,也就是说如果我们通过某种方式获得libc的基地址,那么只有这一次的程序运行时地址才是有效的,下次运行地址就发生变化了。那么我们要怎么获得libc基地址呢,前面说过了函数与基地址之间是有着固定的相对偏移的,如果我们能泄露出一个函数的地址减去偏移就能获得libc基地址,然后再加上偏移可以在libc随意查找函数和代码片段。5.大端序与小端序,这是在内存中两种不同的存储字节的方式,大端序:高位字节存入低地址,低位字节存入高地址;小端序:低位字节存入低地址,高位字节存入高地址,在Windows/Linux操作系统一般都是采用小端序方式写入内存。查看一片内存来说明一下:
关注黄字部分,虽然0x7f910710daf0
地址中存储的值是0x7f910710c260
,但是并不像我们看到的一样,最开始有几个x00
字节,实际上最后的0x60
字节是存储在内存中的低位,做一个实验我们+1偏移来查看,也就是0x7f910710daf1
的值:
可以发现+1偏移之后略过的是最后的0x60字节,这就是小端序的特点。然后利用这种特点,产生了一种内存错位的技巧,通过偏移将内存地址的高字节分离出来,用来伪造堆块头部的size位,一步一步加偏移来看:
通过加偏移这种方式逐渐将低字节和高字节分离开,最终红框位置只剩下0x7f字节,适合让我们伪造堆头部的size位,我们把橙框位置当做堆头部起始位置来看:
如果使用0x7f910710daed
做为堆地址,此时就能绕过检查,伪造成合法的堆块,因为此时size位合理,这就是内存错位的利用技巧。
然后说一个实际的利用方式,由于fastbin
是单链表结构,通过堆块中的fd
字段来索引到下一个堆块,可以利用堆溢出修改堆块fastbin上一个堆块的size
位置,将size
改大,之后就能利用fastbin上一个堆块来控制fastbin的堆头部位置来修改fd
,将fd
修改为malloc_hook
。但是吧,由于内存地址随机化的保护,我们得先知道malloc_hook
在哪也就是它的内存地址才能利用,需要先泄露libc基地址再加偏移算出malloc_hook
的内存地址,通过泄露unsorted bin中的fd、bk指针来算出libc基地址是个常见的手法,这是因为前面提到过的当unsorted bin
只有一个堆块时,fd、bk指针都会指向main_arena
位置,而main_arena的内存地址又是跟libc基地址有着固定偏移量的,所以可以动态调试出偏移量,获得main_arena
地址后每次减去固定偏移得到libc基地址。为什么要控制malloc_hook?
这是因为,当malloc函数调用时会检测malloc_hook
是否为空,如果为空什么也不干,如果不为空,则跳转到malloc_hook
记录的内存地址去执行,由于malloc就是申请堆块的函数,存在堆溢出漏洞首先得有堆块,所以存在堆溢出漏洞的程序肯定会调用malloc,设置好malloc_hook的值之后通过malloc触发,就能让程序执行到一个我们想要执行的位置去,在这里我们为了实现任意命令执行,将malloc_hook设置为one_gadget,在之后调用malloc的时候就直接执行one_gadget(前提是满足one_gadget的触发条件,上面已经提到)达到一个getshell的效果。
babyheap_0ctf_2017
题目来源:babyheap_0ctf_2017
首先将程序拖入IDA进行静态分析伪代码,已经对伪代码进行注释解析,先查看main函数
可以看到这是一道常见的堆的菜单题,主要有四个功能增删查改,查看一下这四个函数的主要代码 add函数:
edit函数:
delete函数:
show函数:
可以看出程序中存在的主要漏洞就是使用edit功能的时候,没有对堆块可以修改的大小做出限制,因为申请的堆块是有大小的,如果写入不受限的大小数据,就会导致堆溢出,接下来就利用堆溢出来攻击fastbin达到任意命令执行。
先贴出利用代码,再逐步进行解释
我们第一阶段的目的是获得libc基地址,才能算出我们所需要的地址,首先申请两个堆块,分别是0x60
大小和0x40
大小
可以在上图中看到现在的内存状态,这里稍微解释一下方框中标下划线的部分,这就是堆块中的size
位置,至于0x60
的堆块为什么记录的size
是0x71
,这里因为0x60
是我们需要的堆块内容区的大小,而堆头部并不能提供给我们写入数据,所以堆块大小是内容区大小(0x60)+堆头部大小(0x10)=0x70
,至于多出来的1,就是我们之前提到的标准位P,复用了size的最低一个二进制位,chunk1
的大小也是同理,然后绿色框的地方就是之前提到的top_chunk
,从size位置可以看出top_chunk的大小很大,所以框住的部分只是top_chunk的头部部分,后面的内存区域都是属于top_chunk,刚开始的时候top_chunk
是在现在chunk0
的位置的,每当我们申请一个堆块,它就切割一块内存给我们,所以就向后移动了。
然后通过堆溢出漏洞修改chunk1
的头部
可以发现与上一张图发生变化的地方就是橙色框和红色框的位置,橙色框中的0x61
是我们使用edit功能填充的a的ASCII码的16进制值,然后红色框的位置是chunk1
的size位,由于edit函数没有对修改大小做出限制,所以直接溢出修改了chunk1
的大小。
然后申请一个0x100
的堆块chunk2
,然后在其中伪造出一个堆块头部的位置绕过检查,因为堆块在释放的时候他是有着一定检查的,不是控制字段说改就改、一改就生效的,在这里的情况是释放堆块的时候会根据自身堆块记录的size到下一个堆块的开头位置是否存在堆块头部信息,堆块头部信息在内存中也就是一些值而已,我们自己也可以填充出合理的值绕过检查,这样就是起到一个配合修改chunk1
大小的操作。
查看一下内存变化,橙色框住的地方就是原来堆块的大小是0x51
(加上chunk头部大小和PREV_INUSE
位),红色框位置就是修改size位后chunk1现在的范围,可以发现是包含了chunk2的头部的,之后可以控制chunk2头部进行修改,黄色下划线是为了配合修改过后的堆块大小布置的假的堆块头信息,让系统以为这里有个堆块。
下一步是释放掉chunk1
再申请回来,因为我们虽然修改了chunk1
的大小,但是不是立即生效的,在程序中记录的大小是在我们申请堆块的时候就记录好的,因为现在chunk1
包含了chunk2
的头部,之后我们就可以通过程序的show
函数来打印chunk1
的内容来打印出chunk2
的头部,泄露其中的fd
、bk
指针获得libc
基地址,这里还要注意一个细节就是在这个程序中申请堆块不是用的malloc
函数而是calloc
,这两个函数其实很像,但主要的区别就是calloc
会在申请堆块的时候初始化缓冲区,也就是将堆块的内容区全部清空,那因为chunk1
包含了chunk2
头部,chunk2
头部信息就被清空了破坏了堆块头部,所以我们使用edit
功能将chunk2
本来的头部信息给写回来。接着申请一个任意大小的堆块,作用是防止chunk2
与top_chunk
合并,因为chunk2
下方就是top_chunk
它们是相邻的(chunk2
现在是最后一个堆块,也就是top_chunk
最新分割出的堆块,所以与top_chunk
相邻),如果直接释放chunk2
就不会进入unosrted bin
而是与top_chunk
合并,所以需要申请一个堆块来起到分割,防止合并的作用。然后释放chunk2
此时chunk2
进入unosrted bin
,chunk1
包含chunk2
头部,所以现在使用show
打印chunk1
的内容,泄露fd
、bk
指针算出libc
基地址。重新申请回chunk1
,堆块被初始化,黄色字是原chunk2
头部:
修复chunk2
头部:
新增chunk3
,防止chunk2
与top_chunk
合并:
释放chunk2
,泄露fd/bk
,算出libc
基地址:
那么拿到libc
基地址后,根据偏移先算出malloc_hook
的内存地址,然后我们之后想申请到这个地方然后修改,但是堆块是有检查的,我们如果想申请到这个地方是需要带有堆块头部信息才行,这里的具体情况就是size位置要满足,因为fastbin中是有着不同大小的链表,如果我们修改了fastbin堆块中的fd指针,那么fd指向的地方也要是和自身是相等大小的堆块,但是我们要修改的地方它本身并不是堆块,所以我们需要想办法构造出假的堆块头部信息来绕过检查,这里是用到一个内存错位的技巧,我们首先来查看malloc_hook
附近的内存
黄色字位置就是malloc_hook
的位置,可以发现他上方有些0x7f
开头的值,那么看下0x7f
的二进制值是1111111
,之前说过低四个二进制位在size字段使用的时候会被忽略,忽略之后就是1110000
,16进制值是0x70
,这就跟我们chunk1
的size
对应上了,也是0x70
大小,我们利用内存错位来将这个0x7f
单个字节给挤出来,作为size
位布置
由于布置的堆块大小有0x70
,所以往前申请一点也能修改到malloc_hook
。然后开始实施攻击,首先释放掉chunk1
,然后利用edit功能的堆溢出漏洞利用chunk0
直接溢出修改chunk1
头部 释放chunk1
,chunk1
进入fastbin
的0x70
大小的链表中,此时chunk1
的fd
指针位置是空的:
溢出修改chunk1
的fd
指针:
再查看堆状态,chunk1
的下一个堆块已经被设置好了:
再查看一下malloc_hook
地址和chunk1
下一个堆块的地址:
可以看见是没什么问题的,之后申请到第二个0x70
大小的堆块就能控制malloc_hook
可以看到现在fastbin
,0x70
大小链表中有两个堆块,我们会先获得chunk1
,再获得malloc_hook
附近的堆块,所以现在申请两个堆块,第一个没用,就是将chunk1
申请回来而已,第二个堆块就是malloc_hook
,这里提一下写入的内容,我们获得的堆块是malloc_hook-0x23
的位置,其中开头0x10
的位置作为堆块头部,那么就是再填充0x13
个字节就偏移到malloc_hook
的位置,而'a'*3+p64(0)+p64(0)
,a*3
就是3个a字母占3个字节,p64(0)
就是构造出64位系统下的空字节,在64位系统下的内存取值范围一次是8字节,所以两个p64(0)
就是0x10
字节,加上前面的3个字节等于0x13
,再将malloc
写入one_gadget
的地址。控制完malloc_hook
之后就要进行触发操作,通过调用add函数申请堆块就会检测到malloc_hook
然后执行one_gadget
。获得chunk1
:
获得malloc_hook
附近的堆块:
malloc_hook
默认为空:
向malloc_hook
写入one_gadget
地址:
申请堆块,这个位置就是检测malloc_hook
是否存在的汇编,现在检测到存在,call rax
就是跳转到malloc_hook
中的地址:
准备跳转到one_gadget
:
执行call rax
后跳转进one_gadget
:
最终执行execve
调用获得shell,执行id命令成功:
同时调试器也提醒我们,产生了一个新的线程是/bin/bash
也就是交互式终端
现在就可以达到任意命令执行的目的了
总结
究其根本,堆溢出就是普通的缓冲区溢出,跟栈溢出并没有本质上的区别,但是由于堆的结构、管理机制和栈是完全不同的,加上堆的各种类型/机制/检查,导致堆溢出的利用手法比较复杂,但同样是有着严重危害的,比如在本例子中产生的堆溢出问题,本来也就是随意的溢出缓冲区覆写到相邻的下一个堆块的结构,比如在例子中修改了下一个堆块的大小,但本质上来说只是修改了一下堆块大小并不会怎么样,因此要结合fastbin
堆块类型的特点+unsorted bin
堆块类型的特点+libc
的知识,打出一个组合拳,利用unosrted bin
特点泄露libc基地址
,然后根据libc
中的malloc
函数和malloc_hook
函数特点和libc
中的固定偏移特点,算出mallc_hook
地址和one_gadget
的地址,再根据fastbin
单链表结构中的fd
指针,覆写fd
指针为malloc
,结合内存错位的利用技巧构造出合理的堆头部,将堆块开在libc
中,覆盖malloc_hook
为one_gadget
,最后利用malloc
函数触发malloc_hook
让程序跳转到one_gadget
执行。
虽然在操作系统中尝试缓解堆溢出问题,但是在本例子中也可以看见绕过了许多保护,这是很难抉择的一个问题,如果假如大量的安全措施进行检查,堆块确实可以变得非常安全,大幅度提高利用难度,但与此同时,可能安全措施的代码都远超申请堆块的代码,带来大量的指令需要执行,也将大幅降低堆的性能与速度,说到底,有漏洞的是写程序的人,并不是堆。所以说在使用缓冲区的时候,无论堆栈都需要做足检查措施,严格限制修改的大小,同时留出一定的内存冗余空间,避免未知错误。在这个示例中,只演示了针对fastbin堆块类型的利用以及缓冲区修改大小不受限制的漏洞,还有很多针对其他类型的堆块进行攻击的手法,我这里没有进行提及,另外,产生漏洞的并不单单只是缓冲区溢出,比如释放堆块后堆块指针未置空导致的野指针利用、缓冲区未初始化、缺少参数的格式化字符串、有符号数转无符号数带来的间接溢出等等,都是需要考虑的安全漏洞,这需要足够的经验和安全编程意识来完成。
End...
最后最后,如果你对CTF比赛也有兴趣,欢迎加入我们!!!
有意向的师傅可以公众号后台回复ACT
联系我们。
原文始发于微信公众号(ACT Team):[Pwn]堆利用Fastbin Attack
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论