前言
本文章以及后面的所有文章的出处都是来自《windows核心编程》这本书,且只是对一些重点且基本的概念做一些笔记。想要更为深入的了解这windows可以自己去看这本书。
pdf版链接在此,提取码为:kkkk
内核对象
像互斥量(mutex)对象/管道对象/进程对象/线程对象等都是常见的内核对象。不同的对象都是通过不同名称的函数来创建的。例如,调用CreateFileMapping函数来创建一个文件内核对象。每个内核对象都只是一个内存块,由操作系统分配,也只能由操作系统来访问。
对与每一个内核对象,其本质就是一个结构体,有着自己的成员属性和方法。此外安全描述符和计数是所有内核对象都有的。
-
安全描述符用来表示谁拥有对象,哪些组和用户被允许访问或使用此对象,哪些被拒绝使用此对象。说白了,就是通过这个安全描述符来约定哪些用户可以访问这个对象。
-
计数是用来表示当前有哪些进程正在使用这个内核对象。这里面涉及到内核对象共享的概念,后面会讲到。这个计数实际上也可以用来表示一个内核对象是否能够被释放。为0则表示可以真正销毁这个内核对象。即使某个进程调用了销毁内核对象的接口,系统也不一定真正释放掉这个内核对象,有可能只是让其计数减一。
进程的内核句柄表
在书中提到,每个进程在初始化的过程中,系统都会为其分配一个句柄表(handle tanble)。这个句柄表仅供内核对象使用。句柄表的结构如下:
可以看出,句柄表其实就是一个由数据结构组成的数组。每一项都记录了当前进程拥有的内核对象资源。对于每一项记录,都拥有一个内核对象的指针(用来找到对象),一个访问掩码 access mask
(表示该进程对该内核对象的访问权限)以及一些标志。
当创建内核对象时,比如CreateFileMapping。内核会为该对象分配一个内存块,该内存块存放在该对象的一些属性。然后,内核会扫描调用这个函数进程的句柄表,查找一个空白的记录项,将创建好的内核对象的指针填进去。具体的说,句柄表的记录项的指针实际上是内核对象的数据结构的内存地址。 此外,由于当前进程是创造者,访问掩码初始化被设置成拥有完全访问权限。
值得注意的是,如果句柄保存到一个变量中,调用CloseHandle函数之后,应该把这个变量重置为NULL。否则,如果错误的将这个变量来调用一个Win32函数,可能会发生两种意外情况:
- 由于该变量引用的句柄已经被清除,所以Windows函数可能会因为参数无效而直接报错
- 后续使用该变量之前,进程又创建了一些新的内核对象,很有可能,巧合地,该变量又指向了一个没有被清除的句柄项。此时,程序会发生意料不到的错误,很难排查。
跨进程边界共享内核对象
为什么不同进程之间需要共享内核对象?书中给出如下理由:
- 利用文件映射对象,可以在同一台机器上运行的两个不同进程之间共享数据块
- 借助邮槽和命名管道,在网络中不同计算机上运行的两个进程可以相互发送数据块。
- 互斥量/信号量可能需要一个进程向另一个进程发送通知
三种共享方式:
- 使用对象句柄继承
- 为对象命名
- 复制对象句柄
使用对象句柄继承
顾名思义,借助继承,一个父进程在创建子进程的时候,父进程可以控制自己的句柄表中的那些句柄能够被子进程访问。
句柄表中每个记录项都有一个指明该句柄是否可以被继承的标志位。在创建内核对象的时候可以设置该标记位。
比如下面代码:
SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(sa);
sa.lpSecurityDescriptor = NULL;
sa.bInheritHandle = TRUE; // Make the returned handle inheritable.
HANDLE hMutex = CreateMutex(&sa, FALSE, NULL);
上面代码是创建互斥量内核对象的过程,其中,sa.bInheritHandle = TRUE;
表明该内核对象是可以被继承的。
其中句柄3是可以被继承的。
对于创建子进程的函数CreateProcess:
BOOL CreateProcess(
PCTSTR pszApplicationName,
PTSTR pszCommandLine,
PSECURITY_ATTRIBUTES psaProcess,
PSECURITY_ATTRIBUTES psaThread,
BOOL bInheritHandles,
DWORD dwCreationFlags,
PVOID pvEnvironment,
PCTSTR pszCurrentDirectory,
LPSTARTUPINFO pStartupInfo,
PPROCESS_INFORMATION pProcessInformation);
其中第五个参数bInheritHandles,表示创建的子进程是否需要继承父进程的句柄表。
当某个进程通过CreateProcess接口创建一个子进程,如果第五个参数为true,系统首先会为子进程创建一个新的空白的句柄表,然后遍历父进程的句柄表,对该句柄表的每一个记录项进行检查,看其标志位是否可继承。如果可继承,这个句柄就会被完整的复制到子进程的句柄表。
当然,在多个子进程时,父进程也可以选择哪些句柄可以被哪些子进程继承,并不一定让所有的子进程都继承句柄表。
可以调用SetHandleInformation函数来更改内核对象句柄的继承标志。函数原型如下:
BOOL SetHandleInformation(
HANDLE hObject,
DWORD dwMask,
DWORD dwFlags);
hObject
:表示一个有效的句柄dwMask
:告诉函数想更改哪个或者哪些标志。目前每个句柄都广联了两个标志:
#define HANDLE_FLAG_INHERIT 0x00000001 //指示句柄是否可以被子进程继承
#define HANDLE_FLAG_PROTECT_FROM_CLOSE 0x00000002 //指示句柄是否应该在关闭的时候受到保护
dwValue
:要设置的新值。这个值依赖于 dwMask 中指定的信息类型。
示例如下
SetHandleInformation(hObj, HANDLE_FLAG_INHERIT, 0);
上行代码表示关闭hObj指向句柄的可继承标记位。
为对象命名
书中指出,大部分内核对象其实都可以命名,但不是全部。例如,下面所有函数在创建内核对象的时候都可以命名。
//创建互斥量对象
HANDLE CreateMutex(
PSECURITY_ATTRIBUTES psa,
BOOL bInitialOwner,
PCTSTR pszName);
//创建事件对象
HANDLE CreateEvent(
PSECURITY_ATTRIBUTES psa,
BOOL bManualReset,
BOOL bInitialState,
PCTSTR pszName);
//创建定时器对象
HANDLE CreateWaitableTimer(
PSECURITY_ATTRIBUTES psa,
BOOL bManualReset,
PCTSTR pszName);
//创建文件映射对象
HANDLE CreateFileMapping(
HANDLE hFile,
PSECURITY_ATTRIBUTES psa,
DWORD flProtect,
DWORD dwMaximumSizeHigh,
DWORD dwMaximumSizeLow,
PCTSTR pszName);
//作业对象
HANDLE CreateJobObject(
PSECURITY_ATTRIBUTES psa,
PCTSTR pszName);
其中最后一个参数pszName,如果为NULL,表明创建的是一个匿名的内核对象。值得注意的是,Microsoft没有提供任何的专门的机制来保证内核对象指定的名称是唯一的。也就是说,如果我们要创建一个有名字的内核对象,那么我们并不能保证在此之前没有一个内核对象与此同名。例如:
HANDLE hMutex = CreateMutex(NULL, FALSE, TEXT("JeffObj"));
HANDLE hSem = CreateSemaphore(NULL, 1, 1, TEXT("JeffObj"));
DWORD dwErrorCode = GetLastError(); //获得上一个报错函数的错误码
例如上面代码创建了两个内核对象,第二个创建失败了,函数调用返回了NULL.
对于继承句柄表来共享内核对象,命名内核对象可以实现两个没有关系的进程共享同一个内核对象。
如何使用共享内核对象呢?或者说一个进程怎么知道A对象可不可以用呢?
方法一:
观察下面例子:
HANDLE hMutex = CreateMutex(&sa, FALSE, TEXT("JeffObj"));
if (GetLastError() == ERROR_ALREADY_EXISTS) {
// Opened a handle to an existing object.
// sa.lpSecurityDescriptor and the second parameter
// (FALSE) are ignored.
} else {
// Created a brand new object.
// sa.lpSecurityDescriptor and the second parameter
// (FALSE) are used to construct the object.
}
在调用Create系列函数创建A内核对象的时候,有两种可能:
- 内核本来就存在A内核对象
- 内核不存在A内核对象
如果内核本来就存在A内核对象,Create*函数会打开这个现有的内核对象,也就是在句柄表中吧该对象的信息填在空白的句柄项中。这种情况错误码会被设置成ERROR_ALREADY_EXISTS。
所以我们可以通过GetLastError()来知道获得的这个内核对象A是否是新的。如果不是那不就和其他进程共享同一个A了吗。
方法二:
可以调用Open系列函数来基于名称共享对象:
HANDLE OpenMutex(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName);
HANDLE OpenEvent(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName);
HANDLE OpenSemaphore(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName);
HANDLE OpenWaitableTimer(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName);
HANDLE OpenFileMapping(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName);
HANDLE OpenJobObject(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName);
同样,pszName表示打开内核对象的名称。和Create函数不一样的是,如果对象不存在,Open函数只是会简单的调用失败,而不会创建一个。
也就是说,Open*只要调用成功,获得的内核对象句柄就一定是之前已经存在的。
虽然,命名共享内核对象在Windows 操作系统中提供了强大的进程间通信方式,但是会带来一些安全问题。
多个进程可能会试图使用相同的名称创建某个内核对象,就可能会导致后面创建的对象会覆盖先创建的对象。
书中指出,任何进程–即使是最低权限的进程都可以指定任何名称来创建一个对象,很容易另外写一个程序来创建一个同名的内核对象,如果它先于单例应用程序启动,单例应用程序可能会错误的认为它的另一个实例已经运行,所以就会一启动就退出,这是拒绝服务(DoS)攻击的基本机制。
为了解决命名冲突的问题,可以创建一个GUID,并将这个字符串形式作为对象名称去使用。
我们可以自定义一个前缀,作为自己的专有命名空间来使用。通过通过定义专有命名空间,可以防止与其他库或模块中的相同名称的标识符冲突。
复制对象句柄
可以使用DuplicateHndle函数来实现跨进程边界共享内核对象。
BOOL DuplicateHandle(
HANDLE hSourceProcessHandle, // 源进程的句柄
HANDLE hSourceHandle, // 源句柄
HANDLE hTargetProcessHandle, // 目标进程的句柄
LPHANDLE lpTargetHandle, // 输出参数,目标句柄
DWORD dwDesiredAccess, // 目标句柄的访问权限
BOOL bInheritHandle, // 是否可继承
DWORD dwOptions // 句柄复制选项
}
这个函数可以创建一个现有句柄的副本,这个副本可以在不同的进程中使用。书中给出了一种使用该API的方式:
如果一个进程拥有对一个文件映射对象的读写权限。在程序的某个位置需要调用一个函数,但是我们希望这个函数只能对该文件映射对象具有读权限。那我们就可以使用这个函数,复制这个文件映射内核对象的同时设置这个内核对象的权限为可读。然后将这个只具有可读权限的句柄传给这个函数。
这个函数并不会创建新的内核对象,只是创建了一个新的指向这个内核对象的句柄。