Skip to content
Published at:

第2章:对象和句柄

Windows 是一个基于对象(Object-based)的操作系统,提供了大量内核对象(Kernel Object)类型来承载其大部分功能。进程、线程和文件就是对象类型的几个例子。本章介绍关于内核对象的通用理论,而不深入讨论任何特定的对象类型。

内核对象

Windows 内核暴露了多种对象类型,供用户模式(User-mode)进程、内核自身以及内核模式(Kernel-mode)驱动程序使用。这些对象类型实例是系统(内核)空间中的数据结枟,由对象管理器(Object Manager,执行体的一部分)根据用户模式或内核模式代码的请求创建和管理。内核对象采用引用计数(Reference Counting)机制,因此只有当指向对象的最后一个引用被释放时,对象才会被销毁并从内存中移除。

Windows 内核支持大量对象类型。你可以使用 Sysinternals 的 WinObj 工具(以管理员身份运行),找到 ObjectTypes 目录来查看它们。这些对象类型按其可见性和用途可以分为以下几类:

  • 通过 Windows API 导出到用户模式的类型:互斥体(Mutex)、信号量(Semaphore)、文件(File)、进程(Process)、线程(Thread)、定时器(Timer)等。
  • 在 Windows Driver Kit(WDK)中为设备驱动程序编写者记录的类型,但不向用户模式导出:设备(Device)、驱动程序(Driver)、回调(Callback)。
  • 即使在 WDK 中也未记录的类型(截至本文撰写时),仅供内核内部使用:分区(Partition)、键控事件(Keyed Event)、核心消息传输(Core Messaging)。

由于内核对象驻留在系统空间中,用户模式无法直接访问它们。应用程序必须使用一种称为句柄(Handle)的间接访问机制。句柄提供了以下几个优点:

  • 对象类型数据结构的任何未来变化都不会影响客户端。
  • 可以通过安全检查(Security Access Check)来控制对对象的访问。
  • 句柄是进程私有的(Process-private)—— 在某个进程的上下文中指向某个对象的句柄,在另一个进程的上下文中没有任何意义。

内核对象是引用计数的。对象管理器维护一个句柄计数(Handle Count)和一个指针计数(Pointer Count),它们的总和等于总引用计数。一旦用户模式客户端不再需要某个对象,就应该调用 CloseHandle 来关闭句柄。之后,代码应认为该句柄无效。尝试通过已关闭的句柄访问对象将会失败,GetLastError 返回 ERROR_INVALID_HANDLE(6)。通常,客户端不知道对象是否已被销毁。如果引用计数降为零,对象管理器就会删除该对象。

句柄值(Handle Value)是 4 的倍数,第一个有效的句柄是 4。0 永远不是一个有效的句柄值。这个机制在 64 位系统上没有变化。

从逻辑上讲,句柄相当于进程中维护的句柄表(Handle Table)中的一个条目数组的索引,该句柄表逻辑上指向位于系统空间中的内核对象。存在各种 Create*Open* 函数用于创建/打开对象并获取句柄。如果创建或打开失败,返回的句柄通常是 NULL(0)。一个显著的例外是 CreateFile,它在失败时返回 INVALID_HANDLE_VALUE(-1)。

例如,CreateMutex 允许创建一个新的互斥体或按名称打开一个现有的互斥体。成功时,它返回该互斥体的句柄。返回值为零意味着句柄无效且调用失败。反之,OpenMutex 尝试打开一个具有指定名称的互斥体的句柄——如果该互斥体不存在,调用就会失败。

如果函数成功并且提供了名称,返回的句柄可能指向一个新创建的对象,也可能指向一个同名的已有对象。代码可以通过调用 GetLastError 并与 ERROR_ALREADY_EXISTS 比较来检查。如果相等,说明这不是一个新对象,而是指向一个已有对象的另一个句柄。

运行单实例进程

ERROR_ALREADY_EXISTS 场景的一个常见用途是将可执行程序限制为单个进程实例。该技术使用一个有名称的内核对象(通常是互斥体,尽管任何可命名的对象类型都可以),以一个特定的名称创建。如果该对象已存在,说明另一个实例正在运行,因此当前进程可以退出。

"SingleInstance" 演示应用程序(使用 WTL 构建为基于对话框的应用程序)展示了这一点。如果启动多个实例,第一个窗口会记录来自新实例的消息,然后新实例退出。

WinMain 函数——创建互斥体:

cpp
HANDLE hMutex = ::CreateMutex(nullptr, FALSE, L"SingleInstanceMutex");
if (!hMutex) {
    CString text;
    text.Format(L"Failed to create mutex (Error: %d)", ::GetLastError());
    ::MessageBox(nullptr, text, L"Single Instance", MB_OK);
    return 0;
}

创建互斥体失败的情况应该极其罕见。最可能的原因是有另一个具有相同名称的内核对象(且该对象不是互斥体)存在。

检查互斥体是否已经存在:

cpp
if (::GetLastError() == ERROR_ALREADY_EXISTS) {
    NotifyOtherInstance();
    return 0;
}

如果该对象在 CreateMutex 调用之前就已存在,一个辅助函数会向现有实例发送一条消息,然后该进程退出。

NotifyOtherInstance 函数:

cpp
#define WM_NOTIFY_INSTANCE (WM_USER + 100)

void NotifyOtherInstance() {
    auto hWnd = ::FindWindow(nullptr, L"Single Instance");
    if (!hWnd) {
        ::MessageBox(nullptr, L"Failed to locate other instance window",
            L"Single Instance", MB_OK);
        return;
    }

    ::PostMessage(hWnd, WM_NOTIFY_INSTANCE, ::GetCurrentProcessId(), 0);
    ::ShowWindow(hWnd, SW_NORMAL);
    ::SetForegroundWindow(hWnd);
}

该函数使用 FindWindow 按窗口标题搜索——并不理想,但在这个例子中足够用了。它发送一条带有当前进程 ID 的自定义消息。

CMainDlg 中的消息映射(来自 MainDlg.h):

cpp
BEGIN_MSG_MAP(CMainDlg)
    MESSAGE_HANDLER(WM_NOTIFY_INSTANCE, OnNotifyInstance)
    MESSAGE_HANDLER(WM_INITDIALOG, OnInitDialog)
    COMMAND_ID_HANDLER(IDCANCEL, OnCancel)
END_MSG_MAP()

OnNotifyInstance 处理函数:

cpp
LRESULT CMainDlg::OnNotifyInstance(UINT, WPARAM wParam, LPARAM, BOOL &) {
    CString text;
    text.Format(L"Message from another instance (PID: %d)", wParam);
    AddText(text);
    return 0;
}

AddText 辅助函数:

cpp
void CMainDlg::AddText(PCWSTR text) {
    CTime dt = CTime::GetCurrentTime();
    m_List.AddString(dt.Format(L"%T") + L": " + text);
}

m_List 是一个 CListBox,WTL 对 Windows 列表框控件的封装。

句柄

句柄间接地指向内核空间中的一个小数据结构,该结构存储了该句柄的相关信息。每个句柄条目包含以下内容:

  • 指向实际对象的指针——在 32 位系统上,对象地址是 8 的倍数;在 64 位系统上是 16 的倍数(低位用于标志位,对齐有利于提高 CPU 访问速度)。
  • 访问掩码(Access Mask)——指示使用该句柄可以执行哪些操作(决定了句柄的权限)。
  • 三个标志位:继承标志(Inheritance Flag)、防止关闭标志(Protect-from-close Flag)和关闭时审计标志(Audit-on-close Flag)。

访问掩码是一个位掩码,其中每个为 "1" 的位表示允许使用该句柄执行的某个特定操作。访问掩码在创建对象或打开已有对象以创建句柄时设置。如果是创建对象,调用者通常会获得完整访问权限。如果是打开对象,调用者会指定所需的访问掩码,但未必能获得。

例如,要终止一个进程,应用程序必须调用 OpenProcess,并(至少)带 PROCESS_TERMINATE 访问权限,以获取该进程的句柄。

按 PID 终止进程的例子:

cpp
bool KillProcess(DWORD pid) {
    HANDLE hProcess = ::OpenProcess(PROCESS_TERMINATE, FALSE, pid);
    if (!hProcess)
        return false;
    BOOL success = ::TerminateProcess(hProcess, 1);
    ::CloseHandle(hProcess);
    return success != FALSE;
}

OpenProcess 的原型:

cpp
HANDLE OpenProcess(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL  bInheritHandle,
    _In_ DWORD dwProcessId
);

由于这是一次打开操作,对象已经存在,客户端需要指定所需的访问掩码。访问掩码有两大类访问位:通用访问位(Generic Access Bits)和特定访问位(Specific Access Bits,详见第 16 章)。上面的例子使用了 PROCESS_TERMINATE。其他可用的位包括 PROCESS_QUERY_INFORMATIONPROCESS_VM_OPERATION 等。

客户端代码应该只请求它打算对对象执行的操作所需的权限。请求比需要的更多可能会失败;请求更少则显然会不够用。

句柄标志

  • 继承(Inheritance):用于句柄继承,这是一种在协作进程之间共享对象的机制(将在第 3 章讨论)。
  • 关闭时审计(Audit on close):指示在关闭此句柄时是否应将一条审计条目写入安全日志。很少使用,默认关闭。
  • 防止关闭(Protect from close):阻止句柄被关闭。CloseHandle 返回 FALSEGetLastError 返回 ERROR_INVALID_HANDLE(6)。在调试器下,会引发一个异常,消息为:"0xC0000235: NtClose was called on a handle that was protected from close via NtSetInformationObject"。很少有用。

SetHandleInformation 函数:

cpp
#define HANDLE_FLAG_INHERIT             0x00000001
#define HANDLE_FLAG_PROTECT_FROM_CLOSE  0x00000002

BOOL SetHandleInformation(
    _In_ HANDLE hObject,
    _In_ DWORD dwMask,
    _In_ DWORD dwFlags
);

设置"防止关闭"位:

cpp
::SetHandleInformation(h, HANDLE_FLAG_PROTECT_FROM_CLOSE, HANDLE_FLAG_PROTECT_FROM_CLOSE);

清除同一位:

cpp
::SetHandleInformation(h, HANDLE_FLAG_PROTECT_FROM_CLOSE, 0);

GetHandleInformation 函数:

cpp
BOOL GetHandleInformation(
    _In_ HANDLE hObject,
    _Out_ LPDWORD lpdwFlags
);

Process Explorer 的句柄视图

Sysinternals 的 Process Explorer 工具可以显示特定进程中已打开的句柄。导航到你感兴趣的进程,确保下方窗格可见(View 菜单 -> Show Lower Pane),然后切换到 "Handles" 视图。

各列说明:

  • Handle:句柄值本身,仅对该进程有意义。相同的值可能指向不同的对象或为空。
  • Type:对象类型名称(对应 WinObj 的 Object Types 目录)。
  • Object Address:实际对象结构的内核地址(64 位系统上以零的十六进制数字结尾,32 位系统上以 "8" 或 "0" 结尾)。对于调试很有用——如果两个句柄指向相同的地址,它们引用的就是同一个对象。
  • Access:访问掩码,以十六进制值显示。
  • Decoded Access:常见对象类型的访问掩码位的字符串表示(作者个人为 Process Explorer 实现了这一列)。

默认情况下,Process Explorer 只显示有名称对象的句柄。要查看所有句柄,请在 View 菜单中启用 "Show Unnamed Handles and Mappings"。

这里"名称"(Name)这个术语比看起来更复杂。Process Explorer 中的"有名称"对象包括:

  • Process 和 Thread 对象:以其唯一 ID 显示。
  • File 对象:显示文件名或文件对象所指向的设备名称——并非真正的名称。
  • Key 对象:显示注册表键路径——并非真正的名称。
  • Directory 对象:显示逻辑路径,并非真正意义上的对象名称。
  • Token 对象:显示存储在令牌中的用户名。

在 WinObj 中浏览时,不会出现文件或键对象,这印证了这些对象确实不能有名称。

句柄计数

进程句柄表中的句柄总数可以在 Process Explorer 和任务管理器(Task Manager)中作为列显示。请注意,显示的数字是句柄计数(Handle Count)而非对象计数(Object Count),因为多个句柄可以引用同一个对象。

在 Process Explorer 中双击某个句柄条目会打开一个对话框,显示对象属性(而非句柄属性)。它显示了基本的对象信息,与句柄条目中的数据重复。此外还会显示该对象有多少个打开的句柄以及引用计数。

内核对象查看器

作者的 "KernelObjectView" 工具(可从 https://github.com/zodiacon/AllTools 获取)可以在给定时刻显示对象和句柄计数。它会按对象类型显示总对象数和总句柄数,可按任意列排序。

伪句柄

某些句柄具有特殊值且不能被关闭,这些被称为伪句柄(Pseudo-Handles)。对伪句柄调用 CloseHandle 总是会失败。返回伪句柄的函数包括:

  • GetCurrentProcess() ——返回调用进程的伪句柄(值 -1)
  • GetCurrentThread() ——返回调用线程的伪句柄(值 -2)
  • GetCurrentProcessToken() ——返回调用进程令牌的伪句柄(值 -4)
  • GetCurrentThreadToken() ——返回调用线程令牌的伪句柄(值 -5)
  • GetCurrentThreadEffectiveToken() ——返回调用线程有效令牌的伪句柄(值 -6,如果线程令牌可用则使用,否则使用进程令牌)

后三个(令牌伪句柄)仅在 Windows 8 及更高版本上受支持,其访问掩码仅限于 TOKEN_QUERYTOKEN_QUERY_SOURCE

句柄的 RAII

及时关闭不再使用的句柄至关重要。不这样做会导致句柄泄漏(Handle Leak),句柄数量会不受控制地增长。

C++ 的 RAII(Resource Acquisition Is Initialization,资源获取即初始化)惯用法有助于管理句柄。其核心思想:将句柄封装在一个类型中,当该封装的析构函数被调用时,确保句柄被关闭。

一个简单的 RAII 句柄封装:

cpp
struct Handle {
    explicit Handle(HANDLE h = nullptr) : _h(h) {}
    ~Handle() { Close(); }

    Handle(const Handle&) = delete;
    Handle& operator=(const Handle&) = delete;

    Handle(Handle&& other) : _h(other._h) {
        other._h = nullptr;
    }
    Handle& operator=(Handle&& other) {
        if (this != &other) {
            Close();
            _h = other._h;
            other._h = nullptr;
        }
        return *this;
    }

    operator bool() const {
        return _h != nullptr && _h != INVALID_HANDLE_VALUE;
    }

    HANDLE Get() const {
        return _h;
    }

    void Close() {
        if (_h) {
            ::CloseHandle(_h);
            _h = nullptr;
        }
    }

private:
    HANDLE _h;
};

拷贝构造函数和拷贝赋值运算符被删除,因为复制一个可能有多个拥有者的句柄会产生问题。移动操作转移所有权。bool 操作符将 0INVALID_HANDLE_VALUE 都视为无效。

使用示例:

cpp
Handle hMyEvent(::CreateEvent(nullptr, TRUE, FALSE, nullptr));
if (!hMyEvent) {
    return;
}

::SetEvent(hMyEvent.Get());

Handle hOtherEvent(std::move(hMyEvent));
::ResetEvent(hOtherEvent.Get());

虽然编写这样一个封装是可行的,但使用已有的库往往更好。

使用 WIL

微软的 Windows Implementation Library(WIL)提供了类似功能,可在 GitHub 上作为 NuGet 包获取。

将 WIL 添加到一个项目中,就像添加任何 NuGet 包一样。在 Visual Studio 中,右键点击 "References",选择 "Manage NuGet Packages...",搜索 "wil"。完整的包名是 "Microsoft.Windows.ImplementationLibrary"。

RAII 句柄封装位于 <wil/resource.h> 中。

使用 WIL 的相同代码:

cpp
#include <wil/resource.h>

void DoWork() {
    wil::unique_handle hMyEvent(::CreateEvent(nullptr, TRUE, FALSE, nullptr));
    if (!hMyEvent) {
        return;
    }
    ::SetEvent(hMyEvent.get());

    auto hOtherEvent(std::move(hMyEvent));
    ::ResetEvent(hOtherEvent.get());
}

wil::unique_handle 封装了 HANDLE,并在析构时调用 CloseHandle,其设计参照了 std::unique_ptr<>。内部的 HANDLE 通过 get() 访问。要替换值(并关闭旧句柄),使用 reset;调用不带参数的 reset() 会关闭句柄并清空封装。

创建对象

用于创建新对象的函数共享一些常见的参数。CreateMutexCreateEvent 可作为示例:

cpp
HANDLE CreateMutex(
    _In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,
    _In_ BOOL bInitialOwner,
    _In_opt_ LPCTSTR lpName);

HANDLE CreateEvent(
    _In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
    _In_ BOOL bManualReset,
    _In_ BOOL bInitialState,
    _In_opt_ LPCTSTR lpName);

两者都接受一个 SECURITY_ATTRIBUTES(安全属性)参数,这是所有创建函数共有的:

cpp
typedef struct _SECURITY_ATTRIBUTES {
    DWORD nLength;
    LPVOID lpSecurityDescriptor;
    BOOL bInheritHandle;
} SECURITY_ATTRIBUTES, *PSECURITY_ATTRIBUTES;

nLength 应设置为结构体的大小——这是一种常见的版本控制技术。SECURITY_ATTRIBUTES 结构体自第一个 Windows NT 版本以来一直未发生变化。

lpSecurityDescriptor 可以指向一个安全描述符(Security Descriptor)对象,指定谁可以对对象执行哪些操作(详见第 16 章)。

bInheritHandle 是继承位,允许设置继承而无需调用 SetHandleInformation

设置继承来创建事件的例子:

cpp
SECURITY_ATTRIBUTES sa = { sizeof(sa) };
sa.bInheritHandle = TRUE;
HANDLE hEvent = ::CreateEvent(&sa, TRUE, FALSE, nullptr);
DWORD flags;
::GetHandleInformation(hEvent, &flags);  // 设置 flags = 1

SECURITY_ATTRIBUTES 设置为 NULL 会使继承位保持清除状态。从安全角度来看,NULL 意味着"默认安全",基于进程访问令牌中的安全描述符(详见第 16 章)。

对象名称

某些对象类型可以具有基于字符串的名称。这些名称允许通过相应的 Open 函数按名称打开对象。并非所有对象都有名称——进程和线程改用 ID,因此 OpenProcessOpenThread 需要数字标识符。

当一个创建函数以名称调用时:

  • 如果不存在具有该名称的对象,则创建一个新对象。
  • 如果已存在具有该名称的对象,则打开现有对象。
  • 在后一种情况下,GetLastError 返回 ERROR_ALREADY_EXISTS,且影响创建的参数(如 SECURITY_ATTRIBUTES)被忽略。

提供给创建函数的名称并非最终名称。在经典(桌面)进程中,名称会被加上前缀 \Sessions\x\BaseNamedObjects\,其中 x 是调用者的会话 ID。如果会话 ID 为零,前缀为 \BaseNamedObjects\。对于在应用容器(App Container,通常是 UWP 进程)中的进程,前缀更为复杂,包含一个唯一的应用容器 SID:\Sessions\x\AppContainerNamedObjects\

对象名称是会话相关(Session-relative)的(对于应用容器则是包相关的)。要进行跨会话共享,请在会话 0 中创建对象,并使用 Global\ 前缀——例如 Global\MyMutex。应用容器不能使用会话 0 的对象命名空间。

WinObj 展示了完整的对象管理器命名空间层次结构。这个完整的结构驻留在内存中,由对象管理器根据需要操作。无名称对象不属于这个结构,因此 WinObj 只显示以名称创建的对象。

WinObj 中显示的 "Directories" 实际上是目录对象(Directory Objects)——作为逻辑容器的内核对象。

共享内核对象

内核对象句柄是进程私有的。一个进程不能简单地将一个句柄值传递给另一个进程,因为在目标进程的句柄表中,这个值可能指向一个不同的对象,或者为空。

共有三种共享机制:

  1. 通过名称共享
  2. 通过句柄继承共享——将在第 3 章讨论
  3. 通过复制句柄共享

通过名称共享

当可行时(意味着对象能够并且确实有名称),这是最简单的选择。协作进程使用相同的对象名称调用相应的创建函数。第一个调用者创建对象,后续调用打开指向同一对象的额外句柄。

"BasicSharing" 示例通过一个用于在进程间共享内存的内存映射文件(Memory-mapped File)对象来演示这一点。运行两个或更多实例可以允许在进程之间共享文本数据。

在 Process Explorer 中,查看 Section 对象(内存映射文件的内核名称),会出现一个名为 "MySharedMemory" 的 section(带有基于会话的前缀)。该对象有两个打开的句柄。共享内存大小为 4KB。两个进程指向同一个对象(相同的 Object Address),但句柄值不同。

创建文件映射(在 WM_INITDIALOG 中):

cpp
m_hSharedMem = ::CreateFileMapping(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE,
    0, 1 << 12, L"MySharedMemory");
if (!m_hSharedMem) {
    AtlMessageBox(m_hWnd, L"Failed to create/open shared memory", IDR_MAINFRAME);
    EndDialog(IDCANCEL);
}

最后一个参数是对象名称。如果这是第一个进程,对象会被创建。后续调用会产生指向同一对象的额外句柄。大小设置为 4KB(1 << 12)。

WM_DESTROY 上关闭句柄:

cpp
if (m_hSharedMem)
    ::CloseHandle(m_hSharedMem);

写入共享内存:

cpp
void* buffer = ::MapViewOfFile(m_hSharedMem, FILE_MAP_WRITE, 0, 0, 0);
if (!buffer) {
    AtlMessageBox(m_hWnd, L"Failed to map memory", IDR_MAINFRAME);
    return 0;
}

CString text;
GetDlgItemText(IDC_TEXT, text);
::wcscpy_s((PWSTR)buffer, text.GetLength() + 1, text);
::UnmapViewOfFile(buffer);

从共享内存中读取:

cpp
void* buffer = ::MapViewOfFile(m_hSharedMem, FILE_MAP_READ, 0, 0, 0);
if (!buffer) {
    AtlMessageBox(m_hWnd, L"Failed to map memory", IDR_MAINFRAME);
    return 0;
}

SetDlgItemText(IDC_TEXT, (PCWSTR)buffer);
::UnmapViewOfFile(buffer);

通过句柄复制共享

对于没有名称或不能有名称的对象,复制句柄可能是解决方案。这种方法没有固有的限制(安全方面除外),几乎适用于任何内核对象,无论是命名的还是未命名的,在任何时间都可以使用。它的缺点是:这是实现难度最高的共享方法。

DuplicateHandle 函数:

cpp
BOOL DuplicateHandle(
    _In_ HANDLE hSourceProcessHandle,
    _In_ HANDLE hSourceHandle,
    _In_ HANDLE hTargetProcessHandle,
    _Outptr_ LPHANDLE lpTargetHandle,
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL bInheritHandle,
    _In_ DWORD dwOptions);

复制需要一个源进程、一个源句柄和一个目标进程。成功时,一个新的句柄条目会被写入目标进程的句柄表中,指向与源句柄相同的对象。

参数详解:

  • hSourceProcessHandle:源进程的句柄,需要 PROCESS_DUP_HANDLE 访问权限。可使用 GetCurrentProcess() 表示调用者的进程。
  • hSourceHandle:要复制的源句柄,在源进程上下文中有效。
  • hTargetProcessHandle:目标进程的句柄,同样需要 PROCESS_DUP_HANDLE 访问权限。
  • lpTargetHandle:结果句柄(在目标进程中有效)。
  • dwDesiredAccess:新句柄的访问掩码(如果设置了 DUPLICATE_SAME_ACCESS,此参数将被忽略)。
  • bInheritHandle:新句柄是否可继承。
  • dwOptions:标志位——DUPLICATE_SAME_ACCESSDUPLICATE_CLOSE_SOURCE(复制后关闭源句柄,不增加句柄计数)。

简单的进程内复制示例:

cpp
HANDLE hJob = ::CreateJobObject(nullptr, nullptr);
HANDLE hJob2;
::DuplicateHandle(::GetCurrentProcess(), hJob, ::GetCurrentProcess(), &hJob2,
    JOB_OBJECT_ASSIGN_PROCESS | JOB_OBJECT_TERMINATE, FALSE, 0);

在 Process Explorer 中可以看到差异:一个句柄有完整访问权限,复制后的句柄只有指定的访问掩码。

复制到另一个进程:

cpp
HANDLE DuplicateToProcess(HANDLE hSource, DWORD pid) {
    HANDLE hProcess = ::OpenProcess(PROCESS_DUP_HANDLE, FALSE, pid);
    if (!hProcess)
        return nullptr;
    HANDLE hTarget = nullptr;
    ::DuplicateHandle(::GetCurrentProcess(), hSource, hProcess,
                      &hTarget, 0, FALSE, DUPLICATE_SAME_ACCESS);
    ::CloseHandle(hProcess);
    return hTarget;
}

复杂性不在于复制本身,而在于如何向目标进程传达何时复制了句柄以及新句柄的值是多少。调用者知道创建的句柄值,但目标进程不知道。必须通过另一种进程间通信(IPC)方式来传达这些信息。

私有对象命名空间

有名称的内核对象有一些弊端:

  • 不相关的进程可能会创建具有相同名称的对象,导致失败或意外的对象共享。
  • 名称是可见的(在工具中和通过编程方式),因此另一个进程可以"劫持"或干扰该对象。无名对象则要隐蔽得多。

自 Windows Vista 起,引入了私有对象命名空间(Private Object Namespaces),允许只有合作的进程才能知道某个对象的完整名称。工具和 API 无法揭示其完整名称。

"PrivateSharing" 示例在 "BasicSharing" 的基础上进行了增强——内存映射文件对象的名称现在位于一个私有命名空间中。Process Explorer 只会显示名称的一部分。随意尝试查找 "MySharedMem" 的代码将无法找到它。

创建私有命名空间

步骤 1:创建边界描述符(Boundary Descriptor)

cpp
HANDLE CreateBoundaryDescriptor(
    _In_ LPCTSTR Name,
    _In_ ULONG Flags);  // 当前未使用

有两个函数用于限制通过该描述符创建的私有命名空间的访问:

cpp
BOOL AddSIDToBoundaryDescriptor(
    _Inout_ HANDLE* BoundaryDescriptor,
    _In_ PSID RequiredSid);

BOOL AddIntegrityLabelToBoundaryDescriptor(
    _Inout_ HANDLE* BoundaryDescriptor,
    _In_ PSID IntegrityLabel);

两者都接受边界描述符句柄的地址和一个 SID。AddSIDToBoundaryDescriptor 通常使用一个组 SID,允许该组中的所有用户访问命名空间。AddIntegrityLabelToBoundaryDescriptor 设置一个最低完整性级别(Integrity Level),用于打开命名空间中的对象的进程。

步骤 2:创建私有命名空间

cpp
HANDLE CreatePrivateNamespace(
    _In_opt_ LPSECURITY_ATTRIBUTES lpPrivateNamespaceAttributes,
    _In_ LPVOID lpBoundaryDescriptor,
    _In_ LPCWSTR lpAliasPrefix);

注意此处的类型不一致——边界描述符是 void* 而非 HANDLE,这是一个微小的 API 不一致。边界描述符不是一个内核对象,尽管返回了一个 HANDLE;它有自己的 DeleteBoundaryDescriptor 函数。

如果命名空间已存在,CreatePrivateNamespace 会失败,此时必须改用 OpenPrivateNamespace

cpp
HANDLE OpenPrivateNamespaceW(
    _In_ LPVOID lpBoundaryDescriptor,
    _In_ LPCWSTR lpAliasPrefix);

BOOLEAN ClosePrivateNamespace(
    _In_ HANDLE Handle,
    _In_ ULONG Flags);  // 0 或 PRIVATE_NAMESPACE_FLAG_DESTROY

另一个不一致之处:ClosePrivateNamespace 返回 BOOLEAN(typedef 为 BYTE),而不是标准的 BOOL

一旦命名空间被创建或打开,有名称的对象就以 alias\name 的形式来创建,其中 "alias" 是 lpAliasPrefix 的值。

PrivateSharing 应用程序代码

对话框类成员:

cpp
private:
    wil::unique_handle m_hSharedMem;
    HANDLE m_hBD{ nullptr }, m_hNamespace{ nullptr };

对内存映射文件句柄使用了 WIL 的 unique_handle,边界描述符和命名空间则用了原始句柄。

对话框创建时的设置:

cpp
// 创建边界描述符
m_hBD = ::CreateBoundaryDescriptor(L"MyDescriptor", 0);
BYTE sid[SECURITY_MAX_SID_SIZE];
auto psid = reinterpret_cast<PSID>(sid);
DWORD sidLen;
::CreateWellKnownSid(WinBuiltinUsersSid, nullptr, psid, &sidLen);
::AddSIDToBoundaryDescriptor(&m_hBD, psid);

// 创建私有命名空间
m_hNamespace = ::CreatePrivateNamespace(nullptr, m_hBD, L"MyNamespace");
if (!m_hNamespace) {
    m_hNamespace = ::OpenPrivateNamespace(m_hBD, L"MyNamespace");
}

m_hSharedMem.reset(::CreateFileMapping(INVALID_HANDLE_VALUE, nullptr,
                                       PAGE_READWRITE, 0, 1 << 12, L"MyNamespace\\MySharedMem"));

向边界描述符添加了一个 SID——WinBuiltinUsersSid,涵盖所有标准用户。更严格的 SID(如管理员组)可以限制访问。

WM_DESTROY 上进行清理:

cpp
if (m_hNamespace)
    ::ClosePrivateNamespace(m_hNamespace, 0);
if (m_hBD)
    ::DeleteBoundaryDescriptor(m_hBD);

WIL 包装器

WIL 没有为边界描述符和私有命名空间提供内置的包装器,但创建它们并不困难:

cpp
namespace wil {
    static void close_private_ns(HANDLE h) {
        ::ClosePrivateNamespace(h, 0);
    };

    using unique_private_ns = unique_any_handle_null_only<decltype(
        &close_private_ns), close_private_ns>;

    using unique_bound_desc = unique_any_handle_null_only<decltype(
        &::DeleteBoundaryDescriptor), ::DeleteBoundaryDescriptor>;
}

"PrivateSharing2" 项目与 "PrivateSharing" 类似,但对所有句柄都使用了这些 WIL 包装器,包括 MapViewOfFile 返回的指针。

PrivateSharing2 中的读取函数:

cpp
wil::unique_mapview_ptr<void> buffer(::MapViewOfFile(
    m_hSharedMem.get(), FILE_MAP_READ, 0, 0, 0));
if (!buffer) {
    AtlMessageBox(m_hWnd, L"Failed to map memory", IDR_MAINFRAME);
    return 0;
}

SetDlgItemText(IDC_TEXT, (PCWSTR)buffer.get());

用户对象和 GDI 对象

除了内核对象之外,Windows 还使用用户对象(User Objects)和 GDI 对象(GDI Objects)。任务管理器可以为每个进程显示这两种对象的计数。

用户对象

用户对象包括窗口(HWND)、菜单(HMENU)和钩子(HHOOK)。它们的句柄具有以下特性:

  • 没有引用计数。第一个销毁对象的调用者就会消除该对象。
  • 句柄值在一个窗口站(Window Station)范围内有效。一个窗口站包含剪贴板、桌面和原子表,因此句柄可以在共享桌面的应用程序之间自由传递。

GDI 对象

GDI(Graphics Device Interface,图形设备接口)是 Windows 最初的图形 API。常见的 GDI 对象包括:设备上下文(HDC)、画笔(HPEN)、画刷(HBRUSH)、位图(HBITMAP)等。它们的特性:

  • 没有引用计数
  • 句柄值仅在创建进程内有效。
  • 不能在进程之间共享

总结

本章介绍了内核对象以及如何通过句柄来访问和共享它们。没有深入探讨特定的对象类型,因为这些将在后续章节中讲述。下一章将深入介绍最著名的内核对象——进程