-
-
[原创]PWN入门-22-FastBin与DoubleFree降妖
-
发表于: 2025-4-23 20:29 3026
-
在GLibC的管理概念中,fast bin
是极其特殊的一个,这种特殊体现在多个方面。
像unsorted bin
、small bin
、large bin
这三类链表需要放在bins数组中进行存储,而fast bin
链表则是可以独占一个fastbinsY
数组。
更不用说独占一个全局变量tcahce
的天龙人tcache链表了。
不管是在申请的_int_malloc
阶段,还是释放的_int_free
,使用fast bin
链表链表的优先级都是相当高的。
对于GLibC提供的malloc
函数来讲,不管是释放还是申请内存时,它的优先级都是仅次于tcache的。
这一点正好体现了fast bin
的设计原则,快速响应较小chunk的分配与释放。
像unsorted bin
、small bin
、large bin
使用的是由fd
和bk
两个字段组成的双向循环链表。
其中fd
根据chunk入链时间的倒序对链表进行维护,链表头是最晚入链的chunk,而字段bk
则根据chunk入链时间的正序维护链表,链表头是最早入链的chunk。
像fast bin
链表就只使用fd
组成单向链表。
这么做的好处就是,程序每次获取到的都是最晚进入fast bin
链表,能有更大的几率取出仍位于CPU缓存上的数据,优化程序的运行速度。
除了只维护fd
的单向链表外,fast bin
链表应该还是唯一的,会在存取数据上会针对多线程进行区分的链表。
从上面可以看到,GLibC会使用SINGLE_THREAD_P
宏判断程序是否为多线程。
在单线程时会使用REVEAL_PTR
直接将最晚入链的chunk移出链表,当chunk入链时也会直接操作链表。
但在多线程时,GLibC会使用REMOVE_FB
将chunk移出链表,而且在chunk入链时会进入do { ...... } while
循环语句中。
其实,REMOVE_FB
宏也是通过do { ...... } while
循环语句实现的。
但为什么在多线程中需要使用循环语句操作fast bin
链表呢?
进出链表时使用的循环语句,会通过catomic_compare_and_exchange_val_rel
宏推进循环,该宏的作用并不复杂,它接受三个参数,当*mem
等于oldval
时,就更新*mem
,最后不管*mem
是否等于oldval
,都会返回oldval
。
至于atomic
,如果你熟悉多线程编程,就应该知道当多个线程访问同一对象时,为了确保线程间不冲突,会添加锁进行保护,就像一个人上厕所时要把门锁上,这样别人就进不来了,同样的,锁也会保证多线程有序的访问同一对象。
但是锁也会带来很多问题,比如常见的死锁,为了避免锁带来的问题,就产生了一个名为无锁编程的概念。无锁编程的基石是原子化atomic
操作,原子操作指的是执行过程中不会被打断。
当线程使用无锁编程时,会利用原子操作访问对象,这时自然就不用担心其他线程和自己争抢访问对象了。
不过啊,所谓的原子的操作指的是一种轻量级的锁,不如在x86架构中,提供一种名为lock
的指令,执行指令前会先通过lock
加锁。
在了解catomic_compare_and_exchange_val_rel
宏之后,我们可以知道上面的循环语句并不会进行多次循环,而且在当前的场景中,该宏一定会给*mem
赋新值。
显然,在这个时候do { ...... } while
循环语句的秘密已经解开了,它就是在多线程环境下,针对链表进行的无锁编程。
P标志只会出现在mchunk_size
和mchunk_prev_size
中,在malloc.c
代码中,可以经常看到set_head(xx, xx | PREV_INUSE)
的语句。
是的,set_head
宏是专门为操控mchunk_size
而准备的。因为set_head
经常会给mchunk_size
添加P位。
mchunk_size
上带着PREV_INUSE
标志位是常态,它只会在非fast chunk
类型且非mmap
方式分配的chunk释放时,才会通过clear_inuse_bit_at_offset
清空前一个nextchunk
的mchunk_size
的P位。
目的是告诉nextchunk
,当nextchunk
被释放时,它可以进行向后合并。
所以mchunk_size
的PREV_INUSE
标志位是“出厂时的默认设置”,PREV_INUSE
标志位想要被清空,那就必须prevchunk
被释放。
不过这种情况并不会涉及到fast chunk
,fast chunk
在释放时,并不会与其他的空闲chunk进行合并。
当然fast chunk
也并不是绝对不合并,GLibC会通过malloc_consolidate
接口c尝试合并fast chunk
。
最常见的场景,就是当程序进入_int_malloc
后,会检查have_fastchunks
标记,如果发现arena中存在fast chunk
就会开始合并。
至于mchunk_prev_size
的P位,甚至可以说它的所有标志位都是无效的,这是因为在GLibC中,mchunk_prev_size
只在chunk被切割后,记录上一个chunk的大小。
fast bin
只使用fd
维护链表,好处就是写出了cache友好的代码,并且会让链表中空闲chunk间的相对关系不那么复杂。
但坏处就是,仅通过fd
维护的成员,只会保证相邻chunk间具有链接关系,至于指向的内容是否正确就不得而知了。
而当使用fd
和bk
维护的双向链表时,不止可以保证相邻chunk间具有链接关系,而且还可以通过双向链表确认指向内容的正确性。
还有一点,就是不管是针对单向链表还是双向循环链表的检查,都是停留只检查两相邻的chunk间的关系,不会遍历整个链表进行检查。
当程序将chunk释放后,按照道理来讲,程序就要将缓冲区变量的指针设置成NULL,如果不设置成空指针,就代表程序还有机会操作缓冲区变量。
首当其中的隐患就是UAF,由于程序还掌握着已释放chunk的内存地址,所以就还可以对已释放的chunk进行读写。
其次就是重复释放chunk了,未被置空的缓冲区变量可以被再次释放,但是chunk被重复释放会有什么安全隐患呢?
在了解重复释放的危害之前,我们先来探讨下fast chunk
是怎么完成重复释放的。
重复释放也被称作是Double Free
,即便你不熟悉二进制安全,也可能会经常听到过该漏洞的大名。
在没有任何安全检查的防范下,重复释放fast chunk
不会收到任何阻力。
但是Double Free
漏洞由来已久,GLibC也早就针对这块内容进行了加固,在版本较新的GLibC中,想要无痛的完成重复释放,还需要我们加深下对堆管理策略的理解。
在GLibC的malloc.c
代码中,存在着多个针对Double Free
漏洞的检查,这些检查主要集中在_int_free
函数中。
显然,这是为chunk的释放而专门准备的。
_int_free
在释放过程中,针对Double Free
漏洞的检查分成两个部分。
至于fast bin
链表中的其余空闲chunk是否和最新被释放的chunk重复,那就检查不到了,遍历整个链表的负担难免有些太重了。
_int_free_merge_chunk
函数中针对重复释放的检查可以分成三步。
a. 第一步是针对top chunk
进行的,top chunk
是一切chunk的源头,所有被使用的chunk最先都是从top chunk
中分割出来的,因此它永远都是空闲的。
所以当GLibC发现被释放chunk的指针等同于top chunk
时,就会判断为重复释放并通过malloc_printerr
抛出异常。
你可能会好奇,fast bin
链表就不用担心被释放的指针是top chunk
吗?
对的!fast bin
链表不用担心这个问题。
fast bin
链表有一个天然的保护屏障,那就是fast chunk
的大小限制。
top chunk
的申请会在sysmalloc
中完成,sysmalloc
通过madvise
完成内存的申请,其中提供给madvise
的第三参数是MADV_HUGEPAGE
,这个参数代表想内核申请透明大页THP Transparent Huge Page
。
既然是大内存页,那么它一定不会太小,事实上GLibC会将内存跟dl_pagesize
进行对齐,dl_pagesize
大小一般跟内核使用的页大小一致,其数值就是0x1000。
这个大小肯定是超过fast chunk
的限制的,所以并不会影响到fast chunk
。
b. 第二步是针对nextchunk
进行的检查。
首先会确认nextchunk
是否位于top chunk
的上方,在GLibC的堆管理逻辑中,最顶部的chunk一般是top chunk
。
然后会通过contiguous
宏确认arena的标志位是否为NONCONTIGUOUS_BIT
,这个标志位的作用是记录GLibC分配的chunk是否连续。
当arena中不存在NONCONTIGUOUS_BIT
标志位时,就代表top chunk
必定是地址最高的chunk,其余的chunk一定位于top chunk
的下方。
当nextchunk
位于top chunk
的上方,且NONCONTIGUOUS_BIT
标志不存在时,GLibC就判断出被释放的chunk仍位于top chunk
中,它的释放属于空闲chunk被再次释放,所以这时GLibC也会抛出异常。
这里需要针对NONCONTIGUOUS_BIT
标志位再说明一下。
NONCONTIGUOUS_BIT
标志位的存在可以看作是是专门针对子线程而设置的。
你应该已经知道了一个知识点,那就是子线程不断申请内存时,并不会向主线程那样不断扩张top chunk
,当子线程让top chunk
超过上限HEAP_MAX_SIZE
时,就会启用子堆,然后通过heap_info
管理子堆,
c. 第三步是确认nextchunk
的PREV_INUSE
是否已经不在了。
按照GLibC的逻辑,当chunk被释放时,它会将nextchunk
的PREV_INUSE
清除掉,所以呢,如果chunk释放时,发现nextchunk
的PREV_INUSE
已经不在了,就说明这个chunk应该是已经被释放过的,这个时候又来释放就属于重复释放了。
从上面的针对Double Free
进行的加固措施中可以看到,加固的措施的局部性有效性非常的明显,所以虽然有加固措施的存在,但我们绕过缓解措施进行重复释放的机会其实还是大大地。
对于fast bin
来讲,只要相同的chunk只要不相邻,就不会被检查机制揪出来。
从表面上来看,chunk重复进入fast bin
链表的危害主要有两类,这两类利用的产生都可以看作是始于类型混淆。
一是程序可以给不同的缓冲区变量申请到同一个chunk,chunk 1
的混用可能会造成主观上的类型混淆,进而导致利用机会产生。
二是程序申请到链表头的chunk 1_1
时,由于在运行期中,malloc_chunk
结构体中除了mchunk_prev_size
和mchunk_size
以外的区域都会作为数据区,数据区就是程序申请完chunk后,实际拿来读写的区域。
GLibC这种设计,是基于入链chunk正常的前提下完成的,但当chunk发生重复释放时,就会造成被动的类型混淆。
当我们申请到链表头上的chunk 1_1
并向其中写入数据时,肯定会影响仍处于链表尾上的chunk 1_0
,最直接的影响就是chunk 1_0
的fd
。
只要fd
被改写,GLibC就不会再将chunk 2
视为chunk 1_0
的上一个chunk,而是将fd
指向的新内存区域看作是上一个入链的chunk。
一旦我们控制了chunk_1_0
的fd
,那么当chunk_1_0
被取出时,fast bin
链表的头成员就变成了我们覆写的恶意地址,此时再次取出chunk,我们就拿到了恶意地址指向的内存区域。
对于程序来讲,操作缓冲区变量就相当于操作恶意地址上数据,此刻任意地址读写的环境创造完成!
在上面针对chunk重复释放的危害的描述中,我们可以隐约的感到fast chunk
的一个特性带来的好处。
这个好处就是,fast chunk
入链之后,不会与其他的chunk进行合并,chunk在链表中的相对关系始终是稳定的,而稳定的相对关系可以保证chunk的fd
始终有效。
如果chunk发生了合并,那么chunk 1_0
的fd
就无法发生作用了,这个时候使用的fd
就变成了其他的chunk的fd
,但新的fd
能否被控制就难说了。
至于这种好处的发生,要源自于fast chunk
的mchunk_size
中始终处于启用状态的标志位PREV_INUSE
,是它保障了fast chunk
不进行合并。
想要改写fd
这个事情,可能很简单,也可能很复杂。
如果GLibC版本较低,向chunk_1_1
数据区写入地址A时,chunk_1_0
的fd
会被顺利改写成地址A,并可以直接拿来使用。
但当GLibC的版本较高时,直接写入的地址就不能被拿来使用了。
在高版本的GLibC针对fast bin
和tcache添加了一种地址随机化的机制,chunk在进入链表时,会通过PROTECT_PTR
将当前链表头上的chunk地址加密,加密后的地址会存放到新入链chunk的fd
上,最后将新入链chunk插入链表头,完成入链。
经过上面的一番操作后,链表头上存放的chunk地址永远都是正确的,但通过fd
链接的其他chunk地址就变成了一个被随机化的地址。
这些不正确的地址都需要经过REVEAL_PTR
解密才可以得到恢复。
chunk出链也并不复杂,先是取出链表头上的victim
,通过REVEAL_PTR
对fd
上的地址进行解密,然后将解密后的chunk地址放到链表头上,最后返回victim
给程序进行使用就可以了。
所以啊,在高版本的GLibC中控制空闲fast chunk
的fd
,就必须保证覆写fd
的数据在经过REVEAL_PTR
解密后,可以得到一个可读可写的内存地址。
而低版本的GLibC没有针对fd
加解密的这一设计,所以fd
上存储的永远都是正确的地址,自然也就不会拥有这种烦恼。
想要在针对fd
地址随机化的情况下,不仅完成fd
的覆写,还要使得GLibC在取出chunk时,可以让经REVEAL_PTR
解密后的数据,指向我们预期中的内存区域。
为了达到这一目的,我们需要了解下异或加解密的流程。
异或加解密,可以看作是一种非常简单的加解密措施,为了完成加解密的流程,它需要两个部分,一是文本信息,二是密钥。
对于PROTECT_PTR
来讲,ptr
是文本信息,而pos
就是密钥。
异或加解密的操作依赖于xor
异或运算,所谓的异或运算指的就是两比特位的数值相等时产生结果为0,反之则产生结果为1的运算规则。
在GLibC的设计中,它要求使用指针类型的数据,其中指针类型变量的内存地址作为密钥,而指针类型变量上存放数据则作为文本信息。
内存地址会右移12个比特位后生成实际使用的密钥,这是因为内存地址间的相似度比较高,所以右移打乱相似度,提高加密复杂度。
明文与密钥经过异或运算产生密文后,只要解密时仍使用相同的密钥,就可以保证被翻转的比特位数值可以再翻转回来,恢复明文信息。
这也是使用指针类型变量的原因,同一指针类型变量的地址是固定,使用指针变量所在地址作为密钥,可以始终和文本信息绑定,我们不需要通过额外的手段获取密钥。
PROTECT_PTR
加密时使用的密钥其长度是跟文本信息一致的,这种做法的好处是安全性较高(相比于单字节密钥或重复密钥来讲)。
只要内存地址不被泄露出去,这种加密手段算是简单加密方法中较为快捷且有效的。
所以,在我们不知道存放fd
的内存地址的情况下,想要向fd
中写入可以被正确解密的数据,其实还是比较困难的。
所谓上有政策,下有对策,难道chunk间的链接地址加固,就再无破解可能了吗?
当然不会是这样的!
PROTECT_PTR
宏中文本信息的低36位会与密钥的有效36比特位进行异或运算,而高28个比特位则会和0进行异或运算。
因此对于高28位来讲,密钥是已知的,当然64位系统中,内存地址的有效值只占48位,所以高28位中只有12位是有效数值,高28位中的高16位全是0,可以忽略。
要知道任何数据与0进行与异或运算,其结果都是数据本身。
这12个比特位上在加密后,仍存储着真实数据的特性将会给予我们莫大的帮助。
一个字节占用8个比特位,所以12个存储原始数据的比特位可以分成两个部分,高8个比特位为第5个字节,低4个比特位则属于第4个字节。
高位的1个半字节已经有着落了,那么后面的字节呢?
不管什么场景下,密文和明文进行异或运算的结果就是密钥,这个自然不用多说。
但是在PROTECT_PTR
宏的场景下,密文和右移12位的明文运算的结果可能就是真实的明文,这个东西可有点诡异,而且当cipher xor (plain >> 12) = plain
成立时,就代表我们在拥有完整密文和部分明文的情况下,逐字节的还原全部明文。
不过这个现象又是怎么产生的呢?
这个现象之所以出现,其实是跟GLibC的arena与chunk的特殊地址息息相关的。
如果你仔细观察真实场景下,观察PROTECT_PTR
宏中pos
与ptr
的实际数值,就会发现,明文有效数据中的高36位数据刚好是等于密钥的。
显然,在PROTECT_PTR
宏的处理方式下,出现上面的情况大概率是一种必然。
密钥pos
的数值是chunk_address + offset(fd)
,而ptr
的数值则是某个真实chunk A
的地址,chunk A
的地址当然不会直接等于pos
的数值,但是,它们的数值应该是极为相似的。
因为这些chunk最初都是从top chunk
中切割出来的,所以chunk的地址可以归纳为基地址加偏移值heap_base + xxx
的格式,当两个chunk越相近时,它们的偏移值之差也不会太大,因此它们地址中相等的字节也会越多,所以明文数据右移12位后很有机会是等于密钥的。
破解之道就在其中!
当上述情况成立时,我们可以利用密文的有效地址获取未加密过的最高的第5字节,然后将最高字节右移12位,作为密钥解出第4字节,以此往复,直到第0字节也被解出。
通过上面的分析,我们已经知道了破解Safe-Linking
机制的方法,比较土的方法呢,就是直接泄露密钥。
稍微先进一点的方法,会收到一点限制,要求明文的高36位等于密钥,当明文和密钥同时处于GLibC的堆环境时,这一点还比较容易达成,因为任何chunk的起始地址都是相同的,chunk在堆中的偏移值决定了它们的相似性。
再加上明文的最高12位不会被随机化,有这两种特性的加持,我们就可以在只拥有密文的前提下,逐字节的完成反随机化的工作。
当然,这是在基于存在信息泄露的前提下完成的,那么还有没有方法,可以在不进行信息泄露的前提下,就完成Safe-Linking
机制的绕过呢?
堆内存一直是安全隐患的多发之地,本次事故的发生在于chunk被重复的释放。
内存是软件的栖息之地,程序需要向内核申请内存进行使用,对于程序来讲,只要内存分配给了自己,那就是可以使用的,不存在真正意义上的内存失效一说。
比如栈虽然随函数的消失,但原来函数分配的栈空间其实还是可以读写的,再比如堆内存被释放后,也只是交给GLibC进行管理,程序并没有向内核申请削减内存,从程序的内存布局中可以看到,堆内存仍保持着原来的大小。
栈内存随函数变迁后,程序丧失了直接控制原来栈内存的途径,对于堆内存而言,也是一样,一旦chunk被释放,程序就应该丧失对已释放chunk的控制方法,比如将缓冲区变量的指针设置成空指针。
如果堆内存释放后,不被清理控制方法(内存地址置空),那么被释放的chunk仍位于程序的内存布局中,程序并没有真正的抛弃内存,那么这段在逻辑上被抛弃的内存,实际就仍是可以被直接控制的。
逻辑上不可用和程序仍拥有直接控制方法的现实起了冲突,造成这一情况的原因,是程序员并不熟悉操作系统与GLibC的机制,以为释放后堆内存就不可以用了。
这种冲突的情况,会产生诸多的安全问题,比如大名鼎鼎的UAF,以及本文中出现的重复释放问题。
堆内存重复释放的问题由来已久,它之所以可以被利用,有一部分原因是由GLibC在管理chunk采用不明确的机制引起的。
GLibC将chunk划分成由struct malloc_chunk
结构体和数据区两个部分,重复释放问题产生的关键就在于struct malloc_chunk
结构体。
struct malloc_chunk
结构体的区域可以被看作是功能区,一旦被怀有恶意者掌控,后果将会不堪设想,而GLibC混乱的管理方案,为我们提供了机会。
GLibC针对chunk的管理需要分成使用期以及释放期,在使用阶段中malloc_chunk
只有mchunk_prev_size
和mchunk_size
两个字段会发挥作用,而全部字段只有在释放阶段时才会全部发挥作用。
在正常情况下,这种管理方式并不会导致问题,非正常情况下就不好说了。
mchunk_prev_size
和mchunk_size
是chunk被切割出来时就产生,这两个字段的数值需要一直保持,不能丢失,这也是两个字段始终发挥作用的原因。
而fdxx
和bkxx
则只会在chunk被释放时才会被使用,其余时候并不会被使用,所以其余的地段也只会在释放时在才会被赋成有效地址。
像fdxx
和bkxx
这样在不同阶段拥有不同身份的角色,很有可能变成间谍。
当程序握有已释放chunk的内存地址时,首先是fd
位置上的真实内存数据可以被泄露出来,其次就是从fd
位置开始的地方写入数据。
在GLibC的眼中,结构体malloc_chunk
中的fdxx
和bkxx
此时指向链表中其余的空闲chunk,指针变更等价于空闲chunk变更,这就说明我们有机会向GLibC申请到指定的内存区域,并从该内存区域中读写数据。
这时任意地址读写的环境已经被我们构造了出来。
在GLibC中,chunk释放时会根据情况进入不同的链表中,这些链表在管理策略上有些不同,导致它们对malloc_chunk
中的fdxx
和bkxx
的使用上有些区别。
首先是fast bin
链表,采用先进后出的原则进行控制,所以只用fd
一个字段,至于unsorted bin
、small bin
、large bin
三个链表,它们不需要考虑缓存友好的问题,所以会主要会使用bk
维护先进先出链表,当然,它们也会使用fd
维护一份先进后出链表,形成双向链表。
large bin
链表还要特殊一些,其余链表中chunk大小是相等的,而large bin
链表中chunk大小是可以不相等的,所以large bin
链表还会使用malloc_chunk
中的fd_nextsize
和bk_nextsize
字段。
前面说过重复释放的漏洞困扰GLibC很长时间了,GLibC也针对重复释放进行了加固,但是因为重复释放chunk很难有效的检测,所以GLibC只会针对相邻的chunk以及永远空闲的top chunk
进行检测,这种加固机制给了我们绕过防御的可能性。
GLibC在加固时使用的检测方法一般与malloc_chunk
中的fdxx
和bkxx
强相关,但这里主要关注fast bin
所维护的fd
链表。
fast bin
链表因为只会检查新释放chunk和前一个释放的chunk是否相同,所以绕过这个并不费劲,但是GLibC引入了一种地址随机化的机制保护fd
上存放的数据。
不管是从fd
上读取数据会面临数据“变形”的问题,向fd
上写入数据后,需要保证后续地址进行反随机化时,可以解出正确的地址。
这个随机化机制,为了保证密钥和数据的绑定,所以直接使用了&fd
作为密钥,由于密钥&fd
和明文数据*fb
都是chunk的地址,如果这两个chunk是相近的,那么它们有效地址中高36位就有极大概率是一样的(密钥地址右移12位),即明文等于密钥,又因为明文的高12位是不被加密,所以密文中还自带明文,这个时候就可以逐字节的让密文解出明文,再用明文作为密钥,接触下个字节的明文,直到解出全部的明文。
当然,要是能直接泄露&fd
的地址获取密钥也是可以的。
构造出异常的chunk环境后,安全风险当然是很大很明确的,除了安全风险之外,异常的chunk环境导致程序崩溃的情况也并不罕见。
如果你现在形成了异常的堆内存环境,比如重复释放chunk,那就一定小心了,因为可能一个正常的堆内存申请都会将你的程序搞到崩溃。
当然啊,如果chunk的位置保持不动,程序要出现崩溃的情况还是不太和道理的,但是在GLibC的管理概念中,chunk常常是需要迁移的。
比如下面的示例中,程序出现了chunk 1
被重复释放的情况,假如这时调用GLibC的接口getchar
,就会产生崩溃。
其根本原因在于,getchar
这个看起来人畜无害的东西,因为需要从stdin
中获取输入,所以会申请堆内存,申请的内存还挺大的,足足一个页的大小,这个大小显然应该归类到large chunk
中,申请large chunk
不要紧,但是_int_malloc
发现申请大小属于large chunk
时,会使用malloc_consolidate
合并fast bin
链表中的空闲chunk。
这一合并操作看似没有问题,实则内含隐患,隐患于重复释放的任意地址读写的原理是一样的,chunk_1
会先被拉进unsorted bin
链表中,这个时候chunk 1
的fd
保存的数据就变成了unsorted bin
中chunk的地址或&bins[0]
的地址。
由于链表中存在两份chunk_1
,所以chunk_1
还会再被取出进行合并,当然这时也不会产生问题,但是malloc_consolidate
会通过chunk_1
的fd
上保持的地址查找下一个fast chunk
。
不管地址是真实的chunk还是&bins[0]
,它们都要经过REVEAL_PTR
宏进行反随机化,经过异或运算后,地址基本上都不会在和MALLOC_ALIGNMENT
对齐,导致无法绕过地址对齐检查misaligned_chunk
。
除了getchar
的问题之外,打印函数printf
也是一个极为常见,且使用堆内存的函数,它因为需要需要向stdout
设备输出信息,所以也会使用堆内存。
下面给出了一个崩溃场景的示例,chunk 1
重复释放后,调用了printf
函数,该函数因为会使用堆内存,所以先通过malloc
接口申请内存。
printf
申请内存的也是一个页,所以会跟getchar
一样进入large chunk
的分支,然后通过检测到空闲chunk存在,并开始通过malloc_consolidate
对chunk进行合并以及迁移。
不过printf
函数触发的崩溃位置和getchar
函数有所不同,它是在检查地址时出现的崩溃,当程序申请出chunk 1
以及chunk 2
后,因为chunk 2
和chunk 1
相邻,所以就把chunk 1
合并了。
因为申请大小是0x30,所以chunk的实际大小是0x40,合并后的chunk大小是0x80,当重复的chunk 1
再被移除链表后,会经过((&fastbin (av, idx)) != fb)
的检查,但是这里chunk 1
的大小刚好超过了fast chunk
的限制,所以刚好无法从数组fastbinsY
中获取有效的fast bin
链表,自然也就通不过检查。
当然即使chunk不是相邻或合并后大小未超过fast chunk
的限制,它也会在后面遇到和getchar
一样的崩溃。
毕竟fb
上变异的数值大概率不会在反随机化后仍和MALLOC_ALIGNMENT
对齐。
如果想要在不适用堆内存的情况下,依旧完成向屏幕输出内容的任务,可以考虑下使用设备stderr
和fprintf
接口,printf
和fprintf
的功能是一样,唯一不同的是,printf
默认使用stdout
作为输出设备,如果fprintf
选择stdout
作为输出设备,那么也会使用堆内存。
是否使用堆内存,取决于设备是否需要缓冲区,像stderr
就不需要缓冲区,它默认是即即时写入的,而stdout
则默认是需要缓冲区的。
缓冲区的大小以及是否需要缓冲区也可以通过setvbuf
接口设置。
所以呢,想要避免构造出异常的堆内存环境后,后续正常的申请操作引发程序的崩溃,就需要尽量避免申请堆内存,造成chunk的合并或迁移。
上面介绍的是一些会使用堆内存的库函数,对于fast chunk
类型来讲,GLibC从链表large bin
或top chunk
中获取chunk时,会检查arena是否存在fast chunk
,如果存在就通过malloc_consolidate
合并。
但是,你可不要以为避免从large bin
或top chunk
中获取chunk就万事大吉了。
因为tcache机制的存在,即使申请的内存大小属于fast chunk
,在tcache中的链表还没有被填满时,会将fast bin
链表中其余的chunk填入tcache中。
即使有重复释放的chunk位于链表中,也不会对fast chunk
移入tcache中有什么影响,但是我们之所以要搞重复释放chunk的环境,是为了申请到一个在仍GLibC中处于释放状态的chunk 1_0
,修改分配到手的chunk 1_1
后,那个仍位于GLibC的空闲链表中的chunk 1_9
自然也会随着更改。
对于fast bin
来讲,如果fd
上保持的地址被修改,就相当于chunk 1_0
指向别的chunk,显然这个地址可以是任意可读可写的内存地址。
tcahce和fast bin
一样都是单向不循环链表,所以它们都会使用空指针作为结束符,检测不到空指针,那就会继续向后检索fd
。
_int_malloc
遍历fast bin
链表时,会判断数值是不是空指针是必然的,除了检查数值有没有和MALLOC_ALIGNMENT
对齐,如果设置的数值没有对齐就会报错。
修改chunk 1_0
的fd
是跟MALLOC_ALIGNMENT
对齐,也不代表就没有问题了,在函数tcache_put
中,它会往fd chunk + 0x10
和bk chunk + 0x18
两个位置写数据,你需要保证这两个内存区域都是可以的,不然会出现SIGSEGV
异常。
在重复释放且chunk 1_0
被修改条件下,关于fast bin
中chunk迁入tcache的问题,你不会以为就这么解决了吧?
_int_malloc
找不到空指针时是会一直向下进行遍历的,当PROTECT_PTR
宏被启用时,fd
上保存的数据需要进行反随机化。
如果你不能保证恶意地址xxx
所指向的伪造chunk的fd
所保存的数值yyy
在经历随机化后变成0,那么程序大概率就又要崩溃了。
这是因为反随机后的地址,大概率是不能跟MALLOC_ALIGNMENT
对齐的。
从上面可以看到,在申请fast chunk
时,如果让链表剩余的chunk进入tcache内,会造成很恶劣的后果,所以最好的办法就是填满tcache,避免chunk迁入tcache中。
如果tcache是满的,那申请也是从tcache里面获取chunk,不会进入fast chunk
申请的分支当中,如果tcache是空的,那么申请chunk时(重复释放下链表中一定不止一个chunk),就必然会将链表中的其余chunk带入tcache中。
为了解决这个做也不是,不做也不是的问题,我们需要calloc
这样的特殊接口,它与常见的malloc
不同之处,在于malloc
优先从tcache中获取chunk,而calloc
只会通过_int_malloc
中获取chunk。
对于fast chunk
来讲,使用calloc
接口时的优先级是高于tcache的。
假如上面提到两个问题都不会造成影响了,但你不会就以为事情就这么结束了吧!
GLibC可比你考虑的周到的多!
当_int_malloc
函数进入从fast bin
获取chunk的分支后,会先根据程序申请内存的大小nb
获取其在fastbinsY
数组中的索引值。
根据索引值取出链表中的头成员后,只要发现不是空指针,就通过REVEAL_PTR
宏更新链表头,结束后会再次判断victim
是否为空,如果不为空,就将victim
地址上存储的mchunk_size
取出,然后根据这个大小拿到它在fastbinsY
数值中的索引值,如果申请大小所得索引值idx
和取出chunk大小所得索引值victim_idx
不匹配,就说明chunk是有问题的,这时GLibC会通过malloc_printerr
抛出异常。
如果_int_malloc
从fast bin
链表中获得到的地址是由你设置的恶意地址,那么就需要保证恶意地址的mchunk_size
位置,存储着正确的大小。
下面给出了示例程序的源代码。
源代码由三个部分组成,一是fast_bin_double_free_env_create
帮助我们主动创建的重复释放环境,二是两个解密Safe-Linking
机制产生的随机化数据,分别是根据密钥进行解密和根据密文进行解密。
最后一部分就是漏洞函数,漏洞函数会申请4个chunk,在当前fast bin
链表中,存在着两个chunk 0
和一个chunk 1
。
vuln
第一次申请chunk时会获得chunk 0
,改写chunk_0 + offset(fd)
的位置会让另一个仍存在于fast bin
链表的chunk 0
不再指向chunk 2
,而是指向你设置的恶意地址,考虑到vuln
函数最后会调用msg_output
的output
函数,所以设置msg_output
显然是个不错的选择。
当vuln
第四次申请申请chunk时,会获得第一次修改chunk时所设置的地址,如果你设置成了msg_output
,那就可以修改output
上的指针指向gift_give
,完成利用并获得Shell。
通过上面的分析构造出下面的exploit。
运行上方的exploit后就可以成功获取Shell。
__libc_malloc
-
>
if
(tc_idx < mp_.tcache_bins && tcache !
=
NULL && tcache
-
>counts[tc_idx] >
0
)
-
> tcache_get
-
> _int_malloc
-
>
if
((nb) <
=
(get_max_fast ()))
-
> ......
-
> ......
_int_free
-
>
if
(tcache !
=
NULL && tc_idx < mp_.tcache_bins)
-
> ......
-
>
if
((size) <
=
get_max_fast())
-
> ......
__libc_malloc
-
>
if
(tc_idx < mp_.tcache_bins && tcache !
=
NULL && tcache
-
>counts[tc_idx] >
0
)
-
> tcache_get
-
> _int_malloc
-
>
if
((nb) <
=
(get_max_fast ()))
-
> ......
-
> ......
_int_free
-
>
if
(tcache !
=
NULL && tc_idx < mp_.tcache_bins)
-
> ......
-
>
if
((size) <
=
get_max_fast())
-
> ......
_int_malloc
-
>
if
((nb) <
=
(get_max_fast ()))
-
>
if
(SINGLE_THREAD_P)
-
>
*
fb
=
REVEAL_PTR (victim
-
>fd);
-
>
else
-
> REMOVE_FB (fb, pp, victim);
_int_free
-
>
if
((size) <
=
get_max_fast())
-
>
if
(SINGLE_THREAD_P)
-
> p
-
>fd
=
PROTECT_PTR (&p
-
>fd, old);
-
>
*
fb
=
p;
-
>
else
-
> do { old2
=
old; p
-
>fd
=
PROTECT_PTR (&p
-
>fd, old); }
while
((old
=
catomic_compare_and_exchange_val_rel (fb, p, old2)) !
=
old2);
_int_malloc
-
>
if
((nb) <
=
(get_max_fast ()))
-
>
if
(SINGLE_THREAD_P)
-
>
*
fb
=
REVEAL_PTR (victim
-
>fd);
-
>
else
-
> REMOVE_FB (fb, pp, victim);
_int_free
-
>
if
((size) <
=
get_max_fast())
-
>
if
(SINGLE_THREAD_P)
-
> p
-
>fd
=
PROTECT_PTR (&p
-
>fd, old);
-
>
*
fb
=
p;
-
>
else
-
> do { old2
=
old; p
-
>fd
=
PROTECT_PTR (&p
-
>fd, old); }
while
((old
=
catomic_compare_and_exchange_val_rel (fb, p, old2)) !
=
old2);
catomic_compare_and_exchange_val_acq(mem, newval, oldval)
-
>
if
(
*
mem
=
=
oldval)
-
>
*
mem
=
newval
-
>
return
oldval
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
#define REMOVE_FB(fb, victim, pp) \
do \
{ \
victim
=
pp; \
if
(victim
=
=
NULL) \
break
; \
pp
=
REVEAL_PTR (victim
-
>fd); \
} \
while
((pp
=
catomic_compare_and_exchange_val_acq (fb, pp, victim)) !
=
victim);
catomic_compare_and_exchange_val_acq(mem, newval, oldval)
-
>
if
(
*
mem
=
=
oldval)
-
>
*
mem
=
newval
-
>
return
oldval
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
#define REMOVE_FB(fb, victim, pp) \
do \
{ \
victim
=
pp; \
if
(victim
=
=
NULL) \
break
; \
pp
=
REVEAL_PTR (victim
-
>fd); \
} \
while
((pp
=
catomic_compare_and_exchange_val_acq (fb, pp, victim)) !
=
victim);
mchunkptr pp;
victim
=
*
fb;
REMOVE_FB (fb, pp, victim);
mchunkptr pp;
victim
=
*
fb;
REMOVE_FB (fb, pp, victim);
_int_free
-
>
if
((size) <
=
get_max_fast ())
-
> ......
-
>
else
if
(!chunk_is_mmapped(p))
-
> _int_free_merge_chunk
-
> _int_free_create_chunk
-
>
if
(nextchunk !
=
av
-
>top)
-
>
if
(!nextinuse)
-
> ......
-
>
else
-
> clear_inuse_bit_at_offset(nextchunk,
0
);
_int_free
-
>
if
((size) <
=
get_max_fast ())
-
> ......
-
>
else
if
(!chunk_is_mmapped(p))
-
> _int_free_merge_chunk
-
> _int_free_create_chunk
-
>
if
(nextchunk !
=
av
-
>top)
-
>
if
(!nextinuse)
-
> ......
-
>
else
-
> clear_inuse_bit_at_offset(nextchunk,
0
);
_int_malloc
-
>
if
(atomic_load_relaxed (&av
-
>have_fastchunks))
-
> malloc_consolidate (av);
malloc_consolidate
-
>
if
(nextchunk !
=
av
-
>top)
-
>
if
(!nextinuse)
-
> ......
-
>
else
-
> clear_inuse_bit_at_offset(nextchunk,
0
);
_int_malloc
-
>
if
(atomic_load_relaxed (&av
-
>have_fastchunks))
-
> malloc_consolidate (av);
malloc_consolidate
-
>
if
(nextchunk !
=
av
-
>top)
-
>
if
(!nextinuse)
-
> ......
-
>
else
-
> clear_inuse_bit_at_offset(nextchunk,
0
);
#define set_foot(p, s) (((mchunkptr) ((char *) (p) + (s)))->mchunk_prev_size = (s))
set_foot (remainder, remainder_size);
#define set_foot(p, s) (((mchunkptr) ((char *) (p) + (s)))->mchunk_prev_size = (s))
set_foot (remainder, remainder_size);
mchunkptr fd
=
p
-
>fd;
mchunkptr bk
=
p
-
>bk;
if
(fd
-
>bk !
=
p || bk
-
>fd !
=
p)
malloc_printerr (
"corrupted double-linked list"
);
mchunkptr fd
=
p
-
>fd;
mchunkptr bk
=
p
-
>bk;
if
(fd
-
>bk !
=
p || bk
-
>fd !
=
p)
malloc_printerr (
"corrupted double-linked list"
);
#define contiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) == 0)
_int_free
-
>
if
((size) <
=
get_max_fast ())
-
> unsigned
int
idx
=
fastbin_index(size);
-
> fb
=
&fastbin (av, idx);
-
> mchunkptr old
=
*
fb, old2;
-
>
if
(old
=
=
p)
-
> malloc_printerr (
"double free or corruption (fasttop)"
);
-
>
else
if
(!chunk_is_mmapped(p))
-
> _int_free_merge_chunk
-
>
if
(p
=
=
av
-
>top)
-
> malloc_printerr (
"double free or corruption (top)"
);
-
>
if
(contiguous (av) && (char
*
) nextchunk >
=
((char
*
) av
-
>top
+
chunksize(av
-
>top)))
-
> malloc_printerr (
"double free or corruption (out)"
);
-
>
if
(__glibc_unlikely (!prev_inuse(nextchunk)))
-
> malloc_printerr (
"double free or corruption (!prev)"
);
#define contiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) == 0)
_int_free
-
>
if
((size) <
=
get_max_fast ())
-
> unsigned
int
idx
=
fastbin_index(size);
-
> fb
=
&fastbin (av, idx);
-
> mchunkptr old
=
*
fb, old2;
-
>
if
(old
=
=
p)
-
> malloc_printerr (
"double free or corruption (fasttop)"
);
-
>
else
if
(!chunk_is_mmapped(p))
-
> _int_free_merge_chunk
-
>
if
(p
=
=
av
-
>top)
-
> malloc_printerr (
"double free or corruption (top)"
);
-
>
if
(contiguous (av) && (char
*
) nextchunk >
=
((char
*
) av
-
>top
+
chunksize(av
-
>top)))
-
> malloc_printerr (
"double free or corruption (out)"
);
-
>
if
(__glibc_unlikely (!prev_inuse(nextchunk)))
-
> malloc_printerr (
"double free or corruption (!prev)"
);
sysmalloc
-
> size
=
ALIGN_UP (size, GLRO(dl_pagesize));
-
>
if
(size >
0
)
-
> madvise_thp (brk, size);
-
> __madvise (p, size, MADV_HUGEPAGE);
sysmalloc
-
> size
=
ALIGN_UP (size, GLRO(dl_pagesize));
-
>
if
(size >
0
)
-
> madvise_thp (brk, size);
-
> __madvise (p, size, MADV_HUGEPAGE);
malloc_init_state
-
>
if
(av !
=
&main_arena)
-
> set_noncontiguous (av);
malloc_init_state
-
>
if
(av !
=
&main_arena)
-
> set_noncontiguous (av);
char
*
chunk_1,
*
chunk_2;
chunk_1
=
malloc(
0x40
);
chunk_2
=
malloc(
0x40
);
free(chunk_1)
free(chunk_2)
free(chunk_1)
fast
bin
: chunk_1
-
> chunk_2
-
> chunk_1
char
*
chunk_1,
*
chunk_2;
chunk_1
=
malloc(
0x40
);
chunk_2
=
malloc(
0x40
);
free(chunk_1)
free(chunk_2)
free(chunk_1)
fast
bin
: chunk_1
-
> chunk_2
-
> chunk_1
|
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
|
↓ |
chunk_1_1
-
> chunk_2
-
> chunk_1_0 |
| ^ | ^ | |
fd
-
-
-
-
-
-
-
-
-
-
-
| fd
-
-
-
-
-
-
-
| fd
-
-
-
-
-
|
|
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
|
↓ |
chunk_1_1
-
> chunk_2
-
> chunk_1_0 |
| ^ | ^ | |
fd
-
-
-
-
-
-
-
-
-
-
-
| fd
-
-
-
-
-
-
-
| fd
-
-
-
-
-
|
chunk_1_1
-
> chunk_2
-
> chunk_1_0 address_a
| ^ | ^ | ^
fd
-
-
-
-
-
-
-
-
-
-
-
| fd
-
-
-
-
-
-
-
| fd
-
-
-
-
-
-
-
-
-
-
-
|
chunk_1_1
-
> chunk_2
-
> chunk_1_0 address_a
| ^ | ^ | ^
fd
-
-
-
-
-
-
-
-
-
-
-
| fd
-
-
-
-
-
-
-
| fd
-
-
-
-
-
-
-
-
-
-
-
|
unsigned
int
idx
=
fastbin_index(size);
fb
=
&fastbin (av, idx);
mchunkptr old
=
*
fb;
p
-
>fd
=
PROTECT_PTR (&p
-
>fd, old);
*
fb
=
p;
unsigned
int
idx
=
fastbin_index(size);
fb
=
&fastbin (av, idx);
mchunkptr old
=
*
fb;
p
-
>fd
=
PROTECT_PTR (&p
-
>fd, old);
*
fb
=
p;
idx
=
fastbin_index (nb);
mfastbinptr
*
fb
=
&fastbin (av, idx);
victim
=
*
fb;
*
fb
=
REVEAL_PTR (victim
-
>fd);
idx
=
fastbin_index (nb);
mfastbinptr
*
fb
=
&fastbin (av, idx);
victim
=
*
fb;
*
fb
=
REVEAL_PTR (victim
-
>fd);
#define PROTECT_PTR(pos, ptr) \
((__typeof (ptr)) ((((size_t) pos) >>
12
) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
#define PROTECT_PTR(pos, ptr) \
((__typeof (ptr)) ((((size_t) pos) >>
12
) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
p
-
>fd
=
PROTECT_PTR (&p
-
>fd, old);
*
fb
=
REVEAL_PTR (victim
-
>fd);
p
-
>fd
=
PROTECT_PTR (&p
-
>fd, old);
*
fb
=
REVEAL_PTR (victim
-
>fd);
plain :
0x55fb52648290
key :
0x00055fb52648
cipher :
0x55fe0dd1a4d8
key xor cipher
=
plain
key xor plain
=
cipher
plain :
0x55fb52648290
key :
0x00055fb52648
cipher :
0x55fe0dd1a4d8
key xor cipher
=
plain
key xor plain
=
cipher
cipher xor plain
=
key
plain xor cipher
=
key
cipher xor (plain >>
12
)
=
plain
cipher xor plain
=
key
plain xor cipher
=
key
cipher xor (plain >>
12
)
=
plain
plain :
0x
55fb52648290
key :
0x00055fb52648
plain :
0x
55fb52648290
key :
0x00055fb52648
for
(i
=
2
; i <
=
8
; i
+
+
) {
bits
=
64
-
8
*
i;
if
(bits <
0
) {
bits
=
0
;
}
plain
=
((cipher ^ key) >> bits) << bits;
key
=
plain >>
12
;
printf(
"stage %d: bits = %02d, key = 0x%016lx, plain = 0x%016lx\n"
,
i, bits, key, plain
);
}
for
(i
=
2
; i <
=
8
; i
+
+
) {
bits
=
64
-
8
*
i;
if
(bits <
0
) {
bits
=
0
;
}
plain
=
((cipher ^ key) >> bits) << bits;
key
=
plain >>
12
;
printf(
"stage %d: bits = %02d, key = 0x%016lx, plain = 0x%016lx\n"
,
i, bits, key, plain
);
}
*
data area
*
*
| bk_nextsize |
*
*
| fd_nextsize |
*
*
| bk |
*
*
| fd |
*
| mchunk_size |
| mchunk_prev_size |
*
data area
*
*
| bk_nextsize |
*
*
| fd_nextsize |
*
*
| bk |
*
*
| fd |
*
| mchunk_size |
| mchunk_prev_size |
chunk
1
-
> chunk
2
-
> chunk
1
chunk_1
=
malloc(
0x30
);
malloc(
0x30
);
chunk_2
=
malloc(
0x30
);
free(chunk_1);
free(chunk_2);
free(chunk_1);
getchar();
chunk
1
-
> chunk
2
-
> chunk
1
chunk_1
=
malloc(
0x30
);
malloc(
0x30
);
chunk_2
=
malloc(
0x30
);
free(chunk_1);
free(chunk_2);
free(chunk_1);
getchar();
#define misaligned_chunk(p) ((p) & MALLOC_ALIGN_MASK)
getchar
-
> __GI___uflow
-
> ......
-
> _int_malloc
-
> malloc_consolidate
-
> fb
=
&fastbin (av,
0
);
-
> p
=
*
fb;
-
>
if
(misaligned_chunk (p))
-
> malloc_printerr
-
>
-
> p
=
REVEAL_PTR (p
-
>fd);
#define misaligned_chunk(p) ((p) & MALLOC_ALIGN_MASK)
getchar
-
> __GI___uflow
-
> ......
-
> _int_malloc
-
> malloc_consolidate
-
> fb
=
&fastbin (av,
0
);
-
> p
=
*
fb;
-
>
if
(misaligned_chunk (p))
-
> malloc_printerr
-
>
-
> p
=
REVEAL_PTR (p
-
>fd);
chunk_1
=
malloc(
0x30
);
chunk_2
=
malloc(
0x30
);
free(chunk_1);
free(chunk_2);
free(chunk_1);
printf(
"xxxx"
);
(gdb) bt
......
#5 0x00007ffff7e4d965 in malloc_printerr
#6 0x00007ffff7e4e4b8 in malloc_consolidate
#7 0x00007ffff7e50b88 in _int_malloc
#8 0x00007ffff7e51eaa in __GI___libc_malloc
#9 0x00007ffff7e2cfc3 in __GI__IO_file_doallocate
......
#15 0x00007ffff7e09952 in __printf_buffer_flush_to_file
#16 0x00007ffff7e09a10 in __printf_buffer_to_file_done
#17 0x00007ffff7e146f9 in __vfprintf_internal
#18 0x00007ffff7e0912b in __printf
......
chunk_1
=
malloc(
0x30
);
chunk_2
=
malloc(
0x30
);
free(chunk_1);
free(chunk_2);
free(chunk_1);
printf(
"xxxx"
);
(gdb) bt
......
#5 0x00007ffff7e4d965 in malloc_printerr
#6 0x00007ffff7e4e4b8 in malloc_consolidate
#7 0x00007ffff7e50b88 in _int_malloc
#8 0x00007ffff7e51eaa in __GI___libc_malloc
#9 0x00007ffff7e2cfc3 in __GI__IO_file_doallocate
......
#15 0x00007ffff7e09952 in __printf_buffer_flush_to_file
#16 0x00007ffff7e09a10 in __printf_buffer_to_file_done
#17 0x00007ffff7e146f9 in __vfprintf_internal