序言
一直偷懒, 没遇到winheap相关利用场景, 所以一直拖着没有学习winheap相关知识, 结果面试时遇到直接尬住
与linux不同, 由于windows闭源的原因, 要想阅读源码只能通过反编译的方式, 这显然不利于学习, 但好在网络上有一群热爱分享的前辈已经整理好了许多相关的知识
之前已经学习过linux下的glibc_ptmalloc2和kernel_slab内存管理器模型, 学习了许多利用方式, 在windows下会发现, 其实本质上并没有过于巨大的差异, 有前者的基础能很好的帮助理解winheap
Win10Heap
glibc会随着时代在进步, 显然windows更不会原地踏步, win98, winxp, win7, win10的堆内存管理肯定是存在差异的
尽管现在依然有许多win老设备坚挺在一线, 但win10/11才应该算是更贴近时代的, 所以我们从win10开始切入
Angel Boy有一篇slide专门用于讲解win10下heap的利用, 十分适合从零开始的winheap学习
win10下的内存管理器较为复杂, 主要可以被归为两类
- NT Heap
- 默认的内存管理器
- SegmentHeap
- win10新增的内存管理器
- 部分系统程序以及UWP(Universal Windows Platform)使用
NT_Heap
NT_Heap可以分为两个部分
- back_end, 后端分配器是主要的堆管理结构, 负责大多数内存管理操作
- front_end, 前端分配器为了提高小块内存分配的速度, 对高频小内存分配进行优化, Windows 使用 LFH(Low Fragmentation Heap) 作为主要的前端分配器
Back-End
让我们从后端开始, 一切开始之前先让我们知道windows堆是如何描述堆和chunk的
结构体声明来自https://www.vergiliusproject.com/
结构描述
_HEAP_ENTRY
1 | //0x10 bytes (sizeof) |
看着很长, 但实际上会根据多种因素决定取用哪部分
对于一个后端chunk可能有以下几种状态
Inused chunk
Freed chunk
VirtualAlloc chunk
与linux类似, 也是头部 + User Data, 对于一个最普通的后端chunk其状态如下
1 | struct _HEAP_ENTRY{ |
PreviousBlockPrivateData
, 与ptmalloc2类似, 基本上存储前一个chunk的数据, 但空闲时不会存储前一个chunk的sizeSize
, 因为chunk是十六字节对齐存储当前chunk的size >> 4
Flags
, 当前chunk的标志:0x1
表示处于占用状态0x2
表示存在额外描述0x4
表示使用固定模式填充0x8
表示VirtualAlloc0x10
表示为该段最后一个chunk
SmallTagIndex
,Size
和Flags
成员三字节数据逐个xor结果, 取出时会进行校验PreviousSize
, 上一个chunk的size, 同样是右移4位SegmentOffset
, 某些情况用于查找segmentUnusedbyte
, 两种情况- 在inuse的时候, 表示
malloc
之后剩下的chunk的空间大小, 可以用来判断chunk是来自于Front-End还是Back-End - 在freed的时候, 恒为0
- 在inuse的时候, 表示
UserData
, 用户使用的区域, 类似的在freed状态下会存储Flink
和Blink
分别指向前一个和后一个chunk, 但指向的是chunk的用户区域
_HEAP_VIRTUAL_ALLOC_ENTRY
该结构体用于描述通过VirtualAlloc得到的chunk
1 | //0x40 bytes (sizeof) |
Entry
,就是一个Flink和Blink, 分别指向上一个和下一个VirtualAlloc得到的chunk- 中间三个字段, 暂时不做过多理会
BusyBlock
, 与普通的_HEAP_ENTRY
头基本一样, 不同在于这里的Size
是没有使用的size, 储存时也没有进行size >> 4
的操作, UnusedBytes恒为4
_HEAP
接下来要知道的是这些chunk是被哪个结构管理
1 | //0x2c0 bytes (sizeof) |
字段很多, 同样只关注部分
EncodeFlagMask
, 用来标志是否要encode该heap中的chunk头, 0x100000表示需要encodeEncoding
, 用来和chunk头进行xor的cookieVirtualAllocdBlocks
, 一个双向链表的dummy head, 存放着Flink
和Blink
, 将VirtualAllocate出来的chunk链接起来BlocksIndex
, 指向一个_HEAP_LIST_LOOKUP
结构, 是backend管理的重要结构体FreeList
, 一个双向链表的dummy head, 同样存放着Flink
和Blink
, 将所有的freed chunk给链起来, 可以理解为unsorted bin, 但链表是有序的FrontEndHeap
, 指向管理Front-End heap的结构体FrontEndHeapUsageData
, 一个对应各个大小chunk的数组, 该数组记录各种大小chunk使用的次数, 达到一定数值的时候就会启用Front-EndFrontEndHeapStatusBitmap
, 标识某个大小的chunk是否有启用LFH
_HEAP_LIST_LOOKUP
(BlocksIndex)_HEAP_LIST_LOOKUP是后端的一个重要结构
1 | //0x38 bytes (sizeof) |
ExtendedLookup
, 指向下一个ExtendedLookup, 通常下一个会管理更大的chunkArraySize
, 当前结构管理的最大chunk的大小, 同样右移4位, 第一个通常是是0x800 >> 4
ItemCount
, 目前管理的chunk数目OutOfRangeItems
, 超出该结构所管理大小的chunk数目BaseIndex
, 该结构所管理的chunk的起始index, 用来从ListHint中找到合适大小的freed chunk用的ListHead
, 指向Freelist的dummy headListsInUseUlong
, 用于判断ListHint中是否有合适大小的chunk的bitmapListHints
, 用来指向对应大小的chunk array, 该array以0x10大小为间隔, 存放一个对应size的freed chunk的地址, 用于快速找到合适大小的chunk, 可以类比linux ptmalloc的tcache bin, 只不过chunk的组织仍然通过双向链表维护
到这里就可以贴一张图了
分配
看完结构体, 接下来就开始分析分配的机制, 分配的主要逻辑是在RtlpAllocateHeap
根据分配的大小, 有三种不同的分配路径
- case1:
size <= 0x4000
- case2:
0x4000 < size <= 0xff000
- case3:
size > 0xff000
case1
- 判断该size对应的FrontEndHeapStatusBitmap是否有启用LFH
- 如果未开启:
- 在size对应的
FrontEndHeapUsageData += 0x21
- 再次判断
FrontEndHeapUsageData > 0xff00 || FrontEndHeapUsageData & 0x1f > 0x10
是否成立, 成立则启用LFH
- 在size对应的
- 如果未开启:
- 进入BlocksIndex中查看对应的ListHint是否有可用freed chunk
- 如果存在size刚好合适的
- 取走该chunk, 并且查看其Flink所连接的chunk大小是否与之相同, 如果相同, 那么对应LIstHint更为Flink, 否则置零对应ListHint
- 将这个chunk从FreeList中unlink, 并返回给使用者(会将header xor回正常状态)
- 如果没有刚好合适的, 但有更大size的
- 优先找最接近所需要size的, 有则按上述相同方法进行取出
- 对chunk进行切割, 剩下的再次加入FreeList, 如果可以放入ListHint则放入
- 将切割后chunk返回给使用者同时还原header
- 如果FreeList中没有能够使用的chunk
- 尝试ExtendHeap拓展heap空间
- 从拓展出空间的chunk中寻找, 同前两种情况
- 如果存在size刚好合适的
case2
除了没有LFH相关操作, 其余与case1一致
case3
0xff000
其实也就是_HEAP结构的VirtualMemoryThreshold << 4
根据这个字段的名称可以知道, 这个大小就是使用VirtualAlloc的临界大小
- 直接使用ZwAllocateVirtualMemory得到类似mmap出的一大块内存
- 将该内存块插入_HEAP-> VirtualAllocBlocks链表
- 并返回给用户使用
释放
释放时只分为两种情况
- case1:
size <= 0xff000
- case2:
size > 0xff000
case1
首先检查alignment对齐, 利用unused byte判断chunk状态
如果该size未开启LFH, 对应的FrontEndHeapUsageData 减 1
判断前后的chunk是否处于空闲状态, 是的话进行合并, 合并时采用unlink, 与之前类似
合并完后, 更新合并后chunk的size, 以及后一个chunk的prevsize, 然后查看此时chunk是不是需要在freelist最前跟最后, 是就插入, 否则就从ListHint中插入, 并且update ListHint
插入时也会对Freelist进行检查, 但是此检查不会触发abort, 原因在于没有做unlink写入
case2
- 检查该chunk的linked list并从
_HEAP->VirtualAllocdBlocks
中移除 - 接着使用
RtlpSecMemFreeVirtualMemory
将chunk整个munmap掉
Back-Exploitation
主要就是unlink
其实原理和linux下的unlink几乎一致, 有前者基础的话能够很好地理解相关知识
需要注意两点
decode header进行check的时候, 需要保证其正确性, 比如找到previous freed chunk, 进行decode以及check的操作的时候
chunk的Flink和Blink直接指向数据区域而不是chunk header, 不用伪造fake chunk的步骤
设置
1
2*(Flink + 0x8) = chunk_addr
*(Blink) = chunk_addr
整体利用思路
- 在已知linux下unlink attack的基础上, 以完全相同的方式,对windows heap进行unlink attack, 可以实现将一个指针指向本身的效果
- 利用这个指向自身的指针, 可以控制周围的可能的指针, 达到任意地址读写的效果
- 不管如何利用, 总是需要先leak得到各类信息
- 代码地址
PEB --> text
- 共享库地址
text --> IAT --> xxx.dll --> xxx.dll
_HEAP->LockVariable.Lock --> ntdll.dll
CrticalSection->DebugInfo --> ntdll.dll
- 栈地址
Kernel32.dll --> kernelbase.dll --> KERNELBASE!BasepFilterInfo --> stack address
kernel32.dll --> ntdll.dll --> ntdll!PebLdr --> PEB --> TEB --> stack address
- 代码地址
Front-End
结构描述
这部分的结构体在vergiliusproject找不到
_LFH_HEAP
在_HEAP中有一个字段FrontEndHeap, 指向一个_LFH_HEAP结构体, 该结构体就是前端最重要的结构
1 | ntdll!_LFH_HEAP |
关注几个重要字段
Heap
, 指向其对应的_HEAP
结构体Buckets
, 一个存放129个_HEAP_BUCKET
结构体的数组, 用来寻找配置大小对应到Block大小的阵列结构SegmentInfoArrays
, 一个存放129个_HEAP_LOCAL_SEGMENT_INFO
结构体指针的数组, 不同大小对应到不同的_HEAP_LOCAL_SEGMENT_INFO
结构体, 主要管理对应到的_HEAP_SUBSEGMENT
的信息LocalData
, 一个_HEAP_LOCAL_DATA
结构体
_HEAP_BUCKET
1 | ntdll!_HEAP_BUCKET |
关注两个部分:
BlockUnits
, 要分配出去的一个block的大小, 存放size >> 4
SizeIndex
, 使用者需要的大小, 存放size >> 4
_HEAP_LOCAL_SEGMENT_INFO
1 | ntdll!_HEAP_LOCAL_SEGMENT_INFO |
关注:
LocalData
, 一个_HEAP_LOCAL_DATA
结构体指针, 指向_LFH_HEAP->LocalData
, 方便从_HEAP_LOCAL_SEGMENT_INFO
找回_LFH_HEAP
BucketIndex
, 对应到的BucketIndex
, 也就是_LFH_HEAP->SegmentInfoArrays
数组中对应的下标ActiveSubsegment
, 非常重要的成员, 一个_HEAP_SUBSEGMENT
结构体指针目的在于管理
UserBlocks
, 记录剩余等多chunk和该UserBlocks
最大分配数等信息CachedItems
, 存放16个_HEAP_SUBSEGMENT
结构体指针的数组, 存放对应到该_HEAP_LOCAL_SEGMENT_INFO
且还有可以分配chunk给用户的_HEAP_SUBSEGMENT
指针可以理解为一个内存池, 当
ActiveSubsegment
没有可用chunk的时候, 即用完的时候, 就从CachedItems
选择填充, 替换ActiveSubsegment
_HEAP_LOCAL_DATA
1 | ntdll!_HEAP_LOCAL_DATA |
其中LowFragHeap
指回_LFH_HEAP
结构本身的位置, 通常用来找回LFH
_HEAP_SUBSEGMENT
1 | ntdll!_HEAP_SUBSEGMENT |
LocalInfo
, 一个指回到对应_HEAP_LOCAL_SEGMENT_INFO
结构体位置的指针UserBlocks
, 一个指向_HEAP_USERDATA_HEADER
结构的指针, 也就是指向LFH chunk的内存分配池该内存分配池包括一个
_HEAP_USERDATA_HEADER
, 存放一些metatdata后面紧跟着要分配出去的所有chunk
AggregateExchg
, 一个_INTERLOCK_SEQ
结构, 储存对应的UserBlocks
的状态信息LFH通过该结构判断是否从该UserBlock分配, 同时具有Lock的作用
BlockSize
, 该UserBlocks
中每个chunk的大小BlockCount
, 该UserBlocks
中chunk的总个数SizeIndex
, 该UserBlocks
对应的index
_INTERLOCK_SEQ
1 | ntdll!_INTERLOCK_SEQ |
Depth
, 用来管理对应到的UserBlocks
还有多少freed chunk, LFH会用这个判断是否还从该UserBlock进行分配Lock
, 即提供锁的作用, 其实只占用第4 byte的最后一个bit
_HEAP_USERDATA_HEADER
1 | ntdll!_HEAP_USERDATA_HEADER |
关注:
SubSegment
, 指回对应的_HEAP_SUBSEGMENT
结构EncodedOffsets
, 一个_HEAP_USERDATA_OFFSETS
结构, 用来验证chunk header是否被改过BusyBitmap
, 记录该UserBlocks
哪些chunk被使用
_HEAP_ENTRY
前端管理的_HEAP_ENTRY与之前在后端的_HEAP_ENTRY存在差异
1 | struct _HEAP_ENTRY{ |
size
, Flags
和SmallTagIndex
变成了SubSegmentCode
相应含义也发生了变化
SubSegmentCode
, encode过的metadata, 用来推回UserBlocks
的位置。PreviousSize
, 该chunk在UserBlock中的index, 实际上是第0xD个byteUnusedBytes
, 用来判断该LFH chunk状态Freed
, 恒为0x80
Inused
,UnusedBytes & 0x80 != 0
在认识了这些结构体之后就可以看这两张图片
补充
_HEAP_USERDATA_HEADER->EncodedOffsets
在UserBlocks
初始化的时候设置, 其算法为下面四个值进行xor:
(sizeof(\_HEAP_USERDATA_HEADER)) | ((_HEAP_BUCKET->BlockUnits) * 0x10 << 16)
LFHkey
UserBlocks
的地址_LFH_HEAP
的地址
所有UserBlocks
里的chunk header在初始化的时候都会经过xor, 其算法为下面各个值得xor:
_HEAP
的地址LFHkey
chunk
本身的地址address >> 4
((chunk address) - (UserBLocks address)) << 12
初始化
在FrontEndHeapUsageData[x] & 0x1F > 0x10
时, 置位_HEAP->CompatibilityFlag |= 0x20000000
, 下一次allocate(也就是第18次)就会启用LFH并初始化
首先会ExtendFrontEndUsageData及增加更大的
_HEAP->BlocksIndex
_HEAP->BlocksIndex
可以理解为一个_HEAP_LIST_LOOKUP
结构的单向链表, 且默认初始情况下只存在一个管理(max 0x800)的chunk的_HEAP_LIST_LOOKUP
所以这里会扩展到(0x800, 0x4000), 即在链表尾追加一个管理更大chunk的
_HEAP_LIST_LOOKUP
结构体结点建立并初始化
_HEAP->FrontEndHeap
(通过mmap), 即初始化_LFH_HEAP
的一些metadata建立并初始化
_LFH_HEAP->SegmentInfoArrays[x]
, 在SegmentInfoArrays[BucketIndex]
处填上对应的_HEAP_LOCAL_SEGMENT_INFO
结构体指针
再之后申请同样大小的chunk就会开始使用LFH
- 分配
UserBlocks
并进行初始化, 即设置对应大小的chunk。 - 然后再设置对应
_HEAP_LOCAL_SEGMENT_INFO->ActiveSubsegment
- 随机地从
UserBlocks
中返回一个chunk
分配
主要函数逻辑在RtlpLowFragHeapAllocFromContext
先看看ActiveSubsegment中有没有空闲的chunk, 也就是通过
ActiveSubsegment->AggregateExchg.depth
判断- 如果没有则从
CacheedItems
中找, 找到有存在空闲chunk的Subsegment
就替换掉当前的ActiveSubsegment
- 如果有则继续往下
- 如果没有则从
取得
RtlpLowFragHeapRandomData[x]
上的值, 取值是依次循环取的, x为1 byte大小的值, 即下一次x = (x + 1) % 256
由于
RtlpLowFragHeapRandomData
是一个存放256个随机数的数列(范围为0x0 ~ 0x7F
), 所以这里相当于在取随机数计算相应的UserBlocks里chunk的index, 通过
RtlpLowFragHeapRandomData[x] * max_index >> 7
(max_index是能取到的最大的index)如果发生了collision, 即该index对应的chunk是busy的, 那么往后取最近的
细节上, 就是检查index对应到的bitmap是否为0, 如果是0就返回对应的bitmap, 否则选取最近的下一个
如果没有发生, 则继续往下
检查
chunk->UnusedBytes & 0x3F != 0
, 因为满足此式表示chunk是free状态的, 否则状态非法该过程中还会设置对应的bitmap, 以及更新
ActiveSubsegment->AggregateExchg.depth
等相关信息最后设置index(即
chunk->PreviousSize
)以及chunk->UnusedBytes
, 并把chunk返回给用户
释放
- 首先更新
chunk->UnusedBytes = 0x80
- 找到该chunk对应的在
UserBlocks
中的index,并且置UserBlocks->BusyBitmap
对应的bit为0 - 更新
ActiveSubsegment->AggregateExchg
- 如果该chunk不属于当前的
ActiveSubsegment
则看能不能放进CachedItems
中去, 如果可以就放进去
LFH-Exploitation
假如拥有UAF的漏洞可以利用, 但是因为LFH分配的随机性, 无法预测下一个分配出的chunk是在哪个位置, 也就是说现在我们free的chunk, 下一次malloc不一定拿得到
那么此时可以通过拿空UserBlocks
的方式(也就是使这个UserBlocks的chunk全部处于busy), 再free掉目标chunk, 这样下一次malloc就必然会拿到目标chunk(因为只有一个可用)
再之后利用这个特性进一步利用