Chapter03_内核对象
- 3.1 何为内核对象
每个内核对象都只是一个内存块,它由内核分配,并只能由内核访问。这个内存块是一个数据结构,其成员维护着与对象相关的信息。少数成员是所有对象都有的,但其他大多数成员都是不同的对象类型特有的——比如进程对象的PID,文件对象的共享模式。
应用程序利用windows提供的一组函数,以经过良好定义的方式来操纵内核对象。句柄(handle)标识了内核对象,在32位进程中句柄为32位的值,在64位进程中则为64位值。句柄的值与进程是相关的。
内核对象的生命期可能长于创建其的进程,内核对象的所有者是内核而非进程。所有的内核对象类型都包含的一个数据成员——使用计数(Usage Count),表示当前有多少个进程正在使用一个特定的内核对象。计数为0,内核就会销毁该内核对象,保证系统中不存在没有被任何进程引用的内核对象。
安全描述符(Securable Object,SD)用来保护内核对象,SD描述了:
- 谁拥有对象(通常为其创建者);
- 哪些组和用户允许访问/使用此对象;
- 哪些组和用户拒绝访问/使用此对象。
typedef struct _SECURITY_ATTRIBUTES { DWORD nLength; LPVOID lpSecurityDescriptor; BOOL bInheritHandle; } SECURITY_ATTRIBUTES, * PSECURITY_ATTRIBUTES, *LPSECURITY_ATTRIBUTES ;
结构 SECURITY_ATTRIBUTES 实际上只包含一个和安全性有关的成员,即 lpSecurityDescriptor,为一个指向 SECURITY_DESCRIPTOR 结构(WinNT.h)的指针。
typedef struct _SECURITY_DESCRIPTOR { BYTE Revision; BYTE Sbz1; SECURITY_DESCRIPTOR_CONTROL Control; PSID Owner; PSID Group; PACL Sacl; PACL Dacl; } SECURITY_DESCRIPTOR, * PISECURITY_DESCRIPTOR;
如果想访问现有的内核对象,必须指定打算对此对象执行哪些操作。
判断一个对象是不是内核对象的最简单方式是查看创建这个对象的函数。几乎所有创建内核对象的函数都有一个允许你制定安全属性信息的参数。
- 3.2 进程内核对象句柄表
一个进程在初始化时,系统将为其分配一个句柄表(handle table),句柄表仅供内核对象使用。
句柄表 是一个由数据结构组成的数组,每个结构都包含一个内核对象的指针、一个访问掩码(access mask)和一些标志。
进程首次初始化的时候,其句柄表为空。进程内的线程创建了内核对象后,内核会为该内核对象分配一个内存块,并查找该进程的句柄表,找到一个空白记录项然后进行初始化。(初始化具体含义:指针会指向内核对象的数据结构的内部内存地址;访问掩码被设置为拥有完全访问权限,标志也会被设置)
句柄值除以4,从而得到在进程句柄表中的真正索引(内核对象的信息即保存在此处)。
如果一个函数接受一个内核对象句柄作为参数,在该函数内部,1. 先查找进程的句柄表,找到其对应的记录项 2. 根据数据结构内的内核对象指针获得目标内核对象的地址。
句柄值只是该句柄所属的进程的句柄表的索引,所以句柄是相对于当前这个进程的,无法供其他进程使用。
调用函数创建一个内核对象时,调用失败会返回NULL(0),这也是进程中第一个有效的句柄值为4的原因。
如上图,是Matlab进程所有的内核对象,在句柄值这一列,最小的值为4,且句柄值为4的倍数(句柄值除以4,即为索引)。
有几个函数(如CreateFile)调用失败时会返回句柄值-1(INVALID_HANDLE_VALUE),而不是0(NULL)。
调用CloseHandle结束使用内核对象。如果传给该函数的句柄是有效的,系统将获得内核对象的数据结构的地址,并在结构中递减“使用计数”成员,使用计数变为0,内核对象将被销毁,并从内存中删除。一旦调用了CloseHandle,你的进程就不能访问那个内核对象(最好将该句柄变量设为NULL),因为其在当前进程内的句柄表中的记录项已经被清楚了,但有可能未被销毁。
当进程终止运行时,OS会确保此进程使用的所有资源(所有内核对象、资源(GDI对象在内)、内存块)都被释放。
- 3.3 跨进程边界共享内核对象
三种不同的进程共享内核对象的机制:
- 使用对象句柄继承;
只有在进程之间有一个“父子”关系的时候,才可以使用这种方式。父进程允许其子进程访问父进程的内核对象。父进程在创建一个内核对象的时候向系统指出希望这个对象的句柄是可以继承的(只有句柄是可以继承的,对象本身是不能继承的)。
SECURITY_ATTRIBUTES sa; sa. nLength = sizeof (sa); sa. lpSecurityDescriptor = NULL ; sa. bInheritHandle = TRUE ; // 使创建的对象的句柄成为可继承的句柄 HANDLE hFile = CreateFile (L"test.txt" , GENERIC_READ, FILE_SHARE_READ, &sa , CREATE_NEW, NULL , NULL);
如上代码,此时返回的内核对象(文件对象)的句柄hFile就可以被子进程所继承。
句柄表中的每个记录项都有一个指明句柄是否可以继承的标志位。如果在创建内核对象的时候将PSECURITY_ATTRIBUTES设置为为NULL,那么返回的句柄就是不可继承的,这个标志位为0,将bInheritHandle设置为TRUE,这个标志位将为1。
创建子进程
WinBase.h(Windows 8的Processthreadsapi.h)声明了CreateProcess函数。其Unicode版本如下:
WINBASEAPI BOOL WINAPI
CreateProcessW(
__in_opt LPCWSTR lpApplicationName ,
__inout_opt LPWSTR lpCommandLine ,
__in_opt LPSECURITY_ATTRIBUTES lpProcessAttributes ,
__in_opt LPSECURITY_ATTRIBUTES lpThreadAttributes ,
__in BOOL bInheritHandles ,
__in DWORD dwCreationFlags ,
__in_opt LPVOID lpEnvironment ,
__in_opt LPCWSTR lpCurrentDirectory ,
__in LPSTARTUPINFOW lpStartupInfo ,
__out LPPROCESS_INFORMATION lpProcessInformation
);
bInheritHandles参数设为FALSE将导致创建的子进程无法继承父进程句柄表中的“可继承的句柄”,TRUE将导致子进程可继承父进程的“可继承的句柄”的值,系统将会在为子进程创建新的空白的进程句柄表的时候遍历父进程的句柄表并完整拷贝(句柄值一样)“可继承的句柄”的项到子进程的句柄表。这样,在父进程和子进程中,对一个内核对象进行标识的句柄值是完全一样的(访问掩码及标志也一样)。系统同时还会将这些句柄所标识的内核对象的使用计数+1。
内核对象的内容被保存在内核地址空间中(32位系统为0x80000000 -- > 0xFFFFFFFF)。
对象句柄的继承只会在生成子进程的时候发生。
子进程为了判断自己期望的一个内核对象的句柄值,最常见的方式是将句柄值作为命令行参数传给子进程。
可以试用其他进程间通信技术将继承的内核对象句柄值从父进程传递到子进程。
让父进程等待子进程完成初始化,然后父进程可以将一条消息send或post到子进程中的一个线程。
让父进程向其环境块添加一个环境变量。变量的值应该是准备被子进程继承的那个内核对象的句柄值。子进程会继承父进程的环境变量,调用GetEnvironmentVariable来获得这个继承到的内核对象的句柄值。环境变量是可以反复继承的。
改变句柄的标志
控制哪些子进程能够继承内核对象句柄,使用SetHandleInformation来改变内核对象句柄的继承标志。
函数原型:
WINBASEAPI BOOL WINAPI
SetHandleInformation(
__in HANDLE hObject ,
__in DWORD dwMask ,
__in DWORD dwFlags
);
第二个参数dwMask告诉函数你想更改哪个或者哪些标志。每个句柄都关联了两个标志:
#define HANDLE_FLAG_INHERIT 0x00000001 #define HANDLE_FLAG_PROTECT_FROM_CLOSE 0x00000002
可执行一次按位OR运算。第三个参数dwFlags指出希望把标志改为什么。
HANDLE_FLAG_PROTECT_FROM_CLOSE标志告诉系统不允许关闭句柄,设置后再调用CloseHandle [1]在调试模式下会引发;[2]在非调试模式下,返回FALSE,内核不会关闭内核对象句柄(Error Code为6(ERROR_INVALID_HANDLE))。
SetHandleInformation( hFile, HANDLE_FLAG_PROTECT_FROM_CLOSE , HANDLE_FLAG_PROTECT_FROM_CLOSE ); bool b = CloseHandle (hFile); DWORD k = GetLastError ();
在调试模式下这段代码运行结果为:
在非调试模式下不会引发异常,但返回值为FALSE,且内核对象句柄不会被关闭。
与SetHandleInformation函数对应的是GetHandleInformation函数:
WINBASEAPI BOOL WINAPI
GetHandleInformation(
__in HANDLE hObject ,
__out LPDWORD lpdwFlags
);
HANDLE_FLAG_INHERIT的值为0x1,HANDLE_FLAG_PROTECT_FROM_CLOSE的值为0x2,所以,这个函数的返回值意义如下:
DWORD dwFlags; // dwFlags 为 0 // 句柄 hFile 不可被继承 可被关闭 SetHandleInformation( hFile, HANDLE_FLAG_PROTECT_FROM_CLOSE , 0); SetHandleInformation( hFile, HANDLE_FLAG_INHERIT , 0); GetHandleInformation( hFile, &dwFlags ); printf_s( "dwFlags: %u\n", dwFlags ); // dwFlags 为 1 // 句柄 hFile 可被继承 可被关闭 SetHandleInformation( hFile, HANDLE_FLAG_PROTECT_FROM_CLOSE , 0); SetHandleInformation( hFile, HANDLE_FLAG_INHERIT , HANDLE_FLAG_INHERIT); GetHandleInformation( hFile, &dwFlags ); printf_s( "dwFlags: %u\n", dwFlags ); // dwFlags 为 2 // 句柄 hFile 不可被继承 不可被关闭 SetHandleInformation( hFile, HANDLE_FLAG_PROTECT_FROM_CLOSE , HANDLE_FLAG_PROTECT_FROM_CLOSE ); SetHandleInformation( hFile, HANDLE_FLAG_INHERIT , 0); GetHandleInformation( hFile, &dwFlags ); printf_s( "dwFlags: %u\n", dwFlags ); // dwFlags 为 3 // 句柄 hFile 可被继承 不可被关闭 SetHandleInformation( hFile, HANDLE_FLAG_PROTECT_FROM_CLOSE , HANDLE_FLAG_PROTECT_FROM_CLOSE ); SetHandleInformation( hFile, HANDLE_FLAG_INHERIT , HANDLE_FLAG_INHERIT); GetHandleInformation( hFile, &dwFlags ); printf_s( "dwFlags: %u\n", dwFlags );
2. 为对象命名
跨进程边界共享对象的第二个办法。许多内核对象都可以进行命名(但不是全部)。
Create*函数的最后一个参数的类型如果是PCWSTR或者PCTSTR等字符串类型,如果传入NULL,表明创建一个未命名(匿名)内核对象,如果要根据对象名称来共享一个对象,必须为此内核对象制定一个名称。
内核对象的名字可以长达MAX_PATH(260)个字符。
微软没有提供任何专门的机制来保证为内核对象指定的名称是唯一的。所有的对象都共享同一个命名空间,即使他们的类型并不相同。
通过为对象命名来实现共享时,就不用关心该对象是否是可继承的。
利用对象的名字(而非利用继承)来共享内核对象时,最大的一个优势是:需要利用共享对象的进程不一定和创建该内核对象的进程存在着父子关系。
用于创建内核对象的函数总是返回一个具有完全访问权限的句柄,如果想限制一个句柄的访问权限,可以使用这些函数的扩展版本(有一个Ex后缀)。
两个进程共享同一个内核对象,这两个进程中的句柄值(同标识了内核中的那个对象)极有可能是不同的值。再次说明句柄值是相对于进程的。
利用名字来实现内核对象的共享的时候,调用Create*函数不一定会新建一个对象,而有可能是打开了一个现有的对象。可以调用GetLastError来判断是否是新建或者是打开了一个内核对象。如下代码:
进程1:
HANDLE hMutexProcessA = CreateMutex(NULL , FALSE , TEXT ("MyMutex" )); // 判断是创建还是打开了一个内核对象 if(GetLastError() == ERROR_ALREADY_EXISTS ) { // 打开了一个现有的对象 printf_s( "Open an Mutex"); } else { // 创建了一个新的对象 printf_s( "Create an Mutex"); }
进程2:
HANDLE hMutexProcessB = CreateMutex(NULL , FALSE , TEXT ("MyMutex" )); // 判断是创建还是打开了一个内核对象 if(GetLastError() == ERROR_ALREADY_EXISTS ) { // 打开了一个现有的对象 printf_s( "Open an Mutex"); } else { // 创建了一个新的对象 printf_s( "Create an Mutex"); }
进程1的输出如下:
进程2的输出如下:
这两个进程所引用的内核对象都为名为MyMutex的Mutex对象:
进程1的句柄表:
进程2的句柄表:
类型为Mutant名字为 ....\MyMutex 的对象地址是一样的。(这里句柄值也是一样的,这是巧合。)
可以不调用Create*函数,而调用一个 Open* 函数来实现打开一个已经存在的内核对象,从而实现依靠对象名来实现共享。
OpenMutexW的定义如下(synchapi.h)
WINBASEAPI _Ret_maybenull_ HANDLE WINAPI
OpenMutexW(
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ LPCWSTR lpName
);
最后一个参数指出内核对象的名字(不能为NULL)。这些函数将在同一个内核对象命名空间搜索,查找一个匹配的对象,没有找到将返回NULL,GetLastError返回2(ERROR_FILE_NOT_FOUND),如果找到了这个对象但类型不对,返回6(ERROR_INVALID_HANDLE),如果类型对,系统会检查请求的访问是否允许。允许的话,主调进程的句柄表就会更新,对象的使用计数也会递增。
Create*函数和Open*函数的区别:如果对象不存在,Create*函数会创建它;Open*函数会调用失败,并不会创建对象。
由于微软没有提供任何的机制来保证我们创建独一无二的对象名,为了确保名称的唯一性,建议创建一个GUID,将其的字符串形式作为自己的对象名称使用。
利用命名的对象来防止运行一个应用程序的多个实例。如下代码:
// 利用GUID作为对象名字 HANDLE hMutexProcess = CreateMutex(NULL , FALSE , TEXT("{93F57A9E-DDAE-4BA4-9A5A-7A72CD31E5AC}" )); if(GetLastError() == ERROR_ALREADY_EXISTS) { // 程序已经存在一个运行的实例,退出 CloseHandle(hMutexProcess ); return -1; }
Terminal Services命名空间
在正在运行Terminal Services的计算机中,有多个用于内核对象的命名空间。其中一个是全局命名空间,所有客户端都能访问的内核对象放在这个命名空间中。主要由服务使用。
每个客户端会话(client session)都有一个自己的命名空间。
服务器,远程桌面,快速用户切换都是利用Terminal Services会话来实现的。
在没有任何用户登录的时候,服务会在一个非交互式的会话(Session 0)中启动。Vista中,只要用户登录,应用程序就会在一个新的会话中启动。这样做,可以隔离系统核心组件。
利用ProcessIdToSessionId可以知道你的进程在哪个Terminal Services会话中运行。
ProcessIdToSessionId(kernel32.dll导入,在WinBase.h中声明):
WINBASEAPI BOOL WINAPI ProcessIdToSessionId( _In_ DWORD dwProcessId, _Out_ DWORD * pSessionId );
代码:
DWORD processID = GetCurrentProcessId(); DWORD sessionID; if(ProcessIdToSessionId( processID, & sessionID)) { wprintf( TEXT( "Process '%u' runs in Terminal Services session '%u'" ), processID, sessionID ); } else { wprintf( TEXT( "Unable to get Terminal Services session ID for process '%u'"), processID ); }
执行结果:
一个服务的命名内核对象始终在全局命名空间。
默认情况下,应用程序自己的命名内核对象在会话的命名空间内。可以在对象的名前加上“Globa\”前缀强迫其进入全局命名空间。在名称前加“Local\”前缀显式指出此对象进入当前会话的命名空间。如下代码:
// 所创建的对象进入 全局命名空间 HANDLE h_global = CreateEvent(NULL, FALSE, FALSE, TEXT("Global\\MyGlobalEvent" )); // 所创建的对象进入 当前会话的命名空间 HANDLE h_local = CreateEvent(NULL, FALSE, FALSE, TEXT("Local\\MyLocalEvent" ));
其创建了两个命名内核对象,一个在全局命名空间,一个在当前会话的命名空间:
微软认为Global和Local是保留关键字,所以除非为了强制一个特定的命名空间,否则不应使用它们。
private命名空间
创建对象时,可以利用SECURITY_ATTRIBUTES 来保护该对象的访问。在Vista之前,你不可能防范一个共享对象的名称被“劫持”。
几种拒绝服务(DoS)攻击的基本机制:按照前面单实例程序的思路,任何进程都可以创建一个指定名称的对象(和单实例程序的一样)从而使单实例程序永远无法运行。未命名对象不会遭受这种攻击(应用程序使用未命名对象很普遍)。
可以定义一个自定义的前缀并作为自己的private命名空间来确保自己的应用程序不会和其他应用程序的名称起冲突。负责创建内核对象的服务器进程将定义一个边界描述符对命名空间的名称自身进行保护。
1. 用CreateBoundaryDescriptor函数创建边界描述符。
CreateBoundaryDescriptor函数声明在WinBase.h中,其Unicode版本如下:
WINBASEAPI HANDLE WINAPI
CreateBoundaryDescriptorW (
_In_ LPCWSTR Name,
_In_ ULONG Flags
);
函数的返回值为一个指向用户模式的结构的指针,结构包含了边界的定义,由于返回值不是一个真正的句柄(伪句柄),永远不要将其传给CloseHandle,而应该传给DeleteBoundaryDescriptor。
WINBASEAPI VOID WINAPI
DeleteBoundaryDescriptor (
_In_ HANDLE BoundaryDescriptor
);
2. 调用AddSIDToBoundaryDescriptor将一个特权用户组的SID与边界描述符关联起来。
WINBASEAPI BOOL WINAPI AddSIDToBoundaryDescriptor ( _Inout_ HANDLE * BoundaryDescriptor, _In_ PSID RequiredSid );
3. 调用CreatePrivateNamespace来创建专有命名空间。
WINBASEAPI HANDLE WINAPI
CreatePrivateNamespaceW (
_In_opt_ LPSECURITY_ATTRIBUTES lpPrivateNamespaceAttributes ,
_In_ LPVOID lpBoundaryDescriptor,
_In_ LPCWSTR lpAliasPrefix
);
其第一个参数lpPrivateNamespaceAttributes(安全描述符)是供Windows使用的。
其第三个参数lpAliasPrefix是用于创建内核对象的字符串前缀。如果试图创建一个已经存在的专有命名空间,函数将返回NULL。在这种情况下,需要调用OpenPrivateNamespace来打开现有的专有命名空间。CreatePrivateNamespace和OpenPrivateNamespace返回的都不是内核对象句柄,调用ClosePrivateNamespace来关闭这种伪句柄。
WINBASEAPI BOOLEAN WINAPI
ClosePrivateNamespace (
_In_ HANDLE Handle,
_In_ ULONG Flags
);
如果希望该专有命名空间在关闭后不可见,应该将PRIVATE_NAMESPACE_FLAG_DESTROY作为第二个参数传给上述函数。
边界将在两种情况下关闭:进程终止或者调用DeleteBoundaryDescriptor。如果还有内核对象正在使用,命名空间一定不能关闭。
private命名空间相当于可供你在其中创建内核对象的一个目录,该目录也有一个和其关联的安全描述符。
在Process Explorer中,对于“前缀以一个private命名空间为基础”的内核对象,显示的是“...\”前缀,这样能隐藏保密信息,防范潜在的黑客。
3. 复制对象句柄
为了跨越进程边界来共享内核对象,最后一个技术是使用DuplicateHandle函数。
WINBASEAPI BOOL WINAPI
DuplicateHandle (
_In_ HANDLE hSourceProcessHandle,
_In_ HANDLE hSourceHandle,
_In_ HANDLE hTargetProcessHandle,
_Outptr_ LPHANDLE lpTargetHandle,
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ DWORD dwOptions
);
这个函数获得一个进程的句柄表的一个记录项,然后再另一个进程的句柄表中创建这个记录项的一个拷贝。
这个函数的第一个参数hSourceProcessHandle是要复制的句柄所在的源进程的进程内核对象的句柄。第二个参数hSourceHandle是要复制的句柄本身(可以指向任何类型的内核对象)。第三个参数hTargetProcessHandle是要复制到的目标进程的进程内核对象的句柄。第四个参数lpTargetHandle是一个句柄变量的地址。函数会在hTargetProcessHandle标识的目标进程的句柄表中,拷贝进源进程hSourceProcessHandle中的一个源内核对象句柄hSourceHandle的句柄信息,拷贝后的地址就是lpTargetHandle。
最后三个参数用于指定访问掩码和继承标志。
dwOptions的值可以可以为0或者标志DUPLICATE_SAME_ACCESS和标志DUPLICATE_CLOSE_SOURCE的任意组合。
指定DUPLICATE_SAME_ACCESS: 表明我们希望目标句柄拥有与源进程的句柄一样的访问掩码。使用后,参数dwDesiredAccess将会被忽略。
指定DUPLICATE_CLOSE_SOURCE:会关闭源进程中的句柄,利用其,一个进程可以将一个内核对象传给另一个进程。内核对象的使用计数不会受到影响。
一个进程想把一个内核对象的访问权授予另一个进程:
进程S:
HANDLE hProcessT = OpenProcess( PROCESS_ALL_ACCESS, FALSE, dwProcessIdT ); if( hProcessT == INVALID_HANDLE_VALUE) { printf( "Can't open Process T.\n" ); } HANDLE hFileInProcessT; DuplicateHandle( GetCurrentProcess (), hMutexInProcessS , hProcessT , & hFileInProcessT , 0, FALSE , DUPLICATE_SAME_ACCESS );
此时,进程S的句柄表为:
调用DuplicateHandle后进程T的句柄表:
可以看出,这两个进程的句柄表都拥有一个名为MyMutex的句柄,且其对象地址也一样。