
本文深入Windows内核对象管理器内部,剖析对象类型、句柄表、引用计数、安全描述符等核心机制,并附带驱动级代码实战,展示如何枚举进程句柄、劫持Token及检测隐藏对象。适合安全研究员与内核开发者。
Windows内核设计中最精妙也最复杂的子系统之一便是对象管理器(Object Manager)。几乎所有的系统资源——进程、线程、文件、注册表键、同步事件、令牌(Token)等——均以“内核对象”的形式存在,并通过统一的接口进行生命周期管理、访问控制和命名空间组织。
理解对象管理器,不仅是编写稳定驱动的基础,更是进行内核安全研究(如进程隐藏、提权、句柄劫持、EOP漏洞挖掘)的必修课。本文将从数据结构出发,结合WRK(Windows Research Kernel)和公开符号,逐层拆解对象管理器,并给出可直接运行的驱动程序代码,演示如何遍历句柄表、解析对象头、修改安全描述符,最终实现一个简易的“隐藏进程检测器”。
_OBJECT_HEADER 与对象体每个内核对象在内存中并非孤立的“对象体”本身,而是在其前部附加一个 _OBJECT_HEADER 结构(以及若干可选头)。布局如下:
+-----------------------+ <- 对象体起始地址 (Body)
| Object Body |
| (e.g., EPROCESS) |
+-----------------------+ <- 对象体偏移 - 0x30 (典型)
| _OBJECT_HEADER |
| - PointerCount |
| - HandleCount |
| - TypeIndex |
| - SecurityDescriptor|
| - ... |
+-----------------------+ <- 对象头起始
| (可选头: QUOTA, HANDLEINFO, etc.) |
+-----------------------+在64位Windows 10/11上,_OBJECT_HEADER 大小为0x30字节。对象体地址 = 对象头地址 + 0x30(并非固定,可通过 OBJECT_HEADER.BodyOffset 获取,但通常为0x30)。
关键字段:
ObReferenceObject/ObDereferenceObject 操作)。_OBJECT_TYPE 数组的索引,标识对象类型(进程、线程、文件等)。_OBJECT_TYPE每个类型(如 PsProcessType、PsThreadType)都是一个 _OBJECT_TYPE 实例,包含类型名称、默认非页池配额、打开/关闭回调、以及关键的方法表(如 Parse、Security、QueryName 等)。
可通过 ObGetObjectType 宏从对象头获取类型指针:
POBJECT_TYPE ObGetObjectType(PVOID ObjectBody) {
POBJECT_HEADER header = OBJECT_HEADER_FROM_BODY(ObjectBody);
return ObpTypeObjectType[header->TypeIndex];
}HANDLE 到对象指针用户态通过 HANDLE 引用内核对象,而内核实际维护的是每个进程独立的句柄表(Handle Table)。句柄表是一个稀疏数组,存储了 _HANDLE_TABLE_ENTRY 结构,其中包含:
PROCESS_ALL_ACCESS)。OBJ_INHERIT、OBJ_PROTECT_CLOSE。下面给出驱动代码,枚举指定进程ID的所有句柄,并打印每个句柄对应的对象类型和访问权限。
#include <ntddk.h>
NTSTATUS EnumerateHandlesForProcess(HANDLE ProcessId) {
PEPROCESS pTarget = NULL;
NTSTATUS status = PsLookupProcessByProcessId(ProcessId, &pTarget);
if (!NT_SUCCESS(status)) return status;
// 获取句柄表地址(不同Windows版本偏移不同,此处动态获取)
PHANDLE_TABLE pHandleTable = *(PHANDLE_TABLE*)((PUCHAR)pTarget + 0x4f8); // Win10 x64偏移,请根据实际更新
if (!pHandleTable) {
ObDereferenceObject(pTarget);
return STATUS_UNSUCCESSFUL;
}
// 获取句柄表锁
FAST_MUTEX* pMutex = &pHandleTable->HandleTableMutex;
KeAcquireGuardedMutex(pMutex);
// 遍历句柄表(简化:仅遍历一级表,实际需处理多层页表)
ULONG TableSize = pHandleTable->TableSize;
PHANDLE_TABLE_ENTRY pEntry = (PHANDLE_TABLE_ENTRY*)pHandleTable->Table;
for (ULONG i = 0; i < TableSize; i++) {
HANDLE_TABLE_ENTRY entry = pEntry[i];
if (!(entry.ObjectPointerBits & 0x1)) continue; // 空闲项
PVOID ObjectBody = (PVOID)(entry.ObjectPointerBits & ~0xF);
POBJECT_HEADER pHeader = OBJECT_HEADER_FROM_BODY(ObjectBody);
POBJECT_TYPE pType = ObGetObjectType(ObjectBody);
DbgPrint("[Handle 0x%X] Type: %ws, Access: 0x%X, Body: %p\n",
(HANDLE)(i << 2), pType->Name.Buffer, entry.GrantedAccess, ObjectBody);
}
KeReleaseGuardedMutex(pMutex);
ObDereferenceObject(pTarget);
return STATUS_SUCCESS;
}注意:句柄表实际是三级甚至四级页表结构(取决于系统配置),上述代码仅演示原理。生产驱动应使用
ExpLookupHandleTableEntry等未导出函数或通过NtQuerySystemInformation实现。
每个内核对象都关联一个安全描述符,存储在 _OBJECT_HEADER.SecurityDescriptor 中(自相对格式)。当用户态调用 OpenProcess、CreateFile 等API时,内核会调用 SeAccessCheck 函数,将调用者的Token与对象的DACL进行比对。
以下代码演示如何从进程对象中提取安全描述符,并打印DACL中的ACE数量:
NTSTATUS GetObjectSecurityDescriptor(PVOID ObjectBody) {
POBJECT_HEADER pHeader = OBJECT_HEADER_FROM_BODY(ObjectBody);
PSECURITY_DESCRIPTOR pSD = (PSECURITY_DESCRIPTOR)pHeader->SecurityDescriptor;
if (!pSD) return STATUS_NO_SECURITY_ON_OBJECT;
// 安全描述符可能是自相对格式,需先转换成绝对格式
SECURITY_DESCRIPTOR absoluteSD;
ULONG sdSize = sizeof(SECURITY_DESCRIPTOR);
RtlAbsoluteToSelfRelativeSD(pSD, &absoluteSD, &sdSize);
PACL pDacl = NULL;
BOOLEAN present, defaulted;
SeQuerySecurityDescriptorInfo(NULL, &pDacl, &present, &defaulted, pSD, &sdSize, NULL);
if (present && pDacl) {
DbgPrint("DACL has %u ACEs\n", pDacl->AceCount);
}
return STATUS_SUCCESS;
}在驱动中,通过 ObSetSecurityDescriptor 可以替换对象的安全描述符(需要 SeSecurityPrivilege)。下面是一个危险但常见的提权示例——将任意进程对象的DACL改为允许所有访问:
NTSTATUS GrantFullAccessToProcess(PEPROCESS Process) {
POBJECT_HEADER pHeader = OBJECT_HEADER_FROM_BODY(Process);
// 构造一个允许所有访问的DACL(实际应调用SeCreateSecurityDescriptor等API)
// 此仅为简化演示,真实场景需分配非页池并设置正确ACE。
PACL pNewDacl = NULL; // 需实际构造
NTSTATUS status = SeSetSecurityDescriptorInfo(NULL, &pNewDacl, &pHeader->SecurityDescriptor,
SE_OBJECT_TYPE_PROCESS, FALSE, FALSE);
return status;
}警告:直接修改内核对象的SD会绕过所有安全检查,极易导致系统不稳定或被检测为Rootkit,仅供研究使用。
PointerCount 和 HandleCount 共同决定对象的销毁时机。当 PointerCount 降为0时,对象将被释放回池。
ObReferenceObjectByHandle 会增加引用计数。ObDereferenceObject 减少引用计数。NtClose),HandleCount 减1,若变为0且引用计数为0,则对象销毁。某些Rootkit通过创建进程后关闭所有句柄,并移除EPROCESS链表,使得任务管理器无法发现。但对象管理器仍保留其引用(如驱动自己持有的引用)。我们可以遍历对象目录或句柄表来发现此类对象。
利用 ObReferenceObjectByName 或 ZwQuerySystemInformation(SystemHandleInformation)可以枚举全局句柄。以下代码通过 ZwQuerySystemInformation 枚举所有句柄,并统计每个进程对象的句柄数,输出无句柄但活动的进程:
NTSTATUS DetectHandleLessProcess() {
PSYSTEM_HANDLE_INFORMATION pInfo = NULL;
ULONG size = 0;
ZwQuerySystemInformation(SystemHandleInformation, NULL, 0, &size);
pInfo = (PSYSTEM_HANDLE_INFORMATION)ExAllocatePool(NonPagedPool, size);
if (!pInfo) return STATUS_NO_MEMORY;
ZwQuerySystemInformation(SystemHandleInformation, pInfo, size, &size);
// 统计每个PID的句柄数
for (ULONG i = 0; i < pInfo->NumberOfHandles; i++) {
// 忽略无效
}
// 然后对比进程列表,若EPROCESS存在但句柄数为0且未终止,则可能是隐藏对象。
ExFreePool(pInfo);
return STATUS_SUCCESS;
}命名对象(如 \Device\、\BaseNamedObjects\)通过 ObpRootDirectoryObject 管理,使用 ObCreateObject 并指定 ObjectAttributes 中的名称即可。
遍历命名空间可使用 ZwQueryDirectoryObject,驱动中可枚举所有设备对象。以下是枚举 \Device 下所有对象的代码:
NTSTATUS EnumerateDeviceObjects() {
UNICODE_STRING deviceDir = RTL_CONSTANT_STRING(L"\\Device");
OBJECT_ATTRIBUTES attr;
InitializeObjectAttributes(&attr, &deviceDir, OBJ_CASE_INSENSITIVE, NULL, NULL);
HANDLE hDir;
NTSTATUS status = ZwOpenDirectoryObject(&hDir, DIRECTORY_QUERY, &attr);
if (!NT_SUCCESS(status)) return status;
ULONG bufSize = 0x1000;
POBJECT_DIRECTORY_INFORMATION pInfo = (POBJECT_DIRECTORY_INFORMATION)ExAllocatePool(PagedPool, bufSize);
ULONG returnLen;
status = ZwQueryDirectoryObject(hDir, pInfo, bufSize, FALSE, TRUE, &returnLen, NULL);
if (NT_SUCCESS(status)) {
for (ULONG i = 0; pInfo[i].Name.Buffer; i++) {
DbgPrint("Object: %wZ, Type: %wZ\n", &pInfo[i].Name, &pInfo[i].TypeName);
}
}
ExFreePool(pInfo);
ZwClose(hDir);
return status;
}下面给出一个完整的小型驱动,在 DriverEntry 中注册进程创建回调,每次新进程创建时,打印其对象头的安全描述符和句柄计数。
VOID ProcessCreateNotify(HANDLE ParentId, HANDLE ProcessId, BOOLEAN Create) {
if (!Create) return;
PEPROCESS pProcess;
if (NT_SUCCESS(PsLookupProcessByProcessId(ProcessId, &pProcess))) {
POBJECT_HEADER pHeader = OBJECT_HEADER_FROM_BODY(pProcess);
DbgPrint("[Process %d] HandleCount=%d, PointerCount=%d, SD=%p\n",
ProcessId, pHeader->HandleCount, pHeader->PointerCount, pHeader->SecurityDescriptor);
ObDereferenceObject(pProcess);
}
}
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriver, PUNICODE_STRING pRegPath) {
PsSetCreateProcessNotifyRoutine(ProcessCreateNotify, FALSE);
return STATUS_SUCCESS;
}
VOID DriverUnload(PDRIVER_OBJECT pDriver) {
PsSetCreateProcessNotifyRoutine(ProcessCreateNotify, TRUE);
}_OBJECT_HEADER 的 TypeIndex 是否在合法范围内,以及 SecurityDescriptor 是否指向有效的自相对SD。ObjectPointerBits 是否指向有效的池内存(可使用 MmIsAddressValid)。PsProcessNotify 回调、CmRegisterCallback 等,检测是否被恶意驱动劫持。Windows内核对象管理器是操作系统的骨架,本文从内存布局、句柄表、安全描述符到引用计数,给出了详尽的代码示例。理解这些底层机制,能够帮助开发者编写高效、安全的驱动,也为安全研究人员提供了检测高级威胁(如Rootkit、内存驻留木马)的知识基础。
在实际开发中,务必注意版本差异(不同Windows build的偏移和结构体大小不同),建议结合微软公开符号(ntoskrnl.pdb)动态定位。同时,操作安全描述符和引用计数需极其谨慎,否则极易引发蓝屏。
延伸阅读:WRK中的
ob.h、ntobapi.h;Windows Internals 7th Edition Part 1 Chapter 5。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。