导语

之前写了堆基础知识,接下来要攻克的难题就是堆的exploit了。

堆溢出

因为堆是从低地址往高地址长,写入也是从低地址往高地址写,所以一般来说,我们利用堆溢出的策略是

  1. 覆盖与其物理相邻的下一个 chunk 的内容。
    • prev_size
    • size,主要有三个比特位,以及该堆块真正的大小。
      • NON_MAIN_ARENA
      • IS_MAPPED
      • PREV_INUSE
      • the True chunk size
    • chunk content,从而改变程序固有的执行流。
  2. 利用堆中的机制(如 unlink 等 )来实现任意地址写入( Write-Anything-Anywhere)或控制堆块中的内容等效果,从而来控制程序的执行流。

步骤

和栈溢出类似,首先要寻找存在漏洞的点,常见的危险函数是

  • 输入
    • gets,直接读取一行,忽略 '\x00'
    • scanf
    • vscanf
  • 输出
    • sprintf
  • 字符串
    • strcpy,字符串复制,遇到 '\x00' 停止
    • strcat,字符串拼接,遇到 '\x00' 停止
    • bcopy

相比于栈溢出,堆溢出多了一个步骤:寻找堆分配函数

通常来说堆是通过调用 glibc 函数 malloc 进行分配的,在某些情况下会使用 calloc 分配。calloc 与 malloc 的区别是 calloc 在分配后会自动进行清空,这对于某些信息泄露漏洞的利用来说是致命的

calloc(0x20);
//等同于
ptr=malloc(0x20);
memset(ptr,0,0x20);

此外还有一种分配是经由 realloc 进行的,realloc 函数可以身兼 malloc 和 free 两个函数的功能。

#include <stdio.h>

int main(void)
{
char *chunk,*chunk1;
chunk=malloc(16);
chunk1=realloc(chunk,32);
return 0;
}
  • 当 realloc(ptr,size) 的 size 不等于 ptr 的 size 时
    • 如果申请 size > ptr size
      • 如果 chunk 与 top chunk 相邻,直接扩展这个 chunk 到新 size 大小
      • 如果 chunk 与 top chunk 不相邻,相当于 free(ptr),malloc(new_size)
    • 如果申请 size < ptr size
      • 如果相差不足以容得下一个最小 chunk(64 位下 32 个字节,32 位下 16 个字节),则保持不变
      • 如果相差可以容得下一个最小 chunk,则切割原 chunk 为两部分,free 掉后一部分
  • 当 realloc(ptr,size) 的 size 等于 0 时,相当于 free(ptr)
  • 当 realloc(ptr,size) 的 size 等于 ptr 的 size,不进行任何操作

接下来要确定填充的长度,也就是计算 我们开始写入的地址与我们所要覆盖的地址之间的距离 。 一个常见的误区是 malloc 的参数等于实际分配堆块的大小,但是事实上 ptmalloc 分配出来的大小是对齐的。这个长度一般是字长的 2 倍,比如 32 位系统是 8 个字节,64 位系统是 16 个字节。但是对于不大于 2 倍字长的请求,malloc 会直接返回 2 倍字长的块也就是最小 chunk,,比如 64 位系统执行 malloc(0)会返回用户区域为 16 字节的块。

此外,需要注意用户区域的大小不等于 chunk_head.size,chunk_head.size = 用户区域大小 + 2 * 字长,包括header的长度,在之前的博客里,我们知道堆的内存结构是这样的

chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if unallocated (P clear) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk, in bytes |A|M|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User data starts here... .
. .
. (malloc_usable_size() bytes) .
next . |
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (size of chunk, but used for application data) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|1|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

head的长度即是两个字长

还有一点是之前所说的用户申请的内存大小会被修改,其有可能会使用与其物理相邻的下一个 chunk 的 prev_size 字段储存内容。即当我们申请到刚好大小的一个chunk时,可能会少一个字,这个字从下一个chunk的previous_size域拿来用。

Off-By-One

严格来说 off-by-one 漏洞是一种特殊的溢出漏洞,off-by-one 指程序向缓冲区中写入时,写入的字节数超过了这个缓冲区本身所申请的字节数并且只越界了一个字节。这种漏洞的产生往往与边界验证不严和字符串操作有关。(自己故意的还是不小心的多写一个字节的情况先不讨论)

边界验证不严通常是

  • 使用循环语句向堆块中写入数据时,循环的次数设置错误(这在 C 语言初学者中很常见)导致多写入了一个字节。

    [for (inti=0; i<=n; i++)就会多写一个]

  • 字符串操作不合适

    主要是没将字符串末尾的’\x00’纳入考虑,导致多写一个字节

exp:

  1. 溢出字节为可控制任意字节:通过修改大小造成块结构之间出现重叠,从而泄露其他块数据,或是覆盖其他块数据。也可使用 NULL 字节溢出的方法
  2. 溢出字节为 NULL 字节:两个chunk紧凑排列的情况下,即下一个chunk的prev_in_use域被上一个chunk的user data所使用时,溢出 NULL 字节可以使下一个chunk的header被清空,包括size和AMP三个bit都会被覆盖。因此,前一个chunk会被认为是 free 块。
  • 这时可以选择使用 unlink 方法(见 unlink 部分)进行处理。
  • 另外,这时 prev_size 域就会启用,就可以伪造 prev_size ,从而造成块之间发生重叠。此方法的关键在于 unlink 的时候没有检查按照 prev_size 找到的块的size域与 prev_size 是否一致。

for example

 1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <string.h>
4
5 int my_gets(char *ptr,int size)
6 {
7 int i;
8 for(i=0;i<=size;i++)
9 {
10 ptr[i]=getchar();
11 }
12 return i;
13 }
14 int main()
15 {
16 void *chunk1,*chunk2;
17 chunk1=malloc(24);
18 chunk2=malloc(16);
19 puts("Get Input:");
20 my_gets(chunk1,24);
21 return 0;
22 }

注意,ctfwiki里有误

1679634924217

他的chunk1申请了16字节,在64位系统中,会进行16字节对齐因此会多8字节,下图画红框的才是chunk1(只是方便理解,在这里我把连续的两个处于使用状态的chunk 的 第二个chunk的previous_size域算作了上一个chunk的user data域,因为在这种情况下第二个chunk的previous_size域确实可以被第一个chunk用做user_data)

1679635017576

因此他其实并没有溢出覆盖chunk2的chunk header,而是覆盖了chunk2的previous_size字段,这个字段在chunk2的P位为1时,可以直接供给chunk1使用,所以说到底还是在chunk1自己的空间里多写了一个字节罢了。在我们的例子里将chunk1大小设为24,并且通过my_gets24个,溢出一个字节刚好能覆盖掉chunk2的head。

1679635139272

Chunk Extend and Overlapping

chunk的大小可以通过以下两种方式获取chunk大小

/* Get size, ignoring use bits */
#define chunksize(p) (chunksize_nomask(p) & ~(SIZE_BITS))

/* Like chunksize, but do not mask SIZE_BITS. */
#define chunksize_nomask(p) ((p)->mchunk_size)

一种是直接获取 chunk 的大小,不忽略掩码部分,另外一种是忽略掩码部分。

通过以下方式获取下一chunk的地址,即当前块地址+当前块大小

/* Ptr to next physical malloc_chunk. */
#define next_chunk(p) ((mchunkptr)(((char *) (p)) + chunksize(p)))

通过以下方式获取上一chunk的信息,即通过 malloc_chunk->prev_size 获取前一块大小,然后使用当前 chunk 地址减之。

通过上面几个宏可以看出,ptmalloc 通过 chunk header 的数据判断 chunk 的使用情况和对 chunk 的前后块进行定位。简而言之,chunk extend 就是通过控制 size 和 pre_size 域来实现跨越块操作从而导致 overlapping 的。

for example

int main(void)
{
void *ptr,*ptr1;

ptr=malloc(0x10);//分配第一个0x10的chunk
malloc(0x10);//分配第二个0x10的chunk

*(long long *)((long long)ptr-0x8)=0x41;// 修改第一个块的size域

free(ptr);
ptr1=malloc(0x30);// 实现 extend,控制了第二个块的内容
return 0;
}

实际上这个0x10也没有考虑chunk size的对齐,ptmalloc会分配24个字节的空间,从header里就可以看出来chunk大小为32字节,其中8字节是header的长度,分配给用户的长度是24字节,不过这里没什么影响,只是做个记录加深一下映像。

1679635913898

此时,ptr指向该chunk的用户空间起始地址,ptr-0x8就是这个chunk的header,通过硬写它修改这个chunk的大小。

1679636170530

ptmalloc在回收时,会通过header来了解这个chunk的大小,所以会认为回收到了一个0x40大小的free chunk。立刻加入到fastbin里。在图里可以看到,ptr所指的chunk已经有了bk指针,说明已经被加入了bin中

1679636647849

可以看到已经修改成功,接下来我们再次申请0x30大小的内存,由于fastbin的LIFO管理,所以首先匹配到的就是这个块,于是就会将这个块分配给用户使用,此时chunk的bk域已经被清空,

1679636875874

那么问题就来了,在free的时候,难道不会到下一个chunk去将他的P位 置0 吗?以及应该是那个0呢?

1679637136544

我们再进行一个实验

 1 #include<stdlib.h>
2 #include<string.h>
3
4
5 int main(void)
6 {
7 void *ptr,*ptr1,*ptr2;
8
9 ptr=malloc(0x10);//分配第一个0x10的chunk
10 ptr2=malloc(0x10);//分配第二个0x10的chunk
11 malloc(0x10); // avoid merge with top chunk
12
13 *(long long *)((long long)ptr-0x8)=0x41;// 修改第一个块的size域
14 memset(ptr2,0xFF,0x18);
15 free(ptr);
16 ptr1=malloc(0x30);// 实现 extend,控制了第二个块的内容
17 free(ptr2);
18
19 return 0;
20 }

1679654373616

可以看到p位根本没有置为0。经过一番查找发现,只有在开拓堆和free后合并时,chunk header才会发生改变,即从top chunk里分出来chunk的时候才会被设置,在chunk被free时,会读chunk的p位,来决定是否要把两个空闲的chunk合并,合并之后会将chunk header变成chunk的用于存储的一段内存。

因此,在这种情况下,不论是先改size后free还是先free后改size,在下一次malloc的时候都会让第二个正处于use状态的chunk被合并,或者说这个被我们free后又malloc的chunk包含进去。这是因为bin里存free chunk的指针和free chunk的大小,而ptmalloc却是以chunk header来判断chunk的大小,二者不论是先改size后free还是先free后改size,都会存在不一样的现象。这就意味着bin以为给出了一个0x10的内存,但是ptmalloc识别这个chunk却会认为他是一个0x41。

unlink

参考

https://ctf-wiki.org/pwn/linux/user-mode/heap/ptmalloc2/heapoverflow-basic/

https://www.sohu.com/a/229677871_354899