正文
这个大小肯定是超过
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
一旦我们控制了
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,以及本文中出现的重复释放问题。