Pwn - 堆利用 Heap_Exploit

系统学习 Pwn 的堆相关知识

堆利用 Heap Exploit

个人认为:堆是庞大而又复杂的东西,要想真正的认识堆,把堆搞明白了,也是需要一段时间的。

提示:

  1. 文章偏新手向,我就一个新手只能写出新手向的文章,所以说它更适合新手,我会用更加详细的语言去描述某些知识点,如果老东西们发现文章有什么问题们请狠狠的提出不足,谢谢。
  2. 由于众所周知的原因,随着 libc 版本的更迭,堆呈现出来的性质也是不一样的,一般入门的话都是从旧版本的堆的特性开始学习,在之后的内容会逐步讲解更高版本 libc 涉及到的堆的特性,所以文章所有用来编译的程序均是在安装了 glibc-2.23 的系统上编译的,如果你也想自己尝试编译一下,那么多安装一个 Ubuntu 16 是再好不过了。(或者是下文的 pwndocker 应该也行)

前置内容:

由于堆的特性与 libc 的版本息息相关,而大部分人都会使用最新最热的 kali - linux 系统,如果你是官网下载的船新版本,你需要更换你的 libc 版本,当然对于向 glibc 这样系统依赖性很强的东西贸然降级是很危险的,所以你可以使用下面几种方法:

  1. patchelfglibc-all-in-one 前者可以指定某个二进制文件使用对应的 libc 运行,后者则是可以方便你下载各个版本的 libc ,具体用法可以百度。

  2. 在你写的脚本中打开本地进程这么写:

    1
    
    process([" ld 路径 ", "./题目"], env={"LD_PRELOAD":" libc 路径 "})
    
  3. 如果你热爱配环境的话可以使用 skysider / pwndocker 这样牛逼的工具,我认得的老东西说牛逼的老东西都用这个

对于调试环境(个人向)喜欢将 gdb 的插件配置为 pwndbg + pwngdb 。(注意不是一个东西)

一 、与堆的初次见面

1.1 堆是什么?

学过C语言的都知道,在C语言中,内存管理有动态内存管理、静态内存管理、自动内存分配三种办法。当然我们的重点并不是后两个,如果你没学过这方面可以了解一下。其中,动态内存管理就是对堆的利用。

动态内存,也称为堆内存。堆内存在手动释放程序结束之前均可访问,同时允许我们在程序执行期间随时分配和释放内存,它非常适合存储大型数据结构大小事先未知的对象。堆使得程序员分配内存变得更加灵活,在一定程度上也解决了内存不足的问题。

1.2 堆在哪里?

  当申请(使用 malloc() 函数)内存块后,堆段才会出现,堆由低地址向高地址方向增长,当我们在 pwndbg 中使用 vmmap 指令可以看到 heap 段在内存中的位置:



可以看到,堆在内存中的位置处于栈区 (内存映射段)与数据段之间,并且紧邻着数据段。

1.3 堆从哪里来?

当然是申请来的(即答),那么当我们在程序中第一次申请了一块空间时到底发生了什么?

提前科普——为了防止你会懵 B 所以对于有些下文会提到但是已经涉及到的东西讲解一下:

  1. Top chunk:当程序第一次进行 malloc 的时候,heap 会被分为两块,一块给用户,剩下的那块就是 top chunk ,再次申请堆块要是没有合适的空间便会使用 top chunk 的空间。
  2. 你申请到的一块堆内存的起始地址 ≠ 你可以写入数据的起始地址,因为堆块头部会记录一些信息,所以你会看到下面的示例中有 0x10 大小差距。
  3. 你申请的大小 ≠ 实际申请的大小,他会有一个的取整的步骤。

我准备了下面的程序来演示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <stdio.h>
#include <stdlib.h>

int main()
{
  
  int *p = NULL;

  p = (int *)malloc(0x114);
  p = (int *)malloc(0x514);
  p = (int *)malloc(0x1919);
  p = (int *)malloc(0x810);

  return 0;
}

(1)第一次申请之前,在我们申请内存之前,堆段并没有出现。


(2)第一次申请后,堆段被建立了。

   通过vmmap观察堆的size,我们得到了21000字节的堆内存


> 为什么图片中堆的长度是21000,和实际申请的大小差距很大? > > 事实上,当申请堆的内存时,我们可以随时随地申请,而且每次申请的内存有大有小,管理申请的堆的工作量会非常大,而申请内存呢肯定是要向系统申请的,那么不妨假设一下,如果 malloc()函数仅仅封装了可以分配内存的系统调用(比如 brk 和 mmap ,本人也不太明白,有兴趣可以了解相关的系统调用),那么频繁地调用系统调用会很消耗系统资源。 > > 所以针对这个问题,程序第一次可能只是向操作系统申请很小的内存,但是为了方便,操作系统会把很大的内存分配给程序。这样的话可以避免上面假设所说的频繁系统调用,就避免了多次内核态与用户态的切换,提高了程序的效率。

(4)再从得到的内存上分配所申请的相应的小段内存,称为Chunk(Allocated Chunk),并返回地址给程序。


辣个"Allocated Chunk"就是我们申请的内存啦
**(5)如果你申请了很多的堆块,不考虑释放堆块,那他们会按申请的顺序依次从低地址到高地址排列**。


> 从系统调用得到的内存又是怎么样分配的呢? > > glibc有自己的内存管理器来进行内存分配操作——Ptmalloc2,它实现了各种对堆的分配,回收,合并,切割等操作。对应的,不同环境也有不同的堆管理器,而针对堆的特性介绍与漏洞利用也与Ptmalloc2息息相关。

1.4 堆的归宿是哪里?(堆如何发挥作用?)

观前提示:使用有关堆的函数前需要头文件 stdlib.h 。

提前科普:

一般使用 free() 函数释放的堆块不会立刻被回收,它们会变成一种叫 Free Chunk 的东西并且加上了一种类似 xxx bin 的名字,一般这类堆块释放后如果挨着一个也被释放的堆块或者是 Top Chunk 会合并,当然请记住 Fast Bin 是一个特例 —— 它不会轻易合并。

(1)malloc() 函数:

分配所需的内存空间,并返回一个指向它的指针

1
void* malloc(size_t size);

参数size:要分配的字节大小,是无符号数,如果你输入了了 -1 ,理论上会有一个很大很大的堆,实际上会报错,因为确实很大! 返回值:如果分配成功,则返回指向分配内存的指针;如果分配失败,则返回 NULL。

(2)calloc() 函数:

分配所需的内存空间,并返回一个(一组)指向它(它们)的指针。 malloc 和 calloc 之间的不同点是,malloc 不会设置内存为零,而 calloc 会设置分配的内存为零。

1
void *calloc(size_t nitems, size_t size)

参数 nitems :需要的堆块数量,也是无符号数。 返回值:如果分配成功,则返回一个(一组)指向分配的堆块(堆块们)的指针;如果分配失败,则返回 NULL。

重点:(3)realloc() 函数:

更改已经配置的内存空间,即更改由 malloc() 函数分配的内存空间的大小。

1
void *realloc(void *ptr, size_t size)

参数 *ptr :一个指针,指向堆块或者为空。 返回值:看情况,下文会说。

1)如果重新申请的大小大于申请内存的大小,且当前内存段后面有需要的内存空间,则直接扩展这段内存空间,realloc() 将返回原指针。 2)如果重新申请的大小大于申请内存的大小且当前内存段后面的空闲字节不够,那么就使用堆中的第一个能够满足这一要求的内存块,将目前的数据复制到新的位置,并将原来的数据块释放掉,返回新的内存块位置,相当于 free() + malloc() 。 3)如果重新申请的大小小于申请内存的大小,堆块会直接缩小,被削减的内存会释放。

提示: 这里的释放不是和 free() 函数一样的释放,而是区别于 free() 函数的释放内存方式,不过具体是什么以后再说吧。

4)如果传入了一个空的堆块地址,但是 size 不是 0 ,那么就相当于 malloc() 。 5)如果传入了一个正常的堆块地址,但是 size 是 0 ,那么就相当于 free() 。 6)如果申请失败,将返回 NULL ,此时,原来的指针仍然有效。

(4)free() 函数:

释放之前调用 calloc、malloc 或 realloc 所分配的内存空间。

1
void free(void *ptr)

参数 ptr :指针指向一个要释放内存的内存块。如果传递的参数是一个空指针,则不会执行任何动作,注意 free() 不会清除内存块的数据。 无返回值。

我准备了由下列代码编译的程序进行演示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main()
{
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
  setvbuf(stderr, 0, 2, 0);
  //设置缓冲区

  int *a = NULL, *b = NULL;
  a = (int *)ni(0x30);//验证 malloc()
  b = (int *)malloc(0x200);//隔离用,实际没有用
  printf("申请的地址为: %p",a);//验证 malloc() 返回值
    
  memset(a,'m',0x30);//
  free(a);//
  a = (int *)malloc(0x30); //验证 malloc() 特性 (雾)
  memset(a,'m',0x30);//
  free(a);
  a = calloc(1,0x30);// 验证 calloc() 特性

  free(a);
  free(b);//打扫现场
  a = (int *)malloc(0x100);
  b = (int *)malloc(0x200);
  memset(a,'m',0x100);
  memset(b,'m',0x200);
  b = realloc(b,0x256);//扩大但后面有空间
  a = realloc(a,0x512);//扩大但后面无空间
  b = realloc(b,0x200);//缩小但后面无空间
  a = realloc(a,0x486);//缩小但后面有空间
  int *c = NULL;
  c = realloc(c,0x114);//c 为 nullptr
  c = realloc(c,0);//释放

  return 0;
}

malloc() 演示: 我们直接打开 gdb开始调试,运行到第一个申请 0x30 大小堆块的 malloc() 。


运行这个命令,查看堆,使用heap命令,看到堆块已经建立,堆块地址为 0x602000 。


执行printf函数,输出参数为数据的起始位置(上文提到了和堆块起始位置的不同),验证了 malloc() 会返回参数这一事实,虽然没什么好验证的


**calloc() 演示:** 接下来我们用 m 填满申请的堆块。

看一下,已经被填好了。


free() 以后回来看,你会发现,填充的东西还在。逝想一下,如果有什么本该清除不该出现的东西留下了,我们是不是就可以…… 至于为什么有一块是空的,是因为它在释放后会进入 fastbin ,而它作为一个单链表结构需要保存一个指针指向前一个大小相同的 fastbin 堆块,看下文把。


当你经过 calloc() 函数,发现它和前面那个就是不一样,申请的内存被清空了!


**重点 —— realloc() 演示:** 现在让另外两个堆块表演!

也被填充满了m,现在让我们看扩大但后面有空间的情况:


当我们执行了第一个 realloc() 函数,第二个堆块成功的变大了,原来的内容被保留,因为后面还有空间,所以就直接占用 Top Chunk 扩大自身。



我们来看第一个堆块的这种扩大但后面无空间的情况,上一个堆块是因为后面还有 Top Chunk,而第一个堆块本身就是打头的,后面还有第二个堆块,显然不能简单的扩大,所以只能释放当前堆块,后面再申请一个新的,提醒一下,紫色的是刚才的第二个堆块,绿色的是新申请的。( 好玩的地方:被释放的堆块内存里仍然残留着 m )。



继续运行,我们来讨论一下堆块缩小的情况,第一种情况是紧邻这申请的堆块的堆块缩小,运行完这个函数,我们会发现缩小的堆块就在原地缩小了,地址不变,剩余部分被释放:


继续运行,刚才提到的堆块缩小有意思的情况来了,上文提到,Fast Bin 不轻易合并,但是当我们紧邻着 Top Chunk 呢?运行完这个函数,我们会发现缩小的堆块就在原地缩小了,但是我们等待的 Fast Bin 并没有出现,很明显是合并了,那么可以得出这里的释放剩余空间并不是简单的 free()


继续运行,当指针为空时 realloc() 就相当于变成了 malloc() ,申请了新的堆块,如图:



继续运行,当大小为空时该堆块释放,相当于 free() ,执行后所圈堆块被释放(看不到是被合并了)。



看完上面的内容,你估计也能看出来,堆的内存分配与操作是极其复杂的 接下来我们就来深入探讨一下吧:

使用 Hugo 构建
主题 StackJimmy 设计