如果你不了解堆的话,请先看我的上一篇博文《堆的工作原理》,然后再来学习堆溢出,结合食用,更易理解。
[TOC]
堆溢出利用(上)—— DWORD SHOOT
链表“拆卸”中的问题
堆管理系统三类操作:堆块分配、堆块释放、堆块合并。
这三种操作实际上是对链表的修改,如果我们能伪造链表结点的指针,那么在 “卸下” 和 “链入” 过程中就可能获得一次读写内存的机会。
堆溢出的精髓:用特殊的数据去溢出下一个堆块的块首,改写块首的前向指针(flink)和后向指针(blink),然后在分配、释放、合并等操作发生时伺机获得一次向内存任意地址写入任意数据的机会。
**DWORD SHOOT:**向内存任意地址写入任意数据。
| 点射目标(Target) | 子弹(payload) | 改写后的结果 |
|---|---|---|
| 栈帧中的函数返回地址 | shellcode 起始地址 | 函数返回时,跳去执行 shellcode |
| 栈帧中的 S.E.H 句柄 | shellcode 起始地址 | 异常发生时,跳去执行 shellcode |
| 重要函数调用地址 | shellcode 起始地址 | 函数调用时,跳去执行 shellcode |
注意:
DWORD SHOOT 发生时,我们不但可以控制射击的目标(任意地址),还可以选用适当的子弹(填充的 4 字节恶意数据)。
DWORD SHOOT 原理
下面我们讲解,将一个结点从双向链表中 “拆卸” 下来的过程中,是怎么向任意地址写入任意数据的(即DWORD SHOOT)
1 | int remove (ListNode * node) |
正常拆卸过程,正如下面 图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
1 |
|
步骤
与前面实验调试一样,先在 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 版本,实验将会失败 |
1 |
|
先简单地解释一下程序和实验步骤。
(1) h1 向堆中申请了 200 字节的空间。
(2) memcpy 的上限错误地写成了0x200,这实际上是 512 字节,所以会产生溢出。
(3) h1 分配完之后,后边紧接着的是一个大空闲块(尾块)。
(4)超过 200 字节的数据将覆盖尾块的块首。
(5)用伪造的指针覆盖尾块块首中的空表指针,当 h2 分配时,将导致 DWORD SHOOT。
1 | DWORD SHOOT 详细过程 |
(6) DWORD SHOOT 的目标是 0x7FFDF020 处的 RtlEnterCriticalSection()函数指针,可以简单地将其直接修改为 shellcode 的位置。
(7) DWORD SHOOT 完毕后,堆溢出导致异常,最终将调用 ExitProcess()结束进程。
(8) ExitProcess()在结束进程时需要调用临界区函数来同步线程,但却从 P.E.B 中拿出了指向 shellcode 的指针,因此 shellcode 被执行。
为了能够调试真实的堆状态,我们在代码中手动加入了一个断点:
1 | __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()函数创建的堆区起始位置会发生变化。
这时,缓冲区内容如下:
1 | char shellcode[]= |
运行一下,发现那个 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 字节伪造的指针。
这时,缓冲区内容如下:
1 | char shellcode[]= |
现在把断点注释掉,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 中搜到不少,代码如下所示。
1 | CALL DWORD PTR [EDI+0x78] |
DWORD SHOOT 后的“指针反射”现象
回顾前面介绍 DWORD SHOOT 时所举的例子:
1 | int remove (ListNode * node) |
其中, 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,软件安全漏洞分析技术》
