什么是内核对象
内核对象本质上就是内存中的一块内存 ,这块内存由操作系统进行管理和分配,任何应用程序都无法直接操作这块内存区域。至于内核对象的作用,我们暂且不说,这里只需要直到它是内存中的一块内存。
在内存中,内核对象的存储类似下图,进程中的每个内核对象都有自己的地址,并且内核对象有一个固定的数据结构。

每个内核对象的结构体如下:
typedef struct _OBJECT_HEADER {
LONG PointerCount;//引用计数,表示有多少指针引用该对象。
union {
LONG HandleCount;//句柄计数,表示有多少句柄引用该对象。
PVOID NextToFree;
};
PVOID Lock;//用于同步访问该对象的锁。
UCHAR TypeIndex;//对象类型的索引。
UCHAR TraceFlags;//跟踪标志,用于调试和跟踪对象的使用。
UCHAR InfoMask;//信息掩码,指示哪些信息可用。
UCHAR Flags;//对象的标志,指示对象的状态和属性。
union {
PVOID ObjectCreateInfo;//对象创建信息。
PVOID QuotaBlockCharged;//配额块信息。
};
PVOID SecurityDescriptor;//安全描述符,定义对象的安全属性。
QUAD Body;//对象的主体,包含对象的实际数据。
} OBJECT_HEADER, *POBJECT_HEADER;
简单一点说,对于内核对象,大家把他理解成存储在内存中的struct结构体数组,每个结构体都有自己的地址,这个地址叫做内核对象的地址。
_OBJECT_HEADER
是一个内部结构体,用于描述内核对象的元数据。这个结构体在 Windows 内核中定义,但并不直接暴露给用户模式应用程序。
句柄表
既然应用程序无法直接操作内核对象,那么应该如何访问和操作内核对象呢,这其中有个很重要的桥梁:句柄表。句柄表本质上也是内存中的一块内存,在每个进程启动的时候,操作系统会在进程的地址空间上开辟一块内存,用来保存句柄表,每个进程都有自己的一个句柄表。句柄表的基本结构如下图:

注意:句柄表中有多条句柄,每个句柄也有自己的数据结构,主要有四个字段,第一个字段索引(句柄值),这是直接暴露给我们的应用程序的,第二个字段内核对象地址,表示的是某个内核对象真正的地址,就是上面第一张图中描述的内核对象的地址,访问掩码和标识我们暂时不讨论,后面会做特殊说明。
从这张图,我们就可以看到,句柄表其实就是连接内核对象和应用程序之间的一个桥梁,因为句柄表中的第二列,内核对象地址存储了真正的内核对象地址。
遗憾的是,句柄表的内存也是由操作系统分配和管理的,应用程序依然无法直接操作句柄表的内存,那到底如何访问内核对象呢,请继续往下看。
查看进程的所有句柄
通过ProcessExplorer可以查看一个进程的所有句柄列表。打开ProcessExplorer,选中一个进程,然后选择下方Tab栏中的Handles,可以查看到当前进程的句柄列表。注意:ProcessExplorer最好用管理员权限打开,如果用普通权限用户打开的话,有些句柄列信息是看不到,比如说ShareFlags这一列。

句柄表有Type、Name、Handle、Address、Access、ObjectAddress、DecodeAccess、ShareFlags、Attributes几列,每列分别代表的意义如下:
1、Type:表示句柄的类型。文件、事件、互斥体、信号量、注册表、作业、线程、管道、文件映射等类型。 通过CreateThread 创建线程内核对象、CreateFileMapping创建文件映射内核对象、CreateMutex创建互斥体内核对象、CreateProcess创建进程内核对象、CreateEvent创建事件内核对象、CreateWaitableTimer创建时间等待期内核对象,还要其他的很多类似CreateXXX的函数用于创建不同类型的内核对象,这里不在举例了
2、Name:句柄名称,一般句柄都可以设置一个名称。在调用CreateXXX
创建内核对象的时候,一般最后一个参数叫做pszName
就是指定内核对象名称的。
3、Handle:句柄值,这个就是传递给WindowsApi的句柄值,也就是上面句柄句柄表那张图中的第一列。
4、Access:表示句柄的访问权限,以下是不同数值的访问权限:
0x0012019F:对文件的读写访问权限。
0x00120189:对文件的只读访问权限。
0x001F01FF:对文件的完全访问权限。
0x00100000:对进程的查询信息权限。
0x001F0FFF:对进程的完全访问权限。
0x00100000:对线程的查询信息权限。
0x001F03FF:对线程的完全访问权限。
5、ObjectAddress:表示内核对象在内核地址空间中的真实地址。这个地址是内核对象的实际地址。也就是上面第一张图表示的内核对象地址。
6、Decoded Access:对Access这一列解码后的访问权限说明,这一列是ProcessExplorer为了方便我们阅读,将二进制解读为字符串。实际内存并没有Decoded Access这一列。
7、ShareFlags:表示句柄的共享标志。
FILE_SHARE_READ (0x00000001):允许其他进程读取文件。
FILE_SHARE_WRITE (0x00000002):允许其他进程写入文件。
FILE_SHARE_DELETE (0x00000004):允许其他进程删除文件
8、 Attributes:表示句柄的属性,比如继承属性。
下面我们用CreateFile创建内核对象,然后在ProcessExplorer中查看我们创建的句柄信息。
#include <iostream>
#include <Windows.h>
int main()
{
LPCWSTR fileName = L"example.txt";
// 定义安全属性,允许句柄继承
SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(SECURITY_ATTRIBUTES);
sa.lpSecurityDescriptor = NULL; // 使用默认安全描述符
sa.bInheritHandle = TRUE; // 允许句柄继承
// 创建文件并设置访问权限和共享模式
HANDLE hFile = CreateFile(
fileName, // 文件名
GENERIC_READ | GENERIC_WRITE, // 访问模式
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, //共享模式
&sa, // 安全属性
CREATE_ALWAYS, // 创建选项
FILE_ATTRIBUTE_NORMAL, // 文件属性
NULL // 模板文件句柄
);
// 关闭文件句柄
CloseHandle(hFile);
return 0;
}
在CloseHandle(hFile);
这一行打上断点,不要着急关闭句柄。运行控制台程序,在ProcessExplorer中会看到一个文件句柄如下:

上图可以清晰的看到,我们创建的句柄类型为File,句柄名称其实就是文件的路径,Handle表示文件句柄的句柄值,后续我们对文件的操作都要用到它,
然后是访问权限Access,我们在程序中设置的访问权限为:GENERIC_READ | GENERIC_WRITE
,表示当前进程对文件有读写的权限,在Decoded Access这一列可以看到FILE_GENERIC_READ | FILE_GENERIC_WRITE
标志已经成功被设置,对 于ShareFlags这一列,RWD分别代表Read、Write和Delete权限,如果没有设置对于权限,一条横线,因为我们在程序中设置了FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE
权限,其他进程可以对该文件读写和删除操作,当然一般情况下,当我们打开这个文件的时候,尽量只给其他进程设置一个读权限。对于最后一个Attributes,Inherit表示句柄可以被继承,这个在主进程创建子进程的时候,如果主进程允许继承,那么这个句柄将会继承到子进程。这个属性是通过句柄的安全描述符来描述的。我们通过sa.bInheritHandle = TRUE;
设置句柄的继承性。当我们调用CloseHandle(hFile);
关闭句柄之后,在ProcessExplorer界面上会发现,上图显示的句柄会立刻消失。
句柄的继承
上面代码,我们注意到句柄是可以继承的。这个继承可以用于主进程创建子进程的时候,由子进程继承父进程中可以被继承的句柄。父进程创建内核对象的时候,如果安全面熟符设置了,允许继承,子进程就可以继承。注意,操作系统会将父进程中可以继承的句柄复制到子进程中,包括句柄值都是相同的,我们可以通过命令行或者环境变量继承,将父进程中的句柄值传递给子进程,子进程直接使用该句柄值就可以了。
下面的代码,我们创建了文件内核对象之后,通过CreateProcess创建一个子进程,然后我们在ProcessExplorer中查看子进程中的句柄。
#include <iostream>
#include <Windows.h>
int main()
{
LPCWSTR fileName = L"example.txt";
// 定义安全属性,允许句柄继承
SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(SECURITY_ATTRIBUTES);
sa.lpSecurityDescriptor = NULL; // 使用默认安全描述符
sa.bInheritHandle = TRUE; // 允许句柄继承
// 创建文件并设置访问权限和共享模式
HANDLE hFile = CreateFile(
fileName, // 文件名
GENERIC_READ | GENERIC_WRITE, // 访问模式
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, //共享模式
&sa, // 安全属性
CREATE_ALWAYS, // 创建选项
FILE_ATTRIBUTE_NORMAL, // 文件属性
NULL // 模板文件句柄
);
//即将启动的exe程序路径
LPCWSTR lpApplicationName = L"D:\\project\\ConsoleApp1\\x64\\Debug\\NewApp.exe";
// 定义启动信息和进程信息结构
STARTUPINFOW si;
PROCESS_INFORMATION pi;
// 初始化启动信息结构
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
ZeroMemory(&pi, sizeof(pi));
BOOL ret = CreateProcess(lpApplicationName,
NULL,
NULL,
NULL,
TRUE,
CREATE_NEW_CONSOLE,
NULL,
NULL,
&si,
&pi);
while (true) {
Sleep(1000);
}
// 关闭文件句柄
CloseHandle(hFile);
return 0;
}
运行上述代码之后,我们首先查看父进程的句柄记录,找到example.txt
这个文件句柄,其句柄值为0x0012019F:

然后我们查看子进程的句柄,同样可以找到example.txt
这个文件句柄。其句柄值为0x0012019F,和父进程中的句柄值是相同的。在子进程中,就可以把这个参数对应的windows 函数。

管理内核对象
每个内核对象结构体都有一个字段叫做引用计数PointerCount
,当一个引用对象被创建的时候,这个计数被设置为1,代表创建这个内核对象的进程正在使用这个内核对象,当有其他进程通过类似OpenXXX函数打开这个内核对象的时候,该计数会递增1。当进程不在对这个内核感兴趣的时候,可以调用CloseHandle函数将内核对象的引用计数递减1,只有当内核对象中引用计数被递减到0的时候,操作系统才会清空该内核对象。所以对于 内核对象来说,当我们不再使用的时候,一定要记得释放。
如何访问内核对象
Window提供了一些列的API来帮助我们操作内核对象。
假设我们要操作内核对象1(地址0x111),句柄表中有一个记录记录了对内核对象0x111的引用,其句柄之为1,内核对象地址0x111。这个时候,操作系统可以提供一个函数,比如叫做HandleObject,然后这个函数有一个参数叫做句柄值,我们把句柄值1传递给该函数,操作系统就可以找到句柄值1对应的内核对象的真实地址,然后进行操作。事实上,操作系统也确实是这样做的。
操作系统提供了一系列API,这些API几乎都会有一个句柄值的参数,这个参数就是句柄表中的第一列,操作系统通过这个参数,可以找到对应的内核对象地址,然后对内核对象进行相应的操作。
对于几乎所有的内核对象,windows都提供一个统一的操作模式,就是先调用系统API打开(一般是OpenXXX函数)内核对象或创建内核对象(一般是CreateXXX函数),OpenXXX和CreateXXX一般都会返回一个当前进程的句柄值,也就是上面句柄表中的第一列。让当前进程与目标对象之间建立起连接,然后再通过别的系统调用进行操作(这些操作内核对象的函数一般都需要一个句柄值的参数),最后通过调用系统API(一般是CloseHandle函数)关闭对象。实际上是关闭进程与目标对象的联系。
我们来简单总结一下应用程序操作内核对象的一般流程,这个流程基本上适用于所有的内核对象。
1、通过CreateXXX函数创建一个内核对象,并且得到句柄值。
2、通过OpenXXX函数打开一个内核对象,也是得到一个句柄值,当然这一步也可以省略,一般创建的时候,就可以打开内核对象。
3、调用对应的函数操作内核对象,我们假设函数叫做HandleObject,那么这个HandleObjet函数的原型大概是这样的。bool HandleObject(HANDLE handle)。返回值表示是否操作成功,参数handle表示需要操作的句柄。操作系统会根据这个句柄从进程句柄表中找到内核对象的真实地址,然后进行操作。
4、调用CloseHandle函数,递减内核对象的引用计数。
改变句柄的继承继承标志
调用SetHandleInformation
可以修改某个句柄的继承标志,函数原型如下:
WINBASEAPI
BOOL
WINAPI
SetHandleInformation(
_In_ HANDLE hObject,//要修改的句柄
_In_ DWORD dwMask,//要修改句柄的哪个标志位,如果要修改继承特性,可以传递HANDLE_FLAG_INHERIT
_In_ DWORD dwFlags//修改后的标志值
);
在上面的代码中,我们创建了一个文件系统句柄hFile,在安全描述符中设置了可以继承,调用以下代码,可以修改句柄为不可继承。
SetHandleInformation(hFile, HANDLE_FLAG_INHERIT, 0);
同时调用以下代码 ,可以设置句柄为可以继承。
SetHandleInformation(hFile, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
通过GetHandleInformation
可以获取句柄的标志位信息
DWORD flags = 0;
GetHandleInformation(hFile, &flags);
BOOL isInherit = flags & HANDLE_FLAG_INHERIT;
句柄拷贝
DuplicateHandle
函数可以将当前进程的句柄拷贝到其他进程,从而在进程间共享内核对象。
BOOL DuplicateHandle(
HANDLE hSourceProcessHandle, // 源进程句柄
HANDLE hSourceHandle, // 源句柄
HANDLE hTargetProcessHandle, // 目标进程句柄
LPHANDLE lpTargetHandle, // 目标句柄
DWORD dwDesiredAccess, // 访问权限
BOOL bInheritHandle, // 继承句柄
DWORD dwOptions // 选项
);
上面代码中,我们创建了一个子进程,加入以下代码,可以将主进程中的句柄拷贝到子进程中。
DuplicateHandle(GetCurrentProcess(), hFile, pi.hProcess, NULL, 0, TRUE, DUPLICATE_SAME_ACCESS);
把断点先打到这行代码,先观察子进程,在ProcessExplorer中你会发现子进程是没有example.txt
这个文件句柄的。当你执行了上面代码之后,你会发现example.txt
这个句柄就出现在了子进程,这证明我们主进程已经成功的把自己的句柄拷贝给另一个进程了。
总结
1、对于单进程中句柄,一般通过CreateXXX、OpenXXX、CloseHandle以及对应的操作函数来操作内核对象。
2、对于多进程,实现内核对象共享的方法如下:
(1)、子进程继承父进程句柄,通过在父进程中设置句柄的安全描述符,允许句柄继承。
(2)、命名对象,对于已经命令的句柄对象,可以在其他进程中通过的OpenXXX函数直接打开内核对象来使用。
(3)、句柄拷贝,通过DuplicateHandle函数拷贝句柄到其他进程,使其他进程可以共享内核对象。