栈是分配局部变量和存储函数调用参数的主要场所,系统在创建每个线程时会自动为其创建栈。对于C/C++这样的编程语言,编译器在编译阶段会生成合适的代码来从栈上分配和释放空间,不需要编写任何的代码。但是,栈空间(尤其内核态)的容量相对较小,为了防止溢出,不适合在栈上分配特别大的内存区。其次,由于栈帧通常是随着函数的调用和返回而创建和消除的,因此分配在栈上的变量只在函数内有效,这使栈只适合分配局部变量,不适合分配需要较长生存期的全局变量和对象。
堆克服了栈的以上局限性,是程序申请和使用内存空间的另一种途径。应用程序通过内存分配函数(malloc或HeapAlloc)或new操作符获得的内存空间都来自堆。从操作系统角度看,堆是系统的内存管理功能向应用软件提供服务的一种方式。通过堆,内存管理器将一块较大内存委托给堆管理器来管理。堆管理器将大块的内存分割成不同大小的很多个小块来满足应用程序的需要。应用程序的内存需求通常是频繁而且零散的,如果把这些请求都直接传递给位于内核中的内存管理器,那么必然会影响系统的性能。有了堆管理器,内存管理器就只需要处理大规模的分配请求。这样做不仅可以减轻内存管理器的负担,而且可以大大缩短应用程序申请内存分配所需的时间,提高程序的运行速度。从这个意义上说,堆管理器就好像经营内存零售业务的中间商,它从内存管理器那里批发大块内存,然后零售给应用程序的各个模块。
总结:
一个应用程序在执行过程中,会经常需要申请一块内存来使用。如果每次都进入到内核中,通过内存管理器来分配内存,就会给系统带来比较大的开销。为了减少这种开销,Windows系统提供了堆管理器。堆管理器会先进入内核中,申请一块比较大的内存存放在用户空间中,以供应用程序使用。当应用程序分配内存的时候,首先会检查要分配的内存大小是否符合要求(不要太大)。如果符合要求,就会从堆管理器中已经分配好的较大的内存中取出一块较小的内存块供程序使用。这样,应用程序申请小内存块的时候,就不需要频繁地进入到内核中去申请,降低了性能开销。如果应用程序申请的内存比较大,那么就会进入内核,通过内存管理器来分配内存
Windows系统在创建一个新的进程时,在加载器函数执行进程的用户态初始化阶段,会调用RtlCreateHeap函数为新的进程创建第一个堆,称为进程的默认堆,有时候简称进程堆。PEB中的以下字段用来描述进程堆的信息:
Windows提供以下的函数来获得进程堆句柄,该函数只是简单地找到PEB结构,然后读出ProcessHeap字段的值。
除了系统为每个进程创建的默认堆,应用程序也可以通过以下函数来创建其他堆,这样的堆只能被发起调用的进程访问,通常称为私有堆。
该参数可以是如下标志中的0个或多个:
HEAP_GENERATE_EXCEPTIONS(0x00000004),通过异常来报告失败情况,如果没有该标志则通过返回NULL报告错误
HEAP_CREATE_ENABLE_EXECUTE(0X00040000),允许执行堆中内存块上的代码
HEAP_NO_SERIALIZE(0x00000001),当堆函数访问这个堆时,不需要进行串行化控制(加锁)。指定这一标志可以提高堆操作函数的速度,但应该在确保不会有多个线程操作同一个堆时才这样做,通常在将某个堆分配给某个线程专用时这么做。也可以在每次调用堆函数时指定该标志,告诉堆管理器u需要堆那次调用进行串行化控制
HeapCreate内部主要调用RtlCreateHeap函数,因此私有堆和默认堆并没有本质的差异,只是创建的用途不同。RtlCreateHeap内部会调用ZwAllocateMemory系统服务从内存管理器申请内存空间,初始化用于维护堆的数据结构,最后将堆句柄记录到进程的PEB结构中,确切地说是PEB结构地堆列表中。
每个进程的PEB结构以列表的形式记录了当前进程的所有堆句柄,包括进程的默认堆。以下是PEB结构中,用来记录这些堆句柄的字段:
和其他的句柄不同的是,堆句柄的值实际上就是这个堆的起始地址。和其他函数创建的对象保存在内核空间中不同,应用程序创建的堆是在用户空间保存的,因此应用程序可以直接通过该地址来操作堆,而不用担心操作失误造成蓝屏错误。所以,此时的句柄值就可以直接是堆的起始地址
应用程序可以调用以下函数来销毁进程的私有堆。该函数内部主要调用NTDLL中的RtlDestoryHeap函数。后者会从PEB的堆列表中将要销毁的堆句柄移除,然后调用NtFreeVirtualMemory向内存管理器归还内存
当应用程序调用堆管理器的分配函数向堆管理器申请内存时,堆管理器会从自己维护的内存区中分割除一个满足用户指定大小的内存块,然后把这个块中允许用户访问部分的起始地址返回给应用程序,堆管理器把这样的块叫一个Chunk,也就是"堆块"。应用程序用完一个堆块后,应该调用堆管理器的释放函数归还堆块。
在Windows系统中,从堆中分配空间的最直接方法就是调用HeapAlloc函数,不过该函数只是RtlAllocateHeap函数的别名
RtlAllocateHeap函数定义如下:
该标志位可以是以下的组合:
HEAP_GENERATE_EXCEPTIONS(0x00000004):使用异常来报告失败情况,如果没有此标志,则使用NULL返回值来报告错误。异常代码可能是STATUS_ACCESS_VIOLATION或STATUS_NO_MEMORY
HEAP_ZERO_MEMORY(0x00000008):将所分配的内存区初始化为0
HEAP_NO_SERIALIZE(0x00000001):不需要堆盖茨分配实施串行控制(加锁)。如果希望堆该堆的所有分配调用都不需要串行化控制,那么可以创建堆时指定HEAP_NO_SEROA;OZE选项。对于进程堆,调用HeapAlloc时用于不应该指定HEAP_NO_SERIALIZE标志,因为系统代码可能随时会调用堆函数访问进程堆
可以使用HeapReAlloc来改变一个堆中分配的内存块的大小,该函数是RtlReAlloc函数的别名
HeapReAlloc函数定义如下:
通过HeapFree释放堆块,该函数是RtlFreeHeap的别名
RtlFreeHeap函数定义如下:
Windows通过HEAP结构来记录和维护堆的管理信息,该结构位于堆的开始处。当使用HeapCreate函数创建堆时,该函数返回的堆句柄,也就是创建的堆的地址,首先保存的就是该结构。HEAP结构的定义如下:
其中偏移0x178的FreeLists是一个包含128个元素的双向链表数组,用来记录堆中空闲堆块链表的表头。当有新的分配请求时,堆管理器会遍历这个链表寻找可以满足请求大小的最接近堆块。如果找到了,便将这个块分配出去;否则,便要考虑为这次请求提交新的内存页和简历新的堆块。当释放一个堆块的时候,除非这个堆块满足解除提交的条件,要直接释放给内存管理器,大多数情况下对其修改属性并加入空闲链表中。
FreeLists双向链表结构如下图所示,可以看到,从一号链表(FreeLists[1])开始,链表所指向的堆块的大小从8开始,随着索引的增加,每次增加八个字节,最大的堆块的1016个字节。大于等于1024字节的堆块,则按顺序从小到大链入零号链表(FreeLists[0])中。
这些链表所指向的堆块面临着被频繁使用的处境,因此,需要有相关的字段来标识它们的状态。Windows使用八字节的HEAP_ENTRY结构来标识这些堆块的状态,该结构定义如下:
其中的Flags字段代表了堆块的状态,其值由以下这些标志的组合:
HEAP_ENTRY_BUSY(0x1):该块处于占用状态
HEAP_ENTRY_EXTRA_PRESENT(0x2):这个块存在额外描述
HEAP_ENTRY_FILL_PATTERN(0x4):使用固定模式填充堆块
HEAP_ENTRY_VIRTUAL_ALLOC(0x8):虚拟分配
HEAP_ENTRY_LAST_ENTRY(0x10):该段的最后一个块
HEAP_ENTRY结构位于每个堆块的起始,用于描述相应堆块的状态,紧随该结构之后保存的就是堆块的用户数据。所以,上面的FreeLists双向链表所指向的堆块的大小,其实还要算上8字节的HEAP_ENTRY,也就是说FreeLists[1]所指堆块的用户可用数据的大小为0,FreeLists[2]所指堆块的用户可用数据才是8。
函数HeapAlloc获取的堆块用户数据的地址,将其减去8得到的就是该堆块的HEAP_ENTRY结构的地址。
因为当某个堆块处于释放的状态的时候,需要把这个堆块链接到FreeLists数组中的某一个链表中,所以堆块中还应该有一个双向链表用来连接。该链表其实就保存在HEAP_ENTRY结构随后的八个字节中,也就是说,当堆块处于未被使用的状态的时候,此时位于堆块其实的结构其实是HEAP_FREE_ENTRY,该结构的定义如下:
该结构就是在HEAP_ENTRY结构后面增加一个双向链表,该链表用于连接到FreeLists数组中,以供程序使用。这也就能解释,为什么FreeLists数组里面连接的链表的堆块大小至少是8个字节,因为当这个堆块不用的时候,它需要八个字节来连入FreeLists数组中去。
要理解该漏洞,需要对堆分配的过程有个理解,考虑以下的代码:
这里从堆中申请了3块八字节的堆块,然后释放第二个堆块,让它连接进FreeLists[1]中,然后再从FreeLists[1]中将其取出。之所以要这样,是为了防止堆块合并。堆块合并的原理是当你释放该堆块的时候,它会通过Size和PreviousSize来找到它前一个堆块和后一个堆块,再查看找到的堆块的Flags是否处于占用状态,如果不是占用状态,就会发生堆块合并。而此时在释放h2的时候,因此h1和h3都在被使用,所以它检查的时候不会发生堆块的合并。
由于处于调试模式下的堆机制有不同的表现,所以这里通过暂停程序的方式调试程序。当程序处于暂停状态的时候,在将调试器附加到进程上。在HeapCreate函数后下断点,此时eax保存的就是创建的堆句柄,也就是堆地址,该地址偏移0x178处保存的就是FreeLists双向链表数组。此时除了FreeLists[0],也就是零号链表以外的链表都指向了自己,所以刚创建的堆,只有一个大的堆块挂在了0号链表中。
而零号链表保存的地址是堆句柄偏移0x688处了,该地址处保存的FreeList链表连接的位置就是FreeLists[0]的0x003C0178,而它前面的八个字节,则说明了这个堆块的信息,这个时候第五个字节是0x10,没有HEAP_ENTRY_BUSY(0x1)标志,表明了该堆块是空闲堆块,而堆块大小Size为0x130。
第一次调用HeapAlloc的时候,eax的保存的堆块地址是0x003C0688,也就是FreeLists[0]所指的大堆块,此时偏移0x688处的堆块被分配出来初始化为0,而堆块的大小(Size)变成了0x02,第五个的Flags变成的0x01,也就表明该堆块此时处于被占用状态。
大的堆块此时已经被分出了8字节,再算上描述两个堆块信息的HEAP_ENTRY结构,剩余的堆块就保存在偏移0x698处。此时该堆块链接进了0号链表中,而堆块的属性中的Size,也从0x130变成了0x12E(减0x2),PreviousSize则是0x2,属性为0x10表明处于空闲状态。
0号链表此时连接的也正是剩余的堆块的地址
余下两次HeapAlloc函数的过程和上述类似,最后大堆块会被切出三个小堆块,此时这三个块的Flags都是0x01,处于被占用状态。
当执行HeapFree的时候,第二个块就会被释放出来,此时第二个块的Flags会变成0x00的空闲状态,但是因为相邻的两个块都处于占用状态,因此不会发生堆块的合并。那么这个八字节大的堆块就会被链接到FreeLists[2]中,该链表的偏移就是0x188
此时也可以看到FreeLists[2]链表所指的地址就是第二个堆块的可用数据地址
此时再次调用HeapAlloc就会将刚才释放的第二个堆块分配出来,第二个堆块的属性和数据再次变成下图所示
而FreeLists[2]链表再次链接到自己,因此可以知道这一次的分配就不是从0号链表,也就是FreeLists[0]中所指的大的堆块中切出一小块使用。而是直接将挂入到FreeLists[2]链表中的堆块取出使用。
从FreeLists[2]链表中取出堆块就需要用到第二个堆块中的链表结构,也就是_HEAP_FREE_ENTRY中的FreeList。可是,此时第二个堆块是紧跟在第一个堆块后面的,如果用户输入到第一个堆块的数据长度过长,就会导致数据淹没到第二个堆块中,修改了堆块的FreeList,这样在分配该堆块的时候就会因为链表所指的地址是非法的,导致程序出现错误。
在上述代码第二次申请堆块前加入如下的代码:
再次编译运行,在memcpy函数处下断点,可以看到此时第二个堆块的保存是正常的,它的链表指向了FreeLists[2]
而执行完memcpy之后,可以看到第二个堆块的数据被淹没了,包括FreeList链表也被淹没成了0x41
此时调用HeapAlloc申请第二个堆块的时候,就会因为链表被淹没成了0x41导致程序崩溃
想要利用这个漏洞还需要明白程序是怎么将堆块从FreeLists数组链表中摘除的,在RtlAllocateHeap中,首先会要申请的堆大小进行更改
其中HEAP_MIN_DATA_SIZE被定义为16
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2022-1-21 17:14
被1900编辑
,原因: