第三章 内核对象
3.1 何为内核对象?
在软件开发中,我们需要创建、打开和处理内核对象比如:令牌对象、事件对象、文件对象、文件映射对象、I/O完成端口对象、作业对象、邮件槽对象、互斥量对象、管道对象、进程对象、信号量对象、线程对象、可等待的计时器对象以及线程池工厂对象等。
每个内核对象都只是一个内存块,当调用创建系统函数创建内核对象时,内存块由操作系统内分配,并只能由操作系统内核访问。这个内存块是一个数据结构,其成员维护着与对象相关的信息。
调用一个会创建内核对象的函数后,函数会返回一个句柄,它标识了所创建的对象。应用程序利用windows提供的一组函数来操纵内核对象。
关于句柄:它是一个32位值,在64为windows中为64位。句柄的值与进程是相关的,如果将本进程的句柄值传递给另一个进程,就可能引发调用失败,还有可能另一个进程根据进程句柄表的索引调用另一个完全不同的对象。当然也可以多个进程共享一个内核对象,不过这是另外的技术点了。
3.1.1 使用计数
内核对象的所有者是操作系统内核而不是某个进程。操作系统会知道当前有多少个进程正在使用这个对象,因为每个对象都包含一个计数。
初次创建 计数为1--->另一个进程打开对象后 会再加1 --->当进程终止后,操作系统内核将递减该进程
打开的所有对象的计数减1 --->当某个对象的技术变为0后,操作系统内核会销毁该对象!!!
所以说内核对象的所有者是操作系统内核而不是进程!!!
3.1.2 内核对象的安全性:
安全描述符描述了谁(通常是对象的创建者)拥有对象;那些组合用户被允许访问或使用此对象;那些组合用户被拒绝访问。
应用:通常在编写服务器应用程序的时候使用安全描述符。(但是在Microsoft Windows Vista中,对于具有专用命名空间的客户端应用程序使用了安全描述符即:管理员以标准用户权限运行时使用)
关于安全描述符的使用和介绍:
用于创建内核对象的所有函数几乎都有一个指向SECURITY——ATTRIBUTES结构的指针,例如CreateFileMapping函数:
HANDLE CreateFileMapping(
HANDLE hFile ,
PSECURITY_ATTRIBUTES psa , //这个参数传入NULL,这个内核对象就有默认的安全属性。
// 关于默认的安全属性:具体包括那些属性,这取决于当前进程的安全令牌(security token)
DWORD flProtect ,
DWORD dwMaximumSizeHight ,
DWORD dwMaximumSizeLow ,
PCTSTR pszName ) ;
SECURITY_ATTRIBUTES这个结构实际上只包含一个和安全性有关的成员,即lpSecurityDescriptor。
如果我们想对我们创建的内核对象加以限制,就必须创建一个安全描述符,然后向下面一样初始化SECURITY_ATTRIBUTES结构:代码:
SECURITY_ATTRIBUTES sa ;
sa.nLength = sizeof( sa ) ;
sa.lpSecurityDescriptor = pSD;
sa.bInhertHandle= FALSE ;
HANDLE hFileMapping = CreateFileMapping (INVALID_HANDLE_VALUE , &sa ,PAGE_READWRITE,0,1024,TEXT("MyFileMapping!!")) ;
由于大多数应用程序都不适用安全性,所以不继续讨论了,但是忽视正确的安全访问标志是很多开发人员的失误之一。
在这儿关于SECURITY_ATTRIBUTES参数,对程序员来说还有一个用处就是判断该对象是不是内核对象:像菜单、窗口、鼠标光标、画刷和字体,这些也是对象,不过是GDI对象,而非内核对象,对于初学者有一 个办法很好区分,因为几乎所有的内核对象在创建的时候都需要SECURITY_ATTRIBUTES参数,那么如果某个对象在创建的时候,有SECURITY_ATTRIBUTES参数,那么可以认定它为内核对象,如果没有SECURITY_ATTRIBUTES参数,则表示不是内核对象,可能为他们对象例如GDI对象。
3.2 进程内核对象句柄表
句柄表只是一个由数据结构组成的数组,每个结构都包含指向一个内核对象的指针、一个访问掩码(access mask)和一些标志。
当进程首次初始化时,其句柄表为空。
3.2.1 创建一个内核对象
当进程中的线程创建内核对象时,系统内核将为这个对象分配并初始化一块内存,然后系统内核会在进程的句柄表空白项对其进行初始化。具体地说,指针成员会被设置为内核对象的数据结构的首地址,访问掩码被设置为完全访问,标志也会被设置。
注意,有些创建内核函数失败时返回值为0,一些则为-1,要非常小心。
3.2.2
无论以什么方式创建内核对象,我们都要调用closehandle向系统表明我们已经结束使用对象:
bool closehandle(handle hobject);
在进程内部,该函数先检查主调进程的句柄表,确认“传给函数的句柄值”标识的是“进程确实有权访问的一个对象”。当句柄有效时,系统将内核结构中的usage count(使用计数)成员减-1,如果usage count 为0,系统内核将对内核对象进行销毁。
如果传递给closehandle函数的一个无效的句柄,会出现两种情况。
第一种, 如果程序是正常运行,closehandle将返回false,而getlasterror返回error_invalid_handle。
第二种,进程正在被调试时,系统抛出0xc000008异常(指定无效句柄)。
调用closehandle函数,系统会清除进程句柄表中对应的记录项,无论内核对象当前是否被销毁,这个擦出动作都会发生,一旦调用closehandle,我们的进程就不能访问那个内存对象。
3.3 跨进程边界共享内核对象
由于内核对象的句柄是与每一个进程相关的,所以执行这些任务并不轻松。不过microsoft将句柄设计成“与进程相关(process-relative)”,其中最重要的原因是健壮性和安全性。
我们可以利用三种不同的机制来允许进程共享内核对象:使用对象句柄继承、为对象命名、复制对象句柄。
3.3.1 使用对象句柄继承
对象句柄继承只能在生成子进程的时候发生。为了使继承有效,父进程必须执行两个步骤。
父进程创建内核对象时,父进程必须指定某个内核对象的句柄是可继承的,当内核对象为可继承时,继承句柄表中该内核对象的标志为0x00000001。
下面例子为创建一个可继承的互斥量对象
SECURITY_ATTRIBUTTES sa;
sa.nLength = sizeof(sa);
sa.lpSecurityDescriptor = null;
sa.BInheritHandle = TRUE;//true为可继承
HANDLE hMutex =CreateMutex(&sa,FALSE,NULL);
通过CreateProcess父进程生成子进程,将bInheritHandles参数设置为true表示继承父进程中可继承的内核对象,系统还会遍历父进程的句柄表,凡是可继承的句柄的项都会被完整地赋值到子进程的句柄表中,并且复制的位置也与父进程句柄表的位置完全一样,并且增加继承内核对象的使用计数
BOOLCreateProcess
(
LPCTSTRlpApplicationName,
LPTSTRlpCommandLine,
LPSECURITY_ATTRIBUTESlpProcessAttributes。
LPSECURITY_ATTRIBUTESlpThreadAttributes,
BOOLbInheritHandles,
DWORDdwCreationFlags,
LPVOIDlpEnvironment,
LPCTSTRlpCurrentDirectory,
LPSTARTUPINFOlpStartupInfo,
LPPROCESS_INFORMATIONlpProcessInformation
);
对象句柄继承还有一个非常奇怪的特征:子进程并不知道自己继承了任何句柄,为了使子进程得到它想要的内核对象的句柄值,有三种方式。
将句柄值作为命令行参数传给子进程,通常是调用_stscanf_s()
父进程向子进程发送一条消息
父进程向环境块添加一个环境变量,变量值是内核对象的句柄,子进程通过调用GetEnvironmenVariable()来获取
3.3.2 改变句柄的标志
当父进程有多个子进程时,我们可能想控制哪些子进程能继承内核对象句柄,可以调用SetHandleInformation函数改变内核对象的继承标志。
BOOL SetHandleInformation(
HANDLEhObject,
DWORDdwMask,
DWORDdwFlags
);
例如,打开一个内核对象句柄的继承标志,我们可以这样
SetHandleInformation(hObj,HANDLE_FLAG_INHERIT,HANDLE_FLAG_INHERIT);
关闭这个标志可以这样
SetHandleInformation(hObj,HANDLE_FLAG_INHERIT,0);
还有一些标志例如下面这个标志告诉系统这个内核对象不允许关闭
SetHandleInformation(hObj,HANDLE_FLAG_PROTECT_FROM_CLOSE);
CloseHandle(hObj);//引发异常
3.3.3 为对象命名
跨进程边界共享内核对象的第二个方法是对象命名(第一个是对象句柄继承),大部分内核对象都可以进行命名(非全部)。
例如,下面创建内核函数的第三个参数为传入一个字符串作为该内核的名字,null为无命名
HANDLE CreateMutex(
PECURITY_ATTRIBUTESpsa,
BOOLbInitialOwner,
PCTSTRpszName);
值得注意的是,该命名最长为260个字符,并且,microsoft没有任何机制保证内核对象的命名是唯一的。
HANDLE hMutex = CreateMutex(NULL, FLASE,TEXT("JeffObj"));
HANDLE hSem = CreateSemaphore(NULL, FLASE,TEXT("JeffObj"));//异常
//GetLastError会出现ERROR_INVALIDE_HANDLE
利用内核对象命名继承句柄,B进程不需要是A进程的子进程,B进程完全可以是其他应用程序生成的进程。
当A进程创建一个名为JeffMutex的互斥量内核对象后,B进程调用
HANDLE hMutexProcessB = CreateMutex(NULL,FALSE, TEXT("JeffMutex"));
这时候系统的工作为:
先检查是否有一个叫JeffMutex的内核对象
如果有,检查是否和B进程创建的类型一样
如果类型一样,系统执行一次安全检查,确定B进程是否拥有该对象的完全访问权
如果有完全访问权,系统就会在进程B的句柄表中查找一个空白记录项,将其初始化并指向该内核对象。
以上任何一条不匹配,CreateMutex将返回NULL
注意:
用于创建内核对象的函数(例如CreateSemaphore)总是返回具有完全访问权限的句柄,如果想限制一个句柄的访问权限,可以使用扩展版本(CreateSemaphoreEx)的函数进行创建,扩展版本多一个用于控制权限的参数DWORD dwDesiredAccess
另外一个特别注意的地方是,如果B进程是继承内核对象,它调用CreateMutex函数时传递的安全属性信息和第二个参数将被忽略。
我们实现这些内核对象共享除了调用Create*函数,还有一个Open*函数,不同的是如果对象不存在,Create*会创建它,而Open*则返回NULL。
3.3.4 终端服务命名空间
在正在终端服务的计算机上,有很多个用于内核对象的命名空间。其中一个是全局命名空间,所有客户都能访问的内核对象要放在这个命名空间,这个命名空间主要由服务器使用。
每个客户端会话(client session)都有一个自己的命名空间,这样多个会话正在运行同一个应用程序才不会彼此干扰,一个会话不会访问另一个会话的对象,即使对象的名称相同。
注意:
在没有任何用户登录时,服务回在第一个会话(session 0)中启动,对于服务开发人员,由于必须在与客户端应用程序不同的一个会话中运行,所以会影响到共享内核对象的命名约定。任何对象想要和用户应用程序共享,必须在全局空间中创建它。
一个服务的命名内核对象始终位于全局命名空间内。默认情况下,在终端服务中,应用程序自己命名的内核对象在会话命名空间内。我们也可以强制把一个命名对象放入全局命名空间中
具体做法在其名称前加上“Global\”
HANDLE h = CreateMutex(NULL , false ,"Global\\myMutex");
也可以把内核对象放入当前的命名空间中
HANDLE h = CreateMutex(NULL, false ,"Local\\MyMutex");
还有一些函数可以知道我们的进程在哪个Terminal Services 会话中。
DWORD processID = GetCurrentProcessId();//获取进程ID
DWORD sessionID;
ProcessIdToSessionId(processID,&sessionID);//获取进程所在的终端会话ID
3.3.5 专有命名空间
为什么需要专有命名空间?
在windows vista之前,我们不可能防止一个共享对象的名称被劫持。任何进程——即使是最低权限的进程都能用任意名称创建对象,如果它先于单实例应用程序启动,单实例应用程序就变成一个“无实例”的应用程序,错误的认为它自己的另一个实例已经在运行,这个就是拒绝服务Dos攻击的基本机制。(未命名的内核对象不会遭到Dos攻击),为了确保应用程序创建的内核对象名称永远不会和其他应用程序的名称冲突,或者确保他们免遭劫持,可以定义一个自定义前缀,并把它作为自己专有的命名空间使用。
创建命名空间需要几样东西,边界描述符(boundary descriptor) ,安全标识符(security identifier,SID),
边界描述符(boundary descriptor):保护命名空间,安全标识符会(security identifier,SID)会被添加到边界描述符内。
安全标识符(security identifier,SID):确定系统的哪个用户组可以访问命名空间。
如果由于名称和SID泄密,导致一个的特权的恶意程序创建了一个相同的边界描述符,当它试图创建或打开一个使用高特权账户保护的专有命名空间就会失败,GetLastError()将返回ERROR_ACCESS_DENIED
创建专有命名空间的步骤:
创建边界标识符(boundary descriptor),注意该函数返回的虽然是HANDLE,但其实是一个指针,释放时应该调用DeleteBoundaryDescriptor来关闭,而不是CloseHandle
CreateBoundaryDescriptor()
创建安全描述符(security identifier),这里设置哪个组有权限创建和进入专有命名空间
将安全描述符添加到边界标识符内
CAddSIDToBoundaryDescriptor()
将一个按安全描述符格式的字符串转换成一个有效的安全描述符结构,也就是转换成
SECURITY_ATTRIBUTES结构的第2个参数lpSecurityDescriptor需要的结构
创建专有命名空间,注意该函数返回的是“伪句柄”,释放时应该调用ClosePrivateNamespace来关闭
CreatePrivateNamespace()
打开专有命名空间,注意该函数返回的是“伪句柄”,释放时应该调用ClosePrivateNamespace来关闭
OpenPrivateNamespace();
3.36 复制对象句柄
跨进程边界共享内核对象的最后一招是使用DuplicateHandle函数(前两招分别是使用对象句柄继承几、为对象命名)
BOOLWINAPI DuplicateHandle(
__in HANDLEhSourceProcessHandle,
__in HANDLE hSourceHandle,
__in HANDLEhTargetProcessHandle,
__out LPHANDLE lpTargetHandle,
__in DWORD dwDesiredAccess,
__in BOOL bInheritHandle,
__in DWORD dwOptions
);
注意:
第一个参数和第三个参数是进程内核对象
第二个参数是第一个参数所指向进程的一个内核对象,被复制对象
第四个参数是用于接收复制得到的HANDLE值
使用DuplicateHandle函数来复制内核对象句柄所遇到的问题和继承内核对象句柄一样:目标进程不知道它现在能访问一个新的内核对象,所以调用DupicateHandle的进程必须通过一种方式告诉目标进程现在可以访问新的内核对象,将lpTargetHandle参数传递给它,由于目标进程已经启动,所以命令行参数或更改目标进程的环境变量行不通,我们必须使用窗口消息或其他进程间通信(IPC)机制来实现