什么是内核对象
内核对象本质上就是内存中的一块内存 ,这块内存由操作系统进行管理和分配,任何应用程序都无法直接操作这块内存区域。至于内核对象的作用,我们暂且不说,这里只需要直到它是内存中的一块内存。
在内存中,内核对象的存储类似下图,进程中的每个内核对象都有自己的地址,并且内核对象有一个固定的数据结构。
每个内核对象的结构体如下:
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界面上会发现,上图显示的句柄会立刻消失。
管理内核对象
每个内核对象结构体都有一个字段叫做引用计数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函数,递减内核对象的引用计数。