堆的调试方法
实验环境:
- Windows 2000虚拟机
- Visual C++6.0
- 默认编译选项(VS2003 2005需要把GS编译选项关掉)
- build版本:release版本,使用debug版本实验会失败
实验代码:
#include <Windows.h>
int 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);
HeapFree(hp, 0, h1);
HeapFree(hp, 0, h3);
HeapFree(hp, 0, h5);
HeapFree(hp, 0, h4);
return 0;
}
堆调试不能直接用调试器直接加载程序,堆管理函数会检测当前进程是否处于调试状态,而使用调试状态的堆管理策略
调试态的堆管理和普通的堆管理有很大差异:
-
调试的堆不使用快表,只使用空表分配
-
所有堆块会加上多余的16字节尾部防止程序溢出,包括8个字节0xAB和0x00
-
块首标志位也不一样
正常运行需要进行配置:在程序中设置断点__asm int 3
,设置调试器(OD)为实时调试器接管程序错误,编译release程序运行发生错误时,如图,点击取消来启动调试器接管
malloc函数会在内部自己使用HeapCreate函数为自己创建堆区
点击OD上面的按钮M可以查看内存映射:
识别堆表
程序初始化过程中,malloc使用的堆块和进程堆已经用过很多次了,已经比较乱了,所以自己建一个堆来理解堆管理策略
HeapCreate创建堆之后返回地址到eax:这里是0x00520000
堆表中包含的信息依次是段表索引,虚表索引,空表使用标识,空表索引区
(图截自《0day安全》)
与堆溢出利用关系比较大的是空表索引区,位于0x178偏移处
当一个堆刚初始化完成后,堆块状况如下:
- 只有一个空闲态的大块--尾块
- 位于堆偏移0x688处(这里是x0x00520688)
- freelist[0]指向尾块
- 除了零号空表外,其余各项索引都指向自己,意思是都是空表
堆块首部
占用态堆块首部:
空闲态堆块首部:
空闲态和占用态的堆块首部结构基本一致,区别是在首部后数据区的前8字节,空闲态堆块会保存空表指针,当变成占用态时,着8个字节会用于存数据
查看创建的堆块:(只有一个空闲态的大块)
- 堆块开始于0x00520680,一般引用堆块的指针会越过堆首8字节直接指向数据区
- 尾块大小为0x0130,计算单位是0x0008字节,共0x980字节
- 堆块大小包括块首
按照堆表数据结构的规定,指向快表的指针位于偏移0x584字节处
指向空表的指针位于0x178处,一共有128个双向链表,结尾地址也就是0x178+0x400=0x578,中间距离一堆0,0x578处有一个数据,然后距离16字节的0之后,便是0x584字节处的快表指针
堆块的分配
堆块分配里:
- 堆块大小包括堆首大小
- 堆块的单位是8字节,不足8字节按8字节分配
- 初始状态使用次优块分配,分配函数会从尾块中切走小块,修改首部信息,再把尾块重新放回0号链表
把这6次申请内存都执行了,分配字节情况如下:
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,3); // 8 bytes + 8 bytes
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,5); // 8 bytes + 8 bytes
h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,6); // 8 bytes + 8 bytes
h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); // 8 bytes + 8 bytes
h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,19); // 24 bytes + 8 bytes
h6 = HeapAlloc(hp,HEAP_ZERO_MEMORY,24); // 24 bytes + 8 bytes
一共分配了2+2+2+2+4+4 = 0x10个堆块大小(堆管理里以堆块(8字节)为单位计算大小),所以剩下的空间应该是0x130-0x10=0x120个堆块大小:
堆块的释放
接下来释放堆块h1,h3,h5,三个不连续的堆块,查看内存:
这三个堆块变成了空闲堆块,数据区添加了双链表指针指向空表(h1和h3在free[2],h5在free[4])
堆块的合并
接下来释放h4,查看内存:
这里h3,h4,h5都是空闲的且连续,则会进行堆块合并,这三个块从空表中摘下,合并后再插入空表
合并后的大小是2+2+4=8个块,链入free[8]
这里合并的结果在内存上的体现是,改变了原h3位置处的堆块大小,双向链表指针,对于其数据区的内容不进行改动
堆块合并可以更加有效的利用内存,但需要修改多处指针,比较费时,因此,堆块合并只发生在空表中,在快表中被禁止
快表的使用
实验代码:
#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);
}
为何这个代码会有快表用而上个代码却没快表用?
问题出在申请堆区的API:HeapCreate
这里的参数不同,第三个参数是最大堆区大小,设置为0则表示这是动态大小,可用大小上限是内存大小,可能会很大,则会启动快表来帮助快速定位
查看空表地址,偏移0x178处,freelist[0]地址为:0x00521E90
跳转到freelist[0]处可见:
尾块的大小是0x178个
代码接下来进行4次申请和4次释放操作,会将分割出来的块都填充到快表里(原先0x688偏移处是尾快,现在是快表):
从上到下的红框,依次是lookaside[0]到lookaside[3]
查看lookaside[2]的堆块首部信息:
前8字节是首部信息,后16字节是数据区,首部里的第六个字节为01,标识这个块为busy状态,也就是为什么不能进行合并堆块的原因
现在看lookaside[1]的两个堆块:
这里的数据区4个字节保存单向链表下一个节点的地址,先释放的h1,再释放的h2,这里的指针是原h2的指针指向原h1的地址,所以这里是头插法插入快表的
接下来再次申请堆内存,申请16字节,也就是2个堆块大小,快表如下:
这里原本是lookaside[2]的指针,因为这里的块被申请了,所以这里不再指向任何空闲块,所以就清空了,当再次释放后,这里的指针又会被设置回来