Handle Table 及 ObCreateHandle 相关随笔
文章来源于对 https://rayanfam.com/topics/reversing-windows-internals-part1/ 的理解。
1. Handle Table 结构体如下所示(Win10, x64, Intel)
kd> dt nt!_HANDLE_TABLE +0x000 NextHandleNeedingPool : Uint4B +0x004 ExtraInfoPages : Int4B +0x008 TableCode : Uint8B //Table_Entry 地址 +0x010 QuotaProcess : Ptr64 _EPROCESS +0x018 HandleTableList : _LIST_ENTRY //连着其它进程的句柄表 +0x028 UniqueProcessId : Uint4B +0x02c Flags : Uint4B +0x02c StrictFIFO : Pos 0, 1 Bit +0x02c EnableHandleExceptions : Pos 1, 1 Bit +0x02c Rundown : Pos 2, 1 Bit // Run-Down Protection,避免访问释放后的句柄表 +0x02c Duplicated : Pos 3, 1 Bit +0x02c RaiseUMExceptionOnInvalidHandleClose : Pos 4, 1 Bit +0x030 HandleContentionEvent : _EX_PUSH_LOCK +0x038 HandleTableLock : _EX_PUSH_LOCK +0x040 FreeLists : [1] _HANDLE_TABLE_FREE_LIST //创建其它进程的句柄会优先写入释放的句柄空间,没有则新建 Entry +0x040 ActualEntry : [32] UChar +0x060 DebugInfo : Ptr64 _HANDLE_TRACE_DEBUG_INFO
偏移 0x10 的 QuotaProcess 实际上就是 _EPROCESS 结构体,里面存储着当前句柄表对应的进程的信息。_EPROCESS 结构体中的 ObjectTable 就是当前进程的句柄表[如下图所示]。
而 HandleTableList 作为双向链表自然指向了下一个和上一个进程的句柄表。 找 FLink 对应的句柄表也很简单,直接取 FLink 存储的值(Address),Address - 0x18 即是下一个句柄表的起始地址。下图 0xffff8482`c14ae680 即是下一个进程的句柄表。
2. Handle_Table_Entry(当前进程持有的句柄)
Handle_Tablle_Entry 结构体如下所示,RefCnt 是从 7fff 开始递减的,(ObjectPointerBits << 4) | 0xFFFF00000000 可以得到此句柄对应的 Object_Header:
注意这里的 InfoTable ,是一个压缩指针。这里以一个新的句柄 Entry 为例,算数右移 20 bits 得到的指针即为 Object Header 的地址,将句柄和对象连接起来,通过 NtQuerySystemInformation 的参数 SystemExtendedHandleInformation 的可查到所有的句柄和对应的对象:
存储在末尾的 2 字节,0x33、0xFC 组成的 20 bits 存储着引用计数和 Entry 的 Lock 属性。
正如 rayanfam 博客中所给的图所示,每个句柄表会有多个 Entry 代表当前进程所持有的多个句柄。下图中的 Object 即 Index 为 4 (4/8/C/...) 的句柄指向的进程对象。使用 !process 或 !object 都可以查看该进程的信息。
结合之前的 HandleTable 结构体,很容易得到 TableEntry 肯定不是在 _HANDLE_TABLE_FREE_LIST 结构体中。结构体如下所示:
//0x40 bytes (sizeof) struct _HANDLE_TABLE_FREE_LIST { struct _EX_PUSH_LOCK FreeListLock; //0x0 union _HANDLE_TABLE_ENTRY* FirstFreeHandleEntry; //0x8 union _HANDLE_TABLE_ENTRY* LastFreeHandleEntry; //0x10 LONG HandleCount; //0x18 ULONG HighWaterMark; //0x1c };
// https://www.vergiliusproject.com/kernels/x64/Windows%2010%20%7C%202016/2110%2021H2%20(November%202021%20Update)/_HANDLE_TABLE_FREE_LIST
遍历得到的 HandleCount 与实际的有偏差,实际上 LastFreeHandleEntry 对应的数据也都是为空,代表了可用于存储 Handle 的空间。
该文章 https://www.sysnative.com/forums/threads/object-headers-handles-and-types.34987/ 提到,TableCode 做一定的运算即可得到 Entry 表的地址(事实也确实很像一个地址)。
1: kd> ??(0xffff8482`b75ff001 & ~7) unsigned int64 0xffff8482`b75ff000
0xffff8482`b75ff001 & 7 得到 1 , 代表一级指针。
若 TabaleCode & 7 为 0 ,则表示 TabaleCode 直接指向了句柄数组(句柄数量不多),不需要再经过一层指针进行转换。
0xffff8482`b75f000 地址存储的数据为句柄表的指针,分别指向了三个句柄表(同属于一个进程的)。
使用 .process 命令切换进程 Context 后,得到进程的如下句柄信息。
而 !handle 4 得到的信息于 handle 表信息中的 0004:object 相同,即 0004 为二级句柄表的 Index ,Index 每 4 字节代表一个 Entry,倘若所有位数都使用则为 0 ~ 0xFFFF,即 Entry 数量为 0x10000/4 = 0x4000。 但是经过实际测试发现,一张表仅能存储 0x1000 的数据, 实际存储的 Entry 数量为 0x1000/10 - 1 = 0xFF 个(表头 0x10 的数据为空)。第 0x100 个 Entry 在第 2 个指针指向的表中。
3.ObpCreateHandle
在使用 ObRegisterCallbacks 注册对象回调函数后,发现在应用层可以保护进程不被其它进程结束,但来自驱动的终止命令 ZwTerminateProcess 却不受此限制。栈回溯发现 ObpCreateHandle 函数,结合其他人的分析试图找到原因。ObCreateHandle 函数定义如下:
__int64 __fastcall ObpCreateHandle( _OB_OPEN_REASON OpenReason, void *Object, unsigned int DesiredAccess, _ACCESS_STATE *AccessState, unsigned int ObjectPointer, unsigned int Attribute, char AccessMode, struct _OBJECT_CREATE_INFO *CreateInfo, int AccessMask2, PVOID *NewObject, PVOID *Handle);
typedef enum _OB_OPEN_REASON
{
ObCreateHandle = 0,
ObOpenHandle = 1,
ObDuplicateHandle = 2,
ObInheritHandle = 3,
ObMaxOpenReason = 4
} OB_OPEN_REASON;
在命中断点后,该函数的栈回溯如下图所示。可以看到在创建句柄时,ObpCreateHandle 会主动调用注册好的回调函数。
为了更好的理解 Object 和句柄,从 ObCreateHandle 函数开始分析。函数开头对传入参数的副本 HandleAttributes 进行一次校验(校验第 10 bit)。经过交叉引用可以发现其中一条调用链 PsOpenProcess -> ObOpenObjectByPointer -> ObpCreateHandle 。
对 HandleAttributes 进行溯源可以发现,对 HandleAttributes 的赋值在 PsOpenProcess 函数中(如下图所示)。
1. 当调用者来自用户态(PreviousMode == 1)对 Attributes 进行过滤 & 0001 1101 1111 0010 (0x1DF2),过滤掉的属性为 OBJ_KERNEL_HANDLE 和其它未导出的属性值。 2. 当调用者来自内核态时(PreviousMode == 0),需要对 PsOpenProcess 的最后一个参数 KPROCESSOR_MODE 进行校验。
在 NtOpenProcess 这条调用链中,调 PsOpenProcess 时传入的 PreviousMode 和 ProcessorMode 都是来自 KTHREAD 的 PreviousMode。其它函数对 ProcessorMode 的使用方式是否一致未知,但此处 OBJ_KERNEL_HANDLE 属性不会被过滤掉。
而后在使用当前进程的句柄表之前,ObReferenceProcessHandleTable 函数内部不仅取得了句柄表,且对句柄表做了保护。
这里 ObReferenceProcessHandleTable 函数的参数的类型其实是 _EPROCESS 结构体。
通过 gs:[0x188] 得到 _ETHREAD 结构体的地址(_KTHREAD 即_ETHREAD 的第一个结构体),随后偏移 0xB8 取到 _EPROCESS 结构体的地址。
3: kd> dt _kthread -r1 ffffe109`2504e080 //gs:[0x188] == ffffe109`2504e080
······························
+0x098 ApcState : _KAPC_STATE
+0x000 ApcListHead : [2] _LIST_ENTRY [ 0xffffe109`2504e118 - 0xffffe109`2504e118 ] +0x020 Process : 0xffffe109`2390b080 _KPROCESS // _KThread + 0x220 也是 _EProcess +0x028 InProgressFlags : 0 '' +0x028 KernelApcInProgress : 0y0 +0x028 SpecialApcInProgress : 0y0 +0x029 KernelApcPending : 0 '' +0x02a UserApcPendingAll : 0 '' +0x02a SpecialUserApcPending : 0y0 +0x02a UserApcPending : 0y0
+0x098 ApcStateFill : [0x2b] "???"
Run-down 机制在微软文档中有介绍:https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/run-down-protection,当一个对象处于 Run-Down 状态时,现有的对该对象的访问都已经完成且将来新的对该对象的请求都会被拒绝。通常在将要删除该对象时,将其置于 Run-Down 状态,后面再将该对象删除。
判断当前句柄表是否为内核句柄表且传入的参数 AccessState 是否有数据。如果 (AccessState 为空) || ( OriginalDesiredAceess 为空 & 句柄表为内核句柄表) ,则此处将对参数 AccessMask 进行过滤。此处需要注意的是,if 语句执行的最后,直接跳向了 LABEL_115 处,?????????????(原因待给出)。
ACCEES MASK 结构如下图所示,链接如下:https://learn.microsoft.com/en-us/windows/win32/secauthz/standard-access-rights 。GenericAccessMask 即高 4 bit,主要用于映射给 ObjectType 中的 GenericMapping。
从 RtlMapGenericMask 即可看出权限的赋予逻辑。这里即是将当前申请创建句柄的 Object 的 ObjectType.Initializer.GenericMapping (通用权限)赋予给参数 AccessMask 。
在前面的栈回溯中我们能看到 ObOpenObjectByPointer 中调用了 ObCreateHandle ,且在其在前段的代码中保证了 AccessState 的存在且 PsOpenProcess 调用 ObOpenObjectByPointer 时传入的 DesiredAccess 为 NULL,因此在驱动 OpenProcess 的行为中,会跳转到 LABEL_115。
在假设不跳转到 LABEL_115 的场景下,可以看到这里再次定位到 Object_Type 并对 Handle_Attributes 做了校验 (0x400 == OBJ_FORCE_ACCESS_CHECK)。
若 OpenReason >=2 ,此时是对句柄做额外操作(Duplicate/Inherit),需要对句柄做权限校验,此处跳过。
判断当前 ObjectType 的 OBJECT_TYPE_INITIALIZER 字段中 SecurityProcedure 是否为默认的 SeDefaultObjectMethod ,如果是则执行默认的对 SecurityDescriptor 的检测否则调用自行设置的函数进行检测。
计算出 SecurityDescriptor 中拒绝的权限(SACL/DACL)。而后应用于 Access_State 中。
在当前对象支持 Callback 时,调用 Callback 对权限进行过滤。值得注意的是,调用 ObpPreInterceptHandleCreate 的第三个参数仅在下图中的代码段会被使用,其它代码段均无涉及。这也就意味着仅仅在句柄表不为系统内核句柄表时修改的权限才会被应用(ObjectAccessBits 后面会被赋值给句柄权限字段)。因此,驱动主动获取句柄时不会被这种方法限制。
对权限进行过滤后就开始对句柄表的 Flags 做校验了。这里需要提一点,驱动作为组件加载在 System.exe 进程中,因此句柄表也在 System 进程中。
随后,查 Handle_Table 的 Flags 字段,0x4 代表该句柄表将被删除/独占 — Rundown,详见下图。 跳转到 LABEL_218 也就意味着将跳过后面的 Entry 分配,也意味着 handle 变量将为 NULL。
这里的 0xC000009A 在此网站 https://cable.ayra.ch/winerr/ 查询到的结果为 STATUS_INSUFFICIENT_RESOURCES 。LABEL_286 即函数末尾。
https://www.vergiliusproject.com/kernels/x64/Windows%2010%20%7C%202016/2110%2021H2%20(November%202021%20Update)/_HANDLE_TABLE 给出的 Flags 的含义如下所示:
在取得当前处理器核心的序号后。若 FirstFreeHandleEntry 不为空,代表还有多余的可用于存放 Entry 的空间,那么将跳过分配 Entry 的代码。
深入看一下 ExpAllocateHandleTableEntrySlow 函数,可以分析得一下结论:
1. 此函数用于分配新的 Entry 空间,实际上是分配一块大内存 0x1000 。 2. 函数内部对 TableCode 的末两位的值做了判断,这里分了 3 种情况。其中第二种情况是比较难触发的,可以配合 SysInternals 的工具 TestLimit 触发( -h )。
(1)末两位所组成的值为 0 时,代表句柄表已经全部用完,当前的句柄表已容纳不下其它句柄表指针(记此时的句柄表级别为 L2)。
此时将申请一块大小为 0x1000 的内存空间(记为 L1)并同时将申请一个大小为 0x1000 的 L2 表,随后将已满的句柄表
指针放置在 L1 表的表头,将新申请的 L2 表指针放在 L1 表的第二个元素处。最后将申请的 L1 表地址与 1 进行或运算
后的值放入 HandleTable->TalbleCode 中。
-------------------
| L2 | L2(new)|
|--------|--------| ===> L1 表。L2 表(也称 MidLevel Table)存储着指针列表,每个指针指向了 L3 表。
|--------|--------| L3 表(也称 LowLevel Table)即存储着多个 Entry
(2)末两位所组成的值为 2 时,校验 HandleTable->NextHandleNeedingPool 的值,若大于 0x80 * 0x80000 = 0x4000000,
则直接返回 NULL。若小于则通过(TableCode & 0xFFFFFFFFFFFFFFFC) + (NextHandleNeedingPool/0x10000)
定位到 L1 表中 L2 表的地址(下图一,这里实际上就是取 NextHandleNeedingPool 的高 4 字节)。随后调用
ExpAllocateLowLevelTable 函数(内部会涉及到 QuotaProcess) 分配 LowLevel Table。将 LowLevel Table 的地址
填入 L2 表中。填入地址的计算方式是: L2TableAddress + [(NextHandleNeedingPool >> 0xA) & 0x1FF]*8 , 实际上
就是取 NextHandleNeedingPool 的第 11 位到第 19 位的值,得到的就是应该写入的第几个元素。下图二所示。
(3)末两位所组成的值为 1 时,校验 NextHandleNeedingPool 的值, 若小于 0x200 * 0x400 = 0x80000,则直接分配
LowLevel Table 并按照与(2)相同的方式将 LowLevel Table 的地址写入到 TableCode 表中,不过此处不需要取低 9 位。
若大于 0x80000,则按照(1)中的方式来做,分配 MidLevel Table 并替换 HandleTable->TalbleCode 中的值。
3. 前三种情况所分配的 LowLevel Table 地址都会作为参数被传入 ExpInsertLowLevelTableIntoFreeList 。此函数抛开对 PushLock
的处理,核心就是将 LowLevel Table + 0x10 得到的地址放入 HandleTable->FreeLists[x].FreeListLock.Value 中,同时
还会将 NextHandleNeedingPool 加 0x400(0x400 >> 0xA == 1)。
if ( *(_QWORD *)(FreeListLockValue + 8) ) // FirstFreeHandleEntry 不为空
*(_QWORD *)(*(_QWORD *)(FreeListLockValue + 0x10) + 8) = LowTableAddr; // [[LastFreeHandleEntry]+8]
else
*(_QWORD *)(FreeListLockValue + 8) = LowTableAddr; // 将 LowLevel Table 地址填入 [FirstFreeHandleEntry] 中
*(_QWORD *)(FreeListLockValue + 0x10) = (LowTableAddr + 0xFF0); //将 LowLevel Table 末尾地址填入 [LastFreeHandleEntry]
FirstFreeHandleEntry 不为空时,数据排布如下图所示。
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
| LowTableAddr+0xFF0 | LowTableAddr |
|--------------------- |---------------------| => LastFreeHandleEntry 指向的地址。
----------------------------------------------
在 FreeList 还充足时则会跳过对 Entry 的分配,此时将从 FreeHandleEntry 中取到 Entry 的地址,随后更新 FirstFreeHandleEntry 并更新相应的数据,如下所示
对 FreeList 解锁,若没有获取到 HandleEntry 则跳到 While 循环中分配 Entry。否则,计算当前 HandleEntry 在句柄表中的 Index。这里的 handle 变量就是句柄表里的 index 。
正如前面提到的,每一张 LowLevel Table 的大小为 0x1000,通过对当前 HandleEntry(ffff8482`b3bfee20) 取低 12 位即可获得当前 Table 的基址。
Table 基址偏移 8 字节可以取当前 Table 所存储句柄的起始 Index。
下图即是当前 Table 所存储的第一个句柄 Entry。
具体计算方式如下所示:
LowAddr = FreeEntryAddr & 0xFFFFFFFFFFFFFFF000; EntryIndex = *(LowAddr + 0x8) + (LowAddr / 0x10)*4; //(IndexBase + IndexOff)
随后将句柄的信息写入
HandleEntry->HighValue = dwValidAccessBits; HandleEntry->LowValue = ObjectHeaderPointerBits; // 运算后指向 ObjectHeader
附录
Strict FIFO 即代表 Strict First-In,First-Out。在微软文档中并未找到明显的出处,但根据名称推测应该是与 CPU 核心相关的某种机制。同样在 vergiliusproject 的 HandleTable 界面,可以看到以下的结构体:
这里的 Union 类型是在 FreeLists 和它下面的结构体选一个使用,下图可以看到,FreeLists 的实际大小为 0x24,地址取整一般来说会取到 0x30,这里是 0x40 则有些可疑。
在内存中看,HandleCount 并没有因为地址对齐使得 HighWaterMark 有变化,这里确实是有些奇怪的。