微软操作系统堆管理机制的发展大致可以分为三个阶段。
《0day》主要讨论 Windows 2000~Windows XP SP1 平台的堆管理策略。
程序在执行时,栈和堆两种不同类型的内存协同配合。
栈内存 | 堆内存 | |
---|---|---|
典型用例 | 函数局部数组 | 动态增长的链表等数据结构 |
申请方式 | 在程序中直接声明,sub esp,xx; |
需要用函数申请,通过返回的指针使用。 如 malloc() 和new |
释放方式 | 函数返回时,由系统自动回收,add esp,xx; 有可能失败 |
需要把指针传给专用的释放函数, .如 free() 和delete ,否则会造成内存泄露 |
管理方式 | 申请后直接使用,申请与释放由系统 自动完成,最后达到栈区平衡 |
需要程序员处理申请与释放 |
所处位置 | 变化范围很大 0x0012XXXX | |
增长方向 | 高–>低 | 低–>高(不考虑碎片等情况) |
存储内容 | 数据+控制流信息 | 数据 |
底层 | 硬件直接支持 | 操作系统库函数 |
栈只有 pop 和 push 两种操作,总是在“线性”变化,其管理机制也相对简单;堆往往显得“杂乱无章”,堆溢出不容易掌握。
操作系统一般会提供一套API
把复杂的堆管理机制屏蔽掉,这里先从宏观上介绍一下堆管理机制的原理。
程序员使用堆只需要做三件事情:
堆管理系统要响应程序的内存使用申请就意味着要在杂乱的堆区中辨别出哪些内存是正在被使用的,哪些内存是空闲的,并最终“寻找”到一片恰当的空闲内存区域,以指针形式返回给程序。
现代操作系统的堆数据结构一般包括堆块和堆表两类。
graph LR
a[堆数据结构]-->b[堆块]
a-->c[堆表]
b-->d[块首]
b-->e[块身]
c-->f[空表]
c-->g[快表]
堆区的内存,以堆块为单位进行标识,而不是传统的按字节标识。
一个堆块包括两个部分:
堆管理系统所返回的指针一般指向块身的起始位置,在程序中是感觉不到块首的存在的。连续地进行内存申请时,可能会发现返回的内存之间存在“空隙”,那就是块首。
块首的具体内容可在[这里](#5.2.3 识别堆表 )查看。
堆表一般位于堆区的起始位置,用于索引堆区中所有堆块的重要信息,包括堆块的位置、堆块的大小、空闲还是占用等。
堆表的数据结构决定了整个堆区的组织方式,是快速检索空闲块、保证堆分配效率的关键。
堆表可能会考虑采用平衡二叉树等高级数据结构用于优化查找效率。现代操作系统的堆表往往不止一种数据结构。
在 Windows 中,占用态的堆块被使用它的程序索引,而堆表只索引所有空闲态的堆块。
最重要的堆表有两种:
Freelist
,简称空表Lookaside
,简称快表空闲堆块的块首中包含一对重要的指针,这对指针用于将空闲堆块组织成双向链表。
按照堆块的大小不同,空表总共被分为 128 条。
堆区一开始的堆表区中有一个 128 项的指针数组,被称做空表索引Freelist array
。该数组的每一项包括两个指针,用于标识一条空表。
0号空表链入了所有大于等于1024
字节,小于 512KB
的堆块,并且升序地依次排列下去。
1-127项指示的空闲堆块都按照索引递增8字节。
空 闲 堆 块 的 大 小 = 索 引 项 ( I D ) × 8 ( 字 节 ) 空闲堆块的大小=索引项(ID)×8(字节) 空闲堆块的大小=索引项(ID)×8(字节)
快表是 Windows 用来加速堆块分配而采用的一种堆表。
之所以把它叫做“快表”是因为这类单向链表中从来不会发生堆块合并.
空闲块块首被设置为占用态,用来防止堆块合并
快表也有 128 条,组织结构与空表类似,只是其中的堆块按照单链表组织。
快表总是被初始化为空,而且每条快表最多只有 4 个结点,故很快就会被填满。
堆中的操作有3种:
Coalesce
分配和释放是在程序提交申请和执行的,而堆块合并则是由堆管理系统自动完成的。
堆块分配可以分为三类:
快表分配
普通空表分配:
零号空表分配
从快表中分配堆块:
普通空表分配,寻找最优的空闲块分配,若失败,则寻找次优的空闲块分配。总之就是寻找最小的能够满足要求的空闲块。
零号空表中按照大小升序链着大小不同的空闲块,故在分配时先从free[0]
反向查找最后一个块(即表中最大块),,如果能满足要求,再正向搜索最小能够满足要求的空闲堆块进行分配。
空表分配存在“找零钱”现象,次优分配发生时,会先从大块中按请求的大小精确地“割”出一块进行分配,然后给剩下的部分重新标注块首,链入空表。这就是堆管理系统的“节约”原则。
于快表只有在精确匹配时才会分配,故不存在“找钱”现象。
这里暂不讨论堆缓存heap cache
、低碎片堆LFH
和虚分配。
释放堆块的操作:
所有的释放块都链入堆表的末尾,分配的时候也先从堆表末尾拿。
再次强调,快表最多只有 4 项。
在堆块分配和释放时,根据操作内存大小的不同,Windows 采取的策略也会有所不同。可以把内存块按照大小分为3类:
SIZE < 1KB
1KB ≤ SIZE < 512KB
SIZE ≥ 512KB
对应的分配和释放算法也有三类,我们可以通过下表来理解 Windows 的堆管理策略。
分 配 | 释 放 | |
---|---|---|
小块 | 分配要考虑的优先级: 快表–>普通空表–>堆缓存–>0号空表–>内存紧缩后再次尝试 若无法分配,则返回 NULL 。 |
优先链入快表(只能链入4个空闲块); 如果快表满,则将其链入相应的空表. |
大块 | 优先级: 堆缓存–>0号空表 |
优先将其放入堆缓存 若堆缓存满,将链入0号空表 |
巨块 | 巨块申请非常罕见,要用到虚分配方法(实际上并不是从堆区分配的)。暂不讨论。 | 直接释放,没有堆表操作 |
经过反复的申请与释放操作,堆区很可能产生很多内存碎片。为了合理有效地利用内存,堆管理系统还要能够进行堆块合并操作。
当堆管理系统发现两个空闲堆块彼此相邻的时候,就会进行堆块合并操作。
堆块合并过程:
堆区还有一种操作叫做内存紧缩shrink the compact
,由RtlCompactHeap执行,这个操作的效果与磁盘碎片整理差不多,会对整个堆进行调整,尽量合并可用的碎片。
总结调一下 Windows 堆管理的几个要点:
Windows 的堆管理策略兼顾了内存合理使用、分配效率等多方面的因素。
Windows的堆分配函数可以在MSDN中找到详细说明。它们之间的关系如下图所示。
所有的堆分配函数最终都将使用位于ntdll.dll
中的RtlAllocateHeap()
函数进行分配,这个函
数也是在用户态能够看到的最底层的堆分配函数。研究Windows堆只要研究这个函数即可。
想写出堆溢出exploit
,需要对堆中的重要数据结构掌握到字节级别。
调试堆与调试栈不同,不能直接用调试器 Ollydbg、Windbg 来加载程序,否则堆管理函数会检测到当前进程处于调试状态,而使用调试态堆管理策略。
调试态堆管理策略:
0xAB
和 8 个字节的0x00
;调试态的堆和常态堆就好像 debug 版本的 PE 和 release 版本的 PE 一样。做堆溢出实验如果发现在调试器中能够正常执行 shellcode,但单独运行程序却发生错误,那很可能就是因为调试堆和常态堆之间的差异造成的。
为了避免程序检测出调试器而使用调试堆管理策略,我们可以在创建堆之后加入一个人工断点:_asm int 3
,然后让程序单独执行。当程序把堆初始化完后,断点会中断程序,这时再用调试器 attach 进程,就能看到真实的堆了。
#include
main()
{
HLOCAL h1, h2, h3, h4, h5, h6;
HANDLE hp;
hp = HeapCreate(0, 0x1000, 0x10000);
__asm int 3
h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 3);
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(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;
}
当HeapCreate()
成功地创建了堆区之后,会把整个堆区的起始地址返回给EAX
,堆表中包含的信息依次是:
当一个堆刚刚被初始化时,它的堆块状况是非常简单的:
0x0688
处(启用快表后这个位置将是快表)Freelist[0]
指向“尾块”先介绍一下堆块的块首中数据的含义。
占用态堆块的结构如下图
self size
计算单位是 8 个字节,即0x0130
表示堆块大小为0x980
字节。
该大小包含块首在内,如果请求 32 字节,实际会分配的堆块为 40 字节。
按照堆表数据结构的规定,指向快表的指针位于偏移 0x584 字节处,在本章所有的实验中,这个指针均为
NULL
。因为只有堆是可扩展的时候快表才会启用,要想启用快表我们在创建堆的时候就不能使用
HeapCreate (0,0x1000,0x10000)
来创建堆了,而要使用HeapCreate(0,0,0
创建一个可扩展的堆。
空闲态堆块和占用态堆块的块首结构基本一致,只是数据区的前 8 个字节移到块首用于存放空表指针了,下面是我从原书修改过的图。
堆块的单位是 8 字节,不足 8 字节的部分按 8 字节分配。
初始状态下,快表和空表都为空,不存在精确分配。请求将使用“次优块”进行分配。这个“次优块”就是位于偏移 0x0688 处的尾块。
由于次优分配的发生,分配函数会陆续从尾块中切走一些小块,并修改尾块块首中的size
信息,最后把freelist[0]
指向新的尾块位置。
上面的测试代码内存请求分配情况如下。
堆句柄 | 请求字节数 | 实际分配(堆单位) | 实际分配(字节) |
---|---|---|---|
h1 | 3 | 2 | 16 |
h2 | 5 | 2 | 16 |
h3 | 6 | 2 | 16 |
h4 | 8 | 2 | 16 |
h5 | 19 | 4 | 32 |
h6 | 24 | 4 | 32 |
“找零钱”现象会使得尾块块首的size
减小2+2+2+2+4+4=0x10
,并且flink in freelist
,即freelist[0]
,它的空表指针会指向新尾块的块身位置。
由于前三次释放的堆块在内存中不连续,因此不会发生合并。按照其大小,h1
和h3
所指向的堆块应该被链入freelist[2]
的空表,h5
则被链入freelist[4]
。
可以到flink in freelist
查看freelist
释放h4
后,h3、h4、h5
这 3 个空闲块彼此相邻,这时会发生按照步骤进行堆块合并操作。
它们的大小一共2+2+4=8
,所以会将被链入freelist[8]
合并只修改了块首的数据,原块的块身基本没有发生变化。注意合并后的新块大小已经被修改为0x0008
,其空表指针指向freelist[8]
。
freelist[2]
现在只剩下h1
;freelist[4]
现在指向自身;freelist[8]
原来指向自身,现在则指向合并后的新空闲块。
#include
#include
void main()
{
HLOCAL h1,h2,h3,h4;
HANDLE hp;
hp = HeapCreate(0,0,0); //extensible heap
__asm int 3
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,24);
HeapFree(hp,0,h1);
HeapFree(hp,0,h2);
HeapFree(hp,0,h3);
HeapFree(hp,0,h4);
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
HeapFree(hp,0,h2);
}
使用快表之后堆结构也会发生一些变化,其中最为主要的变化是空表使用标识的“尾块”不再位于堆0x0688
偏移处了,这个位置被快表霸占。
堆刚初始化后快表是空的,这也是为什么代码中我们要反复的申请释放空间。首先我们从FreeList[0]
中依次申请8、16、24 个字节的空间,然后再通过HeapFree()
操作将其释放到快表中。
快表未满时优先释放到快表中
8字节的会被插入到Lookaside[1]
中、16 字节的会被插入到Lookaside[2]
中、24 字节的会被插入到Lokkaside[3]
中。
快表中的堆块与空表中的堆块有着两个明显的区别:
0x01
,也就是这个堆块是 Busy 状态,这也是为什么快表中的堆块不进行合并操作的原因.此时如果我们再申请 8、16 或 24 字节大小空间时系统会从快表中给我们分配,同时修改Lookaside[i]
表头。
堆管理系统的三类操作都是对链表的修改。
如果我们能伪造链表结点的指针,在“卸下”和“链入”堆块的过程中就有可能获得一次读写内存的机会。
堆溢出利用的精髓就是用精心构造的数据去溢出下一个堆块的块首,改写块首中的前向指针flink
和后向指针blink
,然后在分配、释放、合并等操作发生时伺机获得一次向内存任意地址写入任意数据的机会。
我们把这种能够向内存任意位置写入任意数据的机会称为DWORD SHOOT
或arbitrary DWORD reset
。
int remove (ListNode * node)
{
node -> blink -> flink = node -> flink;
node -> flink -> blink = node -> blink;
return 0;
}
当堆溢出发生时,非法数据可以淹没下一个堆块的块首。这时,块首是可以被攻击者控制的,即块首中存放的前向指针flink
和后向指针blink
是可以被攻击者伪造的。当这个堆块被从双向链表中“卸下”时,node -> blink -> flink = node -> flink
将把伪造的flink
指针值写入伪造的blink
所指的地址中去,从而发生DWORD SHOOT
。
#include
main()
{
HLOCAL h1, h2,h3,h4,h5,h6;
HANDLE hp;
hp = HeapCreate(0,0x1000,0x10000);
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
h6 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
_asm int 3//used to break the process
//free the odd blocks to prevent coalesing
HeapFree(hp,0,h1);
HeapFree(hp,0,h3);
HeapFree(hp,0,h5); //now freelist[2] got 3 entries
//will allocate from freelist[2] which means unlink the last entry
//(h5)
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
return 0;
}
程序步骤:
0x1000
的堆区,并从其中连续申请了 6 个大小为 8 字节的堆块(实际上是 16 字节),除了freelist[0]
和freelist[2]
之外,所有的空表索引都为空。再次强调,初始状态下,快表和空表都为空。freelist[2]
所标识的空表中应该链入了 3 个空闲堆块;h5
被从freelist[2]
拆下;h5
块首中的指针,应该能够观察到DWORD SHOOT
的发生。把h5
前向指针改为0x44444444
,后向指针改为0x00000000
,当最后一个分配函数被调用后,调试器被异常中断,原因是无法将0x44444444
写入0x00000000
。
与栈溢出中的“地毯式轰炸”不同,堆溢出更加精准,往往直接狙击重要目标。精准是DWORD SHOOT
的优点,但“火力不足”有时也会限制堆溢出的利用,这样就需要选择最重要的目标用来“狙击”。
DWORD SHOOT
的常用目标(Windows XP SP1 之前的平台)大概可以概括为以下几类:
P.E.B
中线程同步函数的入口地址:下一部分介绍从这里开始就有点懵了?
多线程有一些同步机制,如锁机制lock
、信号量semaphore
、临界区critical section
等。ExitProcess()
函数要做很多善后工作,其中必然需要用到临界区函数RtlEnterCriticalSection()
和RtlLeaveCriticalSection()
来同步线程防止“脏数据”的产生。
每个进程的P.E.B
中都存放着一对同步函数指针,指向RtlEnterCriticalSection()
和RtlLeaveCriticalSection()
,并且在进程退出时会被ExitProcess()
调用。如果能够通过DWORD SHOOT
修改这对指针中的其中一个,那么在程序退出时ExitProcess()
将会被骗去调用我们的shellcode
。
ExitProcess()
调用临界区函数,是通过进程环境块P.E.B
中偏移0x20
处存放的函数指针来间接完成的。例如,0x7FFDF020
存放指向 RtlEnterCriticalSection()
的指针, 0x7FFDF024
处存放着指向RtlLeaveCriticalSection()
的指针。
从 Windows 2003 Server 开始,微软已经修改了这里的实现。
如果把0x7FFDF020
修改为shellcode
的位置,ExitProcess()
在结束进程时需要调用临界区函数来同步线程,但却从 P.E.B
中拿出了指向shellcode
的指针,因此shellcode
被执行。
缓冲区布置如下:
0x90
覆盖该块P.E.B
中的函数指针地址0x7FFDF020
但是,被我们修改的P.E.B
里的函数指针不光会被ExitProcess()
调用,当shellcode
的函数使用临界区时,会像 ExitProcess()
一样被骗。所以,对shellcode
稍加修改,在一开始就把我们DWORD SHOOT
的指针用汇编指令对应的机器码修复回去,以防出错。
P.E.B 中存放
RtlEnterCriticalSection()
函数指针的位置0x7FFDF020
是固定的,但是,RtlEnterCriticalSection()
的地址也就是这个指针的值0x77F8AA4C
有可能会因为补丁和操作系统而不一样.
没有经验的初学者在做堆溢出实验时往往会被误导去研究调试态的堆。
书中使用了int 3
中断指令,在堆分配之后暂停程序,然后attach
进程的方法。,但大多数时候我们是无法修改源码的。另一种办法是直接修改用于检测调试器的函数的返回值,这种方法在调试异常处理机制时会经常用到,第 6 章会举例介绍。
在劫持进程后需要立刻修复P.E.B
中的函数指针,否则会引起很多其他异常。一般说来,在大多数堆溢出中都需要做一些修复环境的工作。
shellcode
中的第一条指令CDF
也是用来修复环境的。如果您把这条指令去掉,会发现shellcode
自身发生内存读写异常。这是因为在ExitProcess()
调用时,这种特殊的上下文会把通常状态为 0 的 DF
标志位修改为 1。这会导致 shellcode
中LODS DWORD PTR DS:[ESI]
指令在向 EAX 装入第一个hash
后将ESI
减 4,而不是通常的加 4,从而在下一个函数名 hash
读取时发生错误。
有时还需要修复被我们折腾得乱七八糟的堆区,比较简单修复堆区的做法包括如下步骤:
0x28
的地方存放着堆区所有空闲块的总和TotalFreeSize
self size
修改成堆区空闲块总容量的大小TotalFreeSize
。freelist[0]
的前向指针和后向指针都指向这个堆块。双向链表拆除时的第二次链表操作node -> flink -> blink = node -> blink
也能导致DWORD SHOOT
。这次,DWORD SHOOT
将把目标地址写回shellcode
起始位置偏移 4 个字节的地方。
类似这样的第二次DWORD SHOOT
称为“指针反射”。
糟糕的是,它会把4个字节的目标地址刚好写进shellcode
中;幸运的是,很多情况下这4个字节不会影响shellcode
的效果。
若这4个字节影响最终效果,可以使用别的目标,或者使用跳板技术