什么是内核对象

内核对象本质上就是内存中的一块内存 ,这块内存由操作系统进行管理和分配,任何应用程序都无法直接操作这块内存区域。至于内核对象的作用,我们暂且不说,这里只需要直到它是内存中的一块内存。

在内存中,内核对象的存储类似下图,进程中的每个内核对象都有自己的地址,并且内核对象有一个固定的数据结构。

每个内核对象的结构体如下:

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函数拷贝句柄到其他进程,使其他进程可以共享内核对象。