之前刷题遇到了不会做的内容,于是就把这个堆溢出给啃了
堆溢出
堆的定义
这的堆和数据结构里面的对不是一个东西,这里的堆就是一个双向链表,每一块数据中都会有一个指向上一个块的指针和指向下一个块的指针。每当要分配空间的时候,就从空闲的块上取一个块下来,并且会在分配的块的某一个位置记录此时的空间大小。
堆块大小与分配
1、堆块的大小分配必须字节对齐:
看下列例子:
1 |
|
此处我们故意输入过长的数据
1 | input your name:namenamename1001 |
然而我们发现,虽然只是分配了10空间,但是name还是成功的存下了16bit大小的数据,并且此时的age内部还是空的,但是如果此时再将数据多输入一点造成溢出的话:
1 | input your name:namenamenamename1001 |
此时的age中存放了name最后四位的数据,并且此时的name也可以放得下数据。也就是说,此时的name所指向的空间中,和age所指向的空间存在重合的地方,并且此时name的实际大小为16bit。
2、堆块的结构
一个堆的最小操作单位为chunk,堆的分配与操作都是基于这个chunk进行的:
如上图,一个使用中的堆的结构体大致如上。其中:
- previous chunk size在32位电脑下为4字节,64位为8字节
- size of chunk的大小其实只占29位(因为要求了字节对其),剩下的3位含义分别为:
- PREV_INUSE§: 表示前一个chunk是否为allocated。(若为第一块,则也为1,因为此时不存在在其前面的数据)
- IS_MMAPPED(M):表示当前chunk是否是通过mmap系统调用产生的。
- NON_MAIN_ARENA(N):表示当前chunk是否是thread arena。
这里就要介绍一下arena是什么。每当一个函数建立的时候,如果使用了malloc,那么程序便会从内存空间中多申请一段空间,这段空间就称为arena,每次分配空间的时候,不是直接由mmap像kernel申请内存,而是从这个位置尝试查找能够分配给用户的数据大小,如果分配不到的话,才使用调整sbrk等手段获得数据。
这个arena可以保证多线程能够同时申请到有效的内存空间,相当于每一个线程都有一个自己独立的arena。(当然,这个arena是有个数限制的)
1 | For 32 bit systems: |
其中堆的chunk的数据管理结构体也就是一个Chunk Head大致如下:
1 | struct malloc_chunk |
然后程序中用全局变量main_arena
来管理这些堆,结构为:
1 | struct malloc_state |
3、堆块chunk的理解
在glibc malloc中将整个堆内存空间分成了连续的、大小不一的chunk,即对于堆内存管理而言chunk就是最小操作单位。Chunk总共分为4类:
- allocated chunk
- free chunk
- top chunk
- Last remainder chunk
其实本质上,chunk都是连续内存中的一部分,通过不同的标志位进行划分后得到的小段小段的数据。
我们首先介绍前两中常见堆块:allocated chunk(已分配的堆块)和free chunk(未分配的堆块)。然后我们来关注一下,堆块之所以要形成一定的结构,无非是为了能够更加方便的分配和释放空间,所以在堆块管理的发展之后,诞生了如下的形式:
allocated-chunk
free-chunk
(会注意到,free chunk的格式比allocated chunk更加复杂,毕竟这些格式都是为了能够更加高效的利用数据来进行分配和释放,所以当然会设置为这个样子)
allocated chunk
- mem:就是我们通过malloc所获得的内存地址指针
- prev_size:如果上一个相邻的chunk是free的话,那么此处记录的就是上一个chunk的大小,否则就是一个分配过了的chunk,此时这个位置存放的就是数据(为了利用空间)
- size:存放的就是当前数据大小
注意到,这个数据块中并不存在bk和fd指针(因为此时并不需要)
free chunk
- prev_size:首先,不能有两个相邻的空闲数据块,如果有的话,必定会被合并成同一个。所以此时的prev_size中始终是上一个allocated chunk的大小
- size:此空闲堆块包含的数据大小
- fd:Forward pointer,指向位于同一个bin的下一个chunk(注意不是物理内存中的chunk)
- bk:Backward pointer,指向位于同一个bin的上一个chunk(注意不是物理内存中的chunk)
此处的bin其实就是【一个将chunk链接起来的链表】。根据不同的块大小,系统将其分成了四种类型:
- Fast bin
- Unsorted bin
- Small bin
- Large bin
在glibc中,有两种记录bin的数据类型,一种叫做fastbinY,用于记录所有fast bins的。还有一个叫做bins,用来记录除了fast bin以外的所有bins事实上,一共有126个bins,分别是:
- bin 1 为unsorted bin;
- bin 2 到63为small bin;
- bin 64到126为large bin。
其中具体数据结构定义如下:
1 | struct malloc_state |
这里mfastbinptr的定义:typedef struct malloc_chunk *mfastbinptr
;
mchunkptr的定义:typedef struct malloc_chunk* mchunkptr
;
Fast bin
所有大小介于16~80
字节的chunk都属于这个bin。为了下文更好的叙述,这里约定:
1 | (1) chunk size指的是malloc_chunk得到的实际大小 |
这些bins是内存分配中分配速度最快的数据。
- fast bin总共有10个
- fast bin为单向链表(只使用fd指针)。每次获取数据大小的时候的原则为LIFO(后入先出)添加操作(free内存)就是将新的fast chunk加入链表尾,删除操作(malloc内存)就是将链表尾部的fast chunk删除。需要注意的是,为了实现LIFO算法,fastbinsY数组中每个fastbin元素均指向了该链表的rear end(尾结点),而尾结点通过其fd指针指向前一个结点,依次类推
- chunk size的大小是按照8字节进行步进的。就是说,fastbinsY[0]中chunk size为16byte,fastbinY[1]中chunk size为24byte。。。同一个fastbinY[index]中的所有chunk大小均为相等。
- 在malloc 初始化的时候,这个最大的fast bin设置的chunk size(不是chunk unused size)为64而不是80。
- 不会对free chunk进行合并。因此此时的P位总是设置为1.
- malloc(size)请求的时候,只有size+16<=80的时候才会被分配到fast bin,但是开始时,fast bin max size和fast bin indicies都是空的,此时small bin会接下这个分配任务。当fast bin不空之后,会重新计算出其大小并交回给fast bin
那么fast bin 是在哪?怎么进行初始化的呢?
当我们第一次调用malloc(fast bin)的时候,系统执行_int_malloc函数,该函数首先会发现当前fast bin为空,就转交给small bin处理,进而又发现small bin 也为空,就调用malloc_consolidate
函数对malloc_state
结构体进行初始化,malloc_consolidate
函数主要完成以下几个功能: - a. 首先判断当前malloc_state结构体中的fast bin是否为空,如果为空就说明整个malloc_state都没有完成初始化,需要对malloc_state进行初始化。
- b. malloc_state的初始化操作由函数malloc_init_state(av)完成,该函数先初始化除fast bin之外的所有的bins(构建双链表),再初始化fast bins。
然后当再次执行malloc(fast chunk)函数的时候,此时fast bin相关数据不为空了,就开始使用fast bin
- 当系统得到了第一个来自fast bin的chunk之后,系统就将chunk从当前fast bin中移除,并且将地址返回给用户。
- free(fast chunk) 操作很简单,主要分为两步:先通过chunksize函数根据传入的地址指针获取该指针对应的chunk的大小;然后根据这个chunk大小获取该chunk所属的fast bin,然后再将此chunk添加到该fast bin的链尾即可。整个操作都是在_int_free函数中完成。
Unsorted bin
当 small chunk 或者 large chunk 被freed之后没有回到它们的bin只中时,就会被加入这个bin。这个方法让glibc malloc能够使用最近释放的这些chunk。因此就节约了查找合适大小bin的时间。
- 只有一个,并且为双向链表。
- 没有大小限制,任何大小的chunk都可以被归类到这里。
这个堆块在malloc和free的时候都会有重要作用。
在free阶段
- 当进行free之后所有的堆块(非fastbin)会落入到这个堆块中。
在malloc阶段
- 当进行malloc的时候,size大小位于当前unsorted bin 链表中的某个堆块范围的时候,会切割当前的堆块,然后将大小合适的堆块分配出去,剩下的堆块继续留在unsorted bin中
- 如果上述过程没有找到合适的堆块(通常是分配的太大了)此时会将unsorted bin中的堆块进行规范化,即是按照大小放到small chunk和large chunk中
- malloc的时候,会check当前的堆块的前后堆块的size,如果不在
(2 * SIZE_SZ, system_mem]
区间内的话,则会被视为invalid next size
。所以在伪造堆块的时候,一定要保证当前堆块的 fake size + addr 能够落在一个有效的数字上(最好就是下一个堆块中)
Small chunk
chunk大小小于512byte并且不属于fast bin的就是这个small chunk。其分配速度介于large chunk和fast bin之间。
- small chunk总共有62个
- 也是为free chunk组成的双向链表。但是采用的是**FIFO(先进先出)**的原则(区别于 fast bin)释放后的chunk添加到前端,分配时就从尾部分配。
- 同一个bin中的chunk大小都是一致的,也是以8byte为步长增长。最小为16byte,最大为16 + 8*62 = 512
- 不允许有两个相邻的空块,所以会进行合并。
- malloc操作类似于fast bin。
- 开始的时候所有的small bin都是空的,所以此时并不会由small bin,而是unsorted bin去处理。
- 第一次调用malloc的时候,small bin和large bin会在malloc_init_state函数中被建立。bins指针将会指向自己,表示此时为空,通俗点说,如果smallbin中只有一个chunk,那么chunk->fd = chunk->bk = chunk。
- 当small bin不再为空的时候,将会返回binlist中最后一个匹配的chunk给用户。
- free操作
- 当free的时候,会检查该块之前和之后的块是否为空。如果是的话,就从它们的bins list中unlink这几块,合并成新的chunk,并且加到unsorted bins中。
Large bin
chunk大小大于512byte的都会进入到当前的bin中。
- large bin的数据量为63个。同样为双向链表
- 每个bin中的chunk大小允许不同。(必须在某个范围内)并且可以在任意位置添加和删除,所以是以从大到小的顺序排列的。
- 前32个bin是以64byte为步长。第一个为512byte至586之间。然后的16个以512byte为步长。之后的8个以4096为步长。之后4个为32768为步长。剩下两个为262144byte。最后一个放剩下的chunk
- 空闲的chunk会进行合并
- malloc操作类似于small bin
- 开始的时候所有的large bin都是空的,所以开始的时候是由下一个最大的large bin提供(?),首先判断用户需求的size属于哪一个large bin,然后判断当前的large
bin 中的最大的chunk(也就是front chunk,最前面那个)是否大于,是的话就遍历整个bin,找到一个大小正好合适或者稍微大于请求size的块。如果大于的话,将当前块拆成两个: - 合适大小的返回给用户
- 剩下的chunk放到unsorted bin中
- 如果该large bin中的最大的chunk size小于用户请求的size的话,那么久依次查看后续的large chunk看看有没有符合要求的。如果是遍历的方式的话,可能会发生多次磁盘读写,导致运行速度下降,所以这里使用了bitmap结构体进行查找。bitmap记录了各个bin中是否为空。如果bitmap也没有找到的话,就使用top chunk进行空间分配。
- free的操作与small chunk类似
Top chunk
之前提到的四种chunk中的一种,位于arena的最上方。它不属于任何一个chunk bin。当不存在free chunk的时候,会使用这个chunk,当top chunk的大小大于用户需求的时候,其会分裂成两部分(像是large chunk一样),合适的分配给用户,剩下的chunk继续作为top chunk。如果大小还是不够的话,那么top chunk就会使用sbrk指针(main arena)或者mmap(thread arena)进行空间分配。
Last Remainder Chunk
这种chunk是最近一次请求的小chunk中分类出来的。Last remainder chunk 可以帮助提高程序的局部性,比如小size的请求会被分配到连续的位置上。
哪种chunk会被称为last remainder chunk呢?
当用户请求small size chunk的时候,未能够从small bin和unsorted bin中得到合适chunk的,bitmaps将会去下一个非空最大bin中查找。就如之前说的,当找到下一个合适的bin的时候,将会将chunk分裂成两部分。那个未被使用,加入到unsorted bin中的就称为Last Remader Chunk
那这么做对于局部性有什么提高?
现在如果用户仍然需要一个小的chunk,假设此时的unsorted bin只有我们刚刚分配的chunk,那么此chunk就会分配成两部分,一部分交给用户,另一部分成为新的Last Remainder Chunk,如此一来,这些小的chunk的大小就变得相邻了。
free,malloc与各个堆块之间的关系
最初分配的时候
当最初分配的时候,只含有Top chunk
,所以会从这里分配空间。此时所有的bins结构都是空的
1 | +----------------+ +----------------+ |
第一次free之后
第一次free之后,就会根据这个chunk的大小分配到不同的位置上:
- 如果大小介于fastbin的话,直接放到fastbin中
- 否则,丢到unsorted bin
多次free之后(通常情况下的free)发生的事情
- 检查当前chunk的是否<=fastbin size,如果满足的话,只是将当前的chunk放到fastbin中,并且不会将相邻的下一个chunk的p位设置为1
- 否则的话,会检查当前chunk的上一个chunk是否为空闲块(检查当前P位),如果为0,则将上一个块与当前块合并
Unsorted bin中请求堆块大小为size,malloc的时候发生了什么
当malloc的时候,进行如下操作
- 如果当前堆块中存在大小和size一致,则直接分配,
- 如果在unsorted bin list中存在大于等于size的堆块,则切割堆块,剩下的继续放在unsorted bin 中
- 如果找不到合适的大小,此时将所有的unsorted bin放回到fast bin/small bin中,并且请求top chunk分配新的区域
堆溢出攻击的利用
前面卸了写了很多对堆的介绍,然后这里就详细介绍一下如何利用这些。
由前文可知,从内存角度上,堆的位置在内存意义上是相邻的(就像之前的实验一样),也就是说,当分配了多个堆块之后,我们可以通过溢出对某些特殊位置的实现更改,从而实现注入攻击
原理
注意到之前提到的,small chunk 和large chunk在free的过程中会查看相邻的位置是否为free chunk,从而进行数据合并从而利用空间:
1 | /* consolidate backward */ |
这里有两种说法:向前合并和向后合并,这个前后的概念是越靠前越接近bins,早分配的靠前。假如此时有两个数据chunk1和chunk2,并且假设两者的地址是连续的,那么此时如果通过编辑数据,使得像chunk1分配数据的时候修改了chunk2的头部8个字节,将P位(IN_USE)修改成0,那么此时的chunk2在进行free的时候就会在检查过程中将chunk1误合并,在合并的过程中,就会发生unlink:
1 | /* Take a chunk off a bin list */ |
看的有点迷糊,就自己做了个实验看看:
测试代码如下:
1 |
|
实验中分配地址为:
name:0x0804a008
age:0x0804a068
info:0x0804a0c8
padding:0x0804a128
(由于不存在相邻的free块,同时由于top chunk中的剩余位置也作为free chunk,导致此时至少需要四个块才能够形成chain)
然后查看内存中的状态为
可以看到,此时的0x0804a004和0x0804a04c已经之后的变量中分别存放了对应chunk的大小和当前的状态(0x9,表示当前的模块正在使用)。
在之后free了一个块后(这里我们将info先free),我们知道此时会诞生fb和bk,由于此时没有别的数据块,所以我们要直接指向自己:
此时注意到,bk和fd指向了一个位置,这个位置实际上为main_arena,同时这个main_arena中的地址+0x8的位置还要指向了这个分配的地址的起始位置(包括了prev_size),而当前地址指向的是padding的下一块的prev_size的地址.
当name也被free之后,main_arena变化为
当前位置上指着的内容变成了最初分配给name的prev_size的位置,并且
此时可以看出,已经形成了free chain,大致如下:
此时可以看出,晚释放的靠前,早释放的靠后。
在分配的时候,main_arena中的地址始终指向的是【下一个空闲块的起始地址】,而在main_arena+0x8和main_arena+0xc的位置中指向的是【当前空闲块的头部(,如果只剩头部的话,则指向自己表示此时为空闲】。同时,这个前和后的概念大概也懂了,大概就是:
- 先分配的称为prev,后分配的称为next
- prev离bins进,而next离bins远
………………
那此时我们在回到原先思考的问题上。原先的状态如下:
1 | chunk2->fw = data; |
那么如果我们正确释放了chunk2之后再释放chunk1的话,那么此时会由于nextchunk=top chunk,使得正常释放,从而不进入unlink;而当我们企图误导程序,覆盖掉chunk2中的prev_size中的P位,让其以为chunk1也为空的时候,会发生这样的事情:
1 |
|
利用上述机制,当我们修改了chunk1中的相关信息时如图:
然后在unlink的时候,此时的FD = P->fd = &chunk1_var-0xc,BK = P->bk = &chunk1_var - 0x8,而此时的
FD->bk = BK;
就相当于*(&chunk1_var - 0x8+0x8) = chunk1_var = &chunk1_var - 0x8
同理,
BK->fd = FD <===> *(&chunk1_var - 0xc + 0xc) = &chunk1_var - 0xc
最终导致变为:
现在再次使用chunk1指针的时候,就能够往&chunk1 - 0xc中写入内容,从而修改掉此时自己的值,可以作如下的事情:
修改当前值为free的got值
这些位置往往是一个字符串空间,这样修改后,往往是可以打印或者修改的,于是【此时我们就能够打印free的真实地址,或者修改成system地址】
堆溢出的方法
由于我们知道,能够实现上述步骤,必然要能够接触到某个堆块并且对其fd和bk进行修改,同时修改其chunksize从而引发unlink,那么我们首先就要能够【对某个堆块内容进行读写】,找到的溢出利用方式有如下几种:
off-by-one overwrite allocated
A是发生有off-by-one的堆块,其中B和C是allocated状态的块。而且C是我们的攻击目标块。
我们的目标是能够读写块C,那么就应该去构造出这样的内存布局。然后通过off-by-one去改写块B的size域(注意要保证inuse域的值为1,否则会触发unlink导致crash)以实现把C块给整个包含进来。通过把B给free掉,然后再allocated一个大于B+C的块就可以返回B的地址,并且可以读写块C了。
具体的操作是:
- 构成图示的内存布局
- off-by-one改写B块的size域(增加大小以包含C,inuse位保持1)
- free掉B块
- malloc一个B+C大小的块
- 通过返回的地址即可对C任意读写
off-by-one overwrite freed
在这种情况下堆块布局依然是这样的
A是发生有off-by-one的堆块,其中B是free状态的块,C是allocated块。而且C是我们的攻击目标块。
我们的目标是能够读写块C,那么就应该去构造出这样的内存布局。然后通过off-by-one去改写块B的size域(注意要保证inuse域的值为1)以实现把C块给整个包含进来。但是这种情况下的B是free状态的,通过增大B块包含C块,然后再allocated一个B+C尺寸的堆块就可以返回B的地址,并且可以读写块C了。
具体的操作是:
- 构成图示的内存布局
- off-by-one改写B块的size域(增加大小以包含C,inuse位保持1)
- malloc一个B+C大小的块
- 通过返回的地址即可对C任意读写
off-by-one null byte
这种情况就与上面两种有所不同了,在这种情况下溢出的这个字节是一个’\x00’字节。这种off-by-one可能是最为常见的,因为诸如:
1 | buf=malloc(124); |
就会产生这种null byte off-by-one,即拷贝一个字符串到一个同样长的缓冲区时,并未考虑到NULL字节。
相比于前两种,这种利用方式就显得更复杂,而且对内存布局的要求也更高了。
首先内存布局需要三个块
其中A,B,C都是allocated块,A块发生了null byte off-by-one,覆盖了B块的inuse位,使B块伪造为空。然后在分配两个稍小的块b1、b2,根据ptmalloc的实现,这两个较小块(不能是fastbin)会分配在B块中。然后只要释放掉b1,再释放掉C,就会引发从原B块到C的合并。那么只要重新分配原B大小的chunk,就会重新得到b2。在这个例子中,b2是我们要进行读写的目标堆块。最后的堆块布局如下所示:
布局堆块结构如ABC所示
- off-by-one覆盖B,目的是覆盖掉B的inuse位
- free B
- malloc b1,malloc b2
- free C
- free b1
- malloc B
- overlapping b2
这种利用方式成功的原因有两点:
通过prev_chunk()宏查找前块时没有对size域进行验证
当B块的size域被伪造后,下一块的pre_size域无法得到更新。
off-by-one small bin
这种方法是要触发unlink宏,因此需要一个指向堆上的指针来绕过fd和bk链表的check。(就是我们之前举例子的那种)
需要在A块上构造一个伪堆结构,然后覆盖B的pre_size域和inuse域。这样当我们free B时,就会触发unlink宏导致指向堆上的指针ptr的值被改成&ptr-0xC(x64下为&ptr-0x18)。通过这个特点,我们可以覆写ptr指针,如果条件允许的话,几乎可以造成无限次的write-anything-anywhere。
- 在A块中构造伪small bin结构,并且修改B块的prev_size域和inuse域。
- free B块
- ptr指针被改为&ptr-0xC
top chunk
前面提到的都是修改small bin等类型,这里记录一下top chunk的控制:
首先我们提供一个参考程序:
1 |
|
我们的目的,就是通过溢出攻击,控制malloc返回的数据地址,使其返回的地址能够指向我们的目标地址。
首先我们看一下分配了vul后的堆空间:
此时堆中只有两块,一个fastbin和一个top-chunk,其中top-chunk始终保持为使用状态,然后为了【使堆中空间足够大,从而不会使用mmap或者brk分配空间,我们使用ffffffff】修改其数据。
此时的top-chunk将会任认为自己的大小变为0xffffffff(最大的整数),然后我来看一下malloc的过程:
1 | Void_t* |
大致内容如上,上文的关键代码为:
1 | size = chunksize(victim); |
此时的size为我们top的大小(直接从chunk对应位置获得),此时我们的目的是修改av->top,也就是remainder
的内容,所以关键就在于修改nb,这个nb是我们下一次malloc的时候传入的,具体来说就是:
1 | unsigned int evil_size = (unsigned int)str - sizeof(int)*2 - (unsigned int)(vul + 20); |
这个evil_size将会导致漏洞的发生!我们观察到,如果size为0xffffffff的话,其实size就相当于0x0(因为只要加上1就回归到0了),然后我们这里的evi_size大小为
【str地址】 - 【sizeof int2】(也就是str的地址如果真的是一个freebuf的话此时的状态) - 【vul+20】(这个正好就是av->top原先的地址)
我们可以观察此时的remainder_size的值为:
size + av->top’s address - str’s address +sizeof int2
此时的remainder应该会**向着原先top所在的位置-str所在的偏移+两个int的大小(prev_size和size)**的位置!也就是说,虽然此时给evil分配的位置还是正确的,但是这个av->top已经指向了错误的位置上,下一次我们分配地址的时候就会将【错的地址指向的数据分配出来】!那么此时我们就将获得一个指向运行中程序位置的地址,从而进行注入攻击!
此地址为str全局变量的位置
程序运行结果为:
第一次写这种文章,参考了各路大神的文章。可能有很多错误的地方,欢迎斧正。
文章相关资料参考:
https://jaq.alibaba.com/community/art/show?spm=a313e.7916648.0.0.XxxJTj&articleid=334
https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/comment-page-1/?spm=a313e.7916648.0.0.fS2yoI
http://bobao.360.cn/learning/detail/3113.html
https://gbmaster.wordpress.com/2015/06/28/x86-exploitation-101-house-of-force-jedi-overflow/