[翻译]Windows 内核池原理 (win7 x86)

0x00:前言

查了一下好像没看到啥关于内核池原理的翻译,国外的一个论文有详细说Win7池溢出的原理。内容比较长,今天来翻译阅读一下Win 7内核的池部分的原理。

0X01:正文

在本节中,我们将详细介绍内核池管理结构和涉及池内存分配和释放的算法。理解内核池行为对于正确评估其安全性和健壮性至关重要。为了简单起见,我们假设是x86架构(32位)。但是,大多数结构都适用于AMD64/x64(64位)。第2.9节讨论了x86和x64架构之间内核池的显著差异。

1. 非一致内存体系结构(NUMA)

对于每个新版本的Windows,内存管理器都得到了增强,以更好地支持非统一内存体系结构(NUMA),这是一种在现代多处理器系统中使用的内存设计体系结构。NUMA将不同的内存库分配给不同的处理器,允许更快地访问本地内存,而更慢地访问远程内存。处理器和内存被分组在称为节点的更小的单元中,由执行内核中的KNODE结构定义。

typedef struct _KNODE
{
/*0x000*/ union _SLIST_HEADER PagedPoolSListHead;
/*0x008*/ union _SLIST_HEADER NonPagedPoolSListHead[3];
/*0x020*/ struct _GROUP_AFFINITY Affinity;
/*0x02C*/ ULONG32 ProximityId;
/*0x030*/ UINT16 NodeNumber;
/*0x032*/ UINT16 PrimaryNodeNumber;
/*0x034*/ UINT8 MaximumProcessors;
/*0x035*/ UINT8 Color;
/*0x036*/ struct _flags Flags;
/*0x037*/ UINT8 NodePad0;
/*0x038*/ ULONG32 Seed;
/*0x03C*/ ULONG32 MmShiftedColor;
/*0x040*/ ULONG32 FreeCount[2];
/*0x048*/ struct _CACHED_KSTACK_LIST CachedKernelStacks;
/*0x060*/ LONG32 ParkLock;
/*0x064*/ ULONG32 NodePad1;
/*0x068*/ UINT8 _PADDING0_[0x18];
} KNODE, *PKNODE;

在多节点系统(nt!KeNumberNodes > 1),内存管理器总是试图从理想的节点分配。因此,KNODE提供了关于在color字段中何处找到本地内存的信息。此值是一个数组索引,分配和自由算法使用该索引将节点与其首选池关联起来。此外,KNODE为空闲池页面定义了四个单链接的每个节点后备列表(在第6节中讨论)。

2. 系统内存池

在系统初始化时,内存管理器根据系统节点的数量创建动态大小的内存池。每个池都由一个池描述符定义,这是一个管理结构,用于跟踪池的使用并定义池属性(如内存类型)。有两种不同类型的池内存:分页内存和非分页内存。

分页池内存可以从任何进程上下文中分配和访问,但只能在中断级别小于DPC/dispatch级别使用。正在使用的分页池的数量由nt!ExpNumberOfPagedPools提供。在单处理器系统上,定义了四(4)个分页池描述符,用nt中的索引1到4表示!ExpPagedPoolDescriptor数组。在多处理器系统上,每个节点定义一个分页池描述符。在这两种情况下,为原型池/全页分配定义了一个额外的分页池描述符,在nt!ExpPagedPoolDescriptor中以索引0表示。因此,在大多数桌面系统中定义了五(5)个分页池描述符。

此外,会话池内存(由win32k使用)用于会话空间分配,并且对于每个用户会话都是惟一的。当非分页会话内存使用全局非分页池描述符时,分页会话池内存在nt!MM_SESSION_SPACE中定义了自己的池描述符。为了获得会话池描述符,Windows 7解析相关的nt!EPROCESS结构去获取绘画空间结构,然后找到嵌入的分页池描述符。

3. 内核池的描述符

与用户模式堆非常相似,每个内核池都需要一个管理结构。池描述符负责跟踪正在运行的分配的数量、正在使用的页面和关于池使用的其他信息。它还帮助系统跟踪可重用的池块。池描述符由以下结构定义(nt!_POOL_DESCRIPTOR)。

typedef struct _POOL_DESCRIPTOR
{
/*0x000*/ enum _POOL_TYPE PoolType;
union {
/*0x004*/ struct _KGUARDED_MUTEX PagedLock;
/*0x004*/ ULONG32 NonPagedLock;
};
/*0x040*/ LONG32 RunningAllocs;
/*0x044*/ LONG32 RunningDeAllocs;
/*0x048*/ LONG32 TotalBigPages;
/*0x04C*/ LONG32 ThreadsProcessingDeferrals;
/*0x050*/ ULONG32 TotalBytes;
/*0x054*/ UINT8 _PADDING0_[0x2C];
/*0x080*/ ULONG32 PoolIndex;
/*0x084*/ UINT8 _PADDING1_[0x3C];
/*0x0C0*/ LONG32 TotalPages;
/*0x0C4*/ UINT8 _PADDING2_[0x3C];
/*0x100*/ VOID** PendingFrees;
/*0x104*/ LONG32 PendingFreeDepth;
/*0x108*/ UINT8 _PADDING3_[0x38];
/*0x140*/ struct _LIST_ENTRY ListHeads[512];
} POOL_DESCRIPTOR, *PPOOL_DESCRIPTOR;

池描述符包含内存管理器使用的几个重要列表。pendingfrees指向的延迟释放列表是等待释放的池块的单链接列表。第8节对此作了详细说明。ListHeads是一个由相同大小的空闲池块的双链表组成的数组。与延迟的空闲列表不同,ListHeads列表中的块已经被释放,可以由内存管理器在任何时候分配。在下一节中,我们将讨论ListHeads。

4. ListHeads Lists (Free Lists)

ListHeads列表(或空闲列表)按8字节粒度大小排序,用于分配高达4080字节的数据。空闲块按块大小索引到ListHeads数组中,计算为请求的字节数四入到8的倍数并除以8,或BlockSize = (NumberOfBytes+0xF) >> 3。执行舍入是为了为池标头保留空间,池标头是位于所有池块之前的结构。在x86 Windows上,池标头的定义如下

typedef struct _POOL_HEADER
{
union {
struct {
/*0x000*/ UINT16 PreviousSize : 9;
/*0x000*/ UINT16 PoolIndex : 7;
/*0x002*/ UINT16 BlockSize : 9;
/*0x002*/ UINT16 PoolType : 7;
};
/*0x000*/ ULONG32 Ulong1;
};
union {
/*0x004*/ ULONG32 PoolTag;
struct {
/*0x004*/ UINT16 AllocatorBackTraceIndex;
/*0x006*/ UINT16 PoolTagHash;
};
};
} POOL_HEADER, *PPOOL_HEADER

池头包含分配和自由算法正常运行所需的信息。PreviousSize指示前一个池块的块大小。由于内存管理器总是试图通过合并边界空闲块来减少碎片,所以它通常用于定位前一个块的池头。PreviousSize的大小也可以是零,在这种情况下,池块位于池页面的开头。

PoolIndex将索引提供到关联的池描述符数组中,例如nt!ExpPagedPoolDescriptor。free算法使用它来确保池块被释放到正确的池描述符ListHeads。在第吃溢出攻击的第四节中,我们将展示攻击者如何破坏这个值,以便将池标头损坏(例如池溢出)扩展为任意内存损坏。

顾名思义,PoolType定义块的池类型。但是,它还指示某个块是繁忙的还是空闲的。如果块是空闲的,则PoolType设置为零。另一方面,如果一个块很忙,PoolType被设置为它的描述符的池类型(池类型enum中的值,如下所示),或者使用池中使用的位掩码’ed。这个位掩码在Vista和更高版本中被设置为2,而在XP/2003中被设置为4。例如,对于Vista和Windows 7中繁忙的分页池块,PoolType = PagedPool|2 = 3

typedef enum _POOL_TYPE
{
NonPagedPool = 0 /*0x0*/,
PagedPool = 1 /*0x1*/,
NonPagedPoolMustSucceed = 2 /*0x2*/,
DontUseThisType = 3 /*0x3*/,
NonPagedPoolCacheAligned = 4 /*0x4*/,
PagedPoolCacheAligned = 5 /*0x5*/,
NonPagedPoolCacheAlignedMustS = 6 /*0x6*/,
MaxPoolType = 7 /*0x7*/,
NonPagedPoolSession = 32 /*0x20*/,
PagedPoolSession = 33 /*0x21*/,
NonPagedPoolMustSucceedSession = 34 /*0x22*/,
DontUseThisTypeSession = 35 /*0x23*/,
NonPagedPoolCacheAlignedSession = 36 /*0x24*/,
PagedPoolCacheAlignedSession = 37 /*0x25*/,
NonPagedPoolCacheAlignedMustSSession = 38 /*0x26*/
} POOL_TYPE, *PPOOL_TYPE;

如果一个池块是空闲的,并且在一个ListHeads列表中,它的池头立即后面跟着一个LIST_ENTRY结构。由于这个原因,单块大小(8字节)的块不会由ListHeads维护,因为它们不够大,容纳不了这个结构。

typedef struct _LIST_ENTRY
{
/*0x000*/ struct _LIST_ENTRY* Flink;
/*0x004*/ struct _LIST_ENTRY* Blink;
} LIST_ENTRY, *PLIST_ENTRY;

列表条目结构用于在双链表上联接池块。历史上,它一直是利用用户模式堆[5]和内核池中的内存损坏漏洞的目标,这主要是由于众所周知的“write-4”利用技术。随着Windows XP SP2的发布,微软解决了用户模式堆中的列表条目攻击问题,同样,在Windows 7的内核池中也解决了这个问题。

5. Lookaside Lists

内核使用单链接的lookaside (LIFO)列表来更快地分配和释放小池块。它们被设计用于操作高度一致的删除条目。为了更好地利用CPU缓存,在处理器控制块(KPRCB)中每个处理器都定义了lookaside 列表。KPRCB结构保存分页分配(Paged Lookaside List)和非分页分配(PPNaged Lookaside List)的lookaside列表,以及用于频繁请求的固定大小分配的专用后备列表(PPLookasideList)(例如I/O请求包和内存描述符列表,当前代码并在添加和时使用原子比较和交换的指令)

typedef struct _KPRCB
{
...
/*0x5A0*/ struct _PP_LOOKASIDE_LIST PPLookasideList[16];
/*0x620*/ struct _GENERAL_LOOKASIDE_POOL PPNPagedLookasideList[32];
/*0xF20*/ struct _GENERAL_LOOKASIDE_POOL PPPagedLookasideList[32];
...
} KPRCB, *PKPRCB;

对于分页和非分页的lookaside列表,最大块大小为0x20。因此,每个类型有32个惟一的lookaside列表。每个lookaside列表由常规lookaside池结构定义,如下所示。

typedef struct _GENERAL_LOOKASIDE_POOL
{
union
{
/*0x000*/ union _SLIST_HEADER ListHead;
/*0x000*/ struct _SINGLE_LIST_ENTRY SingleListHead;
};
/*0x008*/ UINT16 Depth;
/*0x00A*/ UINT16 MaximumDepth;
/*0x00C*/ ULONG32 TotalAllocates;
union
{
/*0x010*/ ULONG32 AllocateMisses;
/*0x010*/ ULONG32 AllocateHits;
};
/*0x014*/ ULONG32 TotalFrees;
union
{
/*0x018*/ ULONG32 FreeMisses;
/*0x018*/ ULONG32 FreeHits;
};
/*0x01C*/ enum _POOL_TYPE Type;
/*0x020*/ ULONG32 Tag;
/*0x024*/ ULONG32 Size;
union
{
/*0x028*/ PVOID AllocateEx;
/*0x028*/ PVOID Allocate;
};
union
{
/*0x02C*/ PVOID FreeEx;
/*0x02C*/ PVOID Free;
};
/*0x030*/ struct _LIST_ENTRY ListEntry;
/*0x038*/ ULONG32 LastTotalAllocates;
union
{
/*0x03C*/ ULONG32 LastAllocateMisses;
/*0x03C*/ ULONG32 LastAllocateHits;
};
/*0x040*/ ULONG32 Future[2];
} GENERAL_LOOKASIDE_POOL, *PGENERAL_LOOKASIDE_POOL;

对于结构,SingleListHead。指向单链接lookaside列表上的第一个空闲池块。后备列表(lookaside list)的大小受深度值的限制,由平衡集管理器根据后备列表上的命中次数和未命中次数定期调整。因此,经常使用的后备列表比不经常使用的列表具有更大的深度值。初始深度为(nt!ExMinimumLookasideDepth),最大深度 为MaximumDepth(256)。如果后备列表已满,则池块将被释放到适当的ListHeads列表。

同时也为会话(Session pool)池定义了后备列表。分页会话池分配使用在会话空间中定义的独立后备列表(nt!ExpSessionPoolLookaside)。每个会话后备列表的最大块大小是0x19,由nt!ExpSessionPoolSmallLists设置。会话池后备列表使用常规后备池结构,与常规后备池相同,但有额外的填充。对于非分页的会话池分配,使用前面讨论的非分页的每个处理器后备列表。

如果设置了热/冷页分离池标志,则禁用池块的后备列表(nt!ExpPoolFlags & 0 x100)。该标志是在系统启动期间设置的,以提高速度和减少内存占用。计时器(在nt!ExpBootFinishedTimer中设置)在启动后2分钟关闭热/冷页面分离。

6. Large Pool Allocations

池描述符ListHeads维护的块小于一个页面。大于4080字节(需要一个页面或更多)的池分配由nt!ExpAllocateBigPool处理。反过来,这个函数调用nt!MiAllocatePoolPages,池页分配器,它将请求的大小舍入到最接近的页大小。块大小为1页并且Previous size 大小为0的碎片块被立即放置在大池分配之后,以便池分配器可以使用剩余的页片段。然后将多余的字节放回适当的池描述符ListHeads列表的尾部。

回顾第1节,每个节点(由KNODE定义)都有4个与之关联的单链接lookaside列表。池页分配器在快速处理小页计数请求时使用这些列表。对于分页内存,KNODE为单页分配定义了一个lookaside列表(PagedPoolSListHead)。对于非分页分配,定义了分页计数1、2和3的lookaside列表(非pagedpoolslisthead[3])。池页面lookaside列表的大小由系统中存在的物理页面的数量决定。

如果不能使用后备列表,则使用分配位图来获取请求的池页面。位图(在RTL位图中定义)是一个位数组,它指示正在使用哪些内存页,并为每个主要池类型创建。搜索第一个索引,该索引保存请求的未使用页面的数量。对于分页池,位图在

MM_PAGED_POOL_INFO息结构中定义,由nt!MmPagedPoolInfo指向。对于非分页池,位图由nt!MiNonPagedPoolBitMap指向。对于会话池,位图在MM_SESSION_SPACE结构中定义

对于大多数大池分配,nt!ExAllocatePoolWithTag将请求额外的4个字节(x64上为8)来存储池体末端的分配大小。随后,当释放分配(在ExFreePoolWithTag中)以捕获可能的池溢出时,将检查此值。

7. 分配算法

为了分配池内存,内核模块和第三方驱动程序调用了执行内核导出的ExAllocatepoolwithtag(或它的任何包装器函数)。这个函数首先尝试使用lookaside列表,然后是ListHeads列表,如果不能返回池块,则从池页分配器请求一个页面。下面的伪代码大致描述了它的实现。

PVOID
ExAllocatePoolWithTag( POOL_TYPE PoolType,
SIZE_T NumberOfBytes,
ULONG Tag)
// call pool page allocator if size is above 4080 bytes
if (NumberOfBytes > 0xff0) {
// call nt!ExpAllocateBigPool
}
// attempt to use lookaside lists
if (PoolType & PagedPool) {
if (PoolType & SessionPool && BlockSize <= 0x19) {
// try the session paged lookaside list
// return on success
}
else if (BlockSize <= 0x20) {
// try the per-processor paged lookaside list
// return on success
}
// lock paged pool descriptor (round robin or local node)
}
else { // NonPagedPool
if (BlockSize <= 0x20) {
// try the per-processor non-paged lookaside list
// return on success
}
// lock non-paged pool descriptor (local node)
}
// attempt to use listheads lists
for (n = BlockSize-1; n < 512; n++) {
if (ListHeads[n].Flink == &ListHeads[n]) { // empty
continue; // try next block size
}
// safe unlink ListHeads[n].Flink
// split if larger than needed
// return chunk
}
// no chunk found, call nt!MiAllocatePoolPages
// split page and return chunk

如果从ListHeads[n]列表返回一个大于所请求大小的块,则对该块进行分割。为了减少碎片,分配器返回的过大块的部分取决于它的相对页面位置。如果块是页面对齐的,则从块的前面部分切割分配请求的大小。如果块没有页面对齐,则从块的后面分配请求的大小。无论如何,分割块的剩余(未使用的)片段被放在适当的ListHeads列表的尾部。

8. 释放算法

free算法由ExFreePoolWithTag实现,它检查要释放的块的池头并将其释放到适当的列表中。为了减少碎片,它还尝试合并相邻的空闲块。下面的伪代码展示了算法是如何工作的。

VOID
ExFreePoolWithTag( PVOID Entry,
ULONG Tag)
if (PAGE_ALIGNED(Entry)) {
// call nt!MiFreePoolPages
// return on success
}
if (Entry->BlockSize != NextEntry->PreviousSize)
BugCheckEx(BAD_POOL_HEADER);
if (Entry->PoolType & SessionPagedPool && Entry->BlockSize <= 0x19) {
// put in session pool lookaside list
// return on success
}
else if (Entry->BlockSize <= 0x20) {
if (Entry->PoolType & PagedPool) {
// put in per-processor paged lookaside list
// return on success
}
else { // NonPagedPool
// put in per-processor non-paged lookaside list
// return on success
} }
if (ExpPoolFlags & DELAY_FREE) { // 0x200
if (PendingFreeDepth >= 0x20) {
// call nt!ExDeferredFreePool
}
// add Entry to PendingFrees list
}
else {
if (IS_FREE(NextEntry) && !PAGE_ALIGNED(NextEntry)) {
// safe unlink next entry
// merge next with current chunk
}
if (IS_FREE(PreviousEntry)) {
// safe unlink previous entry
// merge previous with current chunk
}
if (IS_FULL_PAGE(Entry))
// call nt!MiFreePoolPages
else {
// insert Entry to ListHeads[BlockSize - 1]
} }

延迟释放池标志(nt!ExpPoolFlags & 0x200)支持一次释放多个池分配的性能优化,以摊销池获取和释放。仅当释放的块的标志MmNumberOfPhysicalPages >= 0x1fc00时开启延时释放。如果列表包含32个或更多的块(由PendingFreeDepth决定),它将在对ExDeferredFreePool的调用中被处理。这个函数遍历每个条目并将其释放到适当的ListHeads列表中,如下面的伪代码所示。

VOID
ExDeferredFreePool( PPOOL_DESCRIPTOR PoolDesc,
BOOLEAN bMultipleThreads)
for each (Entry in PendingFrees) {
if (IS_FREE(NextEntry) && !PAGE_ALIGNED(NextEntry)) {
// safe unlink next entry
// merge next with current chunk
}
if (IS_FREE(PreviousEntry)) {
// safe unlink previous entry
// merge previous with current chunk
}
if (IS_FULL_PAGE(Entry))
// add to full page list
else {
// insert Entry to ListHeads[BlockSize - 1]
} }
for each (page in full page list) {
// call nt!MiFreePoolPages
}

释放到lookaside和listhead总是放在适当列表的前面。(这个规则的例外是放在列表末尾的分割块的剩余片段。当内存管理器返回大于请求大小的块(如2.7节所解释的)时,块就会被分割,例如在ExpBigPoolAllocation中被分割的完整页面和在locatepoolwithtag中被分割的ListHeads条目。)为了尽可能频繁地使用CPU缓存,分配总是从最近使用的块开始,从适当列表的前面开始。

9. AMD64/x64 Kernel Pool Changes

尽管支持更大的物理地址空间,但x64 Windows并没有对内核池引入任何重大更改。但是,为了适应指针宽度的变化,块大小粒度增加到16字节,计算方法是BlockSize = (NumberOfBytes+0x1F) >> 4。为了反映这一变化,将相应地更新池标头。

typedef struct _POOL_HEADER
{
union
{
struct
{
/*0x000*/ ULONG32 PreviousSize : 8;
/*0x000*/ ULONG32 PoolIndex : 8;
/*0x000*/ ULONG32 BlockSize : 8;
/*0x000*/ ULONG32 PoolType : 8;
};
/*0x000*/ ULONG32 Ulong1;
};
/*0x004*/ ULONG32 PoolTag;
union
{
/*0x008*/ struct _EPROCESS* ProcessBilled;
struct
{
/*0x008*/ UINT16 AllocatorBackTraceIndex;
/*0x00A*/ UINT16 PoolTagHash;
/*0x00C*/ UINT8 _PADDING0_[0x4];
};
};
} POOL_HEADER, *PPOOL_HEADER;

由于块大小粒度的变化,以前的块大小和块大小都减少到8位。因此,池描述符ListHeads包含256个双链表,而不是x86上的512个。这还允许为PoolIndex分配额外的位,因此在x64上可能支持256个节点(池描述符),而在x86上支持128个节点。此外,池标头被扩展到16字节,幷包括配额管理中用于标识为分配收费的进程的processbilling指针。在x86上,这个指针存储在池体的最后四个字节中。我们将在第3.5节中讨论利用配额进程指针的攻击。

0x02:明日计划

继续翻译并阅读该论文的内核攻击部分

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章