初探堆

知识来源处:

linux下的堆

堆不同于栈,堆是动态分配的(由操作系统内核或者堆管理器),只有在程序中需要时才会分配。栈是程序加载进内存后就会出现,而堆是由 malloc、alloc、realloc 函数分配内存后才会出现。

堆其实就是程序虚拟地址空间的一块连续的线性区域,其生长方向是从低地址向高地址生长的,与栈的从高地址向低地址生长是不同的。内核一般都会预先分配很大的一块连续的内存,因此堆可以申请到的内存空间比栈要大很多,然后让堆管理器通过某种算法管理这块内存。对堆操作的是由堆管理器(ptmalloc2)来实现的,而不是操作系统内核。因为如果程序每次申请或者释放堆时都需要进行系统调用,系统调用的开销巨大,当频繁进行堆操作时,就会严重影响程序的性能

目前 Linux 标准发行版中使用的堆分配器是 glibc 中的堆分配器:ptmalloc2。ptmalloc2 主要是通过 malloc/free 函数来分配和释放内存块。在内存分配与使用的过程中,Linux 有这样的一个基本内存管理思想,只有当真正访问一个地址的时候,系统才会建立虚拟页面与物理页面的映射关系。 所以虽然操作系统已经给程序分配了很大的一块内存,但是这块内存其实只是虚拟内存。只有当用户使用到相应的内存时,系统才会真正分配物理页面给用户使用。

(s)brk

1
2
3
函数原型
int brk(void *addr);
void *sbrk(intptr_t increment);

对于堆的操作,操作系统提供了 brk 函数,glibc 库提供了 sbrk 函数,我们可以通过增加 brk的大小来向操作系统申请内存。

初始时,堆的起始地址 start_brk 以及堆的当前末尾 brk 指向同一地址。根据是否开启 ASLR,两者的具体位置会有所不同

  • 不开启 ASLR 保护时,start_brk 以及 brk 会指向 data/bss 段的结尾。
  • 开启 ASLR 保护时,start_brk 以及 brk 也会指向同一位置,只是这个位置是在 data/bss 段结尾后的随机偏移处。

brk和sbrk会改变program break的位置(在heap后的那块区域),brk通过传递的addr来重新设置program break,成功则返回0,否则返回-1。而sbrk用来增加heap,增加的大小通过参数increment决定,返回增加大小的heap的program break,如果increment为0则返回program break

大部分我们使用的是malloc和free函数来分配和释放内存,而brk和sbrk分配的堆空间类似于缓冲池(这个缓冲池的大小比malloc所设置的更大),每次malloc从缓冲池获得内存,如果缓冲池不够了,再调用brk或sbrk扩充缓冲池,直到达到缓冲池大小的上限,free则将应用程序使用的内存空间归还给缓冲池。

mmap

malloc 会使用 mmap 来创建独立的匿名映射段。匿名映射的目的主要是可以申请以 0 填充的内存,并且这块内存仅被调用进程所使用。

可以用munmap函数清除mmap的匿名映射段

多线程支持

许多时候程序可能只是向操作系统申请很小的内存,但是为了方便,操作系统会把很大的内存分配给程序。这样的话,就避免了多次内核态与用户态的切换,提高了程序的效率。我们称这一块连续的内存区域为 arena。此外,我们称由主线程申请的内存为main_arena。后续的申请的内存会一直从这个 arena 中获取,直到空间不足。当 arena 空间不足时,它可以通过增加 brk 的方式来增加堆的空间。类似地,arena 也可以通过减小 brk 来缩小自己的空间。

在主线程释放内存后,其对应的 arena 并没有进行回收,而是交由 glibc 来进行管理。当后面程序再次申请内存时,在 glibc 中管理的内存充足的情况下,glibc 就会根据堆分配的算法来给程序分配相应的内存。

注:当用户请求的内存大于 128KB 时,并且没有任何 arena 有足够的空间时,那么系统就会执行 mmap 函数来分配相应的内存空间。这与这个请求来自于主线程还是从线程无关。

堆溢出

介绍

(转载自ctf-wikihttps://wiki.x10sec.org/pwn/linux/glibc-heap/heapoverflow_basic-zh/)

堆溢出是指程序向某个堆块中写入的字节数超过了堆块本身可使用的字节数(之所以是可使用而不是用户申请的字节数,是因为堆管理器会对用户所申请的字节数进行调整,这也导致可利用的字节数都不小于用户申请的字节数),因而导致了数据溢出,并覆盖到物理相邻的高地址的下一个堆块。

不难发现,堆溢出漏洞发生的基本前提是

  • 程序向堆上写入数据。
  • 写入的数据大小没有被良好地控制。

对于攻击者来说,堆溢出漏洞轻则可以使得程序崩溃,重则可以使得攻击者控制程序执行流程。

堆溢出是一种特定的缓冲区溢出(还有栈溢出, bss 段溢出等)。但是其与栈溢出所不同的是,堆上并不存在返回地址等可以让攻击者直接控制执行流程的数据,因此我们一般无法直接通过堆溢出来控制 EIP 。一般来说,我们利用堆溢出的策略是

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

基本示例

下面我们举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main(void)
{
char *chunk;
chunk=malloc(24);
puts("Get input:");
gets(chunk);
return 0;
}

这个程序的主要目的是调用 malloc 分配一块堆上的内存,之后向这个堆块中写入一个字符串,如果输入的字符串过长会导致溢出 chunk 的区域并覆盖到其后的 top chunk 之中(实际上 puts 内部会调用 malloc 分配堆内存,覆盖到的可能并不是 top chunk)。

1
2
3
4
5
0x602000:	0x0000000000000000	0x0000000000000021 <===chunk
0x602010: 0x0000000000000000 0x0000000000000000
0x602020: 0x0000000000000000 0x0000000000020fe1 <===top chunk
0x602030: 0x0000000000000000 0x0000000000000000
0x602040: 0x0000000000000000 0x0000000000000000

print ‘A’*100
进行写入

1
2
3
4
5
0x602000:	0x0000000000000000	0x0000000000000021 <===chunk
0x602010: 0x4141414141414141 0x4141414141414141
0x602020: 0x4141414141414141 0x4141414141414141 <===top chunk(已被溢出)
0x602030: 0x4141414141414141 0x4141414141414141
0x602040: 0x4141414141414141 0x4141414141414141

小总结

堆溢出中比较重要的几个步骤:

寻找堆分配函数

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

1
2
3
4
calloc(0x20);
//等同于
ptr=malloc(0x20);
memset(ptr,0,0x20);

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

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main(void)
{
char *chunk,*chunk1;
chunk=malloc(16);
chunk1=realloc(chunk,32);
return 0;
}

realloc的操作并不是像字面意义上那么简单,其内部会根据不同的情况进行不同操作

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

寻找危险函数

通过寻找危险函数,我们快速确定程序是否可能有堆溢出,以及有的话,堆溢出的位置在哪里。

常见的危险函数如下

  • 输入
    • gets,直接读取一行,忽略 '\x00'
    • scanf
    • vscanf
  • 输出
    • sprintf
  • 字符串
    • strcpy,字符串复制,遇到 '\x00' 停止
    • strcat,字符串拼接,遇到 '\x00' 停止
    • bcopy,将字符串src的前n个字节复制到dest中,并且不检查’\x00’

确定填充长度

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

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main(void)
{
char *chunk;
chunk=malloc(0);
puts("Get input:");
gets(chunk);
return 0;
}
1
2
3
4
5
//根据系统的位数,malloc会分配816字节的用户空间
0x602000: 0x0000000000000000 0x0000000000000021
0x602010: 0x0000000000000000 0x0000000000000000
0x602020: 0x0000000000000000 0x0000000000020fe1
0x602030: 0x0000000000000000 0x0000000000000000

注意用户区域的大小不等于 chunk_hear.size,chunk_hear.size=用户区域大小+2*字长

还有一点是之前所说的用户申请的内存大小会被修改,其有可能会使用与其物理相邻的下一个chunk的prev_size字段储存内容。回头再来看下之前的示例代码

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main(void)
{
char *chunk;
chunk=malloc(24);
puts("Get input:");
gets(chunk);
return 0;
}

观察如上代码,我们申请的chunk大小是24个字节。但是我们将其编译为64位可执行程序时,实际上分配的内存会是16个字节而不是24个。

1
2
3
0x602000:	0x0000000000000000	0x0000000000000021
0x602010: 0x0000000000000000 0x0000000000000000
0x602020: 0x0000000000000000 0x0000000000020fe1

16个字节的空间是如何装得下24个字节的内容呢?答案是借用了下一个块的pre_size域。我们可来看一下用户申请的内存大小与glibc中实际分配的内存大小之间的转换。

1
2
3
4
5
6
/* pad request bytes into a usable size -- internal version */
//MALLOC_ALIGN_MASK = 2 * SIZE_SZ -1
#define request2size(req) \
(((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) \
? MINSIZE \
: ((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)

当req=24时,request2size(24)=32。而除去chunk 头部的16个字节。实际上用户可用chunk的字节数为16。而根据我们前面学到的知识可以知道chunk的pre_size仅当它的前一块处于释放状态时才起作用。所以用户这时候其实还可以使用下一个chunk的prev_size字段,正好24个字节。实际上 ptmalloc 分配内存是以双字为基本单位,以64位系统为例,分配出来的空间是16的整数倍,即用户申请的chunk都是16字节对齐的。

相关函数知识

1、mmap

函数原型为void mmap(void start,size_t length,int prot,int flags,int fd,off_t offset)** 功能为:mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页剩下的空间(未使用的)将会清零。mmap创建的chunk紧邻libc

条件:mmap()必须以PAGE_SIZE为单位进行映射,而内存也只能以页为单位(32位系统中页为4k字节)进行映射,若要映射非PAGE_SIZE整数倍的地址范围,要先进行内存对齐,强行以PAGE_SIZE的倍数大小进行映射。

参数说明:

start:映射区的开始地址,设置为0时表示由系统决定映射区的起始地址。

length:映射区的长度。//长度单位是 以字节为单位,不足一内存页按一内存页处理

prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or运算合理地组合在一起

PROT_EXEC //页内容可以被执行(值为1)

PROT_READ //页内容可以被读取(值为4)

PROT_WRITE //页可以被写入(值为2)

PROT_NONE //页不可访问

flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体:

MAP_FIXED //使用指定的映射起始地址,如果由start和len参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。

MAP_SHARED //与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到**msync()或者munmap()**被调用,文件实际上不会被更新。

MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。

MAP_DENYWRITE //这个标志被忽略。

MAP_EXECUTABLE //同上

MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。

MAP_LOCKED //锁定映射区的页面,从而防止页面被交换出内存。

MAP_GROWSDOWN //用于堆栈,告诉内核VM系统,映射区可以向下扩展。

MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。

MAP_ANON //MAP_ANONYMOUS的别称,不再被使用。

MAP_FILE //兼容标志,被忽略。

MAP_32BIT //将映射区放在进程地址空间的低2GB,MAP_FIXED指定时会被忽略。当前这个标志只在x86-64平台上得到支持。

MAP_POPULATE //为文件映射通过预读的方式准备好页表。随后对映射区的访问不会被页违例阻塞。

MAP_NONBLOCK //仅和MAP_POPULATE一起使用时才有意义。不执行预读,只为已存在于内存中的页面建立页表入口。

fd:有效的文件描述词。一般是由**open()**函数返回,其值可以设置为-1,此时需要指定flags参数中的MAP_ANONYMOUS,表明进行的是匿名映射。

offset:被映射对象内容的起点。

成功执行时,mmap()返回被映射区的指针,munmap()返回0。失败时,mmap()返回MAP_FAILED[其值为(void *)-1],munmap返回-1。

注:((void*)-1) :一个某类型指针类型,指向地址为0xffffffff,无效地址,表明错误

映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。

2、munmap

函数原型为int munmap(void start,size_t length)* 用来取消参数start所指的映射内存起始地址,参数length则是欲取消的内存大小。当进程结束或利用exec相关函数来执行其他程序时,映射内存会自动解除,但关闭对应的文件描述符时不会解除映射。

addr是调用mmap()时返回的地址,len是映射区的大小;当映射关系解除后,对原来映射地址的访问将导致段错误发生。

3、msync

函数原型为**int msync( void *addr, size_t len, int flags )**,一般说来,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用munmap()后才执行该操作。

可以通过调用msync()实现磁盘上文件内容与共享内存区的内容一致。

4、malloc

函数原型为**void *malloc(unsigned int size)**;其作用是在内存的动态存储区中分配一个长度为size的连续空间。此函数的返回值是分配区域的起始地址,或者说,此函数是一个指针型函数,返回的指针指向该分配域的开头位置。

分配成功则返回指向被分配内存的指针(此存储区中的初始值不确定),否则返回空指针NULL。当动态内存不再使用时,应使用free()函数将内存块释放。

  • 当 size=0 时,返回当前系统允许的堆的最小内存块。
  • 当 size 为负数时,由于在大多数系统上,size_t 是无符号数(这一点非常重要),所以程序就会申请很大的内存空间,但通常来说都会失败,因为系统没有那么多的内存可以分配。

5、free

函数原型为**void free(void *ptr) **是C语言中释放内存空间的函数,通常与申请内存空间的函数malloc()结合使用,可以释放由 malloc()、calloc()、realloc() 等函数申请的内存空间。

ptr– 指针指向一个要释放内存的内存块,该内存块之前是通过调用 malloc、calloc 或 realloc 进行分配内存的。如果传递的参数是一个空指针,则不会执行任何动作。free函数不返回任何值

当 p 已经被释放之后,再次释放会出现乱七八糟的效果,这其实就是 double free

6、**__free_hook**

1
2
3
4
5
6
7
8
9
10
11
12
void __libc_free (void *mem)
{
mstate ar_ptr;
mchunkptr p; /* chunk corresponding to mem */

void (*hook) (void *, const void *)
= atomic_forced_read (__free_hook);
if (__builtin_expect (hook != NULL, 0))
{
(*hook)(mem, RETURN_ADDRESS (0));
return;
}

上面的代码对是 free() 函数的一部分,可以看出程序先把全局变量 __free_hook 赋给了局部变量 hook ,然后对 hook 是否为 NULL 进行判断,如果不为空,则执行 hook ,第一个参数就是 chunk 的内容部分。

一般的情况下 __free_hook 是为 NULL 的,所以是不会执行的,但是如果有人恶意修改 __free_hook 的话,就会造成 __free_hook 劫持

查看评论