【译】Scoop the Windows 10 Pool
SSTIC2020-Article-pool_overflow_exploitation_since_windows_10_19h1-bayet_fariello.pdf https://github.com/synacktiv/Windows-kernel-SegmentHeap-Aligned-Chunk-Confusion https://paper.seebug.org/1743/
https://github.com/cbayet/Exploit-CVE-2017-6008/blob/master/Windows10PoolParty.pdf
摘要:堆溢出是应用程序中相当常见的漏洞。利用这类漏洞通常依赖于对用于管理堆的基础机制的深入理解。Windows 10 最近改变了其在内核空间中管理堆的方式。本文旨在介绍Windows NT内核中堆机制的最新演变,并介绍针对内核池的新的利用技术。
1 Introduction
池(Pool)是Windows系统中保留给内核空间的堆。多年来,池分配器与用户空间中的分配器有很大的区别。然而,自 2019 年 3 月的 Windows 10 19H1 更新以来,这种情况发生了变化。在内核中引入了用户空间中广为人知和有文档记录的段堆(Segment Heap)[7]。然而,由于内核空间仍然需要一些特定的材料,因此内核中实现的分配器和用户空间中的分配器之间仍存在一些差异。本文从利用的角度重点介绍了内核段堆的自定义内部机制。
本文的研究针对x64架构进行了定制。对于不同架构的调整尚未进行研究。
在简要回顾历史池内部机制之后,本文将解释内核中如何实现段堆以及它对内核池特定材料的影响。然后,本文将介绍一种新的攻击方法,用于利用内核池中的堆溢出漏洞时的池内部机制。最后,本文将介绍一种通用的利用方法,利用最小的可控堆溢出,实现从低完整性级别到SYSTEM级别的本地权限提升。
1.1 Pool internals
本文不会深入探讨池分配器的内部机制,因为这个主题已经被广泛涵盖[5],但为了对本文有一个全面的理解,仍然需要快速回顾一些内部机制。本节将介绍一些Windows 7中的池内部机制,以及在过去几年中对池进行的各种缓解和更改。这里解释的内部机制将重点关注适合单个页面的块,这是内核中最常见的分配方式。大小大于0xFE0的分配行为不同,本文不涵盖这种情况。
在Windows内核中,用于分配和释放内存的主要函数分别是ExAllocatePoolWithTag和ExFreePoolWithTag。
PVOID ExAllocatePoolWithTag(POOL_TYPE PoolType, SIZE_T NumberOfBytes, ULONG Tag)
{
// 如果大小超过4080字节,则调用池页分配器
if (NumberOfBytes > 0xff0)
{
// 调用 nt!ExpAllocateBigPool
}
// 尝试使用 lookaside 列表
if (PoolType & PagedPool)
{
if (PoolType & SessionPool && NumberOfBytes <= 0x19)
{
// 尝试会话分页 lookaside 列表
// 成功时返回
}
else if (NumberOfBytes <= 0x20)
{
// 尝试每个处理器的分页 lookaside 列表
// 成功时返回
}
// 锁定分页池描述符(循环或本地节点)
}
else
{ // NonPagedPool
if (NumberOfBytes <= 0x20)
{
// 尝试每个处理器的非分页 lookaside 列表
// 成功时返回
}
// 锁定非分页池描述符(本地节点)
}
// 尝试使用 listheads 列表
for (n = NumberOfBytes - 1; n < 512; n++)
{
if (ListHeads[n].Flink == &ListHeads[n])
{ // 空的
continue; // 尝试下一个块大小
}
// 安全地取消链接 ListHeads[n].Flink
// 如果大于所需大小,则拆分
// 返回块
}
// 未找到块,调用 nt!MiAllocatePoolPages
// 拆分页面并返回块
}
VOID ExFreePoolWithTag(PVOID Entry, ULONG Tag)
{
if (PAGE_ALIGNED(Entry))
{
// 调用 nt!MiFreePoolPages
// 成功时返回
}
if (Entry->BlockSize != NextEntry->PreviousSize)
BugCheckEx(BAD_POOL_HEADER);
if (Entry->PoolType & SessionPagedPool && Entry->BlockSize <= 0x19)
{
// 放入会话池的 lookaside 列表
// 成功时返回
}
else if (Entry->BlockSize <= 0x20)
{
if (Entry->PoolType & PagedPool)
{
// 放入每个处理器的分页 lookaside 列表
// 成功时返回
}
else
{ // NonPagedPool
// 放入每个处理器的非分页 lookaside 列表
// 成功时返回
}
}
if (ExpPoolFlags & DELAY_FREE)
{ // 0x200
if (PendingFreeDepth >= 0x20)
{
// 调用 nt!ExDeferredFreePool
}
// 将 Entry 添加到 PendingFrees 列表
}
else
{
if (IS_FREE(NextEntry) && !PAGE_ALIGNED(NextEntry))
{
// 安全地取消链接下一个条目
// 将下一个条目与当前块合并
}
if (IS_FREE(PreviousEntry))
{
// 安全地取消链接上一个条目
// 将上一个条目与当前块合并
}
if (IS_FULL_PAGE(Entry))
{
// 调用 nt!MiFreePoolPages
}
else
{
// 将 Entry 插入到 ListHeads[BlockSize - 1]
}
}
}PoolType是一个位字段,具有以下关联的枚举:
NonPagedPool = 0;
PagedPool = 1;
NonPagedPoolMustSucceed = 2;
DontUseThisType = 3;
NonPagedPoolCacheAligned = 4;
PagedPoolCacheAligned = 5;
NonPagedPoolCacheAlignedMustSucceed = 6;
MaxPoolType = 7;
PoolQuota = 8;
NonPagedPoolSession = 20h;
PagedPoolSession = 21h;
NonPagedPoolMustSucceedSession = 22h;
DontUseThisTypeSession = 23h;
NonPagedPoolCacheAlignedSession = 24h;
PagedPoolCacheAlignedSession = 25h;
NonPagedPoolCacheAlignedMustSSession = 26h;
NonPagedPoolNx = 200h;
NonPagedPoolNxCacheAligned = 204h;
NonPagedPoolSessionNx = 220h;PoolType可以存储多种信息:
- 内存类型,可以是
NonPagedPool、PagedPool、SessionPool或NonPagedPoolNx; - 分配是否关键(第1位),必须成功。如果分配失败,将触发
BugCheck; - 分配是否按缓存大小对齐(第2位);
- 分配是否使用
PoolQuota机制(第3位); - 其他未记录的机制。
使用的内存类型很重要,因为它将分配隔离在不同的内存范围内。使用的两种主要内存类型是PagedPool和NonPagedPool。MSDN文档对其进行了以下描述:
“Nonpaged pool"是不可分页的系统内存。它可以从任何IRQL访问,但它是一种有限的资源,驱动程序应仅在必要时分配它。“Paged pool"是可分页的系统内存,只能在
IRQL<DISPATCH_LEVEL级别进行分配和访问。
正如在1.2节中解释的那样,Windows 8 引入了NonPagedPoolNx,必须使用它来替代NonPagedPool。
SessionPool用于会话空间分配,对于每个用户会话都是唯一的。它主要由win32k使用。最后,“tag"是一个由一个到四个非零字符构成的字符字面量(例如,“Tag1”)。建议内核开发人员按照代码路径使用唯一的池标记,以帮助调试器和验证器识别代码路径。
在内存池中,所有适合于单个页面的块都以POOL_HEADER结构开始。此头部包含分配器所需的信息和标记。当尝试利用Windows内核中的堆溢出漏洞时,首先要被覆盖的是POOL_HEADER结构。攻击者有两种选择:正确地重写POOL_HEADER结构并攻击下一个块的数据,或直接攻击POOL_HEADER结构本身。
在这两种情况下,POOL_HEADER结构都将被覆盖,因此需要对每个字段及其使用方式有很好的理解,才能利用这种类型的漏洞。本文将重点讨论直接针对POOL_HEADER进行的攻击。
// Simplified POOL_HEADER structure in Windows 1809:
struct POOL_HEADER
{
char PreviousSize;
char PoolIndex;
char BlockSize;
char PoolType;
int PoolTag;
Ptr64 ProcessBilled;
};POOL_HEADER结构在不同版本的Windows中略有变化,但始终保持相同的主要字段。在Windows 1809 和 Windows 19H1 之前的版本中,所有字段都被使用:
PreviousSize是前一个块的大小除以16的结果;PoolIndex是一个指向PoolDescriptor数组的索引;BlockSize是当前分配的大小除以16的结果;PoolType是一个位字段,包含有关分配类型的信息;ProcessBilled是指向进行分配的KPROCESS的指针。只有在PoolType中设置了PoolQuota标志时才会设置该字段。
1.2 自Windows 7以来的攻击和缓解措施
Tarjei Mandt的论文《Windows 7上的内核池利用》[5]是一篇全面的参考文献,探讨了针对Windows 7内核池的攻击。该论文对内核池的内部工作原理进行了深入分析,并介绍了许多攻击方法,其中一些攻击针对POOL_HEADER进行了研究。
Quota Process Pointer Overwrite Allocation可以用于针对给定进程收取配额。为了实现这一目的,ExAllocatePoolWithQuotaTag函数将利用POOL_HEADER的ProcessBilled字段,将指向负责分配的_KPROCESS的指针存储其中。
论文中描述的一种攻击是配额进程指针覆写(Quota Process Pointer Overwrite)。该攻击利用堆溢出来覆写已分配块的ProcessBilled指针。当释放该块时,如果块的PoolType包含PoolQuota标志(0x8),则使用该指针来解引用一个值。控制这个指针提供了一个任意解引用的基本操作,足以从用户态提升特权。图4展示了这个攻击的过程。

自 Windows 8 开始,这种攻击已经得到了缓解,引入了ExpPoolQuotaCookie。该Cookie在启动时随机生成,并用于保护指针免受攻击者的覆写。例如,它用于对ProcessBilled字段进行异或操作。
ProcessBilled = KPROCESS_PTR ^ ExpPoolQuotaCookie ^ CHUNK_ADDR当释放该块时,内核会检查编码后的指针是否是一个有效的KPROCESS指针:
process_ptr = (struct _KPROCESS *)(chunk_addr ^ ExpPoolQuotaCookie ^ chunk_addr->process_billed);
if (process_ptr)
{
if (process_ptr < 0 xFFFF800000000000 || (process_ptr->Header.Type & 0 x7F) != 3)
KeBugCheckEx([...])
[...]
}在不知道块的地址和ExpPoolQuotaCookie的值的情况下,无法提供有效的指针,也无法进行任意解引用。然而,仍然有可能正确重写POOL_HEADER并进行全数据攻击,只需不在PoolType中设置PoolQuota标志。关于配额进程指针覆写攻击的更多信息,可以参考在**Nuit du Hack XV [1]**会议上的相关内容。
在 Windows 8 中引入了一种新的池内存类型:NonPagedPoolNx。它的工作方式与NonPagedPool完全相同,唯一的区别是内存页面不再可执行,从而减轻了使用这种内存存储Shellcode的所有攻击。之前在NonPagedPool中进行的分配现在改为使用NonPagedPoolNx,但为了与第三方驱动程序保持兼容性,保留了NonPagedPool类型。即使在今天的Windows 10中,仍有很多第三方驱动程序仍在使用可执行的NonPagedPool。
随着时间的推移,引入的各种缓解措施使得使用堆溢出攻击POOl_HEADER变得不再有趣。如今,更简单的方法是正确地重写POOL_HEADER并攻击下一个块的数据。然而,引入池中的Segment Heap改变了POOL_HEADER的使用方式,本论文展示了如何再次攻击它以利用内核池中的堆溢出漏洞。
2 The Pool Allocator with the Segment Heap
2.1 Segment Heap internals
Segment Heap自Windows 10 19H1起在内核中使用,与用户空间中使用的Segment Heap非常相似。本节旨在介绍Segment Heap的主要特点,并重点关注与用户空间中使用的Segment Heap的区别。用户空间Segment Heap的内部工作原理有一个非常详细的解释,可以在[7]中找到。
就像用户空间中使用的Segment Heap一样,内核中的Segment Heap旨在根据分配的大小提供不同的功能。为此,定义了四个所谓的后端(backends)。
- 低碎片化堆(LFH):
RtlHpLfhContextAllocate - 可变大小(VS,Variable Size):
RtlHpVsContextAllocateInternal - 分段分配(Seg,Segment Alloc):
RtlHpSegAlloc - 大型分配:
RtlHpLargeAlloc
请求的分配大小和选择的后端之间的映射如图5所示。

前三个后端,Seg、VS和LFH,分别与上下文相关联:_HEAP_SEG_CONTEXT、_HEAP_VS_CONTEXT和_HEAP_LFH_CONTEXT。后端上下文存储在_SEGMENT_HEAP结构中。
1: kd > dt nt!_SEGMENT_HEAP
+0 x000 EnvHandle : RTL_HP_ENV_HANDLE
+0 x010 Signature : Uint4B
+0 x014 GlobalFlags : Uint4B
+0 x018 Interceptor : Uint4B
+0 x01c ProcessHeapListIndex : Uint2B
+0 x01e AllocatedFromMetadata : Pos 0, 1 Bit
+0 x020 CommitLimitData : _RTL_HEAP_MEMORY_LIMIT_DATA
+0 x020 ReservedMustBeZero1 : Uint8B
+0 x028 UserContext : Ptr64 Void
+0 x030 ReservedMustBeZero2 : Uint8B
+0 x038 Spare : Ptr64 Void
+0 x040 LargeMetadataLock : Uint8B
+0 x048 LargeAllocMetadata : _RTL_RB_TREE
+0 x058 LargeReservedPages : Uint8B
+0 x060 LargeCommittedPages : Uint8B
+0 x068 StackTraceInitVar : _RTL_RUN_ONCE
+0 x080 MemStats : _HEAP_RUNTIME_MEMORY_STATS
+0 x0d8 GlobalLockCount : Uint2B
+0 x0dc GlobalLockOwner : Uint4B
+0 x0e0 ContextExtendLock : Uint8B
+0 x0e8 AllocatedBase : Ptr64 UChar
+0 x0f0 UncommittedBase : Ptr64 UChar
+0 x0f8 ReservedLimit : Ptr64 UChar
+0 x100 SegContexts : [2] _HEAP_SEG_CONTEXT
+0 x280 VsContext : _HEAP_VS_CONTEXT
+0 x340 LfhContext : _HEAP_LFH_CONTEXT存在5个这样的结构,对应不同的_POOL_TYPE值:
- 非分页池(位0未设置)
- 非分页Nx池(位0未设置且位9设置)
- 分页池(位0设置)
- 分页会话池(位5和1设置)
还分配了第五个_SEGEMENT_HEAP,但作者无法确定其目的。前三个_SEGEMENT_HEAP,对应于NonPaged、非分页NonPagedNx和分页池,存储在HEAP_POOL_NODES中。至于PagedPoolSession,相应的_SEGEMENT_HEAP存储在当前线程中。图6总结了这五个_SEGEMENT_HEAP:

尽管用户空间的Segment Heap仅使用一个Segment分配上下文来进行128 KiB到508 KiB之间的分配,在内核空间中,Segment Heap使用了两个Segment分配上下文。第二个分配上下文用于 508 KiB 到 7 GiB 之间的分配。
Segment Backend
Segment Backend用于分配大小在128 KiB到7 GiB之间的内存块。它还在幕后用于为 VS 和 LFH 后端分配内存。
Segment Backend上下文存储在一个名为_HEAP_SEG_CONTEXT的结构中。
1: kd > dt nt! _HEAP_SEG_CONTEXT
+0 x000 SegmentMask : Uint8B
+0 x008 UnitShift : UChar
+0 x009 PagesPerUnitShift : UChar
+0 x00a FirstDescriptorIndex : UChar
+0 x00b CachedCommitSoftShift : UChar
+0 x00c CachedCommitHighShift : UChar
+0 x00d Flags : <anonymous -tag >
+0 x010 MaxAllocationSize : Uint4B
+0 x014 OlpStatsOffset : Int2B
+0 x016 MemStatsOffset : Int2B
+0 x018 LfhContext : Ptr64 Void
+0 x020 VsContext : Ptr64 Void
+0 x028 EnvHandle : RTL_HP_ENV_HANDLE
+0 x038 Heap : Ptr64 Void
+0 x040 SegmentLock : Uint8B
+0 x048 SegmentListHead : _LIST_ENTRY
+0 x058 SegmentCount : Uint8B
+0 x060 FreePageRanges : _RTL_RB_TREE
+0 x070 FreeSegmentListLock : Uint8B
+0 x078 FreeSegmentList : [2] _SINGLE_LIST_ENTRYSegment Backend通过可变大小的块(称为段)来分配内存。每个段由多个可分配页面组成。段以链表的形式存储在SegmentListHead中。段以一个_HEAP_PAGE_SEGMENT开头,后跟256个_HEAP_PAGE_RANGE_DESCRIPTOR结构。
1: kd > dt nt! _HEAP_PAGE_SEGMENT
+0 x000 ListEntry : _LIST_ENTRY
+0 x010 Signature : Uint8B
+0 x018 SegmentCommitState : Ptr64 _HEAP_SEGMENT_MGR_COMMIT_STATE
+0 x020 UnusedWatermark : UChar
+0 x000 DescArray : [256] _HEAP_PAGE_RANGE_DESCRIPTOR
1: kd > dt nt! _HEAP_PAGE_RANGE_DESCRIPTOR
+0 x000 TreeNode : _RTL_BALANCED_NODE
+0 x000 TreeSignature : Uint4B
+0 x004 UnusedBytes : Uint4B
+0 x008 ExtraPresent : Pos 0, 1 Bit
+0 x008 Spare0 : Pos 1, 15 Bits
+0 x018 RangeFlags : UChar
+0 x019 CommittedPageCount : UChar
+0 x01a Spare : Uint2B
+0 x01c Key : _HEAP_DESCRIPTOR_KEY
+0 x01c Align : [3] UChar
+0 x01f UnitOffset : UChar
+0 x01f UnitSize : UChar为了提供快速查找空闲页面范围的功能,_HEAP_SEG_CONTEXT还维护了一个红黑树。每个_HEAP_PAGE_SEGMENT具有以下计算方法生成的签名:
Signature = Segment ^ SegContext ^ RtlpHpHeapGlobals ^ 0xA2E64EADA2E64EAD;这个签名用于从任何分配的内存块中检索所属的_HEAP_SEG_CONTEXT和相应的_SEGMENT_HEAP。图7总结了段后端使用的内部结构。可以通过将地址与_HEAP_SEG_CONTEXT中存储的SegmentMask进行屏蔽来轻松计算原始段。SegmentMask的值为0xfffffffffff00000。
Segment = Addr & SegContext->SegmentMask;通过使用_HEAP_SEG_CONTEXT中的UnitShift,可以轻松地从任何地址计算出相应的PageRange。UnitShift被设置为12。
PageRange = Segment + sizeof(_HEAP_PAGE_RANGE_DESCRIPTOR) * (Addr - Segment) >> SegContext->UnitShift;当Segment Backend被其他后端之一使用时,_HEAP_PAGE_RANGE_DESCRIPTOR的RangeFlags字段用于存储请求分配的后端信息。

Variable Size Backend
可变大小Backend
可变大小后端(Variable Size Backend)分配大小在512字节到128 KiB之间的内存块,旨在提供空闲块的便捷重用。可变大小后端的上下文存储在一个名为_HEAP_VS_CONTEXT的结构中:
0: kd > dt nt! _HEAP_VS_CONTEXT
+0 x000 Lock : Uint8B
+0 x008 LockType : _RTLP_HP_LOCK_TYPE
+0 x010 FreeChunkTree : _RTL_RB_TREE
+0 x020 SubsegmentList : _LIST_ENTRY
+0 x030 TotalCommittedUnits : Uint8B
+0 x038 FreeCommittedUnits : Uint8B
+0 x040 DelayFreeContext : _HEAP_VS_DELAY_FREE_CONTEXT
+0 x080 BackendCtx : Ptr64 Void
+0 x088 Callbacks : _HEAP_SUBALLOCATOR_CALLBACKS
+0 x0b0 Config : _RTL_HP_VS_CONFIG
+0 x0b4 Flags : Uint4B空闲块以红黑树的形式存储在名为FreeChunkTree的数据结构中。当请求进行内存分配时,红黑树用于查找具有精确大小的空闲块或第一个大于请求大小的空闲块。
释放的空闲块由一个专用的结构_HEAP_VS_CHUNK_FREE_HEADER作为头部进行管理:
0: kd > dt nt! _HEAP_VS_CHUNK_FREE_HEADER
+0 x000 Header : _HEAP_VS_CHUNK_HEADER
+0 x000 OverlapsHeader : Uint8B
+0 x008 Node : _RTL_BALANCED_NODE一旦找到一个空闲块,就会通过调用RtlpHpVsChunkSplit将其分割为所需的大小。分配的块都由一个专用的结构_HEAP_VS_CHUNK_HEADER作为头部进行管理:
0: kd > dt nt!_HEAP_VS_CHUNK_HEADER
+0 x000 Sizes : _HEAP_VS_CHUNK_HEADER_SIZE
+0 x008 EncodedSegmentPageOffset : Pos 0, 8 Bits
+0 x008 UnusedBytes : Pos 8, 1 Bit
+0 x008 SkipDuringWalk : Pos 9, 1 Bit
+0 x008 Spare : Pos 10, 22 Bits
+0 x008 AllocatedChunkBits : Uint4B
0: kd > dt nt!_HEAP_VS_CHUNK_HEADER_SIZE
+0 x000 MemoryCost : Pos 0, 16 Bits
+0 x000 UnsafeSize : Pos 16, 16 Bits
+0 x004 UnsafePrevSize : Pos 0, 16 Bits
+0 x004 Allocated : Pos 16, 8 Bits
+0 x000 KeyUShort : Uint2B
+0 x000 KeyULong : Uint4B
+0 x000 HeaderBits : Uint8B该头部内的所有字段都与RtlpHpHeapGlobals和块的地址进行异或运算。
Chunk->Sizes = Chunk->Sizes ^ Chunk ^ RtlpHpHeapGlobals;在内部,可变大小分配器(VS allocator)使用段分配器。它在RtlpHpVsSubsegmentCreate函数中通过_HEAP_VS_CONTEXT的_HEAP_SUBALLOCATOR_CALLBACKS字段使用。子分配器回调函数都与VS上下文和RtlpHpHeapGlobals的地址进行异或运算。
callbacks.Allocate = RtlpHpSegVsAllocate;
callbacks.Free = RtlpHpSegLfhVsFree;
callbacks.Commit = RtlpHpSegLfhVsCommit;
callbacks.Decommit = RtlpHpSegLfhVsDecommit;
callbacks.ExtendContext = NULL;如果在FreeChunkTree中没有足够大的块存在,将分配一个新的子段,其大小范围从64 KiB到256 KiB,并将其插入到SubsegmentList中。它以_HEAP_VS_SUBSEGMENT结构作为头部。剩余的所有空间都被用作自由块,并插入到FreeChunkTree中。
0: kd > dt nt! _HEAP_VS_SUBSEGMENT
+0 x000 ListEntry : _LIST_ENTRY
+0 x010 CommitBitmap : Uint8B
+0 x018 CommitLock : Uint8B
+0 x020 Size : Uint2B
+0 x022 Signature : Pos 0, 15 Bits
+0 x022 FullCommit : Pos 15, 1 Bit
图8总结了VS backends的内存结构。
当一个VS块被释放时,如果它的大小小于1 KiB,并且VS backends已经正确配置(Config.Flags的第4位设置为1),它将被临时存储在DelayFreeContext内部的列表中。一旦DelayFreeContext中填满了32个块,它们将一次性真正释放。DelayFreeContext永远不会用于直接分配。
Low Fragmentation Heap Backend
低碎片化堆
低碎片化堆(Low Fragmentation Heap)是专门用于从1字节到512字节的小内存分配的后端。LFH后端上下文存储在一个名为_HEAP_LFH_CONTEXT的结构中。
0: kd > dt nt! _HEAP_LFH_CONTEXT
+0 x000 BackendCtx : Ptr64 Void
+0 x008 Callbacks : _HEAP_SUBALLOCATOR_CALLBACKS
+0 x030 AffinityModArray : Ptr64 UChar
+0 x038 MaxAffinity : UChar
+0 x039 LockType : UChar
+0 x03a MemStatsOffset : Int2B
+0 x03c Config : _RTL_HP_LFH_CONFIG
+0 x040 BucketStats : _HEAP_LFH_SUBSEGMENT_STATS
+0 x048 SubsegmentCreationLock : Uint8B
+0 x080 Buckets : [129] Ptr64 _HEAP_LFH_BUCKETLFH后端的主要特点是使用不同大小的桶(buckets)来避免碎片化。
| Bucket | Allocation Size | Bucket granularity |
|---|---|---|
| 1 - 64 | 1 B - 1008 B | 16 B |
| 65 - 80 | 1009 B - 2032 B | 64 B |
| 81 - 96 | 2033 B - 4080 B | 128 B |
| 97 - 112 | 4081 B - 8176 B | 256 B |
| 113 - 128 | 8177 B - 16,368 B | 512 B |
每个桶由段分配器分配的子段组成。段分配器通过_HEAP_SUBALLOCATOR_CALLBACKS字段在_HEAP_LFH_CONTEXT中使用。子分配器回调函数与LFH上下文和RtlpHpHeapGlobals的地址进行异或运算。
callbacks.Allocate = RtlpHpSegLfhAllocate;
callbacks.Free = RtlpHpSegLfhVsFree;
callbacks.Commit = RtlpHpSegLfhVsCommit;
callbacks.Decommit = RtlpHpSegLfhVsDecommit;
callbacks.ExtendContext = RtlpHpSegLfhExtendContext;LFH子段以_HEAP_LFH_SUBSEGMENT结构为首:
0: kd > dt nt! _HEAP_LFH_SUBSEGMENT
+0 x000 ListEntry : _LIST_ENTRY
+0 x010 Owner : Ptr64 _HEAP_LFH_SUBSEGMENT_OWNER
+0 x010 DelayFree : _HEAP_LFH_SUBSEGMENT_DELAY_FREE
+0 x018 CommitLock : Uint8B
+0 x020 FreeCount : Uint2B
+0 x022 BlockCount : Uint2B
+0 x020 InterlockedShort : Int2B
+0 x020 InterlockedLong : Int4B
+0 x024 FreeHint : Uint2B
+0 x026 Location : UChar
+0 x027 WitheldBlockCount : UChar
+0 x028 BlockOffsets : _HEAP_LFH_SUBSEGMENT_ENCODED_OFFSETS
+0 x02c CommitUnitShift : UChar
+0 x02d CommitUnitCount : UChar
+0 x02e CommitStateOffset : Uint2B
+0 x030 BlockBitmap : [1] Uint8B然后,每个子段被分割成具有相应桶大小的不同LFH块。为了知道使用了哪个桶,每个子段头部都维护着一个位图。

当请求进行分配时,LFH分配器首先会查找_HEAP_LFH_SUBSEGMENT结构中的FreeHint字段,以找到子段中最后一个释放的块的偏移量。然后,它会按照32个块一组扫描BlockBitmap,寻找一个空闲块。这个扫描过程是通过RtlpLowFragHeapRandomData表进行随机化的。
根据给定桶上的竞争情况,可以启用一种机制,通过为每个CPU分配专用的子段来简化分配过程。这种机制被称为Affinity Slot(亲和槽)。
图9展示了LFH后端的主要架构。
Dynamic Lookaside
大小在0x200到0xF80字节之间的释放块可以临时存储在一个Lookaside列表中,以提供快速分配。当它们在Lookaside中时,这些块不会通过它们各自的backend释放机制。
Lookaside由_RTL_DYNAMIC_LOOKASIDE结构表示,并存储在_SEGMENT_HEAP的UserContext字段中:
0: kd > dt nt! _RTL_DYNAMIC_LOOKASIDE
+0 x000 EnabledBucketBitmap : Uint8B
+0 x008 BucketCount : Uint4B
+0 x00c ActiveBucketCount : Uint4B
+0 x040 Buckets : [64] _RTL_LOOKASIDE每个释放的块都存储在与其大小(以POOL_HEADER中表示)相对应的_RTL_LOOKASIDE中。大小对应关系与LFH中的桶的模式相同。
| Free List | Allocation Size | Bucket granularity |
|---|---|---|
| 1 – 32 | 512 B – 1024 B | 16 B |
| 33 – 48 | 1025 B – 2048 B | 64 B |
| 49 – 64 | 2049 B – 3967 B | 128 B |
在同一时间内,只有可用桶的一个子集被启用(_RTL_DYNAMIC_LOOKASIDE的ActiveBucketCount字段)。每次请求分配时,相应Lookaside的指标将被更新。
在平衡集管理器进行3次扫描后,动态Lookaside将进行重新平衡。自上次重新平衡以来使用最频繁的Lookaside将被启用。每个Lookaside的大小取决于其使用情况,但不能超过最大深度(MaximumDepth)或小于4。当新分配的数量少于25时,深度将减少10。否则,如果未命中比率低于0.5%,则深度将减少1;否则,它将按以下公式增长:

2.2 POOL_HEADER
如1.1节所述,POOL_HEADER结构在Windows 10 19H1之前的内核堆分配器中用作所有分配的块的头部。使用了所有字段。随着内核堆分配器的更新,POOL_HEADER的大部分字段变得无用,但仍然用于小内存分配的头部。 POOL_HEADER的定义如下所示。
struct POOL_HEADER
{
char PreviousSize;
char PoolIndex;
char BlockSize;
char PoolType;
int PoolTag;
Ptr64 ProcessBilled;
};分配器设置的唯一字段如下所示:
PoolHeader->PoolTag = PoolTag;
PoolHeader->BlockSize = BucketBlockSize >> 4;
PoolHeader->PreviousSize = 0;
PoolHeader->PoolType = changedPoolType & 0 x6D | 2;以下是自Windows 19H1以来每个POOL_HEADER字段的用途总结:
PreviousSize:未使用,保持为0。PoolIndex:未使用。BlockSize:块的大小。仅在最终将块存储在动态Lookaside列表中时使用(参见2.1节)。PoolType:用于保持请求的POOL_TYPE,使用方式未更改。PoolTag:用于保持PoolTag,使用方式未更改。ProcessBilled:用于跟踪需要分配的进程,如果PoolType为PoolQuota(位3),值的计算如下:ProcessBilled = chunk_addr ^ ExpPoolQuotaCookie ^ KPROCESS;
CacheAligned
在调用ExAllocatePoolWithTag时,如果PoolType的CacheAligned位设置(位2),返回的内存将按照缓存行大小对齐。缓存行大小的值取决于CPU,但通常为0x40。
首先,分配器将按照ExpCacheLineSize增加分配的大小:
if (PoolType & 4)
{
request_alloc_size += ExpCacheLineSize;
if (request_alloc_size > 0 xFE0)
{
request_alloc_size -= ExpCacheLineSize;
PoolType = PoolType & 0 xFB;
}
}如果新的分配大小无法填满(fit)单个页面,那么CacheAligned位将被忽略。
然后,分配的块必须满足以下三个条件:
- 最终分配的地址必须按照
ExpCacheLineSize对齐; - 块的开始处必须有一个
POOL_HEADER; - 块的地址减去
sizeof(POOL_HEADER)处必须有一个POOL_HEADER。
因此,如果分配的地址没有正确对齐,块可能会有两个头部。

第一个POOL_HEADER将位于块的开头,与通常情况一样,而第二个POOL_HEADER将在ExpCacheLineSize - sizeof(POOL_HEADER)的位置对齐,使得最终分配的地址按照ExpCacheLineSize对齐。第一个POOL_HEADER中将移除CacheAligned位,并且第二个POOL_HEADER将填充以下值:
PreviousSize:用于存储两个头部之间的偏移量。PoolIndex:未使用。BlockSize:在第一个POOL_HEADER中为分配的桶的大小,在第二个POOL_HEADER中为缩小的大小。PoolType:与通常情况一样,但设置了CacheAligned位。PoolTag:与通常情况一样,在两个POOL_HEADER上相同。ProcessBilled:未使用。
此外,如果在对齐填充中有足够的空间,可能会在第一个POOL_HEADER之后存储一个指针,我们称之为AlignedPoolHeader。它指向第二个POOL_HEADER,并与ExpPoolQuotaCookie进行异或运算。
图11总结了在进行缓存对齐时使用的两个POOL_HEADER的布局。
2.3 Summary
自Windows 19H1以及引入Segment Heap以来,不再需要将每个块的某些信息存储在POOL_HEADER中。然而,仍然需要Pooltype、Pooltag以及使用CacheAligned和PoolQuota机制的能力。
这就是为什么在0xFE0以下的所有分配仍然至少在前面有一个POOL_HEADER。自Windows 19H1以来,POOL_HEADER的字段使用情况在第2.2节中进行了描述。图12表示使用LFH后端分配的块,因此只有一个前置的POOL_HEADER。

如2.1节所述,根据backend的不同,内存可能会由特定的头部组成。例如,大小为0x280的块将使用VS后端,因此前面会有一个大小为0x10的_HEAP_VS_CHUNK_HEADER。图13表示使用VS段分配的块,因此前面有一个VS头部和一个POOL_HEADER。

最后,如果要求在缓存行上对齐分配,该块可能包含两个POOL_HEADER。第二个POOL_HEADER将设置CacheAligned位,并用于检索第一个POOL_HEADER和实际分配的地址。图14表示使用LFH分配并要求在缓存大小上对齐的块,因此前面有两个POOL_HEADER。
图15总结了进行分配时使用的决策树。


从利用的角度来看,可以得出两个结论。首先,POOL_HEADER的新用法将简化利用过程:由于大多数字段未使用,覆盖它们时需要更少的注意。另一个结果可能是利用POOL_HEADER的新用法来发现新的利用技术。
3 Attacking the POOL_HEADER
如果堆溢出漏洞允许对写入的数据及其大小有很好的控制,最简单的解决方案可能是重写POOL_HEADER并直接攻击下一个块的数据。唯一需要做的是确保PoolType中未设置PoolQuota位,以避免在释放受损块时对ProcessBilled字段进行完整性检查。
然而,本节将介绍一些仅使用几个字节的堆溢出可以进行的攻击,目标是POOL_HEADER。
3.1 Targeting the BlockSize
从堆溢出到更大的堆溢出
正如在2.1节中解释的那样,BlockSize字段用于在释放机制中将某些块存储在动态Lookaside中。
攻击者可以利用堆溢出来将BlockSize字段的值更改为更大的值,大于0x200。如果释放损坏的块,控制的BlockSize将用于将块存储在错误大小的Lookaside中。下一个此大小的分配可能会使用一个太小的分配来存储所有所需的数据,从而触发另一个堆溢出。
通过使用喷射技术和特定的对象,攻击者可以将一个3字节的堆溢出转变为最多0xFD0字节的堆溢出,具体取决于受漏洞影响的块的大小。这还允许攻击者选择溢出的对象,并可能对溢出条件具有更多控制。
3.2 Targeting the PoolType
大多数情况下,存储在PoolType中的信息仅用作信息提示;它在分配时提供,并存储在PoolType中,但不会在释放机制中使用。
例如,更改存储在PoolType中的内存类型实际上不会改变分配使用的内存类型。通过仅更改此位,无法将NonPagedPoolNx内存转换为NonPagedPool。
但是,对于PoolQuota和CacheAligned位,情况并非如此。设置PoolQuota位将触发在释放时使用POOL_HEADER中的ProcessBilled指针来取消引用配额。正如在1.2节中介绍的那样,对ProcessBilled指针的攻击已经得到缓解。
所以唯一剩下的位是cachealign位。
对齐块混淆
正如在第2.2节中所述,如果在PoolType中设置了CacheAligned位,则分配的布局将不同。
当分配器释放这样的分配时,它将尝试找到原始块地址以便在正确的地址释放块。它将使用对齐的POOL_HEADER的PreviousSize字段。分配器执行简单的减法运算来计算原始块地址:
if (AlignedHeader->PoolType & 4)
{
OriginalHeader = (QWORD)AlignedHeader - AlignedHeader->PreviousSize * 0 x10;
OriginalHeader->PoolType |= 4;
}在引入内核中的Segment Heap之前,这个操作之后进行了几个检查:
- 分配器检查原始块的
PoolType中是否设置了MustSucceed位。 - 使用
ExpCacheLineSize重新计算两个头部之间的偏移量,并验证它与实际偏移量是否相同。 - 分配器检查对齐的头部的
BlockSize是否等于原始头部的BlockSize加上对齐的头部的PreviousSize。 - 分配器检查存储在
OriginalHeader + sizeof(POOL_HEADER)处的指针是否等于对齐的头部的地址与ExpPoolQuotaCookie进行异或运算的结果。
自从Windows 10 19H1版本开始,使用Segment Heap的池分配器已经删除了所有这些检查。在原始头部之后,异或后的指针仍然存在,但是释放机制从未对其进行检查。作者认为某些检查可能被错误地删除了。未来的版本可能会重新启用某些检查,但是Windows 10 20H1的预构建版本中并没有这样的补丁。
目前,缺乏这些检查使得攻击者可以将PoolType作为攻击向量。攻击者可以利用堆溢出来设置下一个块的PoolType的CacheAligned位,并完全控制PreviousSize字段。当块被释放时,释放机制使用受控的PreviousSize来找到原始块并释放它。由于PreviousSize字段只占用一个字节,攻击者可以释放任何对齐于0x10至0xFF * 0x10 = 0xFF0的地址,这些地址都在原始块地址之前。
这篇论文的最后部分旨在使用前面介绍的技术演示一个通用的利用方式。它介绍了在池溢出或Use-After-Free情况下有趣的通用对象,并提供了多个对象和技术来重复使用具有受控数据的已释放分配。
4 Generic Exploitation
4.1 Required conditions
本节旨在介绍利用漏洞提升Windows系统权限的技术。假设攻击者处于低完整性级别。
最终的目的是开发尽可能通用的利用方式,可以在不同类型的内存(PagedPool和NonPagedPoolNx)、不同大小的块以及提供以下所需条件的任何堆溢出漏洞上使用。
- 当针对
BlockSize时,漏洞需要提供重写下一个块的POOL_HEADER的第三个字节为受控值的能力。 - 当针对
PoolType时,漏洞需要提供重写下一个块的POOL_HEADER的第一个和第四个字节为受控值的能力。 - 在所有情况下,需要控制易受攻击对象的分配和释放,以最大程度地提高喷洒(spraying)成功率。
4.2 Exploitation strategies
选择的利用策略利用了攻击下一个块的POOL_HEADER中的PoolType和PreviousSize字段的能力。易受堆溢出攻击的块被称为“易受攻击块”(“vulnerable chunk),其后放置的块被称为“被覆写块”(overwritten chunk)。
正如在第3.2节中描述的那样,通过控制下一个块的POOL_HEADER中的PoolType和PreviousSize字段,攻击者可以改变被覆写块实际上将被释放的位置。这个基本操作可以以多种方式进行利用。
这可以将池溢出转化为Use-After-Free的情况,当攻击者将PreviousSize字段设置为与易受攻击块的大小完全相同时。因此,在请求释放被覆写块时,实际上会释放易受攻击块,并使其处于使用后释放的状态。图16展示了这种技术。

然而,我们选择了另一种技术。这个基本操作还可以用于在易受攻击块的中间触发被覆写块的释放。可以在易受攻击块中(或替代它的块中)伪造一个假的POOL_HEADER,并使用PoolType攻击将释放重定向到这个块上。这将允许在合法块的中间创建一个假块,并处于一个非常好的溢出情况。这个对应的块被称为“幽灵块”(ghost chunk)。
幽灵块至少覆盖了两个块,即易受攻击块和被覆写块。图17展示了这种技术。

这种最后一种技术似乎比UAF更容易被利用,因为它使攻击者能够更好地控制任意对象的内容。 然后,易受攻击块可以被重新分配给一个允许任意数据控制的对象。这使得攻击者能够部分地控制在幽灵块中分配的对象。 为了找到一个有趣的对象放置在幽灵块中,需要满足以下要求,以获得最通用的利用方式:
- 如果完全或部分受控,则提供任意读/写基本操作;
- 能够控制其分配和释放;
- 具有最小为0x210的可变大小,以便从相应的
lookaside中分配到幽灵块中,但尽可能小(以避免在分配时破坏堆太多)。
由于易受攻击块可以放置在PagedPool和NonPagedPoolNx中,因此需要两个这种类型的对象,一个在PagedPool中分配,另一个在NonPagedPoolNx中分配。
这种类型的对象并不常见,作者没有找到这种完美的对象。因此,他们开发了一种利用策略,使用仅提供任意读取基本操作的对象。攻击者仍然能够控制幽灵块的POOL_HEADER。这意味着可以使用Quota Pointer Process Overwrite(配额指针进程覆写)攻击获得任意递减基本操作。使用任意读取基本操作可以恢复ExpPoolQuotaCookie和幽灵块的地址。
开发的利用利用了这种最后一种技术。通过利用堆整理和溢出对象,可以将4字节的受控溢出转化为权限提升,从低完整性级别到SYSTEM级别。
4.3 Targeted objects
Paged Pool
在创建管道后,用户可以向管道添加属性。这些属性是键值对,并存储在一个链表中。PipeAttribute(该结构不公开,以逆向工程命名)对象在PagedPool中分配,并由以下中的结构在内核中定义。
struct PipeAttribute
{
LIST_ENTRY list;
char *AttributeName;
uint64_t AttributeValueSize;
char *AttributeValue;
char data[0];
};分配的大小和数据完全由攻击者控制。AttributeName和AttributeValue是指向数据字段的不同偏移的指针。
可以使用NtFsControlFile系统调用和0x11003C控制码在管道上创建管道属性,如下所示:
HANDLE read_pipe;
HANDLE write_pipe;
char attribute[] = " attribute_name \00 attribute_value " char output[0 x100];
CreatePipe(read_pipe, write_pipe, NULL, bufsize);
NtFsControlFile(write_pipe,
NULL,
NULL,
NULL,
&status,
0 x11003C,
attribute,
sizeof(attribute),
output,
sizeof(output));然后可以使用0x110038控制码读取属性的值。AttributeValue指针和AttributeValueSize将用于读取属性的值并将其返回给用户。属性的值可以更改,但这将触发先前PipeAttribute的释放和新分配。
这意味着如果攻击者能够控制PipeAttribute的AttributeValue和AttributeValueSize字段,它可以在内核中读取任意数据,但不能任意写入。该对象还可以用于在内核中放置任意数据。这意味着它可以用于重新分配易受攻击块并控制幽灵块的内容。
NonPagedPoolNx
使用WriteFile向管道写入数据是一种已知的技术,用于喷射NonPagedPoolNx。在向管道写入数据时,NpAddDataQueueEntry函数会创建如下定义的结构:
struct PipeQueueEntry
{
LIST_ENTRY list;
IRP *linkedIRP;
__int64 SecurityClientContext;
int isDataInKernel;
int remaining_bytes__;
int DataSize;
int field_2C;
char data[1];
};PipeQueueEntry(该结构不公开,以逆向工程命名)的数据和大小由用户控制,因为数据直接存储在结构后面。
在函数NpReadDataQueue中使用该条目时,内核将遍历条目列表,并使用每个条目来检索数据
if (PipeQueueEntry->isDataAllocated == 1)
data_ptr = (PipeQueueEntry->linkedIRP->SystemBuffer);
else
data_ptr = PipeQueueEntry->data;
[...]
memmove((void *)(dst_buf + dst_len - cur_read_offset), &data_ptr[PipeQueueEntry->DataSize - cur_entry_offset], copy_size);如果isDataInKernel字段等于1,数据就不会直接存储在结构后面,而是指针存储在由linkedIRP指向的IRP中。如果攻击者能够完全控制这个结构,他可以将isDataInKernel设置为1,并将linkedIRP指向用户空间。然后,用户空间中的linkedIRP的SystemBuffer字段(偏移0x18)用于从条目中读取数据。这提供了任意读取的基本功能。这个对象也非常适合在内核中放置任意数据。这意味着它可以用于重新分配易受攻击的块并控制幽灵块的内容。
4.4 Spraying
本节描述了喷洒内核堆以获得所需的内存布局的技术。
为了获得第4.2节中所需的内存布局,需要进行一些堆喷洒。堆喷洒取决于易受攻击的块的大小,因为它将最终分配到不同的分配后端。
为了简化喷洒过程,可以确保相应的lookaside为空。分配超过256个相同大小的块将确保这一点。
如果易受攻击的块小于0x200,则它将位于LFH(低碎片化堆)后端。然后,喷洒应该使用完全相同大小的块进行,对应桶的粒度取模,以确保它们都从同一个桶中分配。如第2.1节所述,当请求分配时,LFH后端将按照最多32个块的组进行扫描BlockBitmap,并随机选择一个空闲块。在易受攻击的块分配之前和之后分配超过32个块应有助于打败随机化。
如果易受攻击的块大于0x200但小于0x10000,则它将位于可变大小(Variable Size)后端。然后,喷洒应该使用与易受攻击的块大小相等的大小进行。较大的块可能会被拆分,从而导致喷洒失败。首先,分配数千个所选大小的块,以确保首先将FreeChunkTree中大于所选大小的所有块清空,然后分配器将分配一个新的0x10000字节的VS子段并将其放入FreeChunkTree中。然后分配另外数千个块,它们将最终位于新的大空闲块中,从而连续。然后释放最后分配块的三分之一,以填充FreeChunkTree。只释放三分之一将确保不会合并任何块。然后让易受攻击的块分配。
最后,可以重新分配已释放的块以最大化喷洒机会。
由于完整的利用技术需要释放和重新分配易受攻击的块和幽灵块,为了方便释放块的恢复,启用相应的动态lookaside非常有趣。为此,一个简单的解决方案是分配数千个相应大小的块,等待2秒钟,然后再分配数千个块并等待1秒钟。这样,我们可以确保平衡集管理器已经重新平衡了相应的lookaside。分配数千个块确保lookaside将成为最常用的lookaside,并且将被启用,并且还确保它在其中有足够的空间。
4.5 Exploitation
演示设置(Demonstration setup)
为了演示以下攻击,我们创建了一个虚假的漏洞。
开发了一个Windows内核驱动程序,它公开了几个IOCTL(输入/输出控制)来实现以下功能:
- 在
PagedPool中分配一个具有可控大小的块 - 触发在该块中进行可控的
memcpy操作,从而导致完全可控的池溢出 - 释放已分配的块
当然,这仅用于演示目的,并提供了比实际攻击所需更多的控制。
这个设置允许攻击者:
- 控制易受攻击块的大小。这不是必需的,但最好能够控制大小,因为使用可控大小的块更容易进行攻击。
- 控制易受攻击块的分配和释放。
- 使用可控的值覆盖下一个块的
POOL_HEADER的前4个字节。
此外,易受攻击的块是在PagedPool中分配的。这一点很重要,因为池的类型可能会改变在攻击中使用的对象,从而对攻击本身产生重大影响。然而,针对NonPagedPoolNx的攻击利用非常相似,只是在喷洒和获取任意读取时使用PipeQueueEntry而不是PipeAttribute。
对于本示例,易受攻击块的选择大小将为0x180。关于易受攻击块大小及其对攻击的影响的讨论在第4.6节中有详细说明。
Creating the ghost chunk
在这里的第一步是调整堆,以便在易受攻击的块之后放置一个受控对象。覆写块中的对象可以是任何东西,唯一的要求是能够控制其何时被释放。为了简化攻击,最好选择一个可以喷洒的对象,请参考第4.2节。
现在可以触发漏洞,覆写块的POOL_HEADER将被替换为以下值:
PreviousSize:0x15。该大小将乘以0x10。0x180 - 0x150 = 0x30,这是易受攻击块中伪造POOL_HEADER的偏移量。PoolIndex:0或任何值,此值未使用。BlockSize:0或任何值,此值未使用。PoolType:PoolType | 4。设置了CacheAligned位。
必须在易受攻击的块中的已知偏移量处放置一个伪造的POOL_HEADER。这是通过释放易受攻击的对象并重新分配带有PipeAttribute对象的块来完成的。
为了演示,易受攻击块中伪造的POOL_HEADER的偏移量将为0x30。伪造的POOL_HEADER的形式如下:
PreviousSize:0或任何值,此值未使用。PoolIndex:0或任何值,此值未使用。BlockSize:0x21。该大小将乘以0x10,并成为释放块的大小。PoolType:PoolType。未设置CacheAligned和PoolQuota位。
所选择的BlockSize不是随机的,它是实际将被释放的块的大小。由于目标是在之后重用此分配,因此需要选择一个容易重用的大小。由于所有小于0x200的大小都在LFH中,必须避免使用这些大小。最小的不在LFH中的大小是0x200的分配,它是一个大小为0x210的块。大小为0x210的块使用VS分配,并且有资格使用第2.1节中描述的动态Lookaside列表。
可以通过喷洒和释放大小为0x210字节的块来启用大小为0x210的动态Lookaside列表。
现在可以释放覆写的块,这将触发缓存对齐。它不会释放覆写块地址处的块,而是释放OverwrittenChunkAddress - (0x15 * 0x10)地址处的块,即VulnerableChunkAddress + 0x30。用于释放的POOL_HEADER是伪造的POOL_HEADER,而不是释放易受攻击的块,内核会释放一个大小为0x210的块,并将其放置在动态Lookaside的顶部。这由图23所示。
不幸的是,伪造的POOL_HEADER的PoolType对于释放的块是放置在PagedPool还是NonPagedPoolNx没有影响。

动态Lookaside列表是使用分配的段来选择的,该段是从块的地址派生出来的。这意味着如果易受攻击的块在分页池中,那么这个幽灵块也将被放置在分页池的Lookaside列表中。
现在,覆写的块处于"丢失”(lost)状态;内核认为它已经被释放,并且对该块的所有引用都已经被丢弃。它将不再被使用。
Leaking the content of the ghost chunk
现在可以重新分配幽灵块,并使用PipeAttribute对象。PipeAttribute结构覆盖了放置在易受攻击块中的属性值。通过读取此管道属性的值,可以读取数据并泄漏幽灵块的PipeAttribute内容。现在已知幽灵块的地址,因此也知道易受攻击块的地址。这一步骤在图24中呈现。

Getting an arbitrary read
易受攻击的块可以再次被释放,并重新分配给另一个PipeAttribute。这次,PipeAttribute的数据将覆盖幽灵块的PipeAttribute。因此,可以完全控制幽灵块的PipeAttribute。一个新的PipeAttribute被注入到位于用户空间的链表中。这一步骤在图25中呈现。

现在,通过请求读取幽灵PipeAttribute上的属性,内核将使用位于用户空间的PipeAttribute,从而完全受控。如前所述,通过控制AttributeValue指针和AttributeValueSize,这提供了任意读取的基本操作。图26表示了一个任意读取操作。

利用第一个指针泄漏和任意读取,可以检索到npfs的文本节上的指针。通过读取导入表,可以读取ntoskrnl的文本节上的指针,这提供了内核的基址。从那里,攻击者可以读取ExpPoolQuotaCookie的值,并检索出用于利用进程的EPROCESS结构的地址和其TOKEN的地址。
Getting an arbitrary decrementation
首先,在内核空间中使用PipeQueueEntry构造一个伪造的EPROCESS结构,并使用任意读取检索其地址。
然后,攻击可以再次释放和重新分配易受攻击的块,以更改幽灵块及其POOL_HEADER的内容。幽灵块的POOL_HEADER被覆盖为以下值:
PreviousSize:0,或任何值,此值未使用。PoolIndex:0,或任何值,此值未使用。BlockSize:0x21。此大小将乘以0x10。PoolType:8。PoolQuota位已设置。PoolQuota:ExpPoolQuotaCookie XOR FakeEprocessAddress XOR GhostChunkAddress
在释放幽灵块时,内核将尝试解引用相关EPROCESS的配额计数器。它将使用伪造的EPROCESS结构来找到要解引用的值的指针。这提供了一个任意减少的原语。减少的值是PoolHeader中的BlockSize,因此它在0x10对齐,并且在0和0xff0之间。
This provides an arbitrary decrement primitive. The value of the decrementation is the BlockSize in the PoolHeader, so it’s aligned on 0x10 and between 0 and 0xff0
From arbitrary decrementation to SYSTEM
在2012年,Cesar Cerrudo [3]描述了一种通过设置TOKEN结构的Privileges.Enabled字段来提升特权的技术。
Privileges.Enabled字段保存了为该进程启用的特权。默认情况下,低完整性级别的令牌的Privileges.Enabled设置为值0x0000000000800000,仅赋予了SeChangeNotifyPrivilege特权。通过从该位域中减去1,它变为0x000000000007ffff,这将启用更多特权。
通过在该位域上设置第20位,可以启用SeDebugPrivilege。SeDebugPrivilege允许进程调试系统上的任何进程,从而能够向特权进程中注入任意代码。
[1]中解释的利用程序介绍了一种配额指针进程覆写的方法,该方法使用任意减少来在其进程中设置SeDebugPrivilege。图27展示了这种技术。

然而,自从Windows 10 v1607版本开始,内核现在还会检查Token的Privileges.Present字段的值。Token的Privileges.Present字段是可以通过使用AdjustTokenPrivileges API为该Token启用的特权列表。因此,TOKEN的实际特权现在是Privileges.Present和Privileges.Enabled的位域运算结果。
默认情况下,低完整性级别的令牌的Privileges.Present设置为0x602880000。因为0x602880000 & (1<<20) == 0,仅在Privileges.Enabled中设置SeDebugPrivilege是不足以获得SeDebugPrivilege特权。
一种思路是递减Privileges.Present位域,以便在Privileges.Present位域中获得SeDebugPrivilege。然后,攻击者可以使用AdjustTokenPrivileges API来启用SeDebugPrivilege。然而,SepAdjustPrivileges函数进行了额外的检查,根据TOKEN的完整性,即使所需的特权位于Privileges.Present位域中,进程也无法启用任何特权。对于高完整性级别,进程可以启用Privileges.Present位域中的任何特权。对于中等完整性级别,进程只能启用同时在Privileges.Present位域和0x1120160684位域中的特权。对于低完整性级别,进程只能启用同时在Privileges.Present位域和0x202800000位域中的特权。
这意味着通过单个任意递减操作来获取SYSTEM权限的技术已经失效。
然而,通过进行两次任意递减操作,首先递减Privileges.Enabled,然后递减Privileges.Present,完全可以实现该技术。
幽灵块可以被重新分配,并且它的POOL_HEADER可以被第二次覆写,以进行第二次任意递减操作。
一旦获得了SeDebugPrivilege特权,攻击者可以打开任何SYSTEM进程,并注入一个弹出SYSTEM权限的shellcode。
4.6 Discussion on the presented exploit
该漏洞利用的代码可在[2]处获取,同时还提供了受漏洞影响的驱动程序。这个漏洞利用只是一个概念验证,始终可以进行改进。
4.7 Discussion on the size of the vulnerable object
根据受漏洞对象的大小,漏洞利用可能有不同的要求。
上述漏洞利用仅适用于最小大小为0x130的受漏洞影响的块。这是由于幽灵块的大小必须至少为0x210。如果受漏洞影响的块的大小小于0x130,则分配幽灵块将覆盖在被覆写块后面的块,并在释放时触发崩溃。这是可以修复的,但留给读者作为练习。
在LFH(大小小于0x200的块)和VS段(大小大于0x200的块)中,受漏洞影响的对象有一些区别。主要区别在于VS块在块前面有一个额外的头部。这意味着为了能够控制VS段中下一个块的POOL_HEADER,至少需要进行0x14字节的堆溢出。这还意味着当覆写的块被释放时,它的_HEAP_VS_CHUNK_HEADER必须已经被修复。此外,还需要注意不要释放覆写块后面的两个喷洒块,因为VS的释放机制可能会尝试合并3个空闲块时读取覆写块的VS头部。
最后,LFH和VS中的堆整理方法有很大的不同,如第4.4节所述。
5 Conclusion
本论文描述了自Windows 10 19H1更新以来的池内部状态。Segment Heap已经引入内核,并且不需要块元数据才能正常工作。然而,每个块顶部原来的POOL_HEADER仍然存在,但用法有所不同。
我们展示了一些可以利用Windows内核中的堆溢出进行的攻击,通过攻击与池相关的内部结构。所展示的漏洞利用可以适应任何提供最小堆溢出的漏洞,并允许从低完整性级别提升到SYSTEM级别的本地权限提升。
References
- Corentin Bayet. Exploit of CVE-2017-6008 with Quota Process Pointer Overwrite attack. https://github.com/cbayet/Exploit-CVE-2017-6008/blob/ master/Windows10PoolParty.pdf, 2017.
- Corentin Bayet and Paul Fariello. PoC exploiting Aligned Chunk Confusion on Windows kernel Segment Heap. https://github.com/synacktiv/Windows-kernelSegmentHeap-Aligned-Chunk-Confusion, 2020.
- Cesar Cerrudo. Tricks to easily elevate its privileges. https://media.blackhat.com/ bh-us-12/Briefings/Cerrudo/BH_US_12_Cerrudo_Windows_Kernel_WP.pdf, 2012.
- Matt Conover and w00w00 Security Development. w00w00 on Heap Overflows. http://www.w00w00.org/files/articles/heaptut.txt, 1999.
- Tarjei Mandt. Kernel Pool Exploitation on Windows 7. Blackhat DC, 2011.
- Haroon Meer. Memory Corruption Attacks The (almost) Complete History. Blackhat USA, 2010.
- Mark Vincent Yason. Windows 10 Segment Heap Internals. Blackhat US, 2016.