第 4 章 对象管理 --- 4.8 系统调用 NtDuplicateObject / 4.9 系统调用 NtClose
本节深入两个最常用的对象管理 API:
NtDuplicateObject和NtClose。前者用于句柄复制(特别是跨进程),后者用于句柄关闭。这两个 API 是 IPC、资源管理、安全边界的核心。
4.8 系统调用 NtDuplicateObject()
4.8.0 框架图
┌──────────────────────────────────────────────────────────────────────────────────┐
│ NtDuplicateObject 调用流程 │
│ │
│ 源进程 (SourceProcess) 目标进程 (TargetProcess) │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ HandleTable │ │ HandleTable │ │
│ │ [SourceHandle] │ │ [TargetHandle] │ │
│ │ - Object │─── 指向 ────▶ │ - Object │ │
│ │ - GrantedAccess │ 同一对象 │ - GrantedAccess │ │
│ │ - ObAttributes │ │ - ObAttributes │ │
│ └────────────────────┘ └────────────────────┘ │
│ │
│ NtDuplicateObject 流程: │
│ 1. 验证源/目标进程句柄(需要 PROCESS_DUP_HANDLE 权限) │
│ 2. 解析源句柄 → 找到对象 │
│ 3. 计算新 GrantedAccess(可缩减) │
│ 4. 在目标进程中分配新句柄 Entry │
│ 5. 复制 ObAttributes(可指定) │
│ 6. 递增对象的引用计数 │
└──────────────────────────────────────────────────────────────────────────────────┘
4.8.0.1 设计意图
核心问题
句柄是进程私有的。一个进程无法直接"看到"另一个进程的句柄。但很多场景需要跨进程句柄传递:
- 进程间通信(IPC):传递管道句柄
- 进程注入:传递线程句柄
- 安全审计:复制句柄以观察其使用
- 服务调用:将客户端的句柄传递给服务
NtDuplicateObject 是这一需求的统一入口。
设计哲学 :「显式跨边界」------任何跨进程的句柄传递都必须显式指定,并通过安全检查。
本节定位
本节以 ReactOS 源码中 NtDuplicateObject(obhandle.c:3410(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L3410))为线索,深入解析:
- 跨进程句柄传递的安全检查
- 权限的"缩减"语义
- 句柄属性的精确控制
- 错误处理与回滚
4.8.1 NtDuplicateObject 函数签名
c
NTSTATUS
NTAPI
NtDuplicateObject(
IN HANDLE SourceProcessHandle,
IN HANDLE SourceHandle,
IN HANDLE TargetProcessHandle OPTIONAL,
OUT PHANDLE TargetHandle OPTIONAL,
IN ACCESS_MASK DesiredAccess,
IN ULONG HandleAttributes,
IN ULONG Options);
参数详解:
| 参数 | 含义 |
|---|---|
SourceProcessHandle |
源进程句柄(需 PROCESS_DUP_HANDLE 权限) |
SourceHandle |
源进程中的句柄值 |
TargetProcessHandle |
目标进程句柄(NULL 表示当前进程) |
TargetHandle |
输出:目标进程中的新句柄值(NULL 表示只检查权限) |
DesiredAccess |
新句柄的访问权限(可缩减) |
HandleAttributes |
新句柄的属性(OBJ_INHERIT、OBJ_KERNEL_HANDLE) |
Options |
选项标志(DUPLICATE_SAME_ACCESS 等) |
返回值:
STATUS_SUCCESS:成功STATUS_ACCESS_DENIED:权限不足STATUS_INVALID_HANDLE:句柄无效STATUS_PROCESS_IS_TERMINATING:进程正在终止
Options 标志:
| 标志 | 含义 |
|---|---|
DUPLICATE_CLOSE_SOURCE (1) |
复制后关闭源句柄(move 语义) |
DUPLICATE_SAME_ACCESS (2) |
目标句柄权限与源句柄完全相同 |
DUPLICATE_SAME_ATTRIBUTES (4) |
目标句柄属性与源句柄完全相同 |
4.8.2 进程句柄的解析
NtDuplicateObject 的第一步是解析源进程和目标进程句柄:
c
/* 1. 解析源进程句柄 */
Status = ObReferenceObjectByHandle(
SourceProcessHandle,
PROCESS_DUP_HANDLE, // 关键权限
PsProcessType,
PreviousMode,
(PVOID*)&SourceProcess,
NULL);
if (!NT_SUCCESS(Status)) return Status;
/* 2. 解析目标进程句柄(如果指定) */
if (TargetProcessHandle) {
Status = ObReferenceObjectByHandle(
TargetProcessHandle,
PROCESS_DUP_HANDLE, // 关键权限
PsProcessType,
PreviousMode,
(PVOID*)&TargetProcess,
NULL);
/* 即使失败也允许 Target = NULL(表示当前进程) */
} else {
TargetProcess = PsGetCurrentProcess();
}
关键设计决策的深度分析:
1. PROCESS_DUP_HANDLE 权限的语义
为什么需要一个专门的权限位来控制句柄复制?而不是简单地使用 PROCESS_ALL_ACCESS?
- 最小权限原则:句柄复制是一项高风险操作,但不等于"完全控制进程"------一个进程可能需要复制另一个进程的句柄,但不需要挂起它的线程或修改它的内存
- 安全边界 :
PROCESS_DUP_HANDLE是 Windows 进程安全描述符中独立的一个访问位(位0x0040),与PROCESS_VM_READ、PROCESS_TERMINATE等并列 - 审计独立:句柄复制操作可以被单独审计,便于追踪"谁尝试复制了谁的句柄"
权限层级关系:
c
// PROCESS_DUP_HANDLE 只是进程访问权限的一个子集
#define PROCESS_TERMINATE 0x0001
#define PROCESS_CREATE_THREAD 0x0002
#define PROCESS_SET_SESSIONID 0x0004
#define PROCESS_VM_OPERATION 0x0008
#define PROCESS_VM_READ 0x0010
#define PROCESS_VM_WRITE 0x0020
#define PROCESS_DUP_HANDLE 0x0040 // ← 句柄复制
#define PROCESS_CREATE_PROCESS 0x0080
#define PROCESS_SET_QUOTA 0x0100
#define PROCESS_SET_INFORMATION 0x0200
#define PROCESS_QUERY_INFORMATION 0x0400
2. 目标进程失败可降级的合理性
c
if (TargetProcessHandle) {
Status = ObReferenceObjectByHandle(TargetProcessHandle, ...);
if (!NT_SUCCESS(Status)) {
TargetProcess = PsGetCurrentProcess(); // ← 降级:使用当前进程
}
}
为什么允许降级?
- 向后兼容 :早期版本的
DuplicateHandle(kernel32)在目标句柄无效时静默地使用当前进程 - 鲁棒性:调用者可能传递了一个已关闭的目标进程句柄,降级可以避免"意外失败"
- 但有代价:如果调用者确实想复制到另一个进程,降级行为会导致句柄被复制到错误的目标------这是"容错性 vs 正确性"的权衡
3. 源进程与目标进程可以不同
NtDuplicateObject 最强大的特性是支持"三方复制":
进程 A(调用者)─┐
├── 进程 B 的句柄 → 进程 C
源进程 B ────────┘
目标进程 C ────────────────────────────┘
典型场景:调试器(进程 A)从被调试进程(进程 B)复制一个文件句柄到调试助手进程(进程 C)。
为什么这是安全的?
- 进程 A 必须有对进程 B 的
PROCESS_DUP_HANDLE权限(读取源句柄) - 进程 A 必须有对进程 C 的
PROCESS_DUP_HANDLE权限(写入目标句柄) - 句柄权限不能超过源句柄(权限缩减原则)
4.8.3 源句柄的解析与权限检查
接下来解析源句柄(要复制的句柄):
c
/* 3. 解析源句柄 */
PEPROCESS SourceProcess = ...; // 已获取
Status = ObReferenceObjectByHandle(
SourceHandle,
0, // DesiredAccess = 0,仅用于获取对象指针
NULL, // 不指定类型(支持任意对象)
PreviousMode,
&Object,
&HandleInfo);
if (!NT_SUCCESS(Status)) goto cleanup;
/* 4. 锁定对象(防止句柄表变化) */
ObpLockObject(Object, NULL);
关键设计决策的深度分析:
1. DesiredAccess = 0 的语义
c
Status = ObReferenceObjectByHandle(
SourceHandle,
0, // ← DesiredAccess = 0
NULL, // ← ObjectType = NULL
PreviousMode,
&Object,
&HandleInfo);
DesiredAccess = 0 意味着"我不请求任何对象特定的权限,只想获取对象指针"。
为什么这是合理的?
- 句柄复制的权限检查在"目标句柄"层面进行(新句柄的权限不能超过源句柄),不在"解析源句柄"层面
ObReferenceObjectByHandle只是验证"这个句柄是有效的、指向一个对象"- 真正的权限限制发生在后续步骤:
ObpDuplicateHandle中的GrantedAccess = DesiredAccess & SourceEntry->GrantedAccess
与普通打开的对比:
| 操作 | DesiredAccess | 效果 |
|---|---|---|
CreateFile(GENERIC_READ) |
非 0 | 请求特定权限,检查对象的 DACL |
ObReferenceObjectByHandle(0) |
0 | 只获取指针,不做权限检查 |
2. ObjectType = NULL 的含义
ObjectType = NULL 告诉对象管理器"不要验证对象类型"------任何类型的句柄都可以被复制。
为什么允许这样?
- 通用性 :
NtDuplicateObject是一个通用 API,需要支持文件、事件、互斥体、进程、线程等所有对象类型 - 类型无关性:句柄复制的核心逻辑(复制 Entry、递增引用计数)与对象类型无关------所有类型共享相同的底层机制
- 但保留类型信息:新句柄仍然指向正确的对象类型,只是在解析步骤不做验证
3. 锁定对象的必要性
c
ObpLockObject(Object, NULL);
如果不锁定会发生什么?
线程 1(NtDuplicateObject) 线程 2(NtClose)
───────────────────────── ─────────────────
解析源句柄 → 获取 Object 指针
关闭同源句柄 → HandleCount 归零
→ 调用 CloseProcedure
→ 释放对象内存
尝试复制句柄 → 访问已释放对象 → BSOD
锁定对象解决了这个问题:
ObpLockObject递增对象的PointerCount(或获取锁)- 即使另一个线程关闭了源句柄,对象本身不会被释放(因为
PointerCount > 0) - 复制完成后,
ObpUnlockObject递减PointerCount
这是"引用计数 + 锁"的经典协同模式:
- HandleCount 追踪用户态句柄数量 → 决定何时调用 CloseProcedure
- PointerCount 追踪内核态指针引用 → 决定何时真正释放对象内存
4.8.4 ObpDuplicateHandle 深入分析
ObpDuplicateHandle(obhandle.c(file:///d:/reactos/ntoskrnl/ob/obhandle.c))是句柄复制的核心函数:
c
NTSTATUS ObpDuplicateHandle(
PHANDLE_TABLE SourceTable,
HANDLE SourceHandle,
PHANDLE_TABLE TargetTable,
POBJECT_TYPE ObjectType,
ACCESS_MASK DesiredAccess,
ULONG HandleAttributes,
BOOLEAN InheritHandle,
ULONG Options,
PACCESS_STATE AccessState OPTIONAL,
POBJECT_TYPE ObjectType OPTIONAL,
PETHREAD InheritedFromThread,
PEPROCESS InheritedFromProcess,
PHANDLE NewHandle)
{
/* 1. 查找源 Entry */
SourceEntry = ExpLookupHandleTableEntry(SourceTable, SourceHandle);
if (!SourceEntry || !SourceEntry->Object) {
return STATUS_INVALID_HANDLE;
}
/* 2. 锁定源 Entry */
ExpLockHandleTableEntry(SourceTable, SourceEntry);
SourceObject = ObpGetHandleObject(SourceEntry);
Header = OBJECT_TO_OBJECT_HEADER(SourceObject);
/* 3. 类型检查 */
if (ObjectType && Header->Type != ObjectType) {
ExUnlockHandleTableEntry(SourceTable, SourceEntry);
return STATUS_OBJECT_TYPE_MISMATCH;
}
/* 4. 计算目标 GrantedAccess */
if (Options & DUPLICATE_SAME_ACCESS) {
GrantedAccess = SourceEntry->GrantedAccess;
} else if (DesiredAccess & MAXIMUM_ALLOWED) {
GrantedAccess = SourceEntry->GrantedAccess; // 不能超过源
} else {
GrantedAccess = DesiredAccess & SourceEntry->GrantedAccess;
}
/* 5. 验证类型访问权限 */
if (HandleAttributes & OBJ_KERNEL_HANDLE) {
// 内核句柄特殊处理
}
/* 6. 分配目标 Entry */
NewEntry = ExpAllocateHandleTableEntry(TargetTable);
NewEntry->Object = SourceObject;
NewEntry->GrantedAccess = GrantedAccess;
NewEntry->ObAttributes = (HandleAttributes & OBJ_HANDLE_ATTRIBUTES);
/* 7. 递增引用计数 */
InterlockedIncrementSizeT(&Header->PointerCount);
ExUnlockHandleTableEntry(SourceTable, SourceEntry);
return STATUS_SUCCESS;
}
关键设计决策的深度分析:
1. 权限缩减算法的详细说明
c
if (Options & DUPLICATE_SAME_ACCESS) {
// 路径 A:保留所有权限
GrantedAccess = SourceEntry->GrantedAccess;
} else if (DesiredAccess & MAXIMUM_ALLOWED) {
// 路径 B:请求最大可用权限
// MAXIMUM_ALLOWED 在这里的含义是"源句柄允许的所有权限"
GrantedAccess = SourceEntry->GrantedAccess;
} else {
// 路径 C:按位与------权限缩减的核心
GrantedAccess = DesiredAccess & SourceEntry->GrantedAccess;
}
三种路径的对比:
| 路径 | 调用方式 | 结果 | 典型用途 |
|---|---|---|---|
| A | DUPLICATE_SAME_ACCESS |
同源句柄 | 简单复制,不需要降级 |
| B | MAXIMUM_ALLOWED |
同源句柄(在此处等同 A) | 用户态代码不确定需要什么权限 |
| C | DesiredAccess = 具体值 |
DesiredAccess & Source |
精确降级,推荐做法 |
路径 C 为什么是"按位与"而不是"检查子集"?
c
// 为什么是这样:
GrantedAccess = DesiredAccess & SourceEntry->GrantedAccess;
// 而不是这样:
if ((DesiredAccess & ~SourceEntry->GrantedAccess) != 0)
return STATUS_ACCESS_DENIED;
原因:
- 容错性:如果调用者请求了源句柄没有的权限,"静默降级"比"直接拒绝"更友好
- 兼容性 :许多旧代码请求了过多的权限(如
GENERIC_ALL),降级后仍可正常工作 - 可预测性 :调用者可以通过
DesiredAccess精确控制新句柄的权限上限 - 但有代价:如果调用者确实需要某些权限但源句柄没有,它不会收到错误------而是获得一个权限不足的句柄,后续操作才会失败
安全影响分析:
权限缩减是 Windows 安全模型的基石之一:
进程 A(高权限):打开文件获得 GENERIC_ALL (0x10000000)
↓ DuplicateHandle(..., GENERIC_READ, ...)
进程 B(低权限):获得文件句柄,权限 = GENERIC_READ & GENERIC_ALL = GENERIC_READ
↓ 尝试写入 → 检查权限 → WRITE not in GrantedAccess → STATUS_ACCESS_DENIED
如果没有权限缩减会发生什么?
- 进程 A 可以把高权限句柄传递给进程 B,绕过安全边界
- 沙箱进程可以通过父进程传递的句柄突破沙箱
- 这等同于"权限继承没有限制"的安全漏洞
2. DUPLICATE_SAME_ACCESS 的简化逻辑
c
if (Options & DUPLICATE_SAME_ACCESS) {
GrantedAccess = SourceEntry->GrantedAccess; // ← 直接赋值
}
为什么这是一个独立的标志,而不是让调用者设置 DesiredAccess = SourceEntry->GrantedAccess?
- 调用者不知道源句柄的 GrantedAccess :用户态代码无法直接读取
SourceEntry->GrantedAccess,这是内核态数据 - 原子性:在一个系统调用内完成"查询源权限 + 复制",不需要两次系统调用
- 性能 :避免了先调用
NtQueryObject查询权限,再调用NtDuplicateObject复制的开销
3. 引用计数递增的关键作用
c
InterlockedIncrementSizeT(&Header->PointerCount);
为什么是 PointerCount 而不是 HandleCount?
- HandleCount 只追踪"用户态句柄表中的条目数"------新句柄确实在目标进程的句柄表中,所以 HandleCount 也递增(在
ExpAllocateHandleTableEntry中) - PointerCount 追踪"内核中所有指向该对象的指针引用"------这是对象生命周期的最终参考
- 两者都需要递增,但目的不同
引用计数交互的完整流程:
初始状态:HandleCount = 1, PointerCount = 2(源句柄 + 一个内核引用)
ObpDuplicateHandle:
1. ExpAllocateHandleTableEntry → HandleCount → 2
2. InterlockedIncrement PointerCount → PointerCount → 3
源进程 NtClose(hSource):
HandleCount → 1(仍 > 0,不调用 CloseProcedure)
PointerCount → 2(仍 > 0,不释放对象)
目标进程 NtClose(hTarget):
HandleCount → 0(调用 CloseProcedure)
PointerCount → 1(仍 > 0,不释放对象)
→ CloseProcedure 执行后 → PointerCount → 0(真正释放对象)
这就是"双引用计数"模型的威力:HandleCount 控制 CloseProcedure 的调用,PointerCount 控制对象内存的释放。两者独立工作,确保了"资源先关闭、内存后释放"的正确顺序。
4.8.5 句柄属性的复制
HandleAttributes 参数控制新句柄的属性:
| 标志 | 含义 |
|---|---|
OBJ_INHERIT (0x00000002) |
子进程可继承 |
OBJ_KERNEL_HANDLE (0x00000200) |
内核态句柄(64 位) |
DUPLICATE_SAME_ATTRIBUTES 标志:
c
if (Options & DUPLICATE_SAME_ATTRIBUTES) {
HandleAttributes = SourceEntry->ObAttributes;
}
不能指定的属性:
OBJ_PERMANENT、OBJ_EXCLUSIVE等对象级属性(不是句柄级)OBJ_OPENIF(仅用于创建)
4.8.6 跨进程句柄传递
跨进程传递是 NtDuplicateObject 的核心场景:
c
/* 场景:父进程将文件句柄传递给子进程 */
HANDLE hFile = CreateFile(L"foo.txt", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
HANDLE hChild;
DuplicateHandle(
GetCurrentProcess(), // 源 = 父进程
hFile,
GetCurrentProcess(), // 目标 = 父进程(先复制到父进程另一个槽位)
&hChild,
GENERIC_READ, // 权限
TRUE, // 可继承
0);
关键点:
- 源和目标可以在同一进程("重新打开")
- 也可以在不同进程(真正的跨进程传递)
- 跨进程需要源/目标进程的句柄
4.8.7 错误处理与回滚
NtDuplicateObject 的错误处理涉及多个层面:
c
/* 完整错误回滚流程 */
cleanup:
if (SourceProcess) ObDereferenceObject(SourceProcess);
if (TargetProcess && TargetProcess != PsGetCurrentProcess()) {
ObDereferenceObject(TargetProcess);
}
if (Object) {
ObpUnlockObject(Object);
ObDereferenceObject(Object); // 释放 ObReferenceObjectByHandle 的引用
}
return Status;
典型错误路径的详细分析:
1. STATUS_INVALID_HANDLE:源句柄无效
这是最常见的错误之一,发生在以下情况:
- 句柄值为
NULL或指向不存在的条目 - 句柄已被关闭(
NtClose后继续使用) - 句柄值被损坏(如缓冲区溢出导致的整数修改)
安全含义:这是一个防御性检查------攻击者可能尝试猜测句柄值来访问未授权的资源。内核拒绝无效句柄可以防止"句柄喷射"攻击。
2. STATUS_ACCESS_DENIED:源/目标进程权限不足
调用者进程 ──想要──→ 源进程句柄表
│
└──需要 PROCESS_DUP_HANDLE 权限──→ 如果没有 → STATUS_ACCESS_DENIED
典型场景:
- 普通用户进程尝试复制系统服务(SYSTEM 身份)的句柄
- 沙箱进程尝试复制主浏览器进程的句柄
- 调试器尝试复制被调试进程的句柄(这是合法的,因为调试器有
PROCESS_ALL_ACCESS)
3. STATUS_OBJECT_TYPE_MISMATCH:对象类型不匹配
当 ObpDuplicateHandle 的调用者指定了特定的 ObjectType,而源句柄指向的对象类型不同时触发。
c
if (ObjectType && Header->Type != ObjectType) {
return STATUS_OBJECT_TYPE_MISMATCH;
}
为什么需要类型检查?
- 类型安全 :防止把文件句柄当作事件句柄使用(后续调用
SetEvent会崩溃) - 防御性编程 :内核中大多数对象操作都依赖对象类型------错误的类型解释会导致
NULL指针引用
4. STATUS_INSUFFICIENT_RESOURCES:句柄表已满
每个进程的句柄表有最大限制(默认为 16,777,216 个条目,实际由 PspMaximumHandles 控制)。超过此限制时无法分配新句柄。
典型原因:
- 句柄泄漏:打开但不关闭
- 恶意代码:试图耗尽系统资源
- 设计错误:在循环中重复打开句柄
5. STATUS_PROCESS_IS_TERMINATING:进程正在退出
如果源进程或目标进程正在调用 NtTerminateProcess,内核会拒绝句柄复制操作。
为什么?
- 竞态条件避免:进程退出时正在清理句柄表,此时复制可能导致"半关闭"的句柄被引用
- 资源清理优先:进程退出有严格的清理顺序,不允许新的引用被创建
6. 更多错误码(完整列表):
| 错误码 | 含义 | 触发条件 |
|---|---|---|
STATUS_INVALID_PARAMETER |
参数组合无效 | Options 标志冲突 |
STATUS_QUOTA_EXCEEDED |
进程配额不足 | 目标进程的句柄配额已满 |
STATUS_NOT_FOUND |
对象在命名空间中找不到 | 命名对象已被删除 |
STATUS_EAS_NOT_SUPPORTED |
对象不支持扩展属性 | 某些特殊对象类型 |
完整的错误回滚流程(增强版):
c
NTSTATUS NtDuplicateObject(...) {
/* 阶段 1:解析源进程 */
Status = ObReferenceObjectByHandle(SourceProcessHandle, ...);
if (!NT_SUCCESS(Status)) return Status; // 无回滚需求
/* 阶段 2:解析目标进程(可能失败) */
if (TargetProcessHandle) {
Status = ObReferenceObjectByHandle(TargetProcessHandle, ...);
if (!NT_SUCCESS(Status)) {
ObDereferenceObject(SourceProcess); // ← 必须回滚阶段 1
return Status;
}
} else {
TargetProcess = PsGetCurrentProcess();
}
/* 阶段 3:解析源句柄 */
Status = ObReferenceObjectByHandle(SourceHandle, ...);
if (!NT_SUCCESS(Status)) {
ObDereferenceObject(SourceProcess);
if (TargetProcess && TargetProcess != PsGetCurrentProcess())
ObDereferenceObject(TargetProcess); // ← 必须回滚阶段 1 和 2
return Status;
}
ObpLockObject(Object);
/* 阶段 4:实际复制 */
Status = ObpDuplicateHandle(...);
if (!NT_SUCCESS(Status)) {
ObpUnlockObject(Object);
ObDereferenceObject(Object); // ← 释放 ObReferenceObjectByHandle 的引用
ObDereferenceObject(SourceProcess);
if (TargetProcess && TargetProcess != PsGetCurrentProcess())
ObDereferenceObject(TargetProcess); // ← 完整回滚
return Status;
}
/* 成功路径:无需回滚,正常释放锁和引用 */
ObpUnlockObject(Object);
ObDereferenceObject(Object);
ObDereferenceObject(SourceProcess);
if (TargetProcess && TargetProcess != PsGetCurrentProcess())
ObDereferenceObject(TargetProcess);
return STATUS_SUCCESS;
}
回滚设计的核心原则:
- 对称释放 :每个
ObReference都对应一个ObDereference,无论成功或失败 - 保持顺序:以获取的相反顺序释放(类似析构函数的逆序)
- 不泄漏引用:错误路径中的引用计数管理与成功路径同等重要
- 避免重复释放 :释放后立即检查
!= CurrentProcess防止重复释放
4.8.8 概念解释
4.8.8.1 PROCESS_DUP_HANDLE 权限
进程访问控制中的特定权限位,允许进程复制其他进程的句柄。
为什么需要这个权限?
- 防止恶意进程猜测其他进程的句柄值
- 防止未授权的跨进程资源访问
如何获取?
- 默认情况下,进程只对自己的句柄有
PROCESS_DUP_HANDLE - 父进程对子进程有此权限
- 调试器对被调试进程有此权限
4.8.8.2 DUPLICATE_SAME_ACCESS
Options = DUPLICATE_SAME_ACCESS 时,新句柄权限与源句柄完全相同,无需指定 DesiredAccess。
4.8.8.3 DUPLICATE_CLOSE_SOURCE
Options = DUPLICATE_CLOSE_SOURCE 时,复制后立即关闭源句柄(move 语义)。
典型用途:
c
/* 不使用 CLOSE_SOURCE */
DuplicateHandle(..., hSrc, &hDst, ...);
CloseHandle(hSrc); // 显式关闭
/* 使用 CLOSE_SOURCE(原子) */
DuplicateHandle(..., hSrc, &hDst, ..., DUPLICATE_CLOSE_SOURCE);
// 源句柄已自动关闭
4.8.8.4 句柄值 vs 句柄
HANDLE 是「句柄值 」(如 0x48),而句柄的内容(指向哪个对象、权限是什么)存储在句柄表中。
NtDuplicateObject 复制的是句柄内容 ,而句柄值在目标进程中是新分配的。
4.8.9 为什么要这样设计
4.8.9.1 问题 1:为什么需要 PROCESS_DUP_HANDLE 权限?
理论上,句柄值是整数,进程 A 可能"猜到"进程 B 的句柄值 0x48 并尝试 DuplicateHandle。如果没有 PROCESS_DUP_HANDLE 检查,恶意进程可以:
- 猜测系统服务的句柄值
- 复制敏感句柄
- 提权
PROCESS_DUP_HANDLE 强制显式授权,确保句柄传递是有意为之。
4.8.9.2 问题 2:为什么新句柄的权限不能超过源句柄?
这是「权限缩减」的核心安全原则。允许权限增加意味着:
- 进程 A 有
READ权限 - 进程 B 通过 DuplicateHandle 给 A 升级为
WRITE - 等于权限提升
Windows 不允许这种操作。
4.8.9.3 问题 3:为什么要把 NtDuplicateObject 设为系统调用?
理论上,用户态已经有 DuplicateHandle API(kernel32)。为什么要单独有 NtDuplicateObject 系统调用?
- 灵活性 :
DuplicateHandle是NtDuplicateObject的封装 - 完整性 :某些标志只在
NtDuplicateObject中可用 - 文档化:系统调用是"真实"的接口
4.8.9.4 问题 4:为什么要支持"只检查不复制"?
TargetHandle = NULL 时,NtDuplicateObject 仅检查权限,不真正复制。这用于:
- 预检:在复制前先检查是否会被允许
- 审计:记录"该句柄可被复制"
4.8.9.5 问题 5:为什么 DuplicatedHandle 中引用计数要递增?
每次复制都生成新的引用,引用计数必须递增。否则:
- 源进程关闭句柄 → 引用计数归零
- 目标进程的句柄指向已释放对象 → 悬挂引用
引用计数是对象生命周期的核心保障。
4.8.9.6 问题 6:为什么 OBJ_INHERIT 属性不是默认继承的?
新句柄的 OBJ_INHERIT 属性不是从源句柄复制的,而是由 HandleAttributes 参数显式指定。
为什么?
- 安全默认值:默认不可继承(0)意味着"不意外传递给子进程"
- 显式声明:调用者必须明确选择"这个新句柄应该被继承"
- 避免泄漏:如果默认继承,复制的每个句柄都可能被子进程访问------导致敏感句柄意外泄漏
对比两种方式:
c
// 不设置 OBJ_INHERIT → 新句柄不可继承(安全默认)
DuplicateHandle(src, hSource, dst, &hTarget, 0, FALSE, 0);
// 显式设置 OBJ_INHERIT → 新句柄可继承(明确意图)
DuplicateHandle(src, hSource, dst, &hTarget, 0, TRUE, 0);
4.8.9.7 问题 7:为什么需要 DUPLICATE_CLOSE_SOURCE 标志?
这个标志在复制后立即关闭源句柄,使"复制"语义变成"移动"。
为什么这是一个标志,而不是两个独立的 API?
- 原子性:在单个系统调用中完成复制+关闭,避免中间状态
- 性能:一次内核态切换 vs 两次
- 竞态避免 :如果分成两个调用,在
DuplicateHandle返回和CloseHandle调用之间,另一个线程可能也关闭了源句柄------导致重复关闭(STATUS_INVALID_HANDLE)
典型用途:进程间通信中,父进程创建管道,将一端"移交"给子进程:
c
// 管道的写端从父进程"移动"到子进程
DuplicateHandle(GetCurrentProcess(), hWritePipe,
hChildProcess, &hChildWrite,
0, TRUE,
DUPLICATE_SAME_ACCESS | DUPLICATE_CLOSE_SOURCE);
// 父进程现在不再拥有 hWritePipe
4.8.9.8 问题 8:为什么目标句柄值由内核分配,而不是调用者指定?
用户态的 DuplicateHandle 原型是:
c
BOOL DuplicateHandle(
HANDLE hSourceProcessHandle,
HANDLE hSourceHandle,
HANDLE hTargetProcessHandle,
LPHANDLE lpTargetHandle, // ← 输出参数,内核填充
...
);
为什么 lpTargetHandle 是输出参数而不是输入参数?
- 句柄表管理是内核特权:用户态代码不应该知道句柄表的内部结构(如哪些槽位空闲)
- 防止冲突:如果允许调用者指定句柄值,可能与目标进程中已存在的句柄冲突
- 简化用户态:调用者不需要关心句柄值------只需要知道"我获得了一个有效的句柄"
对比 Unix fork() :Unix fork 后子进程获得与父进程相同的文件描述符编号。这是可行的,因为 fork 时子进程获得父进程文件描述符表的完整拷贝------表结构相同,所以编号自然相同。Windows 的 CreateProcess 模型不同------子进程获得全新的、空的句柄表,只继承标记为 OBJ_INHERIT 的句柄,这些句柄被分配新的句柄值。
4.8.9.9 问题 9:为什么跨进程句柄复制不重新做权限检查?
当从进程 A 复制文件句柄到进程 B 时,内核不重新检查进程 B 是否有权访问该文件。为什么?
设计哲学:"句柄是能力"
- 句柄本身就是"已授权的能力"------持有句柄意味着已经通过了权限检查
- 句柄复制是"能力的传递",而不是"权限的重新申请"
- 重新检查权限会破坏"打开一次、多次使用"的性能模型
安全保障来自两个层面:
- 源进程的
PROCESS_DUP_HANDLE权限:确保只有被信任的进程可以从源进程"取出"句柄 - 权限缩减:确保复制后的句柄权限不会超过源句柄
如果重新检查权限会怎样?
想象一个场景:管理员进程打开文件获得读+写权限,然后把句柄复制给低权限用户的进程。如果重新检查权限,低权限用户进程可能会失去写权限------这违反了"能力传递"的语义。设计决定是:句柄的权限在"打开时"确定,复制时保持不变。
4.8.9.10 问题 10:为什么 NtDuplicateObject 需要 PreviousMode 参数?
c
NTSTATUS
NTAPI
NtDuplicateObject(
...
// PreviousMode 是隐含参数,从系统调用栈获取
);
PreviousMode(KernelMode 或 UserMode)决定了:
- 参数验证:用户态调用的参数需要严格验证(指针是否可读/可写、是否在用户地址空间)
- 权限检查:内核态调用可以绕过某些权限检查
- 句柄表选择:内核态调用使用内核句柄表,用户态调用使用进程句柄表
为什么这是必要的?
- 驱动调用 :内核模式驱动需要复制句柄(如在 I/O 完成例程中),它们运行在
KernelMode - 系统线程:没有用户态上下文的系统线程需要特殊处理
- 安全边界:防止用户态代码伪装成内核态绕过检查
4.8.10 增强子节:DuplicateHandle 的典型场景
4.8.10.1 设计意图
核心问题 :DuplicateHandle 在实际代码中如何使用?本节展示典型模式。
4.8.10.2 概念解释
- 句柄传递:将句柄从一个进程"给"另一个进程
- 句柄降级:从高权限句柄获取低权限副本
- 句柄升级:不允许(安全原则)
4.8.10.3 典型场景
场景 1:进程间管道通信
c
/* 父进程创建管道 */
HANDLE hRead, hWrite;
CreatePipe(&hRead, &hWrite, NULL, 0);
/* 调整写句柄的继承性 */
SetHandleInformation(hWrite, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
/* 启动子进程,子进程将继承 hWrite */
CreateProcess(..., bInheritHandles = TRUE, ...);
/* 父进程使用 hRead 读取 */
ReadFile(hRead, buf, sizeof(buf), &bytesRead, NULL);
场景 2:跨进程句柄复制(API 调用)
c
/* 服务进程接收客户端的句柄 */
HANDLE ClientHandle; // 来自客户端的句柄值(已知)
/* 服务进程在客户端上下文中复制句柄 */
DuplicateHandle(
ClientProcess, // 客户端进程句柄
ClientHandle, // 客户端进程中的句柄
ServerProcess, // 服务进程句柄
&ServerHandle, // 输出:服务进程中的句柄
0, // 请求默认权限
FALSE, // 不可继承
DUPLICATE_SAME_ACCESS | DUPLICATE_CLOSE_SOURCE);
场景 3:句柄降级(减小权限)
c
/* 句柄具有完整权限 */
HANDLE hFile = CreateFile(L"foo", GENERIC_ALL, ...);
/* 降级为只读句柄 */
HANDLE hReadOnly;
DuplicateHandle(
GetCurrentProcess(),
hFile,
GetCurrentProcess(),
&hReadOnly,
GENERIC_READ, // 只读
FALSE,
0);
4.8.10.4 为什么要这样设计
问题:为什么不直接传递句柄值(整数)?
答案:
- 句柄值仅在拥有句柄表的进程中有效
- 跨进程需要重新分配句柄值
- 重新分配时需要权限检查
4.8.11 增强子节:句柄复制与进程间通信
4.8.11.1 设计意图
核心问题:句柄复制是 IPC 的核心机制之一。本节深入分析它与各种 IPC 方式的关系。
4.8.11.2 概念解释
- IPC(Inter-Process Communication):进程间通信
- 句柄复制 IPC:通过传递句柄实现资源共享
- 句柄继承 IPC:通过进程创建时继承实现资源共享
4.8.11.3 句柄复制在 IPC 中的应用
句柄复制的优势:
- 资源句柄(文件、管道、事件)可跨进程共享
- 比内存映射、命名管道更轻量
- 内核保证安全检查
典型 IPC 模式:
- 共享内存:复制文件映射句柄
- 管道:复制管道句柄
- 同步对象:复制事件、互斥体句柄
- 进程间通信:复制进程、线程句柄
4.8.11.4 与其他 IPC 方式的对比
| IPC 方式 | 资源类型 | 跨机器 | 复杂度 |
|---|---|---|---|
| 句柄复制 | 内核对象 | 否 | 低 |
| 命名管道 | 字节流 | 是 | 中 |
| 共享内存 | 内存 | 否 | 中 |
| Socket | 字节流 | 是 | 高 |
| COM | 跨语言对象 | 是 | 高 |
4.8.11.5 为什么要这样设计
问题:为什么 IPC 要用句柄复制?
答案:
- 效率:比序列化/反序列化快
- 安全:内核强制安全检查
- 资源管理:句柄关闭语义清晰
反例:Socket 等基于网络的 IPC 不使用句柄复制,因为它们跨机器、跨语言。
4.8.12 小结
4.8.12.1 关键知识点
| 主题 | 关键点 |
|---|---|
| 核心流程 | 解析进程句柄 → 解析源句柄 → 计算权限 → 分配新 Entry → 递增引用 |
| 安全检查 | PROCESS_DUP_HANDLE 权限 |
| 权限缩减 | 新句柄权限 ≤ 源句柄 |
| 错误处理 | 严格的回滚路径 |
4.8.12.2 设计原则
- 显式授权 :
PROCESS_DUP_HANDLE强制 - 权限只减不增:安全保证
- 引用计数正确:生命周期管理
- 跨进程可降级:资源限制
4.8.12.3 后续学习路径
- 4.9 节:NtClose(句柄关闭)
- 第 6 章:I/O 管理器
- 第 7 章:配置管理器
4.9 系统调用 NtClose()
4.9.0 框架图
┌──────────────────────────────────────────────────────────────────────────────────┐
│ NtClose 流程 │
│ │
│ NtClose(Handle) │
│ │ │
│ ▼ │
│ ObpCloseHandle │
│ │ │
│ ├─ 1. 通过 HandleTable 定位 Entry │
│ │ │
│ ├─ 2. 锁定 Entry │
│ │ │
│ ├─ 3. 调用 ObpCloseHandleTableEntry │
│ │ │ │
│ │ ├─ 调用 Type->CloseProcedure (if HandleCount == 0) │
│ │ │ └─ File: IopCloseFile → 关闭 IRP, 释放 FCB │
│ │ │ └─ Event: KeClearEvent │
│ │ │ │
│ │ ├─ 递减 HandleCount │
│ │ │ │
│ │ ├─ 如果 HandleCount == 0, 递减 PointerCount │
│ │ │ │
│ │ └─ 释放 Entry 到空闲链表 │
│ │ │
│ └─ 4. 解锁 Entry │
└──────────────────────────────────────────────────────────────────────────────────┘
4.9.0.1 设计意图
核心问题
句柄是进程访问内核对象的唯一凭据。句柄关闭涉及:
- 资源释放:句柄槽位
- 对象销毁 :当
HandleCount = 0时,可能需要释放对象 - 类型专属清理:每种对象的关闭逻辑不同
- 安全审计:记录敏感对象的关闭
设计哲学 :「分层关闭」------句柄层、对象层、类型层分别处理各自的清理。
本节定位
本节以 NtClose(obhandle.c:3402(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L3402))和 ObpCloseHandle(obhandle.c:1730(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L1730))为线索,揭示:
- 句柄关闭的完整流程
CloseProcedure的调用时机- 引用计数与对象销毁的交互
- 伪句柄和内核句柄的特殊处理
4.9.1 NtClose 函数签名
c
NTSTATUS
NTAPI
NtClose(IN HANDLE Handle);
参数:
Handle:要关闭的句柄值
返回值:
STATUS_SUCCESS:成功STATUS_INVALID_HANDLE:句柄值无效
特殊值:
-1(NtCurrentProcess()):特殊处理-2(NtCurrentThread()):特殊处理- 内核句柄(最高位置 1):需要
KernelMode
源码位置:ntoskrnl/ob/obhandle.c:3402(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L3402)
4.9.2 ObpCloseHandle 主流程
NtClose 直接调用 ObpCloseHandle:
c
NTSTATUS NtClose(HANDLE Handle)
{
return ObpCloseHandle(Handle, ExGetPreviousMode());
}
ObpCloseHandle 的实现:
c
NTSTATUS ObpCloseHandle(HANDLE Handle, KPROCESSOR_MODE AccessMode)
{
PHANDLE_TABLE HandleTable;
NTSTATUS Status;
BOOLEAN HandleWasValid;
/* 1. 获取当前进程的 HandleTable */
if (AccessMode == KernelMode) {
HandleTable = ObpKernelHandleTable; // 内核句柄表
} else {
HandleTable = PsGetCurrentProcess()->ObjectTable;
}
/* 2. 伪句柄处理 */
if (Handle == NtCurrentProcess() || Handle == NtCurrentThread()) {
// 伪句柄不能被关闭
return STATUS_INVALID_HANDLE;
}
/* 3. 调用 ExSweepHandleTable */
Status = ExSweepHandleTable(HandleTable, Handle, FALSE);
return Status;
}
关键设计决策的深度分析:
1. 双 HandleTable 架构
内核空间
├── ObpKernelHandleTable ← 所有进程共享的内核句柄表
│ └── Entry 1, 2, 3...(内核对象引用)
│
用户空间(每个进程独立)
├── Process A → ObjectTable
│ └── Entry 1, 2, 3...(用户态句柄)
├── Process B → ObjectTable
│ └── Entry 1, 2, 3...(用户态句柄)
└── ...
为什么需要两个句柄表?
- 安全隔离:用户态进程绝对不能直接访问内核句柄表------内核句柄指向的对象是系统关键资源(如驱动对象、磁盘控制器等)
- 配额管理:用户态句柄有配额限制(防止单个进程耗尽系统句柄),内核句柄不计入用户配额
- 生命周期管理:内核句柄在系统关机时清理,用户态句柄在进程退出时清理------两者生命周期完全不同
- 性能:内核代码频繁创建和关闭句柄,使用独立的表避免与用户态代码竞争锁
AccessMode 检查的重要性:
c
if (AccessMode == KernelMode) {
HandleTable = ObpKernelHandleTable;
} else {
HandleTable = PsGetCurrentProcess()->ObjectTable; // ← 用户态只能用自己的
}
AccessMode来自系统调用栈,不可伪造 ------CPU 的 Ring 级别决定了PreviousMode- 用户态代码即使能把
Handle参数设为"内核句柄值",也只能访问自己的ObjectTable------内核句柄表的 Entry 根本不在用户地址空间中
2. 伪句柄保护机制
伪句柄(NtCurrentProcess() = -1,NtCurrentThread() = -2)是一种特殊的"句柄"------它们不占用句柄表,内核在解析时直接返回当前进程/线程的对象指针。
c
// 解析时(ObReferenceObjectByHandle):
if (Handle == NtCurrentProcess()) {
*Object = PsGetCurrentProcess(); // 直接返回指针
return STATUS_SUCCESS;
}
// 关闭时(NtClose):
if (Handle == NtCurrentProcess() || Handle == NtCurrentThread()) {
return STATUS_INVALID_HANDLE; // ← 拒绝关闭
}
为什么伪句柄不能关闭?
- 它不是真正的句柄 :伪句柄不占用句柄表条目,没有对应的
HANDLE_TABLE_ENTRY - 关闭它没有意义:当前进程/线程是调用者的固有上下文,关闭伪句柄不会释放任何资源
- 防止误用:如果代码不小心把伪句柄当作普通句柄关闭,至少会收到一个明确的错误(而不是静默损坏系统)
有趣的对比:
| 类型 | 句柄值 | 句柄表条目 | 可关闭 | 可继承 |
|---|---|---|---|---|
| 普通句柄 | 0x4, 0x8, 0xc... | 有 | 是 | 可选 |
| 伪句柄 | -1, -2 | 无 | 否 | 否(但子进程也有自己的伪句柄) |
| 内核句柄 | 64 位高位为 1 | 在内核表中 | 仅 KernelMode | 否 |
3. ExSweepHandleTable 的共用设计
ExSweepHandleTable 是一个"通用句柄操作函数"------不仅被 NtClose 使用,还被:
NtDuplicateObject使用(验证源句柄)- 进程退出代码使用(关闭所有句柄)
- 调试器 API 使用(枚举进程句柄)
为什么设计成共用函数?
- 代码复用:句柄表遍历逻辑只写一次,所有调用者都受益于 bug 修复
- 锁一致性:所有句柄表操作都通过同一函数获取锁------确保锁定顺序一致
- 审计统一:可以在一个地方加入句柄访问的审计日志
但这种设计有代价:
ExSweepHandleTable的函数签名比较复杂(需要回调函数参数)- 调用者需要理解回调语义(什么情况下调用、锁定状态如何)
- 过度抽象可能导致性能下降(每个调用者都要通过回调间接操作)
ReactOS 中的实际实现思路:
ObpCloseHandle(Handle, AccessMode)
│
├── 选择句柄表(内核 vs 用户)
│
└── ExSweepHandleTable(HandleTable, Handle, CloseAll=FALSE)
│
├── 锁定句柄表
├── 查找 Entry(通过索引计算)
├── 验证 Entry 有效性
├── 如果有效 → 调用 ObpCloseHandleTableEntry
└── 解锁句柄表
4.9.3 HandleTable 定位与锁定
ExSweepHandleTable 在句柄表中定位并锁定 Entry:
c
NTSTATUS ExSweepHandleTable(PHANDLE_TABLE HandleTable, HANDLE Handle, BOOLEAN CloseAll)
{
PHANDLE_TABLE_ENTRY Entry;
BOOLEAN CallCallbacks = FALSE;
if (CloseAll) {
/* 关闭所有句柄(用于进程退出) */
Status = ExpCloseHandleTableEntryForProcess(HandleTable);
} else {
/* 关闭单个句柄 */
ExpLockHandleTable(HandleTable);
if (HandleToLong(Handle) < 0) {
// 负值在 64 位系统上 = 内核句柄
if (HandleToLong(Handle) & ~0x8000000000000000ULL) {
ExpUnlockHandleTable(HandleTable);
return STATUS_INVALID_HANDLE;
}
}
Entry = ExpLookupHandleTableEntry(HandleTable, Handle);
if (!Entry) {
ExpUnlockHandleTable(HandleTable);
return STATUS_INVALID_HANDLE;
}
if (Entry->Object) {
// 调用 CloseProcedure
CallCallbacks = TRUE;
}
if (CallCallbacks) {
ExpUnlockHandleTable(HandleTable);
ExpCallHandleTableCallback(HandleTable, Entry, HandleInformation, ...);
} else {
ObpCloseHandleTableEntry(HandleTable, Entry, /* ... */);
ExpUnlockHandleTable(HandleTable);
}
}
return STATUS_SUCCESS;
}
关键设计决策的深度分析:
1. 句柄表锁定的并发安全模型
线程 A(NtClose(hFile)) 线程 B(NtDuplicateHandle(..., hFile, ...))
─────────────────── ──────────────────────────────────
ExpLockHandleTable(Table)
查找 Entry → 找到
ExpUnlockHandleTable(Table) ExpLockHandleTable(Table)
调用 CloseProcedure 查找 Entry → 可能已无效
ExpUnlockHandleTable(Table)
为什么不持锁调用 CloseProcedure?
- CloseProcedure 可能非常耗时 :特别是文件对象的
IopCloseFile------需要发送 IRP 到文件系统驱动,等待磁盘 I/O 完成 - 避免全局锁阻塞:如果持锁调用 CloseProcedure,进程中所有其他线程的句柄操作(打开、关闭、复制)都会被阻塞------这对于高并发服务来说是致命的
- 降低死锁风险:CloseProcedure 内部可能获取其他锁(如文件系统锁、缓存管理器锁)------如果持有句柄表锁进入这些代码,可能与其他线程形成死锁循环
正确的模式(先查后锁,锁内只做快操作):
- 锁表 → 快速验证句柄有效 → 解锁
- 调用 CloseProcedure(慢操作,不持锁)
- 再次锁表 → 清理 Entry(如果需要)
这个"检查-释放-操作-再次检查"模式是避免长期持锁的经典技巧。
2. 64 位内核句柄的特殊编码
c
// 64 位系统上的句柄值布局:
// 高位 = 1 → 内核句柄
// 高位 = 0 → 用户态句柄
//
// 0x8000000000000004 = 内核句柄表中的第 4 个条目
// 0x0000000000000004 = 用户态句柄表中的第 4 个条目
c
if (HandleToLong(Handle) < 0) {
// 负值表示内核句柄
if (HandleToLong(Handle) & ~0x8000000000000000ULL) {
// 除了最高位还有其他位 → 检查剩余部分是否是有效索引
// ...
}
}
为什么使用"高位为1"编码?
- 简单高效:不需要额外的标志位存储在句柄值之外
- 快速区分 :一个比较指令(
< 0)就能判断是哪种句柄 - 地址空间分离:用户态句柄值在低地址范围,内核句柄值在高地址范围------不会重叠
安全含义:
- 用户态代码无法"猜测"内核句柄值来访问内核对象------因为内核句柄表根本不在用户地址空间中
- 即使攻击者能把 Handle 参数设为负值,
AccessMode = UserMode检查会拒绝访问内核句柄表
3. ExpCallHandleTableCallback 的回调机制
调用 CloseProcedure 的流程:
1. 定位 Entry → 2. 解锁句柄表 → 3. 调用 CloseProcedure → 4. 重新锁表 → 5. 清理 Entry
↑
这里不持锁!允许并发操作
为什么在回调前解锁?
c
// 好的设计(先解锁):
ExpUnlockHandleTable(HandleTable); // ← 先解锁
ObjectType->CloseProcedure(Object); // ← 慢操作(不阻塞其他线程)
// 坏的设计(持锁调用):
ObjectType->CloseProcedure(Object); // ← 持锁时慢操作会阻塞所有其他句柄操作
ExpUnlockHandleTable(HandleTable);
并发风险与缓解:
- 风险:在解锁期间,另一个线程可能也尝试关闭同一个句柄------导致 HandleCount 递减两次
- 缓解 :
ObpCloseHandleTableEntry使用原子操作(InterlockedDecrement)递减 HandleCount,即使多个线程并发递减也安全 - 代价:如果 Entry 已经被释放,回调中的代码可能访问已释放的对象------为此 CloseProcedure 通常在调用前检查对象是否仍然有效
4. CloseAll 模式的特殊处理
当进程退出时,系统需要关闭该进程的所有句柄:
c
if (CloseAll) {
// 遍历整个句柄表,对每个有效 Entry 调用关闭逻辑
Status = ExpCloseHandleTableEntryForProcess(HandleTable);
}
为什么需要特殊处理?
- 效率 :遍历整个表一次关闭所有句柄,比每个句柄分别调用
NtClose快得多 - 原子性:进程退出期间不允许新的句柄被打开或复制------这是一个"冻结"状态
- 资源回收:确保所有内核对象的引用计数被正确递减,防止泄漏
与正常关闭的区别:
| 操作 | 正常 NtClose | 进程退出时的 CloseAll |
|---|---|---|
| 锁模式 | 细粒度锁(Entry 级别) | 粗粒度锁(整个表锁定) |
| 回调顺序 | 随机(取决于调用顺序) | 有序(按表索引顺序) |
| 错误处理 | 返回错误码给调用者 | 忽略错误(尽力而为) |
| 性能 | O(1) 单次操作 | O(n) 遍历整个表 |
4.9.4 ObpCloseHandleTableEntry 深入分析
ObpCloseHandleTableEntry(obhandle.c:685(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L685))是关闭句柄的"实际操作":
c
VOID ObpCloseHandleTableEntry(
PHANDLE_TABLE HandleTable,
PHANDLE_TABLE_ENTRY Entry,
NTSTATUS Status,
PVOID *OldObject,
PACCESS_STATE AccessState)
{
POBJECT_HEADER ObjectHeader = OBJECT_TO_OBJECT_HEADER(Entry->Object);
POBJECT_TYPE ObjectType = ObjectHeader->Type;
PVOID Object = &ObjectHeader->Body;
/* 1. 释放 Entry 到空闲链表 */
ExpRemoveHandleTableEntry(HandleTable, Entry);
/* 2. 递减 HandleCount */
if (InterlockedDecrementSizeT(&ObjectHeader->HandleCount) == 0) {
/* HandleCount 归零:调用 CloseProcedure */
/* 3. 清理 AccessState */
if (AccessState) {
SeDeleteAccessState(AccessState);
}
/* 4. 减少配额 */
if (ObjectHeader->QuotaInfoOffset) {
ObpUndoChargeQuotaForObject(ObjectHeader);
}
/* 5. 递减 PointerCount */
ObDereferenceObject(Object);
}
}
关键设计决策的深度分析:
1. HandleCount 与 PointerCount 的双层引用计数
这是 Windows 对象管理最核心的设计之一------使用两个独立的引用计数:
HandleCount(用户态句柄计数) PointerCount(内核指针计数)
───────────────────────────── ────────────────────────────
初始 = 1 初始 = 1(加上内核引用)
↓
NtOpenProcess → HandleCount++ NtOpenProcess → PointerCount++
DuplicateHandle → HandleCount++ ObReferenceObject → PointerCount++
NtClose → HandleCount-- NtClose → PointerCount--(当 HandleCount 归零后)
↓
当 HandleCount == 0 时 当 PointerCount == 0 时
→ 调用 CloseProcedure → 调用 DeleteProcedure + 释放内存
→ 释放 AccessState → 从命名空间移除
→ 回滚配额 → 释放安全描述符
为什么需要两个计数?
- 职责分离:HandleCount 追踪"有多少个用户态句柄指向这个对象"------归零意味着"不再有用户态程序使用这个对象",此时可以调用 CloseProcedure 做类型专属清理
- 安全边界:PointerCount 追踪"内核中有多少个指针指向这个对象"------归零意味着"内核也不再使用它",此时可以安全释放内存
- 生命周期模型 :典型的对象生命周期是:
- 创建 → HandleCount=1, PointerCount=1
- 被引用 → HandleCount++ 或 PointerCount++(取决于引用方式)
- 关闭 → HandleCount--;HandleCount==0 → CloseProcedure
- 释放 → PointerCount--;PointerCount==0 → ObpDeleteObject
2. InterlockedDecrement 的原子性保证
c
LONG_PTR NewCount = InterlockedDecrementSizeT(&ObjectHeader->HandleCount);
if (NewCount == 0) {
// 只有一个线程会走到这里 ------ 因为原子递减的结果是唯一的
CloseProcedure(...);
}
为什么必须是原子操作?
- 场景:两个线程同时关闭指向同一对象的不同句柄
- 如果使用"读取-判断-递减"非原子模式,两个线程可能都看到
HandleCount == 1,都认为自己应该调用 CloseProcedure → 重复清理 InterlockedDecrement保证只有一个线程会看到NewCount == 0
这是"引用计数系统"正确性的基石 :所有引用计数的增减操作都必须是原子的------在多处理器系统上,这意味着使用 CPU 级别的锁定指令(如 x86 的 LOCK DEC)。
3. Entry 释放的顺序(先释放 Entry,再递减 HandleCount)
c
// 注意顺序:
ExpRemoveHandleTableEntry(HandleTable, Entry); // 先释放句柄表条目
// ...
InterlockedDecrementSizeT(&ObjectHeader->HandleCount); // 后递减计数
为什么是这个顺序?
- 防止悬挂引用:如果先递减 HandleCount 再释放 Entry,另一个线程可能在这期间打开该句柄------HandleCount 已经递减但 Entry 仍然有效,导致计数不一致
- 原子化释放 :
ExpRemoveHandleTableEntry会将 Entry 的 Object 指针设为 NULL------任何后续对该句柄的操作都会立即失败(因为 Entry 已无效)
4. AccessState 清理与配额回滚
AccessState 是对象打开时创建的安全审计信息,记录了"谁以什么权限打开了这个对象"。当最后一个句柄关闭时,系统也必须清理这些信息。
c
if (AccessState) {
SeDeleteAccessState(AccessState); // 释放访问状态
}
if (ObjectHeader->QuotaInfoOffset) {
ObpUndoChargeQuotaForObject(ObjectHeader); // 返还配额
}
为什么只在 HandleCount==0 时做?
- AccessState 是"每次打开"都有的,但所有句柄共享同一对象体
- 正确的设计是:AccessState 在"第一次打开"时分配,在"最后一次关闭"时释放
- 配额(Quota)同理------对象占用的内存/句柄配额在创建时分配,在"最后一次关闭"时返还
5. 为什么 HandleCount 归零时还需要 ObDereferenceObject?
c
// HandleCount 归零时:
if (NewCount == 0) {
// ... 清理 CloseProcedure ...
ObDereferenceObject(Object); // ← 为什么还要递减 PointerCount?
}
因为 HandleCount 和 PointerCount 是"绑定"的:
- 每次创建句柄时,HandleCount++ 同时 PointerCount++
- 每次关闭句柄时,HandleCount--;当 HandleCount 归零时,PointerCount 也需要递减一次(对应"句柄引用"的那一份)
- PointerCount 归零后,对象才真正被释放
完整的引用计数交互示例:
打开文件 CreateFile("foo") →
HandleCount = 1, PointerCount = 1 (初始状态)
复制句柄 DuplicateHandle(hFile, &hFile2) →
HandleCount = 2, PointerCount = 2
ObReferenceObjectByHandle(hFile) →
HandleCount = 2, PointerCount = 3 (内核多了一个引用)
NtClose(hFile2) →
HandleCount = 1, PointerCount = 3 (HandleCount > 0,不调用 CloseProcedure)
ObDereferenceObject(Object) →
HandleCount = 1, PointerCount = 2 (PointerCount > 0,不释放内存)
NtClose(hFile) →
HandleCount = 0 → 调用 CloseProcedure
→ PointerCount 减 1(Handle 引用)→ PointerCount = 1
→ PointerCount > 0,不释放内存(还有内核引用)
ObDereferenceObject(Object) →
HandleCount = 0, PointerCount = 0 → 调用 ObpDeleteObject
→ 真正释放对象内存
4.9.5 CloseProcedure 调用时机
CloseProcedure 是类型专属的关闭回调:
c
typedef struct _OBJECT_TYPE_INITIALIZER {
// ...
OB_CLOSE_METHOD CloseProcedure; // 关闭回调
OB_DELETE_METHOD DeleteProcedure; // 删除回调
// ...
} OBJECT_TYPE_INITIALIZER, *POBJECT_TYPE_INITIALIZER;
typedef NTSTATUS (*OB_CLOSE_METHOD)(
IN PEPROCESS Process OPTIONAL,
IN PVOID Object,
IN ACCESS_MASK GrantedAccess,
IN ULONG_PTR ProcessHandleCount,
IN ULONG_PTR SystemHandleCount);
典型 CloseProcedure:
| 类型 | CloseProcedure | 作用 |
|---|---|---|
| File | IopCloseFile |
关闭 IRP、释放 FCB |
| Process | PspCloseProcess |
释放句柄表、安全引用 |
| Thread | PspCloseThread |
释放线程结构、清理 APC |
| Event | NULL | (无 Body 资源) |
| Section | MiSectionClose |
取消映射、释放数据 |
IopCloseFile 简化实现:
c
NTSTATUS IopCloseFile(POPEN_PACKET OpenPacket, PFILE_OBJECT FileObject, ...)
{
/* 1. 关闭文件对象 */
ObDereferenceObject(FileObject->Vpb->DeviceObject);
/* 2. 发送 IRP_MJ_CLOSE */
IRP = IoAllocateIrp(...);
IRP->Tail.Overlay.AuxiliaryBuffer = ...;
Status = IofCallDriver(FileObject->DeviceObject, IRP);
/* 3. 清理文件对象字段 */
FileObject->DeviceObject = NULL;
FileObject->Vpb = NULL;
return Status;
}
CloseProcedure 与 DeleteProcedure 的区别:
| 维度 | CloseProcedure | DeleteProcedure |
|---|---|---|
| 触发条件 | HandleCount 归零 |
PointerCount 归零 |
| 典型作用 | 关闭资源、释放句柄引用 | 释放对象内存 |
| 调用次数 | 多次(每个进程关闭都调用) | 仅一次 |
| 锁定要求 | 不需要锁(已被 ObpCloseHandleTableEntry 解锁) |
不需要锁 |
4.9.6 引用计数递减与对象销毁
ObDereferenceObject 触发对象销毁:
c
VOID ObDereferenceObject(PVOID Object)
{
POBJECT_HEADER Header = OBJECT_TO_OBJECT_HEADER(Object);
LONG_PTR NewCount;
NewCount = InterlockedDecrementSizeT(&Header->PointerCount);
if (NewCount == 0) {
ObpDeleteObject(Header);
}
}
ObpDeleteObject 流程:
c
VOID ObpDeleteObject(POBJECT_HEADER ObjectHeader)
{
POBJECT_TYPE ObjectType = ObjectHeader->Type;
PVOID Object = &ObjectHeader->Body;
/* 1. 从命名空间移除 */
if (ObjectHeader->NameInfoOffset) {
ObpRemoveObject(ObjectHeader);
}
/* 2. 调用 DeleteProcedure */
if (ObjectType->TypeInfo.DeleteProcedure) {
ObjectType->TypeInfo.DeleteProcedure(Object);
}
/* 3. 释放安全描述符 */
if (ObjectHeader->SecurityDescriptor) {
ObpDeassignSecurity(ObjectHeader);
}
/* 4. 释放配额 */
if (ObjectHeader->QuotaInfoOffset) {
/* 已在 CloseProcedure 中处理 */
}
/* 5. 释放对象内存 */
ObpDeallocateObject(ObjectHeader);
}
4.9.7 伪句柄与内核句柄的特殊处理
4.9.7.1 伪句柄
NtCurrentProcess()(值 -1)和 NtCurrentThread()(值 -2)是伪句柄:
-
不占用句柄表:
cif (Handle == NtCurrentProcess() || Handle == NtCurrentThread()) { return STATUS_INVALID_HANDLE; // 不能关闭伪句柄 } -
直接返回对象指针:
cif (Handle == NtCurrentProcess()) { *Object = PsGetCurrentProcess(); return STATUS_SUCCESS; }
4.9.7.2 内核句柄
内核句柄在 64 位系统上是负数(最高位置 1):
c
#define OBJ_KERNEL_HANDLE 0x00000200L // 句柄属性标志
#define NtCurrentProcess() (HANDLE)(-1) // 伪句柄
内核句柄的关闭:
c
if (AccessMode == KernelMode) {
HandleTable = ObpKernelHandleTable; // 独立的内核句柄表
} else {
HandleTable = PsGetCurrentProcess()->ObjectTable; // 用户态句柄表
}
关键设计决策:
- 内核句柄独立表:避免用户态访问内核对象
- 必须
KernelMode才能关闭内核句柄:防止用户态误操作
4.9.8 概念解释
4.9.8.1 HandleCount
句柄级别的引用计数。每个句柄对应一个引用。
作用:判断是否有用户态进程仍持有该对象的句柄。
4.9.8.2 PointerCount
内核态的引用计数。每次 ObReference* 递增,每次 ObDereferenceObject 递减。
作用:判断对象是否仍在使用。
4.9.8.3 CloseProcedure vs DeleteProcedure
| 维度 | CloseProcedure | DeleteProcedure |
|---|---|---|
| 调用时机 | HandleCount 归零 |
PointerCount 归零 |
| 典型作用 | 关闭资源(文件、设备) | 释放内存 |
| 是否可重入 | 否 | 否 |
4.9.8.4 HandleTable 锁
PHANDLE_TABLE->HandleTableLock:保护整个句柄表的并发访问。
为什么需要?
- 多个线程可能同时打开/关闭句柄
- 句柄表的扩展、收缩需要原子性
4.9.9 为什么要这样设计
4.9.9.1 问题 1:为什么 HandleCount 和 PointerCount 分离?
两个引用计数追踪不同维度的引用:
HandleCount:用户态句柄引用PointerCount:内核态指针引用
分离的好处:
- 细粒度管理 :
HandleCount归零时关闭资源;PointerCount归零时释放内存 - 避免过早关闭:内核仍持有指针时,资源不应被关闭
- 支持 DuplicateHandle :每次 DuplicateHandle 递增
PointerCount
4.9.9.2 问题 2:为什么先解锁再调用 CloseProcedure?
c
ExpCallHandleTableCallback // 解锁
// 回调中调用 Type->CloseProcedure
ObpCloseHandleTableEntry // 重新进入
CloseProcedure 可能会执行长时间操作(如 I/O 完成),持锁会导致整个句柄表被阻塞。
关键洞察 :内核调用 CloseProcedure 时不持句柄表锁,但仍可以安全地引用对象(因为 PointerCount 已被递增)。
4.9.9.3 问题 3:为什么伪句柄不能关闭?
伪句柄(如 NtCurrentProcess())不是真正的句柄,不在句柄表中。关闭它没有意义。内核在 NtClose 中显式拒绝关闭伪句柄,防止误用。
4.9.9.4 问题 4:为什么需要 ObpKernelHandleTable?
ObpKernelHandleTable 是内核态的句柄表,与用户态句柄表完全隔离:
- 安全:用户态无法访问内核句柄
- 效率:内核句柄不需要用户态验证
- 隔离:内核对象的引用不计入用户态配额
典型用途:
- 驱动访问内核对象
- 系统服务
- 内核线程
4.9.9.5 问题 5:为什么句柄关闭使用乐观的原子操作?
c
LONG_PTR NewCount = InterlockedDecrementSizeT(&Header->HandleCount);
if (NewCount == 0) {
/* 关闭资源 */
}
InterlockedDecrement 是原子操作,不需要额外的锁。这避免了"先锁后减"模式带来的锁竞争。
反例 :如果使用 KeAcquireSpinLock,开销大且容易死锁。
为什么这被称为"乐观"?
- 假设无竞争:原子操作假设"大多数情况下没有冲突",直接在缓存一致性协议层面完成操作
- 失败即重试:如果 CPU 层面检测到冲突(其他 CPU 同时修改同一内存),会自动重试------不需要内核代码处理
- 性能优势:锁需要两次内存访问(获取 + 释放)加可能的上下文切换;原子操作通常只需一次总线事务
性能影响量化:
- 原子递增/递减:~10-50 纳秒(取决于缓存状态)
- 获取/释放自旋锁:~50-200 纳秒
- 获取/释放互斥量(可能切换上下文):~1-5 微秒
对于高频调用的 NtClose(每秒可能调用数百万次),这是巨大的性能差异。
4.9.9.6 问题 6:为什么 NtClose 返回成功即使句柄无效?
在某些情况下,NtClose 对无效句柄也返回 STATUS_SUCCESS。为什么?
答案:鲁棒性与性能权衡
c
// 伪代码:ObpCloseHandle 的错误处理
Status = ExSweepHandleTable(HandleTable, Handle, FALSE);
// 如果 Handle 无效 → ExSweepHandleTable 返回 STATUS_INVALID_HANDLE
// NtClose 会...返回这个错误吗?
//
// 实际上:ReactOS 严格返回错误
// :Windows 某些版本可能静默忽略重复关闭
return Status;
为什么这是一个值得讨论的设计?
- 容错 :如果应用程序不小心调用了两次
NtClose(hHandle),第二次应该报错还是静默? - 安全:如果攻击者能让应用程序"关闭任意句柄",严格的错误报告可以帮助检测攻击
- 兼容性:许多旧应用程序依赖"重复关闭无害"的行为
最佳实践:
- 内核级:严格检查并返回
STATUS_INVALID_HANDLE(ReactOS 这么做) - 用户态:
CloseHandle()在检测到无效句柄时触发结构化异常(SEH) - 应用程序:确保每个句柄只关闭一次
4.9.9.7 问题 7:为什么进程退出时要关闭所有句柄?
当进程调用 NtTerminateProcess 时,内核遍历该进程的整个句柄表,关闭每个有效句柄。为什么不直接丢弃整个表?
因为对象的引用计数必须正确:
如果进程退出时不递减 HandleCount →
对象的 HandleCount 永远不为零 →
CloseProcedure 永远不被调用 →
资源泄漏(文件未刷新到磁盘、设备未释放、IRP 未完成)
这是"优雅关闭 vs 暴力终止"的权衡:
- 优雅关闭:遍历句柄表,逐一递减计数和调用 CloseProcedure------慢但正确
- 暴力终止:直接释放句柄表内存,不关心引用计数------快但可能泄漏
- Windows 的选择:优雅关闭(但有超时限制------如果 CloseProcedure 卡住太久,系统会跳过)
极端情况:崩溃进程(如被用户强制结束)可能无法完成优雅关闭------为此 Windows 有"最后一个引用者关闭"的兜底机制:进程对象的 HandleCount 归零后,内核会检查是否还有关联资源未释放。
4.9.9.8 问题 8:为什么 DeleteProcedure 不是每次 Close 都调用?
CloseProcedure 在 HandleCount == 0 时调用;DeleteProcedure 在 PointerCount == 0 时调用。为什么需要两层清理?
因为"关闭资源"和"释放内存"是不同的操作:
| 操作 | 时机 | 典型行为 |
|---|---|---|
| CloseProcedure | 最后一个用户态句柄关闭时 | 关闭文件句柄、取消挂起 IRP、刷新缓存 |
| DeleteProcedure | 最后一个内核指针引用释放时 | 释放对象体内存、释放对象头内存 |
典型文件对象的完整生命周期:
CreateFile → ObCreateObject → 分配内存 → FsInitialize
↓(使用中)
NtClose → HandleCount==0 → IopCloseFile → 发送 IRP_MJ_CLOSE
↓(不再有用户态引用,但可能有内核引用)
所有 ObDereferenceObject 完成 → PointerCount==0
↓
ObpDeleteObject → FsDelete → 释放 FILE_OBJECT 内存
这种分层设计的核心优势:
- 支持异步操作:应用程序关闭文件后,文件系统可能仍有未完成的 I/O------此时不能释放内存
- 支持缓存管理器:即使应用程序关闭了文件,缓存管理器仍可能保留文件对象的引用
- 支持延迟清理:某些资源的清理可以推迟到系统空闲时执行
4.9.9.9 问题 9:为什么句柄表是多级页表结构?
句柄表(HANDLE_TABLE)不是简单的数组,而是类似 CPU 页表的多层结构:
HANDLE_TABLE
└── Table[0] → 指向一级表(含 256 个 Entry)
└── Table[1] → 指向一级表
└── ...
为什么不用简单的数组?
- 稀疏性:大多数进程只有几十个句柄------使用数组会浪费大量内存
- 扩展性:使用多级表,句柄表可以从几百个条目扩展到数百万个条目,无需重新分配
- 分配粒度:操作系统以页(4KB)为单位分配内存------多级表刚好匹配这种粒度
为什么这对 NtClose 重要?
NtClose 的第一步是将句柄值翻译成 HANDLE_TABLE_ENTRY*------这通过索引计算完成,不需要搜索:
c
// 简化版:句柄值 → Entry 指针
PHANDLE_TABLE_ENTRY ExpLookupHandleTableEntry(
PHANDLE_TABLE HandleTable,
HANDLE Handle)
{
ULONG_PTR Index = (ULONG_PTR)Handle >> 2; // 句柄值除以 4 得到索引
// 根据索引查找对应的一级表和二级表
// ...(类似 MMU 的页表遍历)
return &Entry;
}
性能保证:无论句柄表多大,查找一个句柄的时间都是 O(1)------只需几次指针解引用。
4.9.9.10 问题 10:为什么 NtClose 不能被抢占?
在 Windows 内核中,NtClose 的执行路径运行在 APC_LEVEL 以下的 IRQL,通常是 PASSIVE_LEVEL。但关键的引用计数操作(InterlockedDecrement)本质上是不可中断的------这是"隐式的原子性保证"。
为什么需要这个保证?
- 引用计数必须精确:如果递减操作中间被切换到另一个线程,然后另一个线程也递减,可能导致计数错误
- CPU 保证 :x86 的
LOCK DEC指令是原子的------在指令执行期间,CPU 不响应中断 - 不需要提高 IRQL:因为原子指令本身就足够了,不需要禁用中断
这是操作系统设计中"利用硬件能力简化软件设计"的经典案例:
- CPU 提供了原子内存操作 → 内核不需要用锁保护引用计数
- CPU 提供了内存屏障(Memory Barrier)→ 内核不需要手动刷新缓存
- MMU 提供了页表 → 内核可以实现虚拟内存,而不需要手工管理分段
4.9.10 增强子节:句柄关闭的连锁影响
4.9.10.1 设计意图
核心问题 :句柄关闭可能触发"链式反应"------关闭文件句柄可能取消文件映射、删除临时文件、清理子对象等。
4.9.10.2 概念解释
- 链式反应:一个操作触发多个后续操作
- 清理顺序:从后向前(栈式)
- 回调链 :多个
CloseProcedure嵌套
4.9.10.3 典型链式反应
场景 1:文件句柄关闭
c
NtClose(hFile);
// 1. 释放句柄表槽位
// 2. 调用 IopCloseFile
// ├─ 关闭文件对象
// ├─ 发送 IRP_MJ_CLOSE
// │ ├─ 文件系统驱动清理
// │ └─ 释放 FCB
// └─ 取消挂起的 IRP
// 3. 递减 PointerCount
// 4. 删除 Section 对象(如果存在)
场景 2:进程句柄关闭
c
NtClose(hProcess);
// 1. 释放句柄表槽位
// 2. 调用 PspCloseProcess
// ├─ 引用计数递减
// ├─ 安全引用清理
// └─ 句柄表清理
// 3. 递减 PointerCount
// 4. 如果是最后一个句柄,触发进程清理
4.9.10.4 链式反应的控制
链式反应可能很深,需要谨慎控制:
- 递归限制:内核有最大递归深度
- 错误恢复:每一步都需要可回滚
- 锁顺序:避免锁顺序倒置
4.9.10.5 为什么要这样设计
问题:链式反应会导致"一个关闭引发大量工作"吗?
答案:
- 是的,但这是正确的行为
- 句柄关闭时清理所有相关资源是必需的
- 延迟清理会导致资源泄漏
反例:如果文件句柄关闭不取消 IRP,会导致死锁(IRP 永远不会被完成)。
4.9.11 增强子节:句柄泄漏的检测
4.9.11.1 设计意图
核心问题:句柄泄漏是 Windows 上最常见的资源泄漏之一。如何检测?
4.9.11.2 概念解释
- 句柄泄漏(Handle Leak):句柄被打开但未被关闭
- 句柄表跟踪 :通过
!handle调试器命令查看 - 对象引用计数 :通过
!object调试器命令查看
4.9.11.3 调试方法
方法 1:使用 WinDbg
windbg
!handle 0 f // 显示当前进程所有句柄
!handle 0 0 myhandle // 显示特定句柄
!object 0x12345678 // 查看对象详情
方法 2:使用 Application Verifier
启用 Handles 检查后:
- 进程退出时未关闭的句柄会触发 assertion
- 自动记录泄漏的句柄
方法 3:使用 Task Manager / Process Explorer
查看进程的句柄数(handle count)是否异常。
4.9.11.4 代码审查
常见泄漏模式:
c
/* 错误:提前 return 未关闭句柄 */
HANDLE hFile = CreateFile(...);
if (error_condition) return; // hFile 泄漏!
if (error_condition) goto cleanup;
cleanup: CloseHandle(hFile);
修复:使用 RAII 模式(Windows 没有 finally)
c
/* 正确:使用 goto cleanup */
HANDLE hFile = INVALID_HANDLE_VALUE;
hFile = CreateFile(...);
if (!hFile) goto cleanup;
if (error_condition) goto cleanup; // 仍然关闭
cleanup:
if (hFile != INVALID_HANDLE_VALUE) CloseHandle(hFile);
4.9.11.5 引用计数泄漏的检测
引用计数泄漏更难检测:
- 对象不会立即消失
- 内存增长缓慢
- 调试器可能误报
调试技巧:
- 设置
OBJECT_HEADER->PointerCount的硬件断点 - 使用
!locks查看对象的锁定状态
4.9.11.6 为什么要这样设计
问题:为什么 Windows 不自动检测句柄泄漏?
答案:
- 自动检测性能开销大
- 句柄关闭语义依赖用户代码
- 性能与安全的权衡
反例 :在驱动开发中,可以使用 ObRegisterCallbacks 监控句柄关闭,发现异常模式。
4.9.12 小结
4.9.12.1 关键知识点
| 主题 | 关键点 |
|---|---|
| 句柄关闭流程 | 定位 → 锁定 → CloseProcedure → 释放 Entry |
| CloseProcedure | HandleCount 归零时调用,关闭资源 |
| DeleteProcedure | PointerCount 归零时调用,释放内存 |
| 伪句柄 | 不能关闭(返回 STATUS_INVALID_HANDLE) |
| 内核句柄 | 64 位上是负数,必须 KernelMode 关闭 |
| 链式反应 | 一个关闭可能触发多个回调 |
4.9.12.2 设计原则
- 分层关闭:句柄层 → CloseProcedure → DeleteProcedure
- 乐观原子操作 :
InterlockedDecrement避免锁 - 双句柄表:用户态和内核态隔离
- 解锁后回调:避免持锁执行长时间操作
4.9.12.3 常见陷阱
- 忘记关闭句柄:内存泄漏
- 重复关闭:引用计数变负,BSOD
- 关闭伪句柄:返回错误
- 关闭内核句柄(用户态):权限错误
- 句柄值混淆:不同进程的句柄值不同
4.9.12.4 后续学习路径
- 第 5 章:进程与线程管理
- 第 6 章:I/O 管理器
- 第 7 章:配置管理器
- WRK:Windows Research Kernel 源码
4.9.12.5 调试技巧
!handle:查看句柄表!object:查看对象详情!referencetable:查看引用计数- Application Verifier:自动泄漏检测
- PoolMon:查看池分配
4.9.12.6 本节回顾
NtDuplicateObject 和 NtClose 是对象管理器最重要的两个"资源管理 API"。掌握它们是 Windows 内核编程的基础。本节涵盖:
- 跨进程句柄传递的安全检查
- 权限缩减原则
- 双引用计数的语义
- 链式关闭的复杂性
- 调试和检测泄漏的方法
至此,第 4 章对象管理的主要内容(4.1~4.9)已经全部完成。读者已具备深入理解 Windows 对象管理器的能力。
附录:4.8 与 4.9 函数交叉引用
| 引用方 | 被引用方 | 关系 |
|---|---|---|
NtDuplicateObject |
ObReferenceObjectByHandle |
解析源/目标进程 |
NtDuplicateObject |
ObpDuplicateHandle |
实际复制 |
ObpDuplicateHandle |
ExpAllocateHandleTableEntry |
分配新 Entry |
ObpDuplicateHandle |
ExpLookupHandleTableEntry |
查找源 Entry |
NtClose |
ObpCloseHandle |
主入口 |
ObpCloseHandle |
ExSweepHandleTable |
共用扫描函数 |
ExSweepHandleTable |
ObpCloseHandleTableEntry |
实际关闭 |
ObpCloseHandleTableEntry |
Type->CloseProcedure |
类型专属清理 |
ObpCloseHandleTableEntry |
ObDereferenceObject |
触发对象销毁 |
ObDereferenceObject |
ObpDeleteObject |
真正释放 |
ObpDeleteObject |
Type->DeleteProcedure |
类型专属销毁 |