0%

堆溢出攻击

​ 堆溢出是指程序向某个堆块中写入的字节数超过了堆块本身可使用的字节数,因而导致了数据溢出,并覆盖到物理相邻的高地址的下一个堆块。但是其与栈溢出所不同的是,堆上并不存在返回地址等可以让攻击者直接控制执行流程的数据,因此我们一般无法直接通过堆溢出来控制 EIP 。

Linux内存布局

先提一下windows的内存布局

image-20220414195634518

linux 64位下内存布局如下,这种内存布局方式沿用的32位模式下内存的经典布局,但是栈和mmap的映射区域不再是从一个固定的地方开始,每次启动时的值都不一样。这样一来,使得使用缓冲区溢出攻击变得更加困难。(32位中,程序能够访问的最后地址是0xbfffffff(3G)的位置,3G以上的位置是给内核使用的,应用程序不能直接访问。)

image-20220414195748633

heap操作的相关函数

brk()是系统调用、sbrk()是库函数。c语言的动态内存分配基本函数是malloc(),在linux上的实现是:malloc()函数调用库函数sbrk()sbrk()的实质是调用brk()函数。brk()是一个简单的系统调用,只是简单的改变mm_struct结构体的成员变量brk的值。

image-20220414201147203

Mmap映射区域操作的相关函数

malloc 会使用mmap来创建独立的匿名映射段。匿名映射的目的主要是可以申请以 0 填充的内存,并且这块内存仅被调用进程所使用。mmap()函数将一个文件或者其他对象映射进内存。文件被映射到多个页上,如果文件大小不是所有页大小之和,最后一个页不被使用的空间将会清零。munmap()执行相反的操作,删除特定地址区域的对象映射。

1
void * mmap(void * start,size_t length,int prot,int flags,int fd,off_t offset)

​ start — 映射区的开始地址。

​ length — 映射区的长度。

​ prot — 期望的内存保护标志。

​ flags — 指定映射对象的类型,映射选项和映射页是否可以共享。

​ fd — 有效的文件描述符。

​ offset — 被映射对象内容的起点。

mmap()系统调用使得进程之间通过映射同一个普通文件实现共享内存。但是并不是完全为了用于共享内存而设计的。它本身提供了不同于一般对普通文件的访问方式,进程可以像读写内存一样对普通文件的操作。

​ 普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。mmap并不分配空间, 只是将文件映射到调用进程的地址空间里(但是会占掉你的 virutal memory), 然后你就可以用memcpy等操作写文件, 而不用write()了。写完后,内存中的内容并不会立即更新到文件中,而是有一段时间的延迟,你可以调用msync()来显式同步一下, 这样你所写的内容就能立即保存到文件里了。不过通过mmap来写文件这种方式没办法增加文件的长度, 因为要映射的长度在调用mmap()的时候就决定了。

堆基础知识

​ 先补充一下堆的基础知识,如下图。堆的结构是由“堆表”以及“堆块”构成这,其中“堆表”主要作用是用来索引堆块的位置。其中,堆表主要是有两种:空闲双向链表(Freelist),快速单向链表(Lookaside)(注意:堆表仅仅是用来索引空闲态的堆块,即未被使用的堆块)“堆块”就是用来提供程序员申请堆空间的。

image-20220401143714225

​ 未使用下的堆块与使用状态下的堆块差别在于,堆首部分添加了8字节的指针对,该指针就是用来链路堆表当中的。

​ 堆表中需要关注的是空表索引区,由128项指针数组组成,这对指针用来将空闲堆组织成双向链表。根据堆块大小的不同,存放的指针数组也不同。每项链接的堆块大小均比其前一项链接的堆块增大8字节。值得注意的是free[0]链接的是大于等于1024字节的堆块。

​ 下图是空闲双向链表,当释放相邻内存堆块后会发生合并现象,该点区别于快速单向链表,快速单向链表每项只有四个节点

image-20220401144733642

image-20220401145728125

​ linux早期的堆分配,为了安全性,一个线程使用堆时,会进行加锁。然而,与此同时,加锁会导致其它线程无法使用堆,降低了内存分配和回收的高效性。同时,如果在多线程使用时,没能正确控制,也可能影响内存分配和回收的正确性。后来在此基础上进行改进使其可以支持多线程,这个堆分配器就是 ptmalloc 。目前 Linux 标准发行版中使用的堆分配器是 glibc 中的堆分配器:ptmalloc2。ptmalloc2 主要是通过 malloc/free 函数来分配和释放内存块。

​ 需要注意的是,在内存分配与使用的过程中,Linux 有这样的一个基本内存管理思想--内存延迟分配,只有当真正访问一个地址的时候,系统才会建立虚拟页面与物理页面的映射关系。 所以虽然操作系统已经给程序分配了很大的一块内存,但是这块内存其实只是虚拟内存。只有当用户使用到相应的内存时,系统才会真正分配物理页面给用户使用。

内存分配与回收

image-20220414205604264

先说上面面三个概念,三者概念的解释如下:

  • arena:通过sbrk或mmap系统调用为线程分配的堆区,按线程的类型可以分为2类:
    • main arena:主线程建立的arena;
    • thread arena:子线程建立的arena;
  • chunk:逻辑上划分的一小块内存,根据作用不同分为4类:
    • Allocated chunk:即分配给用户且未释放的内存块;
    • Free chunk:即用户已经释放的内存块;
    • Top chunk:顶块, 位于所有块之后, 保存着未分配的所有内存;
    • Last Remainder chunk
  • bin:一个用以保存Free chunk链表的表头信息的指针数组,按所悬挂链表的类型可以分为4类:
    • Fast bin:chunk 的指针数组 , 每个元素是一 条单向链表的头部 , 且同一条链表中块的大小相同,主要保存大小 32 至 128 字节的块
    • Unsorted bin:与 Small Bins 和 Large Bins 类似是双向循环链表 , 只有一个 bin, 其中保存的块大小不定,用于收集刚刚被 free 或从大的块中分裂剩下的块;
    • Small bin:chunk 的指针数组 , 每个元素是一条双向循环链表的头部 , 且同一条链表中块的大小相同,主要保存大小 32 至 1024 字节的块;
    • Large bin:每个元素是一条双向循环链表的头部 , 但同一条链表中块的大小不一定相同 , 按照从大到小的顺序排列 , 每个 bin 保存一定大小范围的块。主要保存大小 1024 字节以上的块。

在这里读者仅需明白arena的等级大于bin的等级大于(free)chunk的等级即可,即A>B>C。

main arena中的内存申请

与 thread 不同的是,main_arena 并不在申请的 heap 中,而是一个全局变量,在 libc.so 的数据段。

image-20220414210612718

thread arena中的申请

image-20220414210545187

chunk

在程序的执行过程中,我们称由 malloc 申请的内存为 chunk 。这块内存在 ptmalloc 内部用 malloc_chunk 结构体来表示。当程序申请的 chunk 被 free 后,会被加入到相应的空闲管理列表中。

1
2
3
4
5
6
7
8
9
10
11
12
struct malloc_chunk {

INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */

struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

用户最小申请的内存大小必须是 2 * SIZE_SZ 的最小整数倍。32 位系统中,SIZE_SZ 是 4;64 位系统中,SIZE_SZ 是 8。

image-20220415095330146

​ 上图是在使用中的chunk 。

​ <1>chunk指针指向chunk开始的地址;mem指针指向用户内存块开始的地址。

​ <2> p=0时,表示前一个chunk为空闲,prev_size才有效。

​ <3> p=1时,表示前一个chunk正在使用,prev_size无效。 p主要用于内存块的合并操作。

​ <4> ptmalloc分配的第一个块总是将p设为1,以防止程序引用到不存在的区域。

​ <5> M=1 为mmap映射区域分配;M=0为heap区域分配。

​ <6> A=1 为非主分区分配;A=0 为主分区分配。

image-20220415095927924

上图是一个空闲的chunk

<1> 空闲的chunk会被放置到空闲的链表bins上。当用户申请内存malloc的时候,会先去查找空闲链表bins上是否有合适的内存。

​ <2> fp和bp分别指向前一个和后一个空闲链表上的chunk

​ <3>fp_nextsize和bp_nextsize分别指向前一个空闲chunk和后一个空闲chunk的大小,主要用于在空闲链表上快速查找合适大小的chunk。

​ <4>fp、bp、fp_nextsize、bp_nextsize的值都会存在原本的用户区域,这样就不需要专门为每个chunk准备单独的内存存储指针了。

​ 当一个 chunk 处于使用状态时,它的下一个 chunk 的 prev_size 域无效,所以下一个 chunk 的该部分也可以被当前 chunk 使用。这就是 chunk 中的空间复用。

空闲chunk容器

bin

​ 用户释放掉的内存并不是马上就归还给操作系统,ptmalloc会统一管理heap和mmap映射区中的空闲的chunk,当用户进行下一次请求分配时,ptmalloc会试图从空闲的内存中挑选一块给用户,这样可以避免频繁的系统调用,降低了内存分配的开销。ptmalloc将大小相似的chunk用双向循环链表连接起来,这样的一个链表称为bin。ptmalloc中一共维护了128个bin,并使用一个数组来存储这些bin(数组实际存储的是指针)。

image-20220415100832075

​ 并不是所有的 chunk 被释放后就 立即被放到 bin 中。ptmalloc 为了提高分配的速度,会把一些小的的 chunk 先放到一个叫做 fast bins 的容器内。

分配算法概述

image-20220415144235424

主分配区分配顺序依次为:

fast bins –> small bins –> 合并fast bins并把chunk加入unsorted bins,找unsorted bins

–> 把unsorted bins加入到large bins,找large bins –> top chunk –> 增加heap大小或mmap分配

回收算法概述

image-20220415104726171

free()函数接受一个指向分配区域的指针作为参数,释放指针指向需要释放的chunk。

(1)free()函数首先需要获取分配区的锁来保证线程安全。

(2)判断传入的指针是否为0,如果为0,则什么都不做,直接return。否则转下一步。

(3)判断所需释放的chunk是否为mmaped chunk,如果是,则调用munmap()释放解除空间映射,该空间不再有效。

(4)判断chunk的大小和所处的位置,若chunk_size<= max_fast,并且chunk并不处于heap的顶部,也就是说不与top chunk相邻,则转到下一步,否则转到第6步。

(5)将chunk放到fast bins中,chunk放入到fast bins中时,并不修改该chunk使用状态位P,也不与相邻的chunk进行合并。只是放进去。这一步做完之后释放就结束了,程序从free()函数中返回。

(6)判断前一个chunk是否正在使用中,如果前一个块也是空闲块,则合并。并转下一步。

(7)判断当前释放的chunk的下一个块是否为top chunk,如果是,则转第9步,否则转下一步。

(8)判断下一个chunk是否处于使用中,如果下一个chunk也是空闲的,则合并,并将合并后的chunk放到unsorted bin中。注意,这里在合并过程中,要更新chunk的大小,以反映合并后的chunk的大小。并转到第10步。

(9)如果执行到这一步,说明释放了一个与top chunk相邻的chunk。则无论它有多大,都将它和top chunk合并,并更新top chunk的大小等信息。转下一步。

(10)判断合并后的chunk的大小是否会大于max_fast(默认是64kb),如果是的话,则会触发进行fast bins的合并操作,fast bins中的chunk将被遍历,并与相邻的chunk进行合并,合并后的chunk会被放到unsorted bin中。fast bins将变为空,操作完成后进入到下一步。

(11)判断 top chunk的大小是否大于mmap收缩阀值(默认是128kb),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。但是最先分配的128KB空间是不会归还给操作系统的,ptmalloc会一直管理这部分内存,用来响应用户的分配请求。如果是非主分配区,会进行sub_heap收缩,将top chunk的一部分返回给操作系统,如果 top chunk是整个sub_heap,会将整个sub_heap归还给操作系统。做完这一步后,释放结束,从free()函数退出。

对于Ubuntu 16.04而言,较小的chunk被free掉后,只是被放入fast bins中,其余什么也不做(见上述第5步);对于Ubuntu 18.04而言,较小的chunk被free掉后,会被放入tcache bins中,

image-20220415111145345

但无论是被放入fast bins还是tcache bins中,chunk的标志位都不会发生变化:

利用该特性,可以在free掉某个内存块后,重新申请处于fast bins或tcache bins中的内存块,并对其进行读写操作,从而达到漏洞利用的目的。

堆溢出利用

堆溢出是一种特定的缓冲区溢出(还有栈溢出, bss 段溢出等)。但是其与栈溢出所不同的是,堆上并不存在返回地址等可以让攻击者直接控制执行流程的数据,因此我们一般无法直接通过堆溢出来控制 EIP 。一般来说,我们利用堆溢出的策略是

  1. 覆盖与其物理相邻的下一个 chunk的内容。
    • prev_size
    • size,主要有三个比特位,以及该堆块真正的大小。
      • NON_MAIN_ARENA
      • IS_MAPPED
      • PREV_INUSE
      • the True chunk size
    • chunk content,从而改变程序固有的执行流。
  2. 利用堆中的机制(如 unlink 等 )来实现任意地址写入( Write-Anything-Anywhere)或控制堆块中的内容等效果,从而来控制程序的执行流。

寻找堆分配函数

​ 通常来说堆是通过调用 glibc 函数 malloc 进行分配的,在某些情况下会使用 calloc 分配。calloc 与 malloc 的区别是 calloc 在分配后会自动进行清空,这对于某些信息泄露漏洞的利用来说是致命的

1
2
3
4
calloc(0x20);
//等同于
ptr=malloc(0x20);
memset(ptr,0,0x20);

​ 此外还有一种是realloc函数,该函数兼并malloc和free两个函数功能。

  • 当 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 掉后一部分
  • 当 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 字节的块。

还有一点是之前所说的用户申请的内存大小会被修改,其有可能会使用与其物理相邻的下一个 chunk 的 prev_size 字段储存内容。

实际上 ptmalloc 分配内存是以双字为基本单位,以 64 位系统为例,分配出来的空间是 16 的整数倍,即用户申请的 chunk 都是 16 字节对齐的。

例如在 64 位程序中:

1
malloc(8)

申请到的堆块总大小为 16 + 8 + 8 + 1 = 0x21

1.第一个 16 字节是系统最小分配的内存,也就是说你如果想要申请的内存小于系统最小分配的内存的话,就会按照最小的内存来分配。

  • 在 64 位系统中这个值是 16 个字节,在 32 位系统中是 8 个字节
  • 例如,如果代码中是 malloc(0) 的话,堆管理器也会分配最小内存空间给你

2.第二个 8 字节是 pre size 字段的大小(32 位的为 4 字节)
3.第三个 8 字节为 size 字段的大小(32 位的为 4 字节)
4.最后一个 1 字节是 PREV_INUSE 的值,只有 0 或 1两个值

image-20220422144438152