17、内核对象管理:对象类型、句柄表、对象引用计数、对象回调、对象安全描述符

内核对象管理,说白了就是Windows内核的“资产管理系统”。

你写的驱动,不管是操作设备、读写文件,还是管理进程,本质上都是在跟内核对象打交道。我早年刚接触驱动开发时,总觉得这玩意儿就是个“黑盒”——CreateFile返回个句柄,然后对着句柄操作就行了。直到有一次,我写的一个过滤驱动莫名其妙蓝屏,查了三天才发现是对象引用计数没处理好。

嗯,从那以后,我老老实实把对象管理这块啃了一遍。今天咱们就把它讲透。

17.1 对象类型:内核里的“类”

Windows内核里,每个对象都有个“身份证”——对象类型(Object Type)。

你可以把它理解成C++里的类。进程是一种类型,线程是一种类型,文件、事件、互斥体……各有各的类型。每种类型都定义了自己的行为:怎么创建、怎么关闭、怎么删除。

对象类型由 OBJECT_TYPE 结构体描述,关键字段如下:

字段含义
Name类型名称,比如 "Process"、"File"
DefaultObject默认对象指针(某些类型有)
TypeInfo类型信息,包含各种操作函数指针

其中 TypeInfo 是个 OBJECT_TYPE_INITIALIZER 结构,里面定义了:

  • DeleteProcedure:对象销毁时调用的回调
  • CloseProcedure:句柄关闭时调用的回调
  • SecurityProcedure:安全检查回调
  • QueryNameProcedure:查询对象名称的回调

核心要点:每种对象类型都有一组“虚函数表”。内核通过这个表来统一管理所有对象,而不需要关心具体类型。

我个人习惯在开发自定义对象类型时,把 DeleteProcedureCloseProcedure 写得特别小心。为什么?因为这两个回调一旦写错,轻则内存泄漏,重则直接蓝屏。

17.2 句柄表:进程的“对象抽屉”

每个进程都有一个句柄表(Handle Table)。

句柄,本质上就是一个索引值。你拿着这个索引,去句柄表里查,就能找到对应的内核对象指针。

句柄表的结构大致如下:

// 句柄表项(简化版)
typedef struct _HANDLE_TABLE_ENTRY {
    union {
        PVOID Object;           // 指向内核对象的指针
        ULONG32 ObAttributes;   // 对象属性(低3位是标志位)
        HANDLE_TABLE_ENTRY_INFO *InfoTable;
    };
    ULONG32 GrantedAccessBits;  // 授予的访问权限
} HANDLE_TABLE_ENTRY, *PHANDLE_TABLE_ENTRY;

注意看,Object 指针的低3位被用来存标志位了。这是内核常用的“偷位”技巧——反正指针低3位在64位系统上永远是0,不用白不用。

句柄表本身是个多级表,类似页表的结构:

  • 一级表:直接映射(小进程)
  • 二级表:中等规模
  • 三级表:大进程(比如跑了几百个线程的浏览器)

实战技巧:遍历句柄表时,别用 ZwQuerySystemInformationSystemHandleInformation——那个返回的是全局句柄信息,性能很差。我一般用 ExpLookupHandleTableEntry 配合进程的 ObjectTable 来查。

17.3 对象引用计数:别让对象“死”早了

每个内核对象都有一个引用计数(Reference Count)。

引用计数归零时,对象就会被销毁。这个机制很简单,但坑很多。

引用计数操作主要有两个函数:

  • ObReferenceObject:增加引用计数
  • ObDereferenceObject:减少引用计数

我遇到过最经典的bug是这样的:

// 错误示例:引用计数失衡
void BadFunction() {
    PDEVICE_OBJECT deviceObj = ...;
    ObReferenceObject(deviceObj);  // 引用+1
    
    // ... 做一些操作 ...
    
    // 忘记调用 ObDereferenceObject!
    // 设备对象永远无法被卸载
}

这种bug不会立刻蓝屏,但会导致驱动卸载不掉。你想想看,用户插拔设备时,系统发现引用计数不为0,直接拒绝卸载——用户就卡在那了。

避坑指南:我曾经在一个项目里,因为某个回调路径上多了一次 ObReferenceObject,导致设备对象泄漏。查了整整两天,最后用 !object 调试命令才发现引用计数多了1。

建议:每次 ObReferenceObject 都配一个 ObDereferenceObject,最好写在同一个函数里,或者用 __try/__finally 保证成对执行。

17.4 对象回调:监控对象的“摄像头”

从Windows Vista开始,内核提供了对象回调机制(Object Callbacks)。

你可以注册回调函数,监控进程、线程、桌面等对象的创建和复制操作。这个功能在安全软件、监控工具里用得特别多。

注册回调的API:

NTSTATUS ObRegisterCallbacks(
    POB_CALLBACK_REGISTRATION CallbackRegistration,
    PVOID *RegistrationHandle
);

回调结构体:

typedef struct _OB_CALLBACK_REGISTRATION {
    USHORT Version;               // 必须为 OB_FLT_REGISTRATION_VERSION
    USHORT OperationRegistrationCount;
    UNICODE_STRING Altitude;      // 海拔高度,决定回调顺序
    PVOID RegistrationContext;
    OB_OPERATION_REGISTRATION OperationRegistration[];
} OB_CALLBACK_REGISTRATION, *POB_CALLBACK_REGISTRATION;

每个 OB_OPERATION_REGISTRATION 指定了:

  • 监控的对象类型(如 PsProcessType、PsThreadType)
  • 监控的操作(创建、复制)
  • 回调函数指针

注意:回调函数运行在APC级别(APC_LEVEL),不能做太多耗时操作。我见过有人直接在回调里申请大内存,结果系统性能直接崩了。

回调函数原型:

OB_PREOP_CALLBACK_STATUS PreOperationCallback(
    PVOID RegistrationContext,
    POB_PRE_OPERATION_INFORMATION OperationInformation
);

你可以在这里决定:

  • 允许操作:返回 OB_PREOP_SUCCESS
  • 拒绝操作:返回 OB_PREOP_ERROR 并设置错误状态
  • 继续监控:返回 OB_PREOP_SUCCESS 并设置后置回调

17.5 对象安全描述符:谁可以碰这个对象?

每个内核对象都可以关联一个安全描述符(Security Descriptor)。

安全描述符定义了:谁(SID)能对这个对象做什么(访问掩码)。

安全描述符的结构:

typedef struct _SECURITY_DESCRIPTOR {
    UCHAR Revision;
    UCHAR Sbz1;
    SECURITY_DESCRIPTOR_CONTROL Control;
    PSID Owner;              // 所有者SID
    PSID Group;              // 主组SID
    PACL Sacl;               // 系统ACL(审计用)
    PACL Dacl;               // 自由ACL(权限控制)
} SECURITY_DESCRIPTOR, *PISECURITY_DESCRIPTOR;

DACL(自由访问控制列表)是核心。它包含一组ACE(访问控制项),每个ACE指定:

  • 允许还是拒绝
  • 针对哪个用户/组
  • 哪些操作权限

举个例子:

// 创建一个只允许SYSTEM访问的设备对象
void CreateSecureDevice() {
    UNICODE_STRING deviceName;
    RtlInitUnicodeString(&deviceName, L"\\Device\\MySecureDevice");
    
    SECURITY_DESCRIPTOR sd;
    InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION);
    
    SID_IDENTIFIER_AUTHORITY ntAuthority = SECURITY_NT_AUTHORITY;
    PSID systemSid;
    AllocateAndInitializeSid(&ntAuthority, 1, SECURITY_LOCAL_SYSTEM_RID, 
                             0, 0, 0, 0, 0, 0, 0, &systemSid);
    
    EXPLICIT_ACCESS ea;
    RtlZeroMemory(&ea, sizeof(ea));
    ea.grfAccessPermissions = GENERIC_ALL;
    ea.grfAccessMode = GRANT_ACCESS;
    ea.Trustee.TrusteeForm = TRUSTEE_IS_SID;
    ea.Trustee.ptstrName = (LPTSTR)systemSid;
    
    PACL dacl;
    SetEntriesInAcl(1, &ea, NULL, &dacl);
    SetSecurityDescriptorDacl(&sd, TRUE, dacl, FALSE);
    
    // 创建设备时指定安全描述符
    // ...
}

我的经验:很多驱动开发者图省事,直接传NULL安全描述符。这意味着所有用户都能访问你的设备——这在生产环境里是个大坑。我建议至少给设备对象设置一个最小权限的DACL,只允许SYSTEM和Administrators访问。

17.6 知识体系总览

下面这张图把内核对象管理的核心脉络串起来了:

内核对象管理体系 内核对象 对象类型 (OBJECT_TYPE) 句柄表 (Handle Table) 引用计数 (RefCount) 对象回调 (Callbacks) 安全描述符 (SD) 每个内核对象都关联这五个维度 对象类型定义行为,句柄表提供访问路径 引用计数控制生命周期,回调实现监控 安全描述符决定谁能碰

17.7 实战要点总结

讲了这么多,我总结几个实战中必须记住的点:

  1. 对象类型是静态的——系统启动时就注册好了,驱动可以注册自己的类型,但别乱改系统类型。
  2. 句柄表是进程私有的——A进程的句柄不能直接在B进程用,除非通过 DuplicateHandle
  3. 引用计数必须成对——多一次 ObReferenceObject 就多一次泄漏,少一次就蓝屏。
  4. 回调里别睡——APC_LEVEL下不能等事件、不能申请分页内存,否则系统直接崩给你看。
  5. 安全描述符别偷懒——NULL安全描述符等于“大门敞开”,生产环境一定要设权限。

最后提醒:调试对象相关问题时,Windbg的 !object!handle!process 命令是你的好帮手。我每次遇到句柄泄漏,第一件事就是 !handle 0 0 Process 看看哪个进程的句柄数异常。

好了,内核对象管理这块就讲到这里。内容不少,但都是驱动开发绕不开的基础。你写驱动时,多想想“这个对象的引用计数对不对”、“安全描述符设了没”,能省下不少调试时间。


公众号:蓝海资料掘金营,微信deep3321