一、什么是内核对象
我们在 windows 开发中经常会遇到内核对象,如事件(Event),管道(Pipe),互斥量(Mutex),完成端口(IOCP),进程(Process),线程(Thread)等,他们都是内核对象。这些内核对象虽然通过不同的系统 API 来创建,但这些 API 都有一个共同特点,就是都需要传入SECURITY_ATTRIBUTES
安全描述符结构体指针,并且返回句柄(HANDLE)。依据这个特点,我们有一个简单方法来判断对象是否是内核对象,就是看创建它的函数是否允许传入SECURITY_ATTRIBUTES
安全描述符。
二、内核对象的创建
大多数创建内核对象的系统 API 函数,如 CreateEvent, CreateMutex, CreateThread, CreateProcess, CreatePipe, CreateNamedPipe 等都会返回一个 HANDLE(无论是以返回值的形式,还是以指针参数的形式返回),创建内核对象成功时 HANDLE 为非NULL
,我们可以通过将 HANDLE 的值与 NULL 进行比较,来判断函数是否执行成功。但是有些函数比较例外,如CreateFile
,这些函数执行失败时,返回的HANDLE
的值为INVALID_HANDLE_VALUIE
。
相关文章:由HANDLE返回值不确定性引发的思考
三、内核对象的访问
虽然内核对象属于系统内核,但创建函数返回的HANDLE
句柄却只和当前进程有关,离开了当前进程这个句柄也就失去了意义。
内核对象属于系统内核级别,为了系统安全性,Windows 不允许我们直接访问内核对象的内存区域,只允许我们通过 Windows 提供的一系列 API 来访问内核对象,如SetEvent
, ResetEvent
等等,使用这些函数时我们都会用到HANDLE
,windows 头文件中HANDLE
的定义如下:
1 | typedef void *HANDLE; |
虽然定义为void*
类型,但很显然这个HANDLE
不是指向内核对象的指针。
如何证明 HANDLE 不是指向内核对象的指针?
一方面直接执行内核对象毫无安全性可言;
另一方面内核对象保存在内核地址空间(32 位系统是0x80000000 到 0xFFFFFFFF
,64 位系统是0x00000040 00000000到0xFFFFFFFF FFFFFFFF
),我们可以调用类似CreateEvent
的函数创建一个内核对象,观察其返回的 HANDLE,明显不在内核地址空间的范围内,且值一般比较小。
那么这个HANDLE句柄
是如何与内核对象关联起来的了?答案是:进程的句柄表。
每个进程在初始化的时候,系统都会为它分配一个句柄表(Windows 没有提供官方的文档来介绍句柄表),参考《Windows 核心编程》得知句柄表的结构,如图:
索引 | 指向内核对象内存块的指针 | 访问掩码 | 标志 |
---|---|---|---|
1 | 0x???????? | 0x???????? | 0x???????? |
2 | 0x???????? | 0x???????? | 0x???????? |
… | … | … | … |
如我们调用类似CreateEvent
的函数返回的句柄HANDLE
就是句柄表中的索引
。因为是索引
,所以它的值一般比较小。我们向 windows API 函数传入这个索引
,API 再通过索引
找到对应的内核对象指针
。
四、内核对象的销毁
4.1 引用计数
内核对象的所有者是操作系统内核,而不是创建它的进程。
多个进程可以引用(使用)同一个内核对象,操作系统使用了计数器的方式来管理内核对象(这个和 C++中的std::shared_ptr
智能指针类似),一个内核对象其实有两个计数器:一个是给用户态(Ring3)用的句柄计数;另一个是指针计数,也叫引用计数,因为核心态程序(Ring0)也经常用到内核对象,为了使用方便,在核心态的代码用指针直接访问对象,所以内核对象的管理器也维护了这个指针引用计数。只有在内核对象的句柄计数
和引用计数
都为 0 时,该内核对象才被释放。一般而言,指针引用计数值比句柄计数值大。
4.2 正确的销毁方式
当程序不再使用内核对象时,需要调用CloseHandle
将内核对象的计数减 1,这样系统内核在该对象计数为 0 时(也就是没有被任何东西引用时)将销毁该对象。 并且在调用CloseHandle
之后,程序还应该将HANDLE
置为NULL
。
如果CloseHandle
之后不将HANDLE
置为NULL
,反而再次使用该HANDLE
,就会出现 2 种情况:
进程句柄表中该
HANDLE
所在的索引项的记录已经被清除,且没有别的线程再次在该索引创建记录项,若此时使用这个过期的HANDLE
调用 Win32 API 函数,Windows 会返回无效参数错误。这种情况还比较好调试。进程句柄表中该
HANDLE
所在的索引项的记录同样也已经被清除,但已经有别的线程(该进程中的其他线程)在该索引
位置创建了记录项,若此时使用这个过期的HANDLE
调用 Win32 API 函数,该HANDLE
就会引用到其他线程新建的那个内核对象,从而出现一些难以预料的错误。这种错误很难调试。
4.3 获取内核对象的引用计数
虽然 windows 没有提供 API 让用户在用户态(Ring3)查询一个内核对象的句柄计数和引用计数,但我们可以从Ntdll.dll
导出NtQueryObject
函数来实现查询内核对象的当前状态(该函数没有被文档化)。
NtQueryObject
函数声明如下:
1 | // 返回值:如果成功则返回0 |
将NtQueryObject
函数调用的细节封装到GetKernelObjectRefCount
函数中,方便使用:
1 | bool GetKernelObjectRefCount(HANDLE handle, DWORD &handle_count, DWORD &point_count) { |
五、内核对象跨进程访问
虽然内核对象位于独立于进程之外的内核区域,我们在开发中却只能通过调用 Win32 API 传入 HANDLE 参数来操作内核对象(如SetEvent
等)。然而HANDLE句柄
只对当前进程有效,离开了当前进程该句柄就无效了。所以说,跨进程访问内核对象的关键在于我们怎么跨进程访问句柄HANDLE
?
下面介绍几种方法来实现跨进程共享内核对象。
5.1 使用句柄继承的方式
只有进程之间有父子关系时,才可以使用句柄继承的方式。在这种情况下,父进程可以生成一个子进程,并允许子进程访问父进程的内核对象。为了使这种继承生效,父进程必须执行几个步骤:
(1). 父进程在创建一个内核对象时,父进程必须向系统指定它希望这个内核对象的句柄是可以继承的。为了创建一个可继承的内核对象,必须分配并初始化一个SECURITY_ATTRIBUTES
结构,如:
1 | SECURITY_ATTRIBUTES sa; |
(2). 父进程通过 CreateProcess 生成子进程,且指定bInheritHandles
为 TRUE,从而允许子进程来继承父进程的那些“可继承的句柄”。
1 | // 启动子进程TestB.exe,将句柄h作为启动参数传给进程TestB |
由于我们传给bInheritHandles
参数的值是TRUE
,所以系统在创建子进程时会多做一件事情:它会遍历父进程的句柄表,对它的每一项进行检查,凡是包含一个有效的“可继承的句柄”的项,都会将该项完整的复制到子进程的句柄表。在子进程的句柄表中,复制项的位置与它在父进程句柄表中的位置完全一样(包含索引),这个就意味着:在父进程和子进程中,对一个内核对象进行标识的句柄值也是完全一样的。所以我们只需要通过某种方式(如上面示例中的启动参数的方式,或者环境变量的方式等任何进程间通讯的方式)将这个值告诉子进程,子进程就可以将该值转成HANDLE
,然后使用这个HANDLE
来调用系统 API。
5.2 使用 DuplicateHandle 方式
DuplicateHandle 函数可以将指定“源进程的句柄表”中的某一项复制到“目的进程句柄表”中(除了索引),并且返回该项在目的进程句柄表中的索引(即 HADNLE)。
可以在任何时候调用 DuplicateHandle 函数,DuplicateHandle 对源句柄是否是可继承的没有要求。
函数声明如下:
1 | BOOL DuplicateHandle( |
DuplicateHandle 详细介绍可以参考 MSDN:https://msdn.microsoft.com/en-us/library/windows/desktop/ms724251(v=vs.85).aspx
DuplicateHandle 函数不能复制所有类型的句柄,只能复制如下类型的句柄(从 MSDN 复制而来):
Object | Description |
---|---|
Access token | The handle is returned by the CreateRestrictedToken, DuplicateToken, DuplicateTokenEx, OpenProcessToken, or OpenThreadToken function. |
Change notification | The handle is returned by the FindFirstChangeNotification function. |
Communications device | The handle is returned by the CreateFile function. |
Console input | The handle is returned by the CreateFile function when CONIN$ is specified, or by the GetStdHandle function when STD_INPUT_HANDLE is specified. Console handles can be duplicated for use only in the same process. |
Console screen buffer | The handle is returned by the CreateFile function when CONOUT$ is specified, or by the GetStdHandle function when STD_OUTPUT_HANDLE is specified. Console handles can be duplicated for use only in the same process. |
Desktop | The handle is returned by the GetThreadDesktop function. |
Event | The handle is returned by the CreateEvent or OpenEvent function. |
File | The handle is returned by the CreateFile function. |
File mapping | The handle is returned by the CreateFileMapping function. |
Job | The handle is returned by the CreateJobObject function. |
Mailslot | The handle is returned by the CreateMailslot function. |
Mutex | The handle is returned by the CreateMutex or OpenMutex function. |
Pipe | A named pipe handle is returned by the CreateNamedPipe or CreateFile function. An anonymous pipe handle is returned by the CreatePipe function. |
Process | The handle is returned by the CreateProcess, GetCurrentProcess, or OpenProcess function. |
Registry key | The handle is returned by the RegCreateKey, RegCreateKeyEx, RegOpenKey, or RegOpenKeyEx function. Note that registry key handles returned by the RegConnectRegistry function cannot be used in a call to DuplicateHandle. |
Semaphore | The handle is returned by the CreateSemaphore or OpenSemaphore function. |
Thread | The handle is returned by the CreateProcess, CreateThread, CreateRemoteThread, or GetCurrentThread function |
Timer | The handle is returned by the CreateWaitableTimer or OpenWaitableTimer function. |
Transaction | The handle is returned by the CreateTransaction function. |
Window station | The handle is returned by the GetProcessWindowStation function. |
不同的事件类型对应的dwDesiredAccess
参数不同,具体参考MSDN。
示例
进程 TestA 源码
1 | int main(int argc, char** argv) { |
子进程 TestB 源码
1 | int main(int argc, char** argv) |
在父进程 TestA 中创建一个不可继承的事件 -> 然后启动子进程 TestB -> 调用 DuplicateHandle 复制句柄项到 TestB 进程句柄表 -> 并向 TestB 输入句柄值 -> TestB 访问该事件句柄,将事件置为有信号状态。
5.3 使用命名的内核对象的方式
5.3.1 实现原理
这种方式严格的说已经不是文章开头说到的跨进程访问句柄了,有点类似跨进程直接访问内核对象了。
该方式实现起来比较简单,就是在调用创建内核对象的Create***
函数时,通过pszName
参数为内核对象取一个名字。
如创建事件 Event 的函数CreateEvent
:
1 | HANDLE WINAPI CreateEvent( |
1 | HANDLE h = CreateEvent(NULL, TRUE, FALSE, TEXT("TestA_Obj")); |
若在其他进程中要访问这个内核对象,只需要使用打开函数Open***
打开该内核对象,系统就会在进程的句柄表中插入一条记录,并返回这条记录的索引,也就是句柄。需要注意的是,在打开内核对象时需要留意返回值
和GetLastError
函数的返回值。由于内核对象是有访问权限的,有时候虽然这个名字的内核对象存在,但该进程却不见得有权限可以打开它,这个时候GetLastError
函数会返回失败的原因。
以打开事件的函数OpenEvent
为例:
1 | HANDLE h = OpenEvent(READ_CONTROL, FALSE, TEXT("TestA_Obj")); |
5.3.2 全局命令空间
不同的会话(Session)有不同的内核对象命名空间(如 windows 服务程序位于Session 0
,而普通的用户进程位于Session 1
),要通过名称访问其他会话中的内核对象,需要在名称前面加上Session\<当前会话ID>
。Windows 提供了一个全局的内核对象命名空间,处于任何会话中的进程都可以访问该命名空间,将内核对象放入全局命令空间的方式很简单:只需要在内核对象名称前加入Global\
即可。
如:
1 | HANDLE h = CreateEvent(NULL, TRUE, FALSE, TEXT("Global\\TestA_Obj")); |