一. 前言
本文的内容包括我自己的一些理解,如果存在错误或不严谨之处,还请大佬们斧正。
不同于栈溢出,无论从分配方式,还是管理方式上来说,堆所涉及的知识和技术都更加复杂,因此,利用堆的手法相应的变得困难起来。不过,没有绝对安全的系统,我们的前辈们针对堆的利用已经开发出了一整套的姿势,利用这些方法,可以达到同栈溢出一样的目的。
二. 关于堆(基于glibc)的一些基础知识
要想成功的利用堆,那么就得先了解一下它的结构和组织方式,有关堆在网络上有很多文章进行了详细的介绍,在这里只简要的概括一下。
1.堆块(chunk)
所谓堆块,可以视为一个堆的基本组成单位,我们平时在C语言中使用的 malloc calloc 等函数所生成的,就是一个堆块,堆块的宏观结构包括:块首和块身,块首又包括 prev_size 、size 以及 flag 标志位,而块身又包括 fd、bk指针,和用户的内容,针对这些结构的大致解释如下:
prev_size 标志位: 它指示了本堆块的前一个堆块的大小,而且只有当前一个堆块存在且为“free”状态时才会有值,否则为0.
size 和 flag 标志位: size 指示了本堆块的大小,由于glibc的管理策略,堆块的大小会以16字节对齐(64位系统,32位系统为8字节对齐),那么,从二进制上面看size的低3位永远都是0,glibc在设计时考虑到了这一点,为了高效利用内存,这3位有了特殊的用处:
1 | NON_MAIN_ARENA,记录当前 chunk 是否不属于主线程,1表示不属于,0表示属于。 |
对我们来说,最重要的标志位就是 prev_inuse 位。
fd 指针和 bk 指针: 这对指针对于理解堆的工作方式至关重要,堆块并不是孤立的,而是由一个双向链表串起来的(free状态的堆块),而这双向链表的两根“线”,就是 fd 和 bk 指针,当一个堆块处于free状态时,它的块身部分前16个字节(64位系统)所存储的就是fd 和 bk 指针,前8个字节为fd,后8个为bk,fd指针指向本块的下一个堆块,而bk指针指向本块的上一个堆块。
综上所述,一个堆块(free状态)可以是这个样子:
2.堆表
前面也说过,堆块并不是孤立的,而是构成了一个个链表,具体有以下几种链表fast bins,small bins,large bins,unsorted bin,对于 small bins,large bins,unsorted bin 来说,Ptmalloc 将它们维护在同一个数组中。
快表(fastbin): 快表不同于其他的表,它只有 fd 这一个指针,由这个指针串成了一个单向链表,而且它的大小被限制在小于128个字节(64位系统),快表的意义就在于,快速分配内存空间,提高内存使用效率,每当我们想要分配一块内存时,如果这块内存较小,系统会优先检查fastbin中是否存在合适的堆块,如果存在,则直接返回此堆块,否则才去其他地方寻找堆块。(快表采取 LIFO 策略)
正常表(smallbin): 当我们分配的内存在 128~1008 字节之间时,系统会从smallbin 表中寻找相应的堆块,此表拥有62个链表,具体构成如图:
fastbin 与 small bin 中 chunk 的大小会有很大一部分重合啊,那 small bin 中对应大小的 bin 是不是就没有什么作用啊? 其实不然,fast bin 中的 chunk 是有可能被放到small bin中去的。
大表(largebin): 当需要的空间非常大时,系统就会在这里进行搜索。
unsortedbin: 这个表是做为缓冲存在的 ,我们所释放的堆块在回到它们的链表之前,会来到这里坐一坐,如果我们在free一个堆块之后,紧接着又分配了一个堆块,那么,这个堆块很可能会从这个表中分割。
有关堆的大致结构暂时到这里,具体的实现可以从网络上寻找相应的文章学习,这里推荐一篇:
https://blog.csdn.net/qq_29343201/article/details/59614863
三. unlink 以及 double free
终于来到这篇文章的核心讨论点了,那就是堆中的unlink机制,在前面我们已经知道,free状态的堆块是由几个指针所串接起来的,每当我们去malloc一块空间,系统就会从相应的链表上面“摘”下来一块供用户使用,但是,我们不能只用不还,同一时间不只有一个程序在运行,而内存资源又是有限的,所以,我们使用完了一块内存之后,需要调用相应的函数(如free)把这块空间交还给系统,这时,由于堆的管理机制,当我们free一块内存时,系统会自动判定 待free堆块的前一个堆块和后一个堆块是否也处于free状态,如果是,那么系统会先将那个堆块从链表中卸下,并与待free堆块合并之后重新插入相应的链表,这样可以降低堆的碎片化程度,提高系统执行效率。
而其中将某个堆块卸下的过程,就称为 unlink 。
说起来很简单,下面让我们动手操作一下。
我们会通过 ISCC2018 pwn 题目中的 write some paper 来触发 unlink,并在实验中加深理解unlink的机制。
首先可以使用IDA 打开文件,大概看一下程序流程:
程序所实现的功能比较简单,选项1 要求用户分别输入下标、长度、内容,作为一张paper,选项2 要求用户输入下标,来delete一张paper,最多可以构造10张paper,这10张paper维护在一个全局数组中。
保护开启了金丝雀以及NX,那么栈溢出是不现实的。
通过审查源代码,可以发现,程序在delete时,free掉某一个堆块,并没有将相应的指针置空,导致产生了“恶性迷途指针”,使我们 double free 触发 unlink 成为了可能,由于程序没有提供编辑堆块功能,所以我们需要绕一些弯路,达到目的。
思路大概是:
- 申请4个堆块。0、1、2、3
- free掉 1 2 这两个堆块。
- 重新分配一个堆块 0 ,它的大小为 1 2 两个堆块大小之和,由于堆的 first_fit 机制,这个堆块会再次分配到原来 1 2 两个堆块的位置上。
- 在0号堆块中伪造两个堆块,使伪造的第一个堆块的fd bk指针分别指向 link_list[0] - 0x18 、 link_list[0] - 0x10并进行其他布局。
- free(2)
- 查看是否成功。
现在看上去可能有些云里雾里,下面我们通过实际操作进一步理解。
新建一个python脚本,导入pwntools模块,载入目标文件:
这里我编写了两个函数,add 以及 delete ,方便我们接下来的操作。
先运行一下脚本,看看有没有什么错误:
很好,脚本一切正常,接下来按照计划的第一步,我们先申请4个堆块:
这一步有必要进行一些说明,首先,第一个问题,为什么要分配4个堆块,而不是3个或者2个? 让我们仔细观察一下所分配堆块的大小,分别是 10 256 256 10,还记得之前说过的堆链表吗? 0号和3号堆块属于 fastbin,而1号和2号属于 smallbin ,这样做的用意是,防止堆块过度结合,如果我们不添加最后一个堆块,那么接下来unlink的时候,很有可能堆块会和顶块(一块很大的未分配的空间)进行合并,那么我们的心血也就白费了(可以把最后一块视为一个栅栏或一堵墙)。而第一块就很明显了,我们后续需要操作0号堆块,所以事先分配好。
下面我们启动gdb来查看一下目前的堆空间(可以在脚本中插入 gdb.attach() 语句启动gdb调试):
首先输入 x /10wx 0x6020c0 查看一下 link_list 全局数组的内容:
接着输入 x /200wx 0x1638020 查看堆的内容:
这就是真实的堆啦,可以发现我们所构造的4个堆块都显示了出来(第一块没有显示全)。
接着进行计划的第二步,free掉块1和块2:
还是启动gdb来看一下堆和 link_list 的情况:
有没有发现什么? 我们free掉的堆块中,多出了几个数据,他们就是所谓的 fd 和 bk 指针,由这些指针构成了双向链表。
紧接着就是关键的一步了!再次分配一个堆块,其大小是刚刚free的两个堆块之和:
我们先忽略那些奇怪的参数,来看一下 link_list的情况:
发现了什么? link_list[0] 的地址和 link_list[1] 的地址居然一样了,这就是所谓的 first_fit ,现在在0号堆块上,已经存在着“恶性迷途指针”了!(原2号堆块指针)
继续操作,我们在0号堆块上伪造一个堆块:堆块的结构在上面已经详细说过,那么所构造的数据就是:
细心观察可以发现,fd 和 bk 指针已经分别指向了 link_list[0] - 0x18(fake_fd) 、 link_list[0] - 0x10 (fake_bk),这里引出了另外一个问题,即为什么伪造的fd以及bk指针偏偏是这两个值?直接把它们设置为GOT表或者shellcode的地址不是更加方便吗?这个问题我们放在后面进行解释,现在权当是一个定义来看,fake_prev_size 设置为0,因为0号堆块就是起始堆块了,它的上方再无堆块,fake_size 设置为 0x100+1,0x100很好理解,就是第一个伪造块的大小,+1的目的就是,伪造flag标志位的 prev_inuse ,防止向上合并,fake_next_chunk_prev_size,让系统认为伪造的第二个堆块的 prev_size 处于启用状态,进而欺骗系统认为第一个伪造的堆块处于free状态,fake_next_size_flag,设置伪造的第二个堆块的 size 以及 flag 标志位。
那么,现在的布局就是:
接下来进行最后一步: free(2) (即 double free 触发 unlink)
我们直接来查看一下free之后的 link_list:
可以看到,link_list[0] 已经被改写到 原link_list[0] - 0x18, 如果此程序提供了编辑功能,我们就能够操控 link_list[0] 的值,进而造成任意地址写入了。
早期的 ptmalloc 对于 unlink 可以说几乎没有任何检查,这就导致了很严重的问题,如果攻击者能够控制 fd 和 bk 指针,就能实现任意地址写入,之后,设计者们对于堆增加了更多的验证,也提高了利用的难度,这里就包括对 unlink 增加的验证,查看malloc的代码就可以发现,增加了以下验证:
1 | if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) |
其实还有更多的检查,但是我们现在只关注这里,这段代码很好理解,它要求本堆块的前一个堆块的fd指针指向本堆块(BK->fd == P),本堆块的后一个堆块的bk指针也指向本堆块(FD->bk == P)两者任意一个不满足,就会报错“corrupted double-linked list(检测到堆表损坏)”,所以,我们在进行构造的时候不能像从前一样去随心所欲的放置指针了,而是像上文的利用方法,绕过这个检验。
四. 总结
本文着重介绍了unlink的有关机制,细心的读者可能会发现,我们并没有将 double free 和 unlink 区分的很明显,实际上,unlink并不属于攻击手段,它是 glibc 堆管理系统的一部分,我们使用的攻击方式其实叫做 双重释放(doublefree),只是它所借用的正是 unlink 审查不严的漏洞以及 恶性迷途指针 ,所以,一般情况下,这两者都是配套来看的。
如何避免程序遭到这种攻击呢? 一定要记住,代码中不要留下迷途指针(或称 悬挂指针),并且对用户的输入做好验证。
完整 payload
1 | from pwn import * |
- 本文作者: CataLpa
- 本文链接: https://wzt.ac.cn/2018/05/27/unlink/
-
版权声明:
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。