首页
社区
课程
招聘
[原创]PWN入门-22-FastBin与DoubleFree降妖
发表于: 2025-4-23 20:29 3026

[原创]PWN入门-22-FastBin与DoubleFree降妖

2025-4-23 20:29
3026

在GLibC的管理概念中,fast bin是极其特殊的一个,这种特殊体现在多个方面。

unsorted binsmall binlarge bin这三类链表需要放在bins数组中进行存储,而fast bin链表则是可以独占一个fastbinsY数组。

更不用说独占一个全局变量tcahce的天龙人tcache链表了。

不管是在申请的_int_malloc阶段,还是释放的_int_free,使用fast bin链表链表的优先级都是相当高的。

对于GLibC提供的malloc函数来讲,不管是释放还是申请内存时,它的优先级都是仅次于tcache的。

这一点正好体现了fast bin的设计原则,快速响应较小chunk的分配与释放。

unsorted binsmall binlarge bin使用的是由fdbk两个字段组成的双向循环链表。

其中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_sizemchunk_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清空前一个nextchunkmchunk_size的P位。

目的是告诉nextchunk,当nextchunk被释放时,它可以进行向后合并。

所以mchunk_sizePREV_INUSE标志位是“出厂时的默认设置”,PREV_INUSE标志位想要被清空,那就必须prevchunk被释放。

不过这种情况并不会涉及到fast chunkfast 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间具有链接关系,至于指向的内容是否正确就不得而知了。

而当使用fdbk维护的双向链表时,不止可以保证相邻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. 第三步是确认nextchunkPREV_INUSE是否已经不在了。

按照GLibC的逻辑,当chunk被释放时,它会将nextchunkPREV_INUSE清除掉,所以呢,如果chunk释放时,发现nextchunkPREV_INUSE已经不在了,就说明这个chunk应该是已经被释放过的,这个时候又来释放就属于重复释放了。

从上面的针对Double Free进行的加固措施中可以看到,加固的措施的局部性有效性非常的明显,所以虽然有加固措施的存在,但我们绕过缓解措施进行重复释放的机会其实还是大大地。

对于fast bin来讲,只要相同的chunk只要不相邻,就不会被检查机制揪出来。

从表面上来看,chunk重复进入fast bin链表的危害主要有两类,这两类利用的产生都可以看作是始于类型混淆。

一是程序可以给不同的缓冲区变量申请到同一个chunk,chunk 1的混用可能会造成主观上的类型混淆,进而导致利用机会产生。

二是程序申请到链表头的chunk 1_1时,由于在运行期中,malloc_chunk结构体中除了mchunk_prev_sizemchunk_size以外的区域都会作为数据区,数据区就是程序申请完chunk后,实际拿来读写的区域。

GLibC这种设计,是基于入链chunk正常的前提下完成的,但当chunk发生重复释放时,就会造成被动的类型混淆。

当我们申请到链表头上的chunk 1_1并向其中写入数据时,肯定会影响仍处于链表尾上的chunk 1_0,最直接的影响就是chunk 1_0fd

只要fd被改写,GLibC就不会再将chunk 2视为chunk 1_0的上一个chunk,而是将fd指向的新内存区域看作是上一个入链的chunk。

一旦我们控制了chunk_1_0fd,那么当chunk_1_0被取出时,fast bin链表的头成员就变成了我们覆写的恶意地址,此时再次取出chunk,我们就拿到了恶意地址指向的内存区域。

对于程序来讲,操作缓冲区变量就相当于操作恶意地址上数据,此刻任意地址读写的环境创造完成!

在上面针对chunk重复释放的危害的描述中,我们可以隐约的感到fast chunk的一个特性带来的好处。

这个好处就是,fast chunk入链之后,不会与其他的chunk进行合并,chunk在链表中的相对关系始终是稳定的,而稳定的相对关系可以保证chunk的fd始终有效。

如果chunk发生了合并,那么chunk 1_0fd就无法发生作用了,这个时候使用的fd就变成了其他的chunk的fd,但新的fd能否被控制就难说了。

至于这种好处的发生,要源自于fast chunkmchunk_size中始终处于启用状态的标志位PREV_INUSE,是它保障了fast chunk不进行合并。

想要改写fd这个事情,可能很简单,也可能很复杂。

如果GLibC版本较低,向chunk_1_1数据区写入地址A时,chunk_1_0fd会被顺利改写成地址A,并可以直接拿来使用。

但当GLibC的版本较高时,直接写入的地址就不能被拿来使用了。

在高版本的GLibC针对fast bin和tcache添加了一种地址随机化的机制,chunk在进入链表时,会通过PROTECT_PTR将当前链表头上的chunk地址加密,加密后的地址会存放到新入链chunk的fd上,最后将新入链chunk插入链表头,完成入链。

经过上面的一番操作后,链表头上存放的chunk地址永远都是正确的,但通过fd链接的其他chunk地址就变成了一个被随机化的地址。

这些不正确的地址都需要经过REVEAL_PTR解密才可以得到恢复。

chunk出链也并不复杂,先是取出链表头上的victim,通过REVEAL_PTRfd上的地址进行解密,然后将解密后的chunk地址放到链表头上,最后返回victim给程序进行使用就可以了。

所以啊,在高版本的GLibC中控制空闲fast chunkfd,就必须保证覆写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宏中posptr的实际数值,就会发现,明文有效数据中的高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_sizemchunk_size两个字段会发挥作用,而全部字段只有在释放阶段时才会全部发挥作用。

在正常情况下,这种管理方式并不会导致问题,非正常情况下就不好说了。

mchunk_prev_sizemchunk_size是chunk被切割出来时就产生,这两个字段的数值需要一直保持,不能丢失,这也是两个字段始终发挥作用的原因。

fdxxbkxx则只会在chunk被释放时才会被使用,其余时候并不会被使用,所以其余的地段也只会在释放时在才会被赋成有效地址。

fdxxbkxx这样在不同阶段拥有不同身份的角色,很有可能变成间谍。

当程序握有已释放chunk的内存地址时,首先是fd位置上的真实内存数据可以被泄露出来,其次就是从fd位置开始的地方写入数据。

在GLibC的眼中,结构体malloc_chunk中的fdxxbkxx此时指向链表中其余的空闲chunk,指针变更等价于空闲chunk变更,这就说明我们有机会向GLibC申请到指定的内存区域,并从该内存区域中读写数据。

这时任意地址读写的环境已经被我们构造了出来。

在GLibC中,chunk释放时会根据情况进入不同的链表中,这些链表在管理策略上有些不同,导致它们对malloc_chunk中的fdxxbkxx的使用上有些区别。

首先是fast bin链表,采用先进后出的原则进行控制,所以只用fd一个字段,至于unsorted binsmall binlarge bin三个链表,它们不需要考虑缓存友好的问题,所以会主要会使用bk维护先进先出链表,当然,它们也会使用fd维护一份先进后出链表,形成双向链表。

large bin链表还要特殊一些,其余链表中chunk大小是相等的,而large bin链表中chunk大小是可以不相等的,所以large bin链表还会使用malloc_chunk中的fd_nextsizebk_nextsize字段。

前面说过重复释放的漏洞困扰GLibC很长时间了,GLibC也针对重复释放进行了加固,但是因为重复释放chunk很难有效的检测,所以GLibC只会针对相邻的chunk以及永远空闲的top chunk进行检测,这种加固机制给了我们绕过防御的可能性。

GLibC在加固时使用的检测方法一般与malloc_chunk中的fdxxbkxx强相关,但这里主要关注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 1fd保存的数据就变成了unsorted bin中chunk的地址或&bins[0]的地址。

由于链表中存在两份chunk_1,所以chunk_1还会再被取出进行合并,当然这时也不会产生问题,但是malloc_consolidate会通过chunk_1fd上保持的地址查找下一个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 2chunk 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对齐。

如果想要在不适用堆内存的情况下,依旧完成向屏幕输出内容的任务,可以考虑下使用设备stderrfprintf接口,printffprintf的功能是一样,唯一不同的是,printf默认使用stdout作为输出设备,如果fprintf选择stdout作为输出设备,那么也会使用堆内存。

是否使用堆内存,取决于设备是否需要缓冲区,像stderr就不需要缓冲区,它默认是即即时写入的,而stdout则默认是需要缓冲区的。

缓冲区的大小以及是否需要缓冲区也可以通过setvbuf接口设置。

所以呢,想要避免构造出异常的堆内存环境后,后续正常的申请操作引发程序的崩溃,就需要尽量避免申请堆内存,造成chunk的合并或迁移。

上面介绍的是一些会使用堆内存的库函数,对于fast chunk类型来讲,GLibC从链表large bintop chunk中获取chunk时,会检查arena是否存在fast chunk,如果存在就通过malloc_consolidate合并。

但是,你可不要以为避免从large bintop 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_0fd是跟MALLOC_ALIGNMENT对齐,也不代表就没有问题了,在函数tcache_put中,它会往fd chunk + 0x10bk 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_mallocfast 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_outputoutput函数,所以设置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

[培训]科锐逆向工程师培训第53期2025年7月8日开班!

收藏
免费 0
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回