0x01. “一切皆对象”
类似于 NIX 系统中一切皆文件的哲学,在 Windows NT kernel 当中同样有一切都是一个对象(object)的说法,文件、设备、同步机制、注册表项等在内核中都表示为对象*,每个对象有一个 header 存储对象基本信息(如名字、类型、位置),以及一个 body 存储数据
NT kernel 中的对象可以分为两类:
- 执行体对象(Executive Objects):执行体对外暴露的资源对象(如进程、线程、文件等),对用户态可见
- 内核对象(Kernel Objects):最基本的资源对象(如物理设备等),对用户态不可见
注:纯用户态对象不在本文讨论范围内,例如归属不同用户态子系统如 GDI、User32 管理的 GDI 对象、窗口对象等
一、_OBJECT_HEADER:对象基本信息
在 NT kernel 中 _OBJECT_HEADER 结构体用来存储对象的基本信息,定义如下:
kd> dt nt!_OBJECT_HEADER
+0x000 PointerCount : Int8B
+0x008 HandleCount : Int8B
+0x008 NextToFree : Ptr64 Void
+0x010 Lock : _EX_PUSH_LOCK
+0x018 TypeIndex : UChar
+0x019 TraceFlags : UChar
+0x019 DbgRefTrace : Pos 0, 1 Bit
+0x019 DbgTracePermanent : Pos 1, 1 Bit
+0x01a InfoMask : UChar
+0x01b Flags : UChar
+0x01b NewObject : Pos 0, 1 Bit
+0x01b KernelObject : Pos 1, 1 Bit
+0x01b KernelOnlyAccess : Pos 2, 1 Bit
+0x01b ExclusiveObject : Pos 3, 1 Bit
+0x01b PermanentObject : Pos 4, 1 Bit
+0x01b DefaultSecurityQuota : Pos 5, 1 Bit
+0x01b SingleHandleEntry : Pos 6, 1 Bit
+0x01b DeletedInline : Pos 7, 1 Bit
+0x01c Reserved : Uint4B
+0x020 ObjectCreateInfo : Ptr64 _OBJECT_CREATE_INFORMATION
+0x020 QuotaBlockCharged : Ptr64 Void
+0x028 SecurityDescriptor : Ptr64 Void
+0x030 Body : _QUAD
一个对象除了固定的 _OBJECT_HEADER 以外还可以有额外的 optional header 来存储额外的信息,对应的 optional header 是否存在由 _OBJECT_HEADER->InfoMask 掩码决定:

当 _OBJECT_HEADER->InfoMask 掩码中存在对应位时则表示 对应的 optional header 存在 ,由于 optional header 的存储顺序固定,NT kernel 可以很容易计算出不同 header 对应的偏移,掩码与 header type & size 对应关系如下:
| Bit | Type | Size (on X86) |
|---|---|---|
| 0x01 | nt!_OBJECT_HEADER_CREATOR_INFO | 0x10 |
| 0x02 | nt!_OBJECT_HEADER_NAME_INFO | 0x10 |
| 0x04 | nt!_OBJECT_HEADER_HANDLE_INFO | 0x08 |
| 0x08 | nt!_OBJECT_HEADER_QUOTA_INFO | 0x10 |
| 0x10 | nt!_OBJECT_HEADER_PROCESS_INFO | 0x08 |
在NT kernel 19H1 版本以前,Windows 内核仅使用 Pool Allocator ,此时对于每个分配的内核池对象都有一个 _POOL_HEADER 结构体存储相应的信息:
kd> dt nt!_POOL_HEADER
+0x000 PreviousSize : Pos 0, 8 Bits
+0x000 PoolIndex : Pos 8, 8 Bits
+0x002 BlockSize : Pos 0, 8 Bits
+0x002 PoolType : Pos 8, 8 Bits
+0x000 Ulong1 : Uint4B
+0x004 PoolTag : Uint4B
+0x008 ProcessBilled : Ptr64 _EPROCESS
+0x008 AllocatorBackTraceIndex : Uint2B
+0x00a PoolTagHash : Uint2B
由此我们可以得到一个内核对象的基本结构如下图所示:

二、对象管理器与命名空间
NT kernel 中的对象通过对象管理器(Object Manager)进行管理,用户态对这些对象的访问通常需要通过对象管理子系统,对象管理器主要负责如下任务:
- 管理对象的创建与销毁
- 维护对象命名空间数据库
- 追踪特定对象的访问权限
- 追踪特定对象的引用计数
- 管理对象的生命周期
对象可以归属于不同的命名空间(Namespace),NT Kernel 为这些 命名的内核对象 提供了多个命名空间:事件、信号量、互斥量、可等待计时器、文件映射对象、工作对象和符号链接对象
有一些内核对象不是命名的,例如线程对象通常是匿名的
不同的用户会话(user session)也是不同的命名空间,对象创建时默认归属于当前会话的命名空间,此外还有一个全局共享的全局命名空间,从而使得对象可以在不同会话间进行共享

创建对象时可以通过
"Global\"前缀来将该对象创建于全局命名空间中,例如通过如下代码可以创建一个属于全局命名空间的名为ARTTNBA3的事件对象:CreateEvent( NULL, FALSE, FALSE, "Global\\ARTTNBA3" );也可以通过
“Local\”前缀显式说明对象创建在会话命名空间中,需要注意的是全局命名空间不适用于 Windows Store 的应用
三、句柄(handler)
有点类似于 Linux 下文件描述符的概念,但是扩展到了大部分内核对象
非常不知所谓的翻译,笔者个人感觉翻译成 引用符 可能会更好一些
句柄(handler)是 Windows 下 用户态程序用来管理对应内核对象的一个对象描述符 ,表示形式为一个 值为 4 的倍数的 整数值(32/64 位系统中为 32/64 位整数),用户程序通过其拥有的句柄可以访问内核中相对应的内核对象
正如我们前面所言,NT Kernel 中有 “一切皆对象” 的概念,而这对于用户态应用程式而言实际上就意味着 “一切皆句柄”
在 WinDbg 当中,我们可以使用 !handle 命令查看当前进程所拥有的句柄:

在 NT Kernel 当中使用 _HANDLE_TABLE_ENTRY 结构体表示一个句柄,在 64 位系统中大小为 16 字节,定义如下(摘自 VERGILIUS - Windows 11 22H2 ):
//0x10 bytes (sizeof)
union _HANDLE_TABLE_ENTRY
{
volatile LONGLONG VolatileLowValue; //0x0
LONGLONG LowValue; //0x0
struct
{
struct _HANDLE_TABLE_ENTRY_INFO* volatile InfoTable; //0x0
LONGLONG HighValue; //0x8
union _HANDLE_TABLE_ENTRY* NextFreeHandleEntry; //0x8
struct _EXHANDLE LeafHandleValue; //0x8
};
LONGLONG RefCountField; //0x0
ULONGLONG Unlocked:1; //0x0
ULONGLONG RefCnt:16; //0x0
ULONGLONG Attributes:3; //0x0
struct
{
ULONGLONG ObjectPointerBits:44; //0x0
ULONG GrantedAccessBits:25; //0x8
ULONG NoRightsUpgrade:1; //0x8
ULONG Spare1:6; //0x8
};
ULONG Spare2; //0xc
};
虽然是个大联合体,我们实际上使用到的是如下部分:
Unlocked:用来锁定/解锁句柄RefCnt:句柄的引用计数的(注:这个字段据称是 反向计数 ,不过笔者尚未进行验证)Attributes:句柄属性,分三个 bitAudit(关闭句柄时是否触发日志)、Inherit(是否将句柄复制到Inheritance==True的子进程中) 、Protected(是否可以关闭句柄)ObjectPointerBits:内核对象的地址GrantedAccessBits:句柄的访问权限NoRightsUpgrade:Spare1 && Spare2:仅用作填充

句柄通过 句柄表 进行管理,在 NT Kernel 当中使用 _HANDLE_TABLE 结构体进行表示,定义如下(摘自 VERGILIUS - Windows 11 22H2 ):
//0x80 bytes (sizeof)
struct _HANDLE_TABLE
{
ULONG NextHandleNeedingPool; //0x0
LONG ExtraInfoPages; //0x4
volatile ULONGLONG TableCode; //0x8
struct _EPROCESS* QuotaProcess; //0x10
struct _LIST_ENTRY HandleTableList; //0x18
ULONG UniqueProcessId; //0x28
union
{
ULONG Flags; //0x2c
struct
{
UCHAR StrictFIFO:1; //0x2c
UCHAR EnableHandleExceptions:1; //0x2c
UCHAR Rundown:1; //0x2c
UCHAR Duplicated:1; //0x2c
UCHAR RaiseUMExceptionOnInvalidHandleClose:1; //0x2c
};
};
struct _EX_PUSH_LOCK HandleContentionEvent; //0x30
struct _EX_PUSH_LOCK HandleTableLock; //0x38
union
{
struct _HANDLE_TABLE_FREE_LIST FreeLists[1]; //0x40
struct
{
UCHAR ActualEntry[32]; //0x40
struct _HANDLE_TRACE_DEBUG_INFO* DebugInfo; //0x60
};
};
};
在 Windows 中一共有以下几种存储对象信息的句柄表:
- Windows NT kernel 中所有对象的句柄都存放在一个全局的句柄表当中
- 每个进程有其独立的一个句柄表(其地址存放在进程控制块的
_EPROCESS->_HANDLER_TABLE->TableCode中),进程所获取到的对象句柄值实际上是【该表对应项的下标索引值x4】
NT Kernel 中的句柄条目实际上以 结构体数组 形式存放在句柄表当中,每个句柄数组的大小应当为单张页面大小,在句柄表的 _HANDLE_TABLE::TableCode 字段中存放着句柄数组第一个条目的地址,其中 第 0 项为保留项 ,句柄表项为 _HANDLE_TABLE_ENTRY 结构,其低 44 位值左移 4 位加上 0xffff000000000000 便是对象头地址:

在 Windows 7 当中进程句柄表实际上仅简单存放对象的地址,但是到了高版本内核一切都开始变得复杂了起来:(
与此同时 TableCode 使用低 4 位标识句柄表的结构层次——即进程句柄表实际上可以有多层结构,类似于页表,仅有最后一层存放对象地址,其他层都用于存放表地址,每张表的大小为 4096 字节(一张内存页大小):
