Reactos 第 4 章 对象管理 — 4.8 系统调用 NtDuplicateObject / 4.9 系统调用 NtClose

第 4 章 对象管理 --- 4.8 系统调用 NtDuplicateObject / 4.9 系统调用 NtClose

本节深入两个最常用的对象管理 API:NtDuplicateObjectNtClose。前者用于句柄复制(特别是跨进程),后者用于句柄关闭。这两个 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 源码中 NtDuplicateObjectobhandle.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_READPROCESS_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 深入分析

ObpDuplicateHandleobhandle.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_PERMANENTOBJ_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 系统调用?

  • 灵活性DuplicateHandleNtDuplicateObject 的封装
  • 完整性 :某些标志只在 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 是否有权访问该文件。为什么?

设计哲学:"句柄是能力"

  • 句柄本身就是"已授权的能力"------持有句柄意味着已经通过了权限检查
  • 句柄复制是"能力的传递",而不是"权限的重新申请"
  • 重新检查权限会破坏"打开一次、多次使用"的性能模型

安全保障来自两个层面

  1. 源进程的 PROCESS_DUP_HANDLE 权限:确保只有被信任的进程可以从源进程"取出"句柄
  2. 权限缩减:确保复制后的句柄权限不会超过源句柄

如果重新检查权限会怎样?

想象一个场景:管理员进程打开文件获得读+写权限,然后把句柄复制给低权限用户的进程。如果重新检查权限,低权限用户进程可能会失去写权限------这违反了"能力传递"的语义。设计决定是:句柄的权限在"打开时"确定,复制时保持不变。

4.8.9.10 问题 10:为什么 NtDuplicateObject 需要 PreviousMode 参数?
c 复制代码
NTSTATUS
NTAPI
NtDuplicateObject(
    ...
    // PreviousMode 是隐含参数,从系统调用栈获取
);

PreviousModeKernelModeUserMode)决定了:

  • 参数验证:用户态调用的参数需要严格验证(指针是否可读/可写、是否在用户地址空间)
  • 权限检查:内核态调用可以绕过某些权限检查
  • 句柄表选择:内核态调用使用内核句柄表,用户态调用使用进程句柄表

为什么这是必要的?

  • 驱动调用 :内核模式驱动需要复制句柄(如在 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 要用句柄复制?

答案

  1. 效率:比序列化/反序列化快
  2. 安全:内核强制安全检查
  3. 资源管理:句柄关闭语义清晰

反例:Socket 等基于网络的 IPC 不使用句柄复制,因为它们跨机器、跨语言。

4.8.12 小结

4.8.12.1 关键知识点
主题 关键点
核心流程 解析进程句柄 → 解析源句柄 → 计算权限 → 分配新 Entry → 递增引用
安全检查 PROCESS_DUP_HANDLE 权限
权限缩减 新句柄权限 ≤ 源句柄
错误处理 严格的回滚路径
4.8.12.2 设计原则
  1. 显式授权PROCESS_DUP_HANDLE 强制
  2. 权限只减不增:安全保证
  3. 引用计数正确:生命周期管理
  4. 跨进程可降级:资源限制
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 设计意图

核心问题

句柄是进程访问内核对象的唯一凭据。句柄关闭涉及:

  1. 资源释放:句柄槽位
  2. 对象销毁 :当 HandleCount = 0 时,可能需要释放对象
  3. 类型专属清理:每种对象的关闭逻辑不同
  4. 安全审计:记录敏感对象的关闭

设计哲学 :「分层关闭」------句柄层、对象层、类型层分别处理各自的清理。

本节定位

本节以 NtCloseobhandle.c:3402(file:///d:/reactos/ntoskrnl/ob/obhandle.c#L3402))和 ObpCloseHandleobhandle.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:句柄值无效

特殊值

  • -1NtCurrentProcess()):特殊处理
  • -2NtCurrentThread()):特殊处理
  • 内核句柄(最高位置 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 内部可能获取其他锁(如文件系统锁、缓存管理器锁)------如果持有句柄表锁进入这些代码,可能与其他线程形成死锁循环

正确的模式(先查后锁,锁内只做快操作)

  1. 锁表 → 快速验证句柄有效 → 解锁
  2. 调用 CloseProcedure(慢操作,不持锁)
  3. 再次锁表 → 清理 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 深入分析

ObpCloseHandleTableEntryobhandle.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 追踪"内核中有多少个指针指向这个对象"------归零意味着"内核也不再使用它",此时可以安全释放内存
  • 生命周期模型 :典型的对象生命周期是:
    1. 创建 → HandleCount=1, PointerCount=1
    2. 被引用 → HandleCount++ 或 PointerCount++(取决于引用方式)
    3. 关闭 → HandleCount--;HandleCount==0 → CloseProcedure
    4. 释放 → 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;
}

CloseProcedureDeleteProcedure 的区别

维度 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)是伪句柄:

  • 不占用句柄表

    c 复制代码
    if (Handle == NtCurrentProcess() || Handle == NtCurrentThread()) {
        return STATUS_INVALID_HANDLE;  // 不能关闭伪句柄
    }
  • 直接返回对象指针

    c 复制代码
    if (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;  // 用户态句柄表
}

关键设计决策

  1. 内核句柄独立表:避免用户态访问内核对象
  2. 必须 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:为什么 HandleCountPointerCount 分离?

两个引用计数追踪不同维度的引用:

  • 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 是内核态的句柄表,与用户态句柄表完全隔离:

  1. 安全:用户态无法访问内核句柄
  2. 效率:内核句柄不需要用户态验证
  3. 隔离:内核对象的引用不计入用户态配额

典型用途

  • 驱动访问内核对象
  • 系统服务
  • 内核线程
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 链式反应的控制

链式反应可能很深,需要谨慎控制:

  1. 递归限制:内核有最大递归深度
  2. 错误恢复:每一步都需要可回滚
  3. 锁顺序:避免锁顺序倒置
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 设计原则
  1. 分层关闭:句柄层 → CloseProcedure → DeleteProcedure
  2. 乐观原子操作InterlockedDecrement 避免锁
  3. 双句柄表:用户态和内核态隔离
  4. 解锁后回调:避免持锁执行长时间操作
4.9.12.3 常见陷阱
  1. 忘记关闭句柄:内存泄漏
  2. 重复关闭:引用计数变负,BSOD
  3. 关闭伪句柄:返回错误
  4. 关闭内核句柄(用户态):权限错误
  5. 句柄值混淆:不同进程的句柄值不同
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 本节回顾

NtDuplicateObjectNtClose 是对象管理器最重要的两个"资源管理 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 类型专属销毁
相关推荐
贺国亚8 小时前
Multi-Agent与Multi-Task编排架构
架构
xieliyu.8 小时前
Java算法精讲:双指针(二)
java·开发语言·算法
何以解忧,唯有..9 小时前
Python包管理工具pip:从入门到精通
开发语言·python·pip
雪的季节9 小时前
RabbitMQ详解
开发语言
ice81303318110 小时前
【Python】Matplotlib折线图绘制
开发语言·python·matplotlib
三品吉他手会点灯10 小时前
C语言学习笔记 - 44.运算符和表达式 - 运算符2 - 除法与取余运算符
c语言·开发语言·笔记·算法
kkeeper~10 小时前
0基础C语言积跬步之动态内存管理
c语言·开发语言
橘右今10 小时前
2026 Java后端高频面试宝典
java·开发语言·面试
Qiuner10 小时前
Pico 重塑Agent时代人与数据交互方式
windows·docker·ai·架构