一、什么是内核对象

我们在 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 种情况:

  1. 进程句柄表中该HANDLE所在的索引项的记录已经被清除,且没有别的线程再次在该索引创建记录项,若此时使用这个过期的HANDLE调用 Win32 API 函数,Windows 会返回无效参数错误。这种情况还比较好调试。

  2. 进程句柄表中该HANDLE所在的索引项的记录同样也已经被清除,但已经有别的线程(该进程中的其他线程)在该索引位置创建了记录项,若此时使用这个过期的HANDLE调用 Win32 API 函数,该HANDLE就会引用到其他线程新建的那个内核对象,从而出现一些难以预料的错误。这种错误很难调试。

4.3 获取内核对象的引用计数

虽然 windows 没有提供 API 让用户在用户态(Ring3)查询一个内核对象的句柄计数和引用计数,但我们可以从Ntdll.dll导出NtQueryObject函数来实现查询内核对象的当前状态(该函数没有被文档化)。

NtQueryObject函数声明如下:

1
2
3
4
5
6
7
8
9
// 返回值:如果成功则返回0
//
DWORD WINAPI NtQueryObject(
HANDLE handle, // 待查询的句柄
DWORD nQueryIndex, // 0为查询对象的当前状态,包括句柄计数,引用计数等等。
VOID* pOutBuffer, // 存放查询结果
DWORD cbInBufferSize, // pOutBuffer的大小,如果nQueryIndex为0,这里为sizeof(SYSTEM_HANDLE_STATE)
VOID* cbOutBufferSize // 实际大小
);

NtQueryObject函数调用的细节封装到GetKernelObjectRefCount函数中,方便使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
bool GetKernelObjectRefCount(HANDLE handle, DWORD &handle_count, DWORD &point_count) {
typedef struct _SYSTEM_HANDLE_STATE {
DWORD r1;
DWORD GrantedAccess;
DWORD HandleCount; // 减1为句柄计数
DWORD ReferenceCount; // 减1为指针引用计数
DWORD r5;
DWORD r6;
DWORD r7;
DWORD r8;
DWORD r9;
DWORD r10;
DWORD r11;
DWORD r12;
DWORD r13;
DWORD r14;
}SYSTEM_HANDLE_STATE, *PSYSTEM_HANDLE_STATE;

typedef DWORD(WINAPI *PFN_NtQueryObject)(HANDLE handle,
DWORD nQueryIndex,
VOID* pOutBuffer,
DWORD cbInBufferSize,
VOID* cbOutBufferSize);

static PFN_NtQueryObject pfnNtQueryObject = NULL;
bool ret = false;

do {
if (pfnNtQueryObject == NULL) {
HMODULE ntdll = GetModuleHandle(TEXT("Ntdll.dll"));
if (ntdll == NULL)
break;

pfnNtQueryObject = (PFN_NtQueryObject)GetProcAddress(ntdll, "NtQueryObject");
if (pfnNtQueryObject == NULL)
break;
}

SYSTEM_HANDLE_STATE sys_handle_state;
memset(&sys_handle_state, 0, sizeof(SYSTEM_HANDLE_STATE));

DWORD out_buf_size = 0;
ret = (pfnNtQueryObject(handle, 0, &sys_handle_state, sizeof(SYSTEM_HANDLE_STATE), &out_buf_size) == 0);
if (ret) {
handle_count = sys_handle_state.HandleCount - 1;
point_count = sys_handle_state.ReferenceCount - 1;
}
} while (false);

return ret;
}

五、内核对象跨进程访问

虽然内核对象位于独立于进程之外的内核区域,我们在开发中却只能通过调用 Win32 API 传入 HANDLE 参数来操作内核对象(如SetEvent等)。然而HANDLE句柄只对当前进程有效,离开了当前进程该句柄就无效了。所以说,跨进程访问内核对象的关键在于我们怎么跨进程访问句柄HANDLE

下面介绍几种方法来实现跨进程共享内核对象。

5.1 使用句柄继承的方式

只有进程之间有父子关系时,才可以使用句柄继承的方式。在这种情况下,父进程可以生成一个子进程,并允许子进程访问父进程的内核对象。为了使这种继承生效,父进程必须执行几个步骤:
(1). 父进程在创建一个内核对象时,父进程必须向系统指定它希望这个内核对象的句柄是可以继承的。为了创建一个可继承的内核对象,必须分配并初始化一个SECURITY_ATTRIBUTES结构,如:

1
2
3
4
5
6
SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(SECURITY_ATTRIBUTES);
sa.bInheritHandle = TRUE; // 可继承的
sa.lpSecurityDescriptor = NULL;

HANDLE h = CreateEvent(&sa, TRUE, FALSE, NULL);

(2). 父进程通过 CreateProcess 生成子进程,且指定bInheritHandles为 TRUE,从而允许子进程来继承父进程的那些“可继承的句柄”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 启动子进程TestB.exe,将句柄h作为启动参数传给进程TestB
//
TCHAR cmd_buf[MAX_PATH];
StringCchPrintf(cmd_buf, MAX_PATH, TEXT("TestB.exe %ld"), (long)h);

STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;
BOOL ret = CreateProcess(
NULL,
cmd_buf,
NULL,
NULL,
TRUE, // 指定子进程可以继承父进程的“可继承句柄”
0,
NULL,
NULL,
&si,
&pi
);


CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);

由于我们传给bInheritHandles参数的值是TRUE,所以系统在创建子进程时会多做一件事情:它会遍历父进程的句柄表,对它的每一项进行检查,凡是包含一个有效的“可继承的句柄”的项,都会将该项完整的复制到子进程的句柄表。在子进程的句柄表中,复制项的位置与它在父进程句柄表中的位置完全一样(包含索引),这个就意味着:在父进程和子进程中,对一个内核对象进行标识的句柄值也是完全一样的。所以我们只需要通过某种方式(如上面示例中的启动参数的方式,或者环境变量的方式等任何进程间通讯的方式)将这个值告诉子进程,子进程就可以将该值转成HANDLE,然后使用这个HANDLE来调用系统 API。

5.2 使用 DuplicateHandle 方式

DuplicateHandle 函数可以将指定“源进程的句柄表”中的某一项复制到“目的进程句柄表”中(除了索引),并且返回该项在目的进程句柄表中的索引(即 HADNLE)。
可以在任何时候调用 DuplicateHandle 函数,DuplicateHandle 对源句柄是否是可继承的没有要求。

函数声明如下:

1
2
3
4
5
6
7
8
9
BOOL DuplicateHandle(
HANDLE hSourceProcessHandle,
HANDLE hSourceHandle,
HANDLE hTargetProcessHandle,
LPHANDLE lpTargetHandle,
DWORD dwDesiredAccess,
BOOL bInheritHandle,
DWORD dwOptions
);

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int main(int argc, char** argv) {
HANDLE h = CreateEvent(NULL, TRUE, FALSE, NULL);

// 启动子进程TestB.exe
//
TCHAR cmd_buf[MAX_PATH];
StringCchPrintf(cmd_buf, MAX_PATH, TEXT("D:\\TestB.exe"), (long)h);

STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;
BOOL ret = CreateProcess(NULL, cmd_buf, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
assert(ret);
assert(pi.hProcess);

HANDLE duplicated_h = NULL;
ret = DuplicateHandle(GetCurrentProcess(), h, pi.hProcess, &duplicated_h, 0, FALSE, DUPLICATE_SAME_ACCESS);


WaitForSingleObject(pi.hProcess, INFINITE);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);

bool has_signal = WaitForSingleObject(h, 0) == WAIT_OBJECT_0;
assert(has_signal == true);

return 0;
}

子进程 TestB 源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int argc, char** argv)
{
long l = 0;
printf("Input Handle:");
scanf("%ld", &l);

HANDLE h = (HANDLE)l;

bool has_signal = WaitForSingleObject(h, 0) == WAIT_OBJECT_0;
assert(has_signal == false);

SetEvent(h);

return 0;
}

在父进程 TestA 中创建一个不可继承的事件 -> 然后启动子进程 TestB -> 调用 DuplicateHandle 复制句柄项到 TestB 进程句柄表 -> 并向 TestB 输入句柄值 -> TestB 访问该事件句柄,将事件置为有信号状态。

5.3 使用命名的内核对象的方式

5.3.1 实现原理

这种方式严格的说已经不是文章开头说到的跨进程访问句柄了,有点类似跨进程直接访问内核对象了。
该方式实现起来比较简单,就是在调用创建内核对象的Create***函数时,通过pszName参数为内核对象取一个名字。
如创建事件 Event 的函数CreateEvent

1
2
3
4
5
6
7
HANDLE WINAPI CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes,
BOOL bManualReset,
BOOL bInitialState,
LPCTSTR lpName // 指定名称
);

1
HANDLE h = CreateEvent(NULL, TRUE, FALSE, TEXT("TestA_Obj"));

若在其他进程中要访问这个内核对象,只需要使用打开函数Open***打开该内核对象,系统就会在进程的句柄表中插入一条记录,并返回这条记录的索引,也就是句柄。需要注意的是,在打开内核对象时需要留意返回值GetLastError函数的返回值。由于内核对象是有访问权限的,有时候虽然这个名字的内核对象存在,但该进程却不见得有权限可以打开它,这个时候GetLastError函数会返回失败的原因。

以打开事件的函数OpenEvent为例:

1
2
3
4
5
6
HANDLE h = OpenEvent(READ_CONTROL, FALSE, TEXT("TestA_Obj"));
if (h == NULL) {
if (GetLastError() == ERROR_ACCESS_DENIED) { // 没有READ_CONTROL权限

}
}

5.3.2 全局命令空间

不同的会话(Session)有不同的内核对象命名空间(如 windows 服务程序位于Session 0,而普通的用户进程位于Session 1),要通过名称访问其他会话中的内核对象,需要在名称前面加上Session\<当前会话ID>。Windows 提供了一个全局的内核对象命名空间,处于任何会话中的进程都可以访问该命名空间,将内核对象放入全局命令空间的方式很简单:只需要在内核对象名称前加入Global\即可。

如:

1
HANDLE h = CreateEvent(NULL, TRUE, FALSE, TEXT("Global\\TestA_Obj"));