堆溢出学习笔记(Win2K) (上)

  • Title(EN): Heap Overflow Learning Notes(Win2K) #1
  • Author: dog2

最近在啃《0day安全:软件漏洞分析技术》(第二版)一书,打算入门二进制漏洞分析。书中第五章“堆溢出利用”较前几章难度有所增大,原因在于堆结构较之前学习的栈更复杂,第五章中的例子涵盖了堆的分布、堆块分配和释放、堆溢出利用,利用堆溢出进行攻击的例子是通过修改P.E.B(进程环境块)中指向RtlEnterCriticalSection()函数的指针,该函数在程序退出时被调用。在调试完所有例子以后,对堆的内部细节和堆溢出利用终于有了些许的理解。

第六章中也涉及到堆溢出利用,不过此处是利用Windows异常处理机制S.E.H(异常处理结构体)来实现攻击的,看完后打算调试一遍,发现几天前通过调试建立的对内存中堆分布和操作的理解都忘得差不多了,这时候终于体会到 学习过程中根据自己的体会做一些重要笔记并且据此定期复习的重要性,因此有了本篇。

希望这是个好的开头,提醒自己谨记对于复杂的重难点,要以日志的形式形成学习笔记,以供日后温故知新。

关于堆以及P.E.B、S.E.H等机制的介绍,0day书中已经非常系统详尽,本文不再赘述,这里仅给出我的实验调试过程,记录其中踩的坑以及一些体会,因此以过程截图为主,相关简述为辅。第六章中堆的例子作者并没有给出相关代码及过程细节,这里也会附上。由于初学,文中难免存在错误,欢迎指正。

0x00 准备工作

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

调试态对管理策略和常态堆管理策略有很大差异。

王清《0day安全:软件漏洞分析技术》(第二版) -> P153

因此需要防止程序运行后进入调试态,按照书中所述以如下方式设置:

  • 设置OllyDBG为默认调试器:OllyDBG -> Options菜单 -> Just-in-time debugging
  • 设置OllyDBG 不捕获INT3中断:OllyDBG -> Options菜单 -> Debugging options

0x01 初识堆


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

堆的操作中可以分为堆块分配、堆块释放和堆块合并(Coalesce)三种。其中,“分配”和“释放”是在程序_(即在用户代码中)_提交申请和执行的,而堆块合并则是有对管理系统自己完成的。

王清《0day安全:软件漏洞分析技术》(第二版) -> P147 & P149

下面观察空表的分配、释放及合并,快表中没有合并操作,因此观察它的分配及释放。

1. 空闲双向链表Freelist

示例代码如下

《0day安全:软件漏洞分析技术》(第二版) -> P152 -> heap_debug.c
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
40
41
42
43
44
/*****************************************************************************
To be the apostrophe which changed "Impossible" into "I'm possible"!

POC code of chapter 6.2 in book "Vulnerability Exploit and Analysis Technique"

file name : heap_debug.c
author : failwest
date : 2007.04.04
description : demo show of how heap works
Noticed : 1 only run on windows 2000
2 complied with VC 6.0
3 build into release version
4 only used for run time debugging
version : 1.0
E-mail : failwest@gmail.com

Only for educational purposes enjoy the fun from exploiting :)
******************************************************************************/

#include <windows.h>
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;
}

编译环境如下:

  • 操作系统:运行于Vmware虚拟机,版本Win 2K SP4 5.00.2195 EN
  • 编译器:Visual C++ 6.0
  • 编译选项:默认选项
  • build版本:release版

运行程序,由于INT3中断产生异常,如下图:

选择Cancel以加载默认调试器OllyDBG。

(1) 寻找代码HeapCreate()所创建的堆

进入OllyDBG后,单击下图中的M按钮,以查看程序当前的内存映射状态:

与上图对应的0day书中图5.2.5指出了内存映射Memory Map中进程堆、malloc使用堆、实验中HeapCreate()创建的堆的起始地址,作者没有说明后2个是如何判断的,我的理解如下:

  • malloc使用堆:这里我还无法判断,因为上图的内存映射情况跟书中的图有较大不同。
  • 实验中HeapCreate()创建的堆:可用根据大小为0x1000以及Type为Priv来筛选,0x0012D000虽然也满足,但是地址很小,在线程及进程堆附近,应该不是用户所创建的堆。因此可用筛选出0x00360000为程序代码创建的堆。

上面讨论的是只根据观察Memory map来识别各种类型的堆,其实本例中要判断HeapCreate()创建的堆可直接根据当前汇编代码运行到INT3时EAX的值来判断,即为0x00360000,因为在INT3代码之前刚刚调用了HeapCreate()函数,并且返回了创建的堆的其实地址,该值正存储在EAX中,如下图:

(2) 观察堆表信息

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

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

王清《0day安全:软件漏洞分析技术》(第二版) -> P147

在内存查看窗口中,Ctrl+G来到地址0x00360000处,观察内存,如下图:

按照书中介绍,从0x00360000开始,堆表中包含的信息依次为

  • 段表索引 Segment List
  • 虚表索引 Virtual Allocation list
  • 空表使用标识 freelist usage bitmap
  • 空表索引区

这里只关心从偏移0x178处(距离堆起始地址0x00360000的偏移,后文省略)开始的空表索引区,这个区域存放着Freelist数组Freelist[0]~Freelist[127],每个数组元素大小为8Byte,包含2个4Byte指针,前向指针flink(指向所在链表中下一个堆块)和后向指针blink(指向所在链表中前一个堆块)。

参考书中图5.1.2,空表索引是用来索引不同大小的堆块数据链的。堆块用来存储数据,其最小单位为8 Byte,空闲堆块按照其大小被区分开来,相同大小的空闲堆块被串接起来,形成一个包含若干个同样大小的堆块堆块链。如Freelist[1]指向的是一条由若干个大小都为 1 x 8 Byte的堆块串接起来的堆块数据链,依此类推,Freelist[127]指向的是一条由若干个大小都为 127 x 8 Byte的堆块串接起来的堆块数据链,其中前向指针flink指向的是堆块数据链中的第一个堆块,后向指针blink指向的是最后一个堆块。Freelist[0]较为特殊,它链入所有 大于等于1024 Byte且小于512 KB的堆块,这些堆块按照各自的大小在Freelist[0]中升序串接。

值得一提的是,参考书中图5.2.8和5.2.9,每个完整的堆块的前8 Byte是块首,存储着该块大小、状态等相关信息。

块首之后数据的作用根据该堆块是否空闲有所不同:

  • 空闲态堆块中,紧接着块首的8 Byte包含该空闲块的前向指针和后向指针,余下的字节就是实际数据了。
  • 占用态堆块中,块首之后便是实际数据的数据。

因此,1个包含8 Byte数据的堆块,在内存中占用的实际空间为16 Byte。当该堆块为空闲态时,8-15 Byte为前向指针和后向指针;当该堆块为占用态时,后8 Byte为实际数据。

我们发现图1.1.4中的空表索引区中除了Freelist[0]中的2个指针都指向0x688,之后每个元素中的前向指针和后向指针都指向该元素的起始地址,这是因为初始状态下的堆是没有的固定大小的空闲块的,除了一个非常大、连续的初始空闲块,即尾块,它正是Freelist[0]的2个指针所指的堆块。

我们来到0x680观察尾块,如下图

从上图可以看出,尾块的大小为0x130 x 8 Byte,前后向指针都指向freelist[0]。

这里值得一提的是块首信息中的Self Size的单位是8 Byte,也就是说该Self Size值为1的最小堆数据块占用的内存为 1 x 8 Byte,这是堆块数据的最小占用内存。

注意,尾块块首位于0x680,而非Freelist[0]中指针指向的0x688,因为空表中的前向指针和后向指针指向的都是堆块中实际数据的内存地址,而非块首的地址。

(3) 观察堆块的分配

继续跟进代码,来观察堆块的分配与释放,但随即遇到一个坑:不管 Debugging options中怎么设置,代码执行到0x0040101D处的INT3之前,继续F8执行INT3,会进入内核代码直到终止,不会继续执行INT3之后的代码。在看雪论坛里看到大家也遇到了这种情况,也没有太好的解决办法。问题肯定出在汇编代码int3,因此我的解决办法是手动将int3改变为nop,执行空指令,这样就可以继续执行后面的代码了,但是也有不方便的地方,每次重启程序执行到该处都得手动修改此处。

连续几次F8代码执行到0x0040102B处,即完成h1堆内存分配之后,如下图:

从上图中可以观察到,h1的堆内存地址是之前的空闲尾块的起始地址0x688,那原来的尾块何去何从?这时可以再观察一下空表索引区,如下图:

从上图可以看到,Freelist[0]所指向的空闲尾块的起始地址变为0x698。再到地址0x680进行观察,如下图:

可以看到,尾块变小到 0x12E x 8 Byte了,缩小了(0x130 - 0x12E) x 8 Byte = 16 Byte,这16 Byte即为h1所用,虽然在C代码中h1申请大小仅为3 Byte,但是据前所述,堆块数据的最小占用内存为 8 Byte,因此包括块首的h1还是占用了16 Byte的内存空间。由于h1仅申请了3 Byte,因此0x688的前4 Byte被置为0,而后4 Byte不做改变也不会被使用,所以其值仍为之前的尾块的后向指针的值。

随后的h2-h6分配过程类似,不再赘述,下面两图给出h6分配完毕后的空表索引区及对数据区的状态:

(4) 观察堆块的释放

接着观察堆块的释放,C代码中首先释放的是3个不相邻的堆块h1 h3 h5,由于它们不连续,因此不会发生合并。

将汇编代码执行到h5释放完毕,h6开始释放之前,观察空表索引区及堆数据区的状态:

由于h1和h3的Self Size都为2且不相邻,当它们被释放时会先后与Freelist[2]串联构成双向链表,具体的链接方式为

  • flink: FreeList[2] -> h1 -> h3 -> FreeList[2]
  • blink: FreeList[2] -> h3 -> h1 -> FreeList[2]

释放后的h5被单独串联到Freelist[4]。

可以观察到在分配及释放前后,h1 h3 h5的块首中从左至右第6 Byte的数据由0x01变为0x00,该字节数据为堆块标识,值0x01表示该块处于Busy状态。处于Busy状态的空闲堆块不会被合并,而0x00状态的空闲堆块在合适的时候会被系统自动合并。

(5) 观察堆块的合并

继续执行释放h4的代码,当h4被释放后,观察空表索引区及堆数据区:

h4被释放后,系统并不会把它与Freelist[4]串联,因为系统检测到h4 前与h3 后与h4相邻,会将它们合并,合并后新空闲块的大小为(2+2+4) x 8 Byte = 8 x 8 Byte,因此新块的Self Size为8。

同时观察空表索引区,由于h3被“拿走”,与Freelist[2]串联的只剩下了h1,原先与Freelist[4]串联的h5也被“拿走”,因此Freelist[4]中的指针再次指向了它自身,而合并得到的Self Size为8的新块与Freelist[8]进行了串接。

2. 快速单向链表Lookaside

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

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

王清《0day安全:软件漏洞分析技术》(第二版) -> P148-149

快表结构如下图:

示例代码如下:

《0day安全:软件漏洞分析技术》(第二版) -> P161 -> heap_lookaside.c
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

/*****************************************************************************
To be the apostrophe which changed "Impossible" into "I'm possible"!

POC code of chapter 5 in book "Lookaside Using"

file name : heap_lookaside.c
author : failwest
date : 2010.09.04
description : demo show of how heap works
Noticed : 1 only run on windows 2000
2 complied with VC 6.0
3 build into release version
4 only used for run time debugging
version : 1.0
E-mail : failwest@gmail.com

Only for educational purposes enjoy the fun from exploiting :)
******************************************************************************/

#include <stdio.h>
#include <windows.h>
void main()
{
HLOCAL h1,h2,h3,h4;
HANDLE hp;
hp = HeapCreate(0,0,0);
__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);
}

编译环境如下:

  • 操作系统:运行于Vmware虚拟机,版本Win 2K SP4 5.00.2195 EN
  • 编译器:Visual C++ 6.0
  • 编译选项:默认选项
  • build版本:release版

本例中创建堆与上例中创建堆时的不同之处在于HeapCreate函数的使用,该函数细节详见MSDN,该函数的第3个参数dwMaximumSize指定了该堆所能占用的最大空间,若dwMaximumSize=0,则将会创建一个按所需空间大小动态自增长的堆。

0day书中没有指出是否在调用HeapCreate时,将dwMaximumSize设置为0时系统就会自动启用快表,而置为非零时系统默认不使用快表,根据我的理解应该是这样的。

在实验调试中,我发现0day书中5.2.7 “快表的使用”一节中有较为明显的错误,而后连锁反应式地在本节中引发了一连串错误。一开始还不敢确定,后来经过反复调试并阅读,基本确定错误属实而非环境不同造成的结果差异,下文将详述之。

(1) 观察堆表信息

0x178处存储的尾块指针不再指向0x688,如下图:

这里插播一个在学习过程中发现的OllyDBG使用小Tips,之前没看过OllyDBG的使用文档,一些最基本的操作都是从0day书中学习到的,因此已经熟悉OllyDBG使用的同学可忽略这条Tips。

观察上图中0x178处指针指向的尾块地址0x00361E90,若我们想要跳转到该地址去查看尾块,可以按照0day书中的方法在上图的内存查看窗口中按下Ctrl+G,然后键入361E90(默认16进制),就能够转到该地址查看内存。

此外,还可以用鼠标右键单击内存0x178处的值0x90,选择"Follow DWORD in Dump",即可跳转到内存0x00361E90处。还可以通过单击+或-以来回切换前面的或后面的内存窗口查看,以实现0x178和0x1E90处内存的快速来回切换查看。

0x688处已经被快表所霸占,如下图:

与上图对应的0day书中图5.2.17标注的Lookaside[0]位于0x6b8处而非0x688处,书中的标注有误。

(2) 观察堆块的分配

接着,我们来观察堆块分配操作,观察h1申请后的内存,如下图:

此时0x178处的尾块指针指向0x1EA0,说明跟上节中讨论的空表内存申请一样,申请过程也是Freelist[0]所指向的尾块为h1腾出了空间,结果表现为尾块缩小 2 x 8 Byte,它的起始部分“向后移动”了。

接着执行代码,完成h1至h4的分配,观察此时内存数据,如下图:

(3) 观察堆块的释放

再观察h1至h4释放后的内存数据,如下图:

可以看到,与空表中不同的是,在释放后各块块首中从左至右第6 Byte的数据并未改变,仍为0x01即Busy状态,该标识可以保证即使该堆块现在已变为空闲堆块也不会被系统自动合并。

其中,被释放后的h2中多出了一个指针,关于其用途,待观察0x688处的快表即可明白,如下图:

从上图可以看出,被释放后的各块被链入快表中,形成了3条单向链表:

  • Lookaside[2] -> h2 -> h1
  • Lookaside[3] -> h3
  • Lookaside[4] -> h4

0day书中图5.2.20标注的Lookaside[1], Lookaside[2], Lookaside[3]分别位于0x6E8 0x718 0x748,书中的标注有误。

运行之后的代码,C代码中的新h2需要16 Byte空间,加上块首,需占 (2+1) x 8 Byte空间,因此在分配时已被链入Lookaside[3]的之前h3的内存0x1EB0将被新h2所使用,0x718处的指针在分配完毕后将被置0,在新h2被释放后该处指针又会恢复,整个过程较为简单,不再赘述。