堆溢出
介绍
堆溢出是指程序向某个堆块中写入的字节数超过了堆块本身可使用的字节数(之所以是可使用而不是用户申请的字节数,是因为堆管理器会对用户所申请的字节数进行调整,这也导致可利用的字节数都不小于用户申请的字节数),因而导致了数据溢出,并覆盖到物理相邻的高地址的下一个堆块。
不难发现,堆溢出漏洞发生的基本前提是
- 程序向堆上写入数据。
- 写入的数据大小没有被良好地控制。
对于攻击者来说,堆溢出漏洞轻则可以使得程序崩溃,重则可以使得攻击者控制程序执行流程。
堆溢出是一种特定的缓冲区溢出(还有栈溢出, bss 段溢出等)。但是其与栈溢出所不同的是,堆上并不存在返回地址等可以让攻击者直接控制执行流程的数据,因此我们一般无法直接通过堆溢出来控制 EIP 。一般来说,我们利用堆溢出的策略是
- 覆盖与其物理相邻的下一个 chunk 的内容。
prev_size
size
,主要有三个比特位,以及该堆块真正的大小。NON_MAIN_ARENA
IS_MAPPED
PREV_INUSE
- the True chunk size
- chunk content,从而改变程序固有的执行流。
- 利用堆中的机制(如 unlink 等 )来实现任意地址写入( Write-Anything-Anywhere)或控制堆块中的内容等效果,从而来控制程序的执行流。
基本示例
下面我们举一个简单的例子:
1 |
|
这个程序的主要目的是调用 malloc 分配一块堆上的内存,之后向这个堆块中写入一个字符串,如果输入的字符串过长会导致溢出 chunk 的区域并覆盖到其后的 top chunk 之中 (实际上 puts 内部会调用 malloc 分配堆内存,覆盖到的可能并不是 top chunk)。
1 |
|
小总结
堆溢出中比较重要的几个步骤:
寻找堆分配函数
通常来说堆是通过调用 glibc 函数malloc
进行分配的,在某些情况下会使用calloc
分配。calloc
与malloc
的区别是 calloc 在分配后会自动进行清空,这对于某些信息泄露漏洞的利用来说是致命的。
1 |
|
除此之外,还有一种分配是经由realloc
进行的,realloc
函数可以身兼malloc
和free
两个函数的功能。
1 |
|
realloc
的操作并不是像字面意义上那么简单,其内部会根据不同的情况进行不同操作
- 当
realloc(ptr,size)
的size
不等于ptr
的size
时- 如果申请 size > 原来 size
- 如果
chunk
与top chunk
相邻,直接扩展这个chunk
到新size
大小 - 如果
chunk
与top chunk
不相邻,相当于free(ptr)
,malloc(new_size)
- 如果
- 如果申请 size < 原来 size
- 如果相差不足以容得下一个最小 chunk(64 位下 32 个字节,32 位下 16 个字节),则保持不变
- 如果相差可以容得下一个最小
chunk
,则切割原chunk
为两部分,free
掉后一部分
- 如果申请 size > 原来 size
- 当 realloc(ptr,size) 的 size 等于 0 时,相当于 free(ptr)
- 当 realloc(ptr,size) 的 size 等于 ptr 的 size,不进行任何操作
寻找危险函数
通过寻找危险函数,我们快速确定程序是否可能有堆溢出,以及有的话,堆溢出的位置在哪里。
常见的危险函数如下
- 输入
gets
,直接读取一行,忽略'\x00'
scanf
vscanf
- 输出
sprintf
- 字符串
strcpy
,字符串复制,遇到'\x00'
停止strcat
,字符串拼接,遇到'\x00'
停止bcopy
确定填充长度
这一部分主要是计算我们开始写入的地址与我们所要覆盖的地址之间的距离。 一个常见的误区是malloc
的参数等于实际分配堆块的大小,但是事实上 ptmalloc 分配出来的大小是对齐的。这个长度一般是字长的 2 倍,比如 32 位系统是 8 个字节,64 位系统是 16 个字节。但是对于不大于 2 倍字长的请求,malloc
会直接返回 2 倍字长的块也就是最小 chunk,比如 64 位系统执行malloc(0)
会返回用户区域为 16 字节的块。
1 |
|
1 |
|
注意用户区域的大小不等于chunk_head.size
,chunk_head.size
= 用户区域大小 + 2 * 字长
还有一点是之前所说的用户申请的内存大小会被修改,其有可能会使用与其物理相邻的下一个 chunk 的 prev_size
字段储存内容。回头再来看下之前的示例代码
1 |
|
观察如上代码,我们申请的chunk
大小是 24 个字节。但是我们将其编译为 64 位可执行程序时,实际上分配的内存会是 16 个字节而不是 24 个。
1 |
|
16 个字节的空间是如何装得下 24 个字节的内容呢?答案是借用了下一个块的 pre_size 域。我们可来看一下用户申请的内存大小与 glibc 中实际分配的内存大小之间的转换。
1 |
|
当 req=24 时,request2size(24)=32。而除去 chunk 头部的 16 个字节。实际上用户可用 chunk 的字节数为 16。而根据我们前面学到的知识可以知道chunk
的pre_size
仅当它的前一块处于释放状态时才起作用。所以用户这时候其实还可以使用下一个chunk
的prev_size
字段,正好 24 个字节。实际上 ptmalloc 分配内存是以双字为基本单位,以 64 位系统为例,分配出来的空间是 16 的整数倍,即用户申请的 chunk 都是 16 字节对齐的。
例题
[NISACTF 2022]ezheap
- 查保护
发现为32位程序,没有canary
保护和PIE
保护。
1 |
|
- 分析
分析main
函数
程序定义了两个char
型指针,并且申请了两个0x16
大小的内存块,由指针分别指向。
然后调用puts
函数输出了一个字符串。
之后调用了危险函数gets
函数向s
指针写入内容。
到了这里我们可以判断这是一个堆溢出漏洞,接下来我们结合动态调试来分析利用思路。
1 |
|
gdb动态调试
- 第一次
malloc
在进行第一次malloc
的之后,我们查看一下堆
发现一共有三个chunk
,第一个和第三个chunk
我们并不需要关系,我们只关心第二个chunk
。
第二个chunk
大小为0x20
,我们malloc
分配的时候分配的大小是0x16
,并不满足现在的chunk
大小,但是我们还要加上prev_size
和size
字段,32位程序下的prev_size
字段和size
字段都是4字节大小,所以0x16
加上两个字段大小正好满足0x20
的chunk
大小。
1 |
|
- 第二次
malloc
第二次malloc
之后我们发现堆中又多了一共chunk
,大小也为0x20
,对应着我们程序中的command
指针。
1 |
|
利用vis
指令查看可视化堆
0x804b198
是s
指向的chunk
,0x804b1b8
是command
指向的chunk
。
我们可以看到,s
指向的chunk
和command
指向的chunk
在内存中是相邻的。
所以gets
函数无限制的写入数据会导致数据从s
指向的chunk
中溢出到command
指向的chunk
。
我们可以实验一下。
1 |
|
我们尝试写入0x20
长度的a
。
查看堆发现最下面的chunk
的size
变成了0x61616160
,这是因为数据从第一个chunk
溢出到了第二个chunk
导致修改了第二个chunk
的size
字段
我们知道用户通过malloc
分配的内存是只使用user_data
部分的,所以我们使用gets
函数写入的数据是从s
指针chunk
的uesr_data
部分开始写入,而s
的user_data
部分大小为0x18
,即我们写入的数据溢出了8
个字节覆盖了下一个chunk
的prev_size
字段和size
字段。
1 |
|
知道了堆溢出的原理后我们继续分析程序,程序在进行读取数据之后将command
字段作为system
函数参数执行。
前面我们也知道了通过堆溢出我们可以将数据溢出到下一个chunk
,即可以从s
溢出到command
。
我们可以通过利用堆溢出从第一个堆块溢出到下一个堆块然后写入/bin/sh\x00
字符串,之后由system
函数执行获取shell。
而system
函数执行的内容同样也是chunk
的user_data
字段,所以我们必须得出溢出到user_data
的填充大小。
而前面我们已经知道了是0x20
,所以接下来我们可以通过利用思路构造exp。
- exp
1 |
|
后言
ctf-wiki