Windows 10系统编程整理——内核对象和用户态句柄
Windows本身呢,是一个倾向于面对对象的操作系统,我们使用这些操作系统提供的抽象,实际上就是在使用Windows 内核公开了各种类型的对象。不管我们后面是在用户模式进程,内核还是在内核模式驱动程序进行编程的,本质上都是在跟这些对象打交道。我们这一章的重点核心也是理解清楚他们是如何在这里进行管理和工作的。
这些类型的实例是系统(内核)空间中的数据结构,由对象管理器(执行体的一部分)在用户或内核代码请求时创建和管理。内核对象是引用计数的,因此只有当对象的最后一个引用被释放时,对象才会被销毁并从内存中释放。
内核对象是引用计数的。对象管理器维护一个句柄计数和一个指针计数,两者之和就是对象的总引用计数(直接指针可以从内核模式获取)。一旦用户模式客户端使用的对象不再需要,客户端代码应该通过调用 CloseHandle 来关闭用于访问该对象的句柄。从此时起,代码应该认为该句柄无效。尝试通过已关闭的句柄访问对象将会失败,GetLastError 将返回 ERROR_INVALID_HANDLE (6)。客户端通常不知道对象是否已被销毁。如果对象的引用降至零,对象管理器将删除该对象。
Windows 的对象类型非常的丰富。基本上也就是这几种:
- 通过 Windows API 导出到用户模式的类型。例如:互斥锁、信号量、文件、进程、线程、计时器。本书讨论了许多此类对象类型。
- 未导出到用户模式,但在 Windows 驱动程序工具包 (WDK) 中记录,供设备驱动程序编写者使用的类型。例如:设备、驱动程序、回调。
- 甚至在 WDK 中也未记录的类型(至少在撰写本文时)。这些对象类型仅供内核本身使用。例如:分区、键控事件、核心消息传递。
咱们可以使用SysInternals来查看我们的Windows系统的对象句柄,地址在:WinObj - Sysinternals | Microsoft Learn
用户态句柄和内核对象的关系
咱们都是学过最基本的操作系统的人了。显然,内核对象出于内核态,就不可能直接被咱们的用户态进程所直接访问。否则的话,权限这关就过不去。那我们怎么做呢?Windows提供了针对我们每一个公开到用户态的句柄的用户态对象句柄。咱们操作用户态对象句柄,就是在操作内内核对象。最重要的是,我们再也没办法随意的操作内核对象。实际上是保证了一种规范化的接口,将误操作的风险从咱的用户转移到微软的内核工程师上去了;我们内核对象到底是如何实现的,也完全不会影响到咱们使用句柄,该怎么样就怎么样。
从接口上,咱们的用户态对象中,都是使用:Create*蔟和Open*的函数创建或者是打开已经存在的,我们指定的一个内核对象。返回的HANDLE很有意思,需要注意的是他们从来都是4的倍数,尽管,嗯,好像并不是完全保证的。我们返回的内核句柄总是从4起头,4的倍数。因此,我们往往会检查返回的句柄是不是NULL,但是有一个是例外,那就是咱们的CreateFile函数,这个函数的失败不会返回NULL,而是有趣的INVALID_HANDLE_VALUE(说真的不太懂这种例外的设计,败坏好感)。所以我们在使用API的时候,还是要多加看看MSDN家的文档。防止出事。
咱们的一个例子还就是经典的CreateMutex,它允许我们创建新的互斥锁或按名称打开互斥锁(取决于具有该名称的互斥锁是否存在)。如果成功,该函数将返回互斥锁的句柄。返回值为零表示句柄无效(函数调用失败)。另一方面,OpenMutex 函数尝试打开指定名称的互斥锁的句柄。如果具有该名称的互斥锁不存在,则该函数调用失败。
如果函数调用成功且提供了名称,则返回的句柄可能是新的互斥锁,也可能是具有该名称的现有互斥锁。代码可以通过调用 GetLastError 并将结果与 ERROR_ALREADY_EXISTS 进行比较来检查这一点。如果是,则它不是新对象,而是另一个现有对象的句柄。这是即使相关 API 调用成功,也可能调用 GetLastError 的罕见情况之一。
句柄
句柄本身是一个很有意思的数据结构。它实际上更加像是一个地址。

在 32 位系统上,该句柄条目大小为 8 字节,在 64 位系统上则为 16 字节(技术上 12 字节就足够了,但为了对齐,会扩展为 16 字节)。每个条目包含以下元素:
- 指向实际对象的指针。由于低位用于标志,并通过地址对齐来缩短 CPU 的访问时间,因此对象的地址在 32 位系统上是 8 的倍数,而在 64 位系统上是 16 的倍数。
- 访问掩码,指示可以使用此句柄执行的操作。换句话说,访问掩码就是句柄的权限。
- 三个标志:继承、防止关闭和关闭时审计(稍后讨论)。
访问掩码是一个位掩码,其中每个“1”位表示可以使用该句柄执行的特定操作。访问掩码在创建对象或打开现有对象时设置。如果创建了对象,则调用者通常拥有对该对象的完全访问权限。但是,如果打开了对象,则调用者需要指定所需的访问掩码,而访问掩码可能获得,也可能得不到。
例如,如果应用程序想要终止某个进程,它必须首先调用 OpenProcess 函数,以获取访问掩码至少为 PROCESS_TERMINATE 的所需进程的句柄,否则无法终止使用该句柄的进程。如果 OpenProcess 函数调用成功,则对 TerminateProcess 函数的调用也必然会成功。
对象名称
我们上面就提到了我们既可以创建对象,也可以打开对象。问题来了。我们怎么索引到这些对象?一定要通过咱们的Handle值来做传递嘛?不是的。答案是我们还有一个重要的东西叫做对象名称。也就是ObjectName,这些名称可用于通过合适的 Open 函数按名称打开对象。
一些对象是没有名称的,比如说进程和线程没有名称——它们有 ID。这就是为什么 OpenProcess 和 OpenThread函数需要进程/线程标识符(一个数字)而不是基于字符串的名称。可以使用 Sysinternals 的 WinObj 工具查看命名的对象。
共享我们的内核对象
内核对象的共享办法有三种,
- 如果我们的对象是有名字的,拿到名称就可以进行共享。
- 如果我们的共享的集合是父子关系(举个例子,咱们的子进程想从父进程继承点东西,这就是这个Case),那我们可以通过句柄的继承来
- 我们也可以主动进行,那就是直接调用专门复制句柄的DuplicateHandle方法。来拷贝我们的内核对象
机制一:句柄继承(Handle Inheritance)
当用 CreateProcess 启动新进程时,可通过设置 bInheritHandles = TRUE 让父进程中**可继承(inheritable)**的句柄在子进程中也可用。句柄是否可继承由 SECURITY_ATTRIBUTES(创建对象时)或 SetHandleInformation 来设置。这个时候,我们的对象就可以被继承到子进程中,进行一定安全控制的使用。
- 父子进程间快速共享管道/文件/同步对象,常见于用
CreateProcess启动子进程并把标准输入/输出重定向给子进程(把管道句柄设置为可继承)。 - 流程链:父进程创建资源 -> 启动子进程并把资源继承下来。
// 父进程:创建一个可继承的管道的写句柄,然后CreateProcess子进程继承
SECURITY_ATTRIBUTES sa = { sizeof(sa), NULL, TRUE }; // bInheritHandle=TRUE
HANDLE hRead, hWrite;
CreatePipe(&hRead, &hWrite, &sa, 0);
// 确保父进程的 hRead 不能被继承(只让子进程继承写端,举例)
SetHandleInformation(hRead, HANDLE_FLAG_INHERIT, 0);
STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = hRead; // 让子进程使用读取端作为标准输入 (示例)
si.hStdOutput = hWrite;
si.hStdError = hWrite;
CreateProcess(NULL, cmdLine, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
DuplicateHandle(显式把句柄复制到目标进程)
使用 API DuplicateHandle 可以把一个进程的句柄复制(或“映射”)到另一个进程的句柄表中,无须通过继承且可在运行时执行。前提是你能获得要复制到的目标进程的进程句柄(通常通过 OpenProcess(PROCESS_DUP_HANDLE, ...) 或进程创建时已持有的 PROCESS_* 权限)。
函数签名(简化):
BOOL DuplicateHandle(
HANDLE hSourceProcessHandle,
HANDLE hSourceHandle,
HANDLE hTargetProcessHandle,
LPHANDLE lpTargetHandle,
DWORD dwDesiredAccess,
BOOL bInheritHandle,
DWORD dwOptions
);
- 父进程/管理进程将资源(句柄)分发给任意已知 PID 的进程(比如服务需要把句柄传给某个工作进程),运行时非常灵活。
- 远程进程之间的句柄传递:A -> B(先 OpenProcess(B) -> DuplicateHandle)。
// 假设我们在进程 A,要把 hSource 复制给进程B(pid)
HANDLE hSource = /* 已有句柄 */;
DWORD targetPid = /* 目标进程ID */;
HANDLE hTargetProc = OpenProcess(PROCESS_DUP_HANDLE, FALSE, targetPid);
HANDLE hNewInTarget = NULL;
BOOL ok = DuplicateHandle(
GetCurrentProcess(), // 源进程句柄
hSource, // 源句柄
hTargetProc, // 目标进程句柄
&hNewInTarget, // 目标进程中返回的句柄
0, // 0 => 使用原句柄访问权限,或者指定新权限
FALSE, // 目标句柄是否可继承
DUPLICATE_SAME_ACCESS
);
CloseHandle(hTargetProc);
if (!ok) { /* 处理错误 GetLastError() */ }
命名内核对象 / 命名 IPC(Named Objects)
很多内核对象支持以字符串 名字 注册到对象管理器的命名空间。其他进程可以直接通过名字打开该对象(OpenMutex, OpenEvent, OpenFileMapping, CreateFileMapping with same name, CreateNamedPipe 等)。命名对象特别适合无父子关系的任意进程之间共享同一资源。
命名空间注意:
- 会话命名空间(
Local\)和全局命名空间(Global\)——在 Terminal Services / Session 隔离中很重要。Global\MyName:跨所有会话(需要 SeCreateGlobalPrivilege 在某些系统/上下文)。Local\MyName或没有前缀:通常局限当前会话。
有例子的:
- 共享内存:
CreateFileMapping(NULL, &sa, PAGE_READWRITE, 0, size, L"Global\\MySharedMem")-> 其他进程OpenFileMapping->MapViewOfFile。 - 命名互斥/事件:多个进程通过名字获得同一个同步对象进行互斥或事件通知。
- 命名管道(Named Pipe):
\\.\pipe\MyPipeName,用于流式 IPC。 - 命名信号量等。
命名文件映射(共享内存)
// 创建(或打开)一个命名的文件映射
HANDLE hMap = CreateFileMapping(
INVALID_HANDLE_VALUE, // 使用系统分页文件(不基于真实文件)
&sa, // 可选安全属性(决定谁能打开)
PAGE_READWRITE,
0,
4096,
L"Global\\MySharedMemory"
);
void *p = MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, 0);
// 其他进程用 OpenFileMapping + MapViewOfFile 打开同名映射
HANDLE hMap2 = OpenFileMapping(FILE_MAP_ALL_ACCESS, FALSE, L"Global\\MySharedMemory");
LL_ACCESS, 0, 0, 0);
// 其他进程用 OpenFileMapping + MapViewOfFile 打开同名映射
HANDLE hMap2 = OpenFileMapping(FILE_MAP_ALL_ACCESS, FALSE, L"Global\MySharedMemory");
1245

被折叠的 条评论
为什么被折叠?



