堆简介

一、堆工作的原理

      Windows的堆时内存中一块神秘的地方、一个耐人寻味的地方,也是一个“乱糟糟”的地方。

       栈和堆的区别:经过对栈溢出利用的学习,我们应该明白栈空间是在程序设计时已经规定好怎么使用,使用多少内存空间的。典型的栈变量包括函数内部的普通变量、数组等。栈变量在使用时不需要额外申请空间,系统栈会根据函数中的变量声明自动在函数栈帧中给其预留空间。栈空间由系统维护,它的分配(如sub esp ,xx ;)和回收(如 add esp,
xxx)都由系统来完成,最终达到栈平衡。所有的这些对程序员来说都是透明的。

        堆呢?从程序员的角度来看,堆具备以下特性。

       (1) 堆是一种在程序运行时动态分配的内存。所谓动态是指所需内存的大小在程序设计时不能预先决定,需要在程序运行时参考用户的反馈。

       (2) 堆在使用时需要程序员用专用函数进行申请,如C 语言中的malloc 等函数、C++中的
new 函数等都是最常见的分配堆内存的函数。堆内存申请有可能成功,也有可能失败,这与申
请内存的大小、机器性能和当前运行环境有关。

        (3)  一般用一个堆指针来使用申请得到的内存,读、写、释放都通过这个指针来完成。

        (4)  使用完毕后需要把堆指针传给堆释放函数回收这片内存,否则会造成内存泄露。典型
的释放函数包括free、delete 等。

        栈只有pop 和push 两种操作,总是在“线性”变化,其管理机制也相对简单,所以,栈溢出的利用很容易掌握。与“整齐”的栈不同,堆往往显得“杂乱无章”,所以堆溢出的利用是内存利用技术的一个转折点。

                       

堆的数据结构和管理策略:

       操作系统一般会提供一套API 把复杂的堆管理机制屏蔽掉,一般无需了解堆分配的细节。然而,要理解堆溢出利用技术,就必须适当了解一些堆的知识。

        程序员在使用堆时只需要做三件事情:申请一定大小的内存,使用内存,释放内存。我们下面将站在实现一个堆管理机制的设计者角度,来看看怎样才能向程序员提供这样透明的操作。

       对于堆管理系统来说,响应程序的内存使用申请就意味着要在“杂乱”的堆区中“辨别”出哪些内存是正在被使用的,哪些内存是空闲的,并最终“寻找”到一片“恰当”的空闲内存区域,以指针形式返回给程序。

     (1)“杂乱”是指堆区经过反复的申请、释放操作之后,原本大片连续的空闲内存区可能呈现出大小不等且空闲块、占用块相间隔的凌乱状态。

     (2)“辨别”是指堆管理程序必须能够正确地识别哪些内存区域是正在被程序使用的占用块,哪些区域是可以返回给当前请求的空闲块。

     (3)“恰当”是指堆管理程序必须能够比较“经济”地分配空闲内存块。如果用户申请使用8 个字节,而返回给用户一片512 字节的连续内存区域并将其标记成占用状态,这将造成大量的内存浪费,以致出现明明有内存却无法满足申请请求的情况。

       堆块:

       出于性能的考虑,堆区的内存按不同大小组织成块,以堆块为单位进行标识,而不是传统的按字节标识。一个堆块包括两个部分:块首和块身。块首是一个堆块头部的几个字节,用来标识这个这个堆块自身的信息,例如,本块的大小、本块空闲还是占用等信息;块身是紧跟在块首后面的部分,也是最终分配给用户使用的数据区。

【注意】:堆管理系统所返回的指针一般指向块身的起始位置,在程序中是感觉不到块首的存在的。然而,连续地进行内存申请时,如果您能够细心,可能会发现返回的内存之间存在“空隙”,那就是块首。

        堆表:

        堆表一般位于堆区的起始位置用于索引堆区中所有堆块的重要信息,包括堆块的位置、堆块的大小、空闲还是占用等。堆表的数据结构决定了整个堆区的组织方式,是快速检索空闲块、保证堆分配效率的关键。现代操作系统的堆表往往不止一种结构。

        堆的内存组织如图:

              

     在Windows中,占用态的堆块被使用它的程序索引,而堆表只索引所有空闲态的堆块。其中,最重要的堆表有两种:空闲双向链表Freelist(以下简称空表,如图5.1.2 所示)和快速单向链表Lookaside(以下简称快表,如图5.1.3 所示)。

      1.空表

       空闲堆块的块首中包含一对重要的指针,这对指针用于将空闲堆块组织成双向链表。按照堆块的大小不同,空表总共被分为128条。

      堆区一开始的堆表区中有一个128项的指针数组,被称做空表索引(Freelist array)。该数组的每一项包括两个指针,用于标识一条空表。

      如图所示,空表索引的第二项(free[1])标识了堆中所有大小为8字节的空闲堆块,之后每个索引项指示的空闲堆块递增8字节,例如,free[2]标识大小为16字节的空闲堆块,free[3]标识大小为24字节的空闲堆块,free[127]标识大小为1016字节的空闲堆块。因此有:

                  空闲堆块的大小=索引项(ID)×8(字节)

         把空闲堆块按照大小的不同链入不同的空表,可以方便堆管理系统高效检索指定大小的空闲堆块。需要注意的是,空表索引的第一项(free[0])所标识的空表相对比较特殊。这条双向链表链入了所有大小等于1024字节的堆块(小于512KB)。这些堆块按照各自的大小在零号空表中升序地依次排列下去,您会在稍后发现这样组织的好处。

     

2.快表

       快表是Windows用来加速堆块分配采用的一种堆表。这里之所以把它叫做“快表”是因为这类单向链表中从来不会发生堆块合并(其中的空闲块块首被设置为占用态,用来防止堆块合并)。

       快表也有128条,组织结构与空表类似,只是其中的堆块按照单链表组织。快表总是被初始化为空,而且每条快表最多只有 4 个结点,故很快就会被填满。

        堆中的操作可以分为堆块分配堆块释放堆块合并(Coalesce)三种。其中,“分配”和“释放”是在程序提交申请和执行的,而堆块合并则是由堆管理系统自动完成的。

      1.堆块分配

       堆块分配可以分为三类:快表分配、普通空表分配和零号空表(free[0])分配。

       从快表中分配堆块比较简单,包括寻找到大小匹配的空闲堆块、将其状态修改为占用态、把它从堆表中“卸下”、最后返回一个指向堆块块身的指针给程序使用。

        普通空表分配时首先寻找最优的空闲块分配,若失败,则寻找次优的空闲块分配,即最小的能够满足要求的空闲块。

        普通空表分配时首先寻找最优的空闲块分配,若失败,则寻找次优的空闲块分配,即最小的能够满足要求的空闲块。

        零号空表中按照大小升序链着大小不同的空闲块,故在分配时先从free[0]反向查找最后一个块(即表中最大块),看能否满足要求,如果能满足要求,再正向搜索最小能够满足要求的空闲堆块进行分配(这就明白为什么零号空表要按照升序排列了)。

       堆块分配中的“找零钱”现象:当空表中无法找到匹配的“最优”堆块时,一个稍大些的块会被用于分配。这种次优分配发生时,会先从大块中按请求的大小精确地“割”出一块进行分配,然后给剩下的部分重新标注块首,链入空表。这里体现的就是堆管理系统的“节约”原则:买东西的时候用最合适的钞票,如果没有,就要找零钱,决不会玩大方。

        由于快表只有在精确匹配时才会分配,故不存在“找钱”现象。

        2.堆块释放

       释放堆块的操作包括将堆块状态改为空闲,链入相应的堆表。所有的释放块都链入堆表的末尾,分配的时候也先从堆表末尾拿。另外需要强调,快表最多只有4 项。

        3.堆块合并

        经过反复的申请与释放操作,堆区很可能变得“千疮百孔”,产生很多内存碎片。为了合理有效地利用内存,堆管理系统还要能够进行堆块合并操作,如图5.1.4 所示。

        当堆管理系统发现两个空闲堆块彼此相邻的时候,就会进行堆块合并操作。堆块合并包括将两个块从空闲链表中“卸下”、合并堆块、调整合并后大块的块首信息(如大小等)、将新块重新链入空闲链表。

        【题外话】:实际上,堆区还有一种操作叫做内存紧缩(shrink the compact),由RtlCompactHeap
执行,这个操作的效果与磁盘碎片整理差不多,会对整个堆进行调整,尽量合并可用
的碎片。

        在具体进行堆块分配和释放时,根据操作内存大小的不同,Windows 采取的策略也会有所不同。可以把内存块按照大小分为三类:

            小块:SIZE < 1KB

            大块:1KB≤SIZE<512KB

             巨块:SIZE≥512KB

              

     对应的分配和释放算法也有三类,我们可以通过表5-1-2 来理解Windows 的堆管理策略。

最后,再强调一下Windows 堆管理的几个要点。

(1)快表中的空闲块被设置为占用态,故不会发生堆块合并操作。

(2)快表只有精确匹配时才会分配,不存在“搜索次优解”和“找零钱”现象。

(3)快表是单链表,操作比双链表简单,插入删除都少用很多指令。

(4)综上所述,快表很“快”,故在分配和释放时总是优先使用快表,失败时才用空表。

(5)快表只有4 项,很容易被填满,因此空表也是被频繁使用的。

综上所述,Windows的堆管理策略兼顾了内存合理使用、分配效率等多方面的因素。

二、堆分配函数之间的调用关系

Windows平台下的堆管理架构可以用图5.2.1来概括。

       Windows 中提供了许多类型的堆分配函数,您可以在MSDN 中找到这些函数的详细说明。它们之间的关系如图5.2.2 所示。

       所有的堆分配函数最终都将使用位于ntdll.dll 中的RtlAllocateHeap()函数进行分配,这个函数也是在用户态能够看到的最底层的堆分配函数。所谓万变不离其宗,这个“宗”就是RtlAllocateHeap()。因此,研究Windows 堆只要研究这个函数即可。

                    

三、堆的调试方法

   想写出漂亮的堆溢出exploit,仅仅知道堆分配策略是远远不够的,我们需要对堆中的重要数据结构掌握到字节级别

   本小节将通过调试一段简单的程序,教会您调试堆的方法,并消除您对堆的神秘感,同时验证上节中所讲的部分堆管理策略。

   用于调试的代码:

#include <windows.h>
main()
{
HLOCAL h1,h2,h3,h4,h5,h6;  //HLOCAL跟void*一个意思,内存地址的句柄,其实就是指向内存的首地址
HANDLE hp;  //句柄
hp = HeapCreate(0,0x1000,0x10000);  //HeapCreate 创建堆
__asm int 3
/******************************************************************************************
HeapAlloc原型:
LPVOID HeapAlloc(
  HANDLE hHeap,   //要从中分配内存的堆句柄
  DWORD dwFlags,  //堆分配选项
  DWORD dwBytes  //要分配的字节数
);
******************************************************************************************/
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,3);  //第二个参数HEAP_ZERO_MEMORY => 指定将分配的内存初始化为零。否则,内存不会初始化为零。
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,5);
h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,6);
h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,19);
h6 = HeapAlloc(hp,HEAP_ZERO_MEMORY,24);
//free block and prevent coaleses
HeapFree(hp,0,h1); //free to freelist[2]  HeapFree释放内存堆
HeapFree(hp,0,h3); //free to freelist[2]
HeapFree(hp,0,h5); //free to freelist[4]
HeapFree(hp,0,h4); //coalese h3,h4,h5,link the large block to
//freelist[8]
return 0;
}

       调试堆与调试栈不同,不能直接用调试器Ollydbg、Windbg 来加载程序,否则堆管理函数会检测到当前进程处于调试状态,而使用调试态堆管理策略。

       调试态堆管理策略和常态堆管理策略有很大差异,集中体现在:

     (1)调试堆不使用快表,只用空表分配。

     (2)所有堆块都被加上了多余的16 字节尾部用来防止溢出(防止程序溢出而不是堆溢出攻击),这包括8 个字节的0xAB 和8 个字节的0x00。

      (3)块首的标志位不同。

        调试态的堆和常态堆的区别就好像debug 版本的PE 和release 版本的PE 一样。如果您做堆溢出实验,发现在调试器中能够正常执行shellcode,但单独运行程序却发生错误,那很可能就是因为调试堆和常态堆之间的差异造成的。

       为了避免程序检测出调试器而使用调试堆管理策略,我们可以在创建堆之后加入一个人工断点:_asm int 3,然后让程序单独执行。当程序把堆初始化完后,断点会中断程序,这时再用调试器attach 进程,就能看到真实的堆了。

       所有的堆块分配函数都需要指明堆区的句柄,然后在堆区内进行堆表修改等操作,最后完成分配工作。

     【注意】:malloc虽然在使用时不用程序员明确指出使用哪个堆区进行分配,但如果您逆向了malloc的实现,您会发现这是因为它已经使用HeapCreate()函数为自己创建了堆区。

       通常情况下,进程中会同时存在若干个堆区。其中包括开始于0x00130000的大小为0x4000的进程堆,可以通过GetProcessHeap()函数获得这个堆的句柄并使用;另外,我们熟悉的内存分配函数malloc()也有属于自己的堆区,大多数情况下(本例中为0x00410000),这是一个紧接着PE镜像处0x00430000 的大小为0x8000 字节的堆。单击Ollydbg 中的“M”按钮,可以得到当前的内存映射状态,如图5.2.5 所示。

 

三、识别堆表

占用态堆块的数据结构:

               

空闲态堆块的数据结构:

       空闲态堆块和占用态堆块的块首结构基本一致,只是将块首后数据区的前8 个字节用于存放空表指针了,如图5.2.9 所示。这8 个字节在变回占用态时将重新分回块身用于存放数据。

四、堆块的分配

经过调试,对于堆块的分配我们应该了解以下细节。

(1)堆块的大小包括了块首在内,即如果请求32 字节,实际会分配的堆块为40 字节:8字节块首+32 字节块身。

(2)堆块的单位是8 字节,不足8 字节的部分按8 字节分配。

(3)初始状态下,快表和空表都为空,不存在精确分配。请求将使用“次优块”进行分配。这个“次优块”就是位于偏移0x0688 处的尾块。

 (4)由于次优分配的发生,分配函数会陆续从尾块中切走一些小块,并修改尾块块首中的size 信息,最后把freelist[0]指向新的尾块位置。

五、堆溢出利用

学习参考:《0day安全软件漏洞第二版》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值