专栏名称: 看雪学苑
致力于移动与安全研究的开发者社区,看雪学院(kanxue.com)官方微信公众帐号。
目录
相关文章推荐
安天集团  ·  HijackLoader加载器的全面分析—— ... ·  1小时前  
四川省消委会  ·  谨慎下载!这49款APP被通报→ ·  2 小时前  
四川省消委会  ·  谨慎下载!这49款APP被通报→ ·  2 小时前  
Java仓库  ·  阿里巴巴:裁减 24940 人。 ·  7 小时前  
Java仓库  ·  阿里巴巴:裁减 24940 人。 ·  7 小时前  
贵州市场监管  ·  一图读懂 | ... ·  昨天  
贵州市场监管  ·  一图读懂 | ... ·  昨天  
云技术  ·  88万元,数据可视化系统大单:帆软中标 ·  2 天前  
云技术  ·  88万元,数据可视化系统大单:帆软中标 ·  2 天前  
51好读  ›  专栏  ›  看雪学苑

PWN入门:FastBin与DoubleFree降妖

看雪学苑  · 公众号  · 互联网安全  · 2025-05-12 17:59

正文

请到「今天看啥」查看全文



这个大小肯定是超过 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 管理子堆。


malloc_init_state
-> if (av != &main_arena)
-> set_noncontiguous (av);


c. 第三步是确认 nextchunk PREV_INUSE 是否已经不在了。


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


在加固措施的眼皮底下完成重复释放

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


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


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重复进入 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_1 -> chunk_2 -> chunk_1_0 |
| ^ | ^ | |
fd-----------| fd-------| fd-----|


一旦我们控制了 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,并可以直接拿来使用。


chunk_1_1 -> chunk_2 -> chunk_1_0     address_a
| ^ | ^ | ^
fd-----------| fd-------| fd-----------|


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


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


unsigned int idx = fastbin_index(size);
fb = &fastbin (av, idx);
mchunkptr old = *fb;
p->fd = PROTECT_PTR (&p->fd, old);
*fb = p;


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


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


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


idx = fastbin_index (nb);
mfastbinptr *fb = &fastbin (av, idx);
victim = *fb;
*fb = REVEAL_PTR (victim->fd);


所以啊,在高版本的GLibC中控制空闲 fast chunk fd ,就必须保证覆写 fd 的数据在经过 REVEAL_PTR 解密后,可以得到一个可读可写的内存地址。


而低版本的GLibC没有针对 fd 加解密的这一设计,所以 fd 上存储的永远都是正确的地址,自然也就不会拥有这种烦恼。


异或加解密的安全性简要分析

想要在针对 fd 地址随机化的情况下,不仅完成 fd 的覆写,还要使得GLibC在取出chunk时,可以让经 REVEAL_PTR 解密后的数据,指向我们预期中的内存区域。


为了达到这一目的,我们需要了解下异或加解密的流程。


异或加解密,可以看作是一种非常简单的加解密措施,为了完成加解密的流程,它需要两个部分,一是文本信息,二是密钥。


对于 PROTECT_PTR 来讲, ptr 是文本信息,而 pos 就是密钥。


异或加解密的操作依赖于 xor 异或运算,所谓的异或运算指的就是两比特位的数值相等时产生结果为0,反之则产生结果为1的运算规则。


#define PROTECT_PTR(pos, ptr)	\
((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)


在GLibC的设计中,它要求使用指针类型的数据,其中指针类型变量的内存地址作为密钥,而指针类型变量上存放数据则作为文本信息。


内存地址会右移12个比特位后生成实际使用的密钥,这是因为内存地址间的相似度比较高,所以右移打乱相似度,提高加密复杂度。


p->fd = PROTECT_PTR (&p->fd, old);

*fb = REVEAL_PTR (victim->fd);


明文与密钥经过异或运算产生密文后,只要解密时仍使用相同的密钥,就可以保证被翻转的比特位数值可以再翻转回来,恢复明文信息。


这也是使用指针类型变量的原因,同一指针类型变量的地址是固定,使用指针变量所在地址作为密钥,可以始终和文本信息绑定,我们不需要通过额外的手段获取密钥。

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个半字节已经有着落了,那么后面的字节呢?


plain  : 0x55fb52648290
key : 0x00055fb52648
cipher : 0x55fe0dd1a4d8

key xor cipher = plain
key xor plain = cipher


不管什么场景下,密文和明文进行异或运算的结果就是密钥,这个自然不用多说。


但是在 PROTECT_PTR 宏的场景下,密文和右移12位的明文运算的结果可能就是真实的明文,这个东西可有点诡异,而且当 cipher xor (plain >> 12) = plain 成立时,就代表我们在拥有完整密文和部分明文的情况下,逐字节的还原全部明文。


不过这个现象又是怎么产生的呢?


cipher xor plain  = key
plain xor cipher = key

cipher xor (plain >> 12) = plain


这个现象之所以出现,其实是跟GLibC的arena与chunk的特殊地址息息相关的。


如果你仔细观察真实场景下,观察 PROTECT_PTR 宏中 pos ptr 的实际数值,就会发现,明文有效数据中的高36位数据刚好是等于密钥的。


plain  : 0x   55fb52648290
key : 0x00055fb52648


显然,在 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字节也被解出。


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
);
}


通过上面的分析,我们已经知道了破解 Safe-Linking 机制的方法,比较土的方法呢,就是直接泄露密钥。


稍微先进一点的方法,会收到一点限制,要求明文的高36位等于密钥,当明文和密钥同时处于GLibC的堆环境时,这一点还比较容易达成,因为任何chunk的起始地址都是相同的,chunk在堆中的偏移值决定了它们的相似性。


再加上明文的最高12位不会被随机化,有这两种特性的加持,我们就可以在只拥有密文的前提下,逐字节的完成反随机化的工作。


当然,这是在基于存在信息泄露的前提下完成的,那么还有没有方法,可以在不进行信息泄露的前提下,就完成 Safe-Linking 机制的绕过呢?

总结

堆内存一直是安全隐患的多发之地,本次事故的发生在于chunk被重复的释放。


释放的内存真的不可用了吗?

内存是软件的栖息之地,程序需要向内核申请内存进行使用,对于程序来讲,只要内存分配给了自己,那就是可以使用的,不存在真正意义上的内存失效一说。


比如栈虽然随函数的消失,但原来函数分配的栈空间其实还是可以读写的,再比如堆内存被释放后,也只是交给GLibC进行管理,程序并没有向内核申请削减内存,从程序的内存布局中可以看到,堆内存仍保持着原来的大小。


栈内存随函数变迁后,程序丧失了直接控制原来栈内存的途径,对于堆内存而言,也是一样,一旦chunk被释放,程序就应该丧失对已释放chunk的控制方法,比如将缓冲区变量的指针设置成空指针。


如果堆内存释放后,不被清理控制方法(内存地址置空),那么被释放的chunk仍位于程序的内存布局中,程序并没有真正的抛弃内存,那么这段在逻辑上被抛弃的内存,实际就仍是可以被直接控制的。


逻辑上不可用和程序仍拥有直接控制方法的现实起了冲突,造成这一情况的原因,是程序员并不熟悉操作系统与GLibC的机制,以为释放后堆内存就不可以用了。


这种冲突的情况,会产生诸多的安全问题,比如大名鼎鼎的UAF,以及本文中出现的重复释放问题。







请到「今天看啥」查看全文