Part I: Required Reading
Chapter 3: Kernel Objects
3.3 跨越进程边界共享内核对象
通常,运行在不同进程中的线程需要共享内核对象。原因如下:
- 文件映射对象允许你在一台计算机上运行的两个进程之间共享数据块。
- 邮件槽和命名管道允许应用程序在连接到网络的不同机器上运行的进程之间发送数据块。
- 互斥对象、信号量和事件允许不同进程中的线程同步它们的连续运行,例如一个应用程序在完成某个任务时需要通知另一个应用程序。
因为内核对象句柄是与进程相关的,所以执行这些任务很困难。Microsoft 将句柄设计为进程相关的理由:
- 最重要的原因是健壮性(鲁棒性)。若内核对象句柄是系统范围的值,则一个进程可以轻松获取另一个进程正在使用的对象的句柄,并对该进程造成严重破坏。
- 另一个原因是安全性。内核对象受到安全性的保护,进程在试图操作对象之前,必须请求权限才能操作对象。对象的创建者可以通过拒绝用户的访问权,来防止未经授权的用户接触该对象。
在下面章节中,介绍允许进程共享内核对象的三种不同机制:使用对象句柄继承、命名对象和复制对象句柄。
使用对象句柄继承
对象句柄继承只能在进程具有父子关系时使用。在这种情况下,父进程可以使用一个或多个内核对象句柄,父进程决定生成一个子进程,向子进程赋予父进程的内核对象的访问权限。要使这种类型的继承起作用,父进程必须执行若干个步骤。
首先,当父进程创建内核对象时,父进程必须向系统指明,它希望对象的句柄是可继承的。
注意,Windows 支持对象句柄继承 (object handle inheritance)。这句话是说,可继承的是句柄,而非对象本身。
要创建可继承的句柄,父进程必须分配并初始化一个 SECURITY_ATTRIBUTES
结构,并将该结构的地址传递给特定的 Create 函数。下面的代码创建一个互斥对象并返回一个可继承的句柄:
SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(sa);
sa.lpSecurityDescriptor = NULL;
sa.bInheritHandle = TRUE; // Make the returned handle inheritable.
HANDLE hMutex = CreateMutex(&sa, FALSE, NULL);
此代码初始化一个 SECURITY_ATTRIBUTES 结构,该结构指明应使用默认安全性创建对象,且返回的句柄应是可继承的。
现在讨论存储在进程的句柄表记录中的标志。每个句柄表记录都有一个标志位指明该句柄是否可继承。如果在创建内核对象时,传递 NULL 作为 PSECURITY_ATTRIBUTES 参数,则返回的句柄不可继承,且该位为 0。将 bInheritHandle
成员设置为 TRUE 会使此标志位设置为 1。
表3-2:包含两个有效记录的进程句柄表
索引 | 指向内核对象内存块的指针 | 访问掩码 (标志位的 DWORD) | 标志 |
---|---|---|---|
1 | 0xF0000000 | 0x???????? | 0x00000000 |
2 | 0x00000000 | (N/A) | (N/A) |
3 | 0xF0000010 | 0x???????? | 0x00000001 |
表3-2指明此进程可以访问两个内核对象 (句柄1和3)。句柄1是不可继承的,而句柄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
参数传递 FALSE。此值告诉系统,不希望子进程继承父进程句柄表中的可继承句柄。
但是,若为 bInheritHandles 参数传递 TRUE,则子进程将继承父进程的可继承句柄值。当传递 TRUE 时,操作系统将创建新的子进程,但不允许该子进程立即开始执行其代码。
系统会为子进程创建一个新的空进程句柄表,就像为任何新进程创建的那样。
但是,因为将 TRUE 传递给 CreateProcess 的 bInheritHandles 参数,所以系统做了另外一件事:它遍历父进程句柄表,并且对于它发现的每个包含有效的可继承句柄的记录,系统都会将该记录精确复制到子进程句柄表中;且该记录被复制到子进程句柄表中的位置与父进程句柄表中的位置完全相同。这意味着标识内核对象的句柄值在父进程和子进程中是相同的。
除了复制句柄表记录外,系统还会递增内核对象的使用计数,因为现在有两个进程正在使用该对象。如果要销毁内核对象,父进程和子进程都必须在对象上调用 CloseHandle,或者终止进程。
实际上,在 CreateProcess 函数返回后,父进程可以立即关闭其对象的句柄,而不会影响子进程操作对象的能力。
表3-3显示了允许子进程开始执行之前的子进程句柄表。索引 3 标识了一个内核对象。它在地址 0xF0000010 处标识内核对象,该对象与父进程句柄表中的对象相同。
表3-3:继承父进程的可继承句柄后,子进程的句柄表
索引 | 指向内核对象内存块的指针 | 访问掩码 (标志位的 DWORD) | 标志 |
---|---|---|---|
1 | 0x00000000 | (N/A) | (N/A) |
2 | 0x00000000 | (N/A) | (N/A) |
3 | 0xF0000010 | 0x???????? | 0x00000001 |
内核对象的内容存储在内核地址空间中,该地址空间由系统上运行的所有进程共享。
对于32位系统,它位于以下内存地址之间的内存中:0x80000000 和 0xFFFFFFFF。
对于64位系统,它位于以下内存地址之间的内存中:0x00000400’00000000 和 0xFFFFFFF’FFFFFFFF。
访问掩码与父进程中的掩码相同,且标志也相同。这意味着,如果子进程使用 CreateProcess 生成自己的子进程 (父进程的孙子进程),且 bInheritHandles 参数设置为 TRUE,则这个孙子进程也将继承这个内核对象句柄——具有相同的句柄值、相同的访问权限和相同的标志,且此对象上的使用计数将再次递增。
注意,对象句柄继承仅在子进程生成时使用。如果父进程要创建具有可继承句柄的新内核对象,已经运行的子进程不会继承这些新句柄。
对象句柄继承有一个奇怪的特性:使用它时,子进程不知道它已经继承了任何句柄。
内核对象句柄继承仅当子进程记录了下面这一情况时才有用:当它从另一个进程生成时,它期望被赋予内核对象的访问权。
通常,父应用程序和子应用程序是由同一公司编写的;但是,如果某家公司记录了子应用程序的期望内容,那么另一家公司可以编写这个子应用程序。
目前,子进程确定它期望的内核对象的句柄值的最常见方法是,将句柄值作为一个命令行实参传递给子进程。子进程的初始化代码解析命令行 (通常通过调用 _stscanf_s) 并提取句柄值。一旦子进程拥有该句柄值后,就可以像父进程一样访问该对象。
注意,句柄继承起作用的唯一原因是,共享内核对象的句柄值在父进程和子进程中是相同的。这就是父进程能够将句柄值作为命令行实参传递的原因。
可以使用其他形式的进程间通信,将继承的内核对象句柄值从父进程传送给子进程。
- 一种技术是让父进程等待子进程完成初始化 (使用 WaitForInputIdle 函数);然后父进程可以将消息发送到或放入子进程中线程创建的窗口。
- 另一种技术是父进程将环境变量添加到它的环境块中。该变量的名字将是子进程知道要查找的某种信息,该变量的值将是要继承的内核对象的句柄值。然后,当父进程生成子进程时,子进程继承父进程的环境变量,并可以调用 GetEnvironmentVariable 来获取继承对象的句柄值。如果子进程将要生成另一个子进程,这种方法非常好,因为可以再次继承环境变量。
改变句柄的标志
有时可能会遇到这样的情况:父进程创建一个内核对象检索可继承句柄,然后生成两个子进程;父进程希望只有一个子进程继承该内核对象句柄。换句话说,想要控制哪些子进程继承内核对象句柄。要更改内核对象句柄的继承标志,可以调用 SetHandleInformation
函数:
BOOL SetHandleInformation(
HANDLE hObject,
DWORD dwMask,
DWORD dwFlags);
这个函数有三个参数。第一个参数 hObject 标识一个有效的句柄。第二个参数 dwMask 告诉函数要更改哪些标志。当前,两个标志与每个句柄相关联:
#define HANDLE_FLAG_INHERIT 0x00000001
#define HANDLE_FLAG_PROTECT_FROM_CLOSE 0x00000002
如果要同时更改对象的两个标志,可以对这两个标志执行按位“或”运算。
SetHandleInformation 的第三个参数 dwFlags 指示要将标志设置成什么。例如,要打开内核对象句柄的继承标志,执行以下操作:
SetHandleInformation(hObj, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
若要关闭这个标志,执行以下操作:
SetHandleInformation(hObj, HANDLE_FLAG_INHERIT, 0);
HANDLE_FLAG_PROTECT_FROM_CLOSE 标志告诉系统,此句柄不应允许被关闭:
SetHandleInformation(hObj, HANDLE_FLAG_PROTECT_FROM_CLOSE, HANDLE_FLAG_PROTECT_FROM_CLOSE);
CloseHandle(hObj); // Exception is raised
在调试器下运行时,如果线程尝试关闭一个受保护的句柄,CloseHandle 会引发异常。若不在调试器的控制下,CloseHandle 仅返回 FALSE。
下面这种情况可以使用此标志,保护句柄不被关闭:如果有一个进程生成了一个子进程,而子进程又生成了一个孙子进程。父进程希望孙子进程继承赋予子进程的对象句柄。但是,子进程可能会在生成孙子进程之前关闭句柄。如果发生这种情况,父进程可能无法与孙子进程进行通信,因为孙子进程未继承内核对象。通过将句柄标记为“受保护不能关闭”,孙子进程可以继承一个有效的句柄。
但这种方法有一个缺陷。子进程可能会调用以下代码关闭 HANDLE_FLAG_PROTECT_FROM_CLOSE 标志,然后关闭句柄:
SetHandleInformation(hobj, HANDLE_FLAG_PROTECT_FROM_CLOSE, 0);
CloseHandle(hObj);
为了完整起见,了解一下 GetHandleInformation
函数:
BOOL GetHandleInformation(HANDLE hObject, PDWORD pdwFlags);
此函数返回指定句柄的当前标志设置,存储在 pdwFlags 指向的 DWORD 中。若要查看句柄是否是可继承的,执行以下操作:
DWORD dwFlags;
GetHandleInformation(hObj, &dwFlags);
BOOL fHandleIsInheritable = (0 != (dwFlags & HANDLE_FLAG_INHERIT));
命名对象
可用于跨进程边界共享内核对象的第二种方法是命名对象 (naming object)。许多 (尽管不是全部) 内核对象可以命名。例如,下面的所有函数均可创建命名的内核对象:
HANDLE CreateMutex(
PSECURITY_ATTRIBUTES psa,
BOOL bInitialOwner,
PCTSTR pszName);
HANDLE CreateEvent(
PSECURITY_ATTRIBUTES psa,
BOOL bManualReset,
BOOL bInitialState,
PCTSTR pszName);
HANDLE CreateSemaphore(
PSECURITY_ATTRIBUTES psa,
LONG lInitialCount,
LONG lMaximumCount,
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 时,表示向系统指明要创建一个未命名的 (匿名) 内核对象。当创建一个未命名对象时,可以使用继承 (如上一节中所述) 或 DuplicateHandle 在进程之间共享该对象。若要按名字共享对象,必须给对象命名。
如果不为 pszName 参数传递 NULL,则应传递以 0 结尾的字符串名字的地址。这个名字的长度最多为 MAX_PATH (定义为260) 个字符。
Microsoft 没有提供有关分配内核对象名字的指导。例如,如果试图创建一个名为"JeffObj"的对象,不能保证名为"JeffObj"的对象不存在。而且,所有这些对象共享一个单一的命名空间。因为这,下面 CreateSemaphore 的调用总是返回 NULL,因为已经存在具有相同名字的互斥对象:
HANDLE hMutex = CreateMutex(NULL, FALSE, TEXT("JeffObj"));
HANDLE hSem = CreateSemaphore(NULL, 1, 1, TEXT("JeffObj"));
DWORD dwErrorCode = GetLastError(); // dwErrorCode 的值为 6 (ERROR_INVALID_HANDLE)
现在知道了如何命名对象,看看如何以这种方式共享对象。假设进程 A 启动并调用下面的函数:
HANDLE hMutexProcessA = CreateMutex(NULL, FALSE, TEXT("JeffMutex"));
此函数调用将创建一个新的互斥内核对象,并为它赋予名字"JeffMutex"。注意,进程 A 的句柄 hMutexProcessA 不是可继承的句柄。当只命名对象时,它不必是可继承的。
一段时间后,某个进程生成了进程 B。进程 B 不必是进程 A 的子进程;它可能是从 Windows 资源管理器或其他应用程序生成的。这是使用命名对象的优点。进程 B 开始执行时,将执行以下代码:
HANDLE hMutexProcessB = CreateMutex(NULL, FALSE, TEXT("JeffMutex"));
当进程 B 调用 CreateMutex 时,系统首先检查名字为"JeffMutex"的内核对象是否已存在。
因为确实存在带有该名字的对象,内核接着检查对象类型。
因为正在试图创建一个互斥对象,且名字为"JeffMutex"的对象也是一个互斥对象,所以系统随后进行安全检查,查看调用者是否对该对象具有完全访问权限。
如果有,系统将在进程 B 的句柄表中找到一个空记录,并初始化该记录,指向现有的内核对象。
如果对象类型不匹配,或者调用者被拒绝访问,则 CreateMutex 失败 (返回NULL)。
🔗注:内核对象创建函数 (如 CreateSemaphore) 始终返回具有完全访问权限的句柄。如果要限制句柄的可用访问权限,可以利用内核对象创建函数的扩展版本 (带有 Ex 后缀),它接受额外的 DWORD dwDesiredAccess 参数。
例如,可以通过在调用 CreateSemaphoreEx 时使用(或不使用) SEMAPHORE_MODIFY_STATE 来允许(或禁止)在信号量句柄上调用 ReleaseSemaphore。阅读 Windows SDK 文档,获取每种内核对象相应的特定权限的详细信息,网址为 https://docs.microsoft.com/zh-cn/windows/win32/sync/synchronization-object-security-and-access-rights。
当进程 B 调用 CreateMutex 成功时,实际上不会创建一个互斥对象。反而,进程 B 被赋予一个与进程相关的句柄值,该值标识内核中现有的互斥对象。由于进程 B 的句柄表中的新记录引用了该对象,该互斥对象的使用计数会递增。注意,两个进程中的句柄值很可能会是不同的值。进程 A 和 B 分别使用它们自己的句柄值,来操作一个互斥内核对象。
当内核对象共享名字时,注意一个重要的细节。当进程 B 调用 CreateMutex 时,它将安全属性信息和第二个参数传递给该函数。如果带有指定名字的对象已存在,则忽略这些参数!应用程序可以确定是否确实创建了一个新的内核对象,而不是在调用 Create* 函数之后立即调用 GetLastError,打开一个现有对象:
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.
}
存在另外一种按名字共享对象的方法。进程可以调用下面的某个 Open* 函数,而不是调用 Create* 函数:
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 指明内核对象的名字。不能为此参数传递 NULL,必须传递以 0 结尾的字符串的地址。这些函数在内核对象的命名空间中搜索,试图找到匹项的对象。
- 如果不存在具有指定名字的内核对象,则函数返回 NULL,而 GetLastError 返回 2 (ERROR_FILE_NOT_FOUND)。
- 如果存在具有指定名字的内核对象,但它具有不同的类型,则函数返回 NULL,而 GetLastError 返回 6 (ERROR_INVALID_HANDLE)。
- 如果对象类型相同,系统接着检查是否允许请求的访问 (通过 dwDesiredAccess 参数)。
- 如果允许访问,则更新调用进程的句柄表,并递增该对象的使用计数。
- 如果为 bInheritHandle 参数传递 TRUE,则返回的句柄将是可继承的。
调用 Create* 函数与调用 Open* 函数之间的主要区别在于,如果对象不存在,则 Create* 函数将创建该对象,而 Open* 函数将运行失败。
如前所述,Microsoft 没有提供如何创建唯一对象名字的实际指南。换句话说,如果用户试图运行来自不同公司的两个程序,而每个程序都试图创建一个名为"MyObject"的对象,那将是一个问题。为了确保名字的唯一性,建议创建一个 GUID 并将 GUID 的字符串表示形式用作对象名字。
命名对象通常用于阻止应用程序的多个实例运行。为此,只需在 _tmain 或 _tWinMain 函数中调用 Create* 函数,创建一个命名对象。当 Create* 函数返回时,调用 GetLastError。若 GetLastError 返回 ERROR_ALREADY_EXISTS,则应用程序的另一个实例正在运行,新实例可以退出。下面是说明这种情况的代码:
int WINAPI _tWinMain(HINSTANCE hInstExe, HINSTANCE, PTSTR pszCmdLine, int nCmdShow) {
HANDLE h = CreateMutex(NULL, FALSE, TEXT("{FA531CC1-0497-11d3-A180-00105A276C3E}"));
if (GetLastError() == ERROR_ALREADY_EXISTS) {
// There is already an instance of this application running.
// Close the object and immediately return.
CloseHandle(h);
return(0);
}
// This is the first instance of this application running.
// ...
// Before exiting, close the object.
CloseHandle(h);
return(0);
}
终端服务命名空间
注意,终端服务 (Terminal Services) 稍微改变了上述方案。运行终端服务的计算机有多个内核对象的命名空间。有一个全局命名空间,由所有客户端会话都可以访问的内核对象使用。该命名空间主要由服务程序使用。此外,每个客户端会话都有它自己的命名空间。这种安排可以防止运行同一应用程序的多个会话彼此干扰,即一个会话无法访问另一会话的对象,即使对象共享相同的名字。
这些方案不只与服务端计算机有关,还可以利用终端服务会话来实现远程桌面 (Remote Desktop) 和快速用户切换 (Fast User Switching) 功能。
- 注:在任何用户登录之前,服务将在第一个会话中启动。在 Windows Vista 中,与以前版本的 Windows 不同,在用户登录后,应用程序将在一个新会话中启动,该会话与专用于服务的 Session 0 不同。这样,系统的这些核心组件 (通常以高权限运行) 与用户启动的任何恶意软件都更加隔离。
对于服务开发人员来说,必然在不同于其客户端应用程序的会话中运行,这会影响共享内核对象的命名约定。现在必须在全局命名空间中创建要与用户应用程序共享的对象。下面的情况也会遇到相似问题:需要编写一个服务支持与某些应用程序通信时;当不同的用户通过快速用户切换 (这个服务不能假定它与用户应用程序运行在相同的会话中) 登录到不同的会话时,这些应用程序可能会运行。
如果想要知道进程在哪个终端服务会话中运行,可以使用 ProcessIdToSessionId
函数 (由 kernel32.dll 导出,并声明在 WinBase.h 中),如下面的示例所示:
DWORD processID = GetCurrentProcessId();
DWORD sessionID;
if (ProcessIdToSessionId(processID, &sessionID)) {
tprintf(TEXT("Process '%u' runs in Terminal Services session '%u'"), processID, sessionID);
} else {
// ProcessIdToSessionId might fail if you don't have enough rights
// to access the process for which you pass the ID as parameter.
// Notice that it is not the case here because we're using our own process ID.
tprintf(TEXT("Unable to get Terminal Services session ID for process '%u'"), processID);
}
服务的命名内核对象始终位于全局命名空间中。默认情况下,在终端服务中,应用程序的命名内核对象放入会话的命名空间中。但是,可以通过在名字前面加上 Global\
作为前缀,强制将命名对象放入全局命名空间中,如下面的示例所示:
HANDLE h = CreateEvent(NULL, FALSE, FALSE, TEXT("Global\\MyName"));
还可以通过在名字前面加上 Local\
前缀,明确声明希望内核对象进入当前会话的命名空间,如下面的示例所示:
HANDLE h = CreateEvent(NULL, FALSE, FALSE, TEXT("Local\\MyName"));
Microsoft 认为 Global 和 Local 是保留关键字,除了强制使用特定的命名空间外,不应在对象名字中使用它们。
Microsoft 还认为 Session 是保留关键字。例如,可以使用 Session\<当前会话ID>\。但是,如果一个对象的名字是另一个会话中带有 Session 前缀的名字,则无法创建该对象,函数调用会失败,且 GetLastError 返回 ERROR_ACCESS_DENIED。
私有命名空间
当创建内核对象时,可以通过传递指向 SECURITY_ATTRIBUTES 结构的指针来保护对它的访问。但是,在 Windows Vista 发行之前,无法防止共享对象的名字被劫持。任何进程,甚至具有最低特权的进程,都可以使用给定名字创建对象。
如果以前面的示例为例,其中应用程序使用命名的互斥对象来检测它是否已经启动,那么可以很容易地编写另一个应用程序来创建具有相同名字的内核对象。若先启动新编写的应用程序,那么启动原应用程序后,它总是立即退出,以为自身的另一个实例已经在运行。
这是拒绝服务 (DoS: Denial of Service) 攻击背后的基本机制。
注意,未命名的内核对象不受 DoS 攻击,应用程序使用未命名对象是很常见的,即使它们不能在进程之间共享。
如果想要确保,应用程序创建的内核对象的名字,不会与其他应用程序的名字发生冲突、或成为劫持攻击的对象,则可以定义一个自定义前缀,并将其用作私有名称空间,就像处理 Global 和 Local 一样。负责创建内核对象的服务端进程定义一个边界描述符 (boundary descriptor),该描述符保护命名空间名字本身。
Singleton 应用程序 03-Singleton.exe 展示了如何使用私有命名空间实现前面介绍的单例模式,但是使用更安全的方式。启动程序时,出现如下图所示的窗口。
如果在第一个程序仍在运行的情况下启动同一个程序,则下图中所示的窗口说明已检测到前一个实例。
Singleton.cpp 源代码中的 CheckInstances 函数说明如何创建边界,将对应于 Local Administrators 组的安全标识符 (SID: security identifier) 与边界描述符关联,创建或打开私有命名空间,它的名字用作互斥内核对象的前缀。
边界描述符获取名字,但更重要的是,它获得与其关联的特权用户组的 SID。
这样,Windows 确保,只有在属于此特权组的用户的环境下运行的应用程序,才可以在同一边界中创建相同的命名空间,从而访问在此边界内创建的内核对象,它以有私有命名空间名字为前缀。
如果由于名字和 SID 被盗,低权限恶意软件应用程序创建了相同的边界描述符,当它尝试创建或打开由高权限帐户保护的私有命名空间时,相应的调用将失败,且 GetLastError 返回 ERROR_ACCESS_DENIED。
若恶意软件应用程序具有足够的控制权,那它造成的破坏要比单纯劫持内核对象名字严重得多。
void CheckInstances() {
// Create the boundary descriptor
g_hBoundary = CreateBoundaryDescriptor(g_szBoundary, 0);
// Create a SID corresponding to the Local Administrator group
BYTE localAdminSID[SECURITY_MAX_SID_SIZE];
PSID pLocalAdminSID = &localAdminSID;
DWORD cbSID = sizeof(localAdminSID);
if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, pLocalAdminSID, &cbSID)) {
AddText(TEXT("AddSIDToBoundaryDescriptor failed: %u\r\n"), GetLastError());
return;
}
// Associate the Local Admin SID to the boundary descriptor
// --> only applications running under an administrator user
// will be able to access the kernel objects in the same namespace
if (!AddSIDToBoundaryDescriptor(&g_hBoundary, pLocalAdminSID)) {
AddText(TEXT("AddSIDToBoundaryDescriptor failed: %u\r\n"), GetLastError());
return;
}
// Create the namespace for Local Administrators only
SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(sa);
sa.bInheritHandle = FALSE;
if (!ConvertStringSecurityDescriptorToSecurityDescriptor(
TEXT("D:(A;;GA;;;BA)"), SDDL_REVISION_1, &sa.lpSecurityDescriptor, NULL))
{
AddText(TEXT("Security Descriptor creation failed: %u\r\n"), GetLastError());
return;
}
g_hNamespace = CreatePrivateNamespace(&sa, g_hBoundary, g_szNamespace);
// Don't forget to release memory for the security descriptor
LocalFree(sa.lpSecurityDescriptor);
// Check the private namespace creation result
DWORD dwLastError = GetLastError();
if (g_hNamespace == NULL) {
// Nothing to do if access is denied
// --> this code must run under a Local Administrator account
if (dwLastError == ERROR_ACCESS_DENIED) {
AddText(TEXT("Access denied when creating the namespace.\r\n"));
AddText(TEXT(" You must be running as Administrator.\r\n\r\n"));
return;
} else {
if (dwLastError == ERROR_ALREADY_EXISTS) {
// If another instance has already created the namespace,
// we need to open it instead.
AddText(TEXT("CreatePrivateNamespace failed: %u\r\n"), dwLastError);
g_hNamespace = OpenPrivateNamespace(g_hBoundary, g_szNamespace);
if (g_hNamespace == NULL) {
AddText(TEXT(" and OpenPrivateNamespace failed: %u\r\n"), dwLastError);
return;
} else {
g_bNamespaceOpened = TRUE;
AddText(TEXT(" but OpenPrivateNamespace succeeded\r\n\r\n"));
}
} else {
AddText(TEXT("Unexpected error occurred: %u\r\n\r\n"), dwLastError);
return;
}
}
}
// Try to create the mutex object with a name
// based on the private namespace
TCHAR szMutexName[64];
StringCchPrintf(szMutexName, _countof(szMutexName), TEXT("%s\\%s"), g_szNamespace, TEXT("Singleton"));
g_hSingleton = CreateMutex(NULL, FALSE, szMutexName);
if (GetLastError() == ERROR_ALREADY_EXISTS) {
// There is already an instance of this Singleton object
AddText(TEXT("Another instance of Singleton is running:\r\n"));
AddText(TEXT("--> Impossible to access application features.\r\n"));
} else {
// First time the Singleton object is created
AddText(TEXT("First instance of Singleton:\r\n"));
AddText(TEXT("--> Access application features now.\r\n"));
}
}
查看一下 CheckInstances 函数的不同步骤。首先,边界描述符的创建需要一个字符串标识符来命名私有命名空间。传递这个名字作为下面函数的第一个参数:
HANDLE CreateBoundaryDescriptor(PCTSTR pszName, DWORD dwFlags);
当前版本的 Windows 不使用第二个参数,为它传递 0。
注意,这个函数的返回值不是一个内核对象句柄,而是一个指向包含边界定义的用户模式结构的指针。
因此,不要将返回的句柄值传递给 CloseHandle,应将其传递给 DeleteBoundaryDescriptor。
下一步是通过调用下面的函数,将用户的特权组的 SID 关联到边界描述符,客户端应用程序可以在这个用户下运行:
BOOL AddSIDToBoundaryDescriptor(HANDLE* phBoundaryDescriptor, PSID pRequiredSid);
在本例中,通过调用 AllocateAndInitializeSid 创建 Local Administrator 组的 SID,使用 SECURITY_BUILTIN_DOMAIN_RID 和 DOMAIN_ALIAS_RID_ADMINS 作为描述该组的参数。WinNT.h 头文件中定义了所有已知组的列表。
当调用下面的函数创建私有命名空间时,传递边界描述符句柄作为第二个参数:
HANDLE CreatePrivateNamespace(PSECURITY_ATTRIBUTES psa, PVOID pvBoundaryDescriptor, PCTSTR pszAliasPrefix);
SECURITY_ATTRIBUTES 被 Windows 用来允许或不允许应用程序调用 OpenPrivateNamespace 访问命名空间,以及在此命名空间内打开或创建对象。你具有与文件系统目录中完全相同的选项。这是为打开命名空间提供的过滤器级别。添加到边界描述符的 SID 用于定义谁可以进入边界并创建命名空间。
在 Singleton 示例中,通过调用 ConvertStringSecurityDescriptorToSecurityDescriptor 函数构造 SECURITY_ATTRIBUTE,该函数将一个复杂语法的字符串作为第一个参数。
安全描述符字符串语法使用 ACE Strings 和 SID Strings。
要用于创建内核对象的字符串前缀作为第三个参数。如果尝试创建已经存在的私有命名空间,则 CreatePrivateNamespace 返回 NULL,而 GetLastError 返回 ERROR_ALREADY_EXISTS。此时,需要使用下面的函数打开现有的私有命名空间:
HANDLE OpenPrivateNamespace(PVOID pvBoundaryDescriptor, PCTSTR pszAliasPrefix);
注意,CreatePrivateNamespace 和 OpenPrivateNamespace 返回的 HANDLE 不是内核对象句柄;可以通过调用 ClosePrivateNamespace 关闭这些伪句柄:
BOOLEAN ClosePrivateNamespace(HANDLE hNamespace, DWORD dwFlags);
如果创建了命名空间,但不想在关闭它后,它是可见的,应传递 PRIVATE_NAMESPACE_FLAG_DESTROY 作为第二个参数,否则应传递 0。
当进程结束时,或者调用 DeleteBoundaryDescriptor 函数并使用边界句柄作为它唯一的参数,则边界会关闭。
使用内核对象时期,不得关闭命名空间。如果在对象存在于命名空间内时关闭命名空间,则可以在同一边界中相同的重新创建的命名空间中,创建另一个具有相同名字的内核对象,从而再次遭遇 DoS 攻击。
总结起来,私有命名空间只是创建内核对象的目录。
与其他目录一样,私有命名空间具有与之关联的安全描述符,该描述符在调用 CreatePrivateNamespace 时设置。
但是,与文件系统目录不同,这个命名空间没有父级或名字——边界描述符被用作引用它的名字。
因此,如果创建内核对象时,带有基于私有命名空间的前缀,那么该内核对象出现在 Process Explorer (来自 Sysinternals) 中带有 ...\
前缀,而不是 namespace name\
这样的前缀。...\
前缀隐藏了信息,从而为抵御黑客攻击提供了更多保护。
其他进程(甚至同一进程)可以打开相同的私有命名空间,并为其赋予不同的别名。
要创建命名空间,需要执行边界测试——当前线程的令牌必须包含作为边界部分的所有 SID。
复制对象句柄
跨进程边界共享内核对象的最后一种技术要求使用 DuplicateHandle 函数:
BOOL DuplicateHandle(
HANDLE hSourceProcessHandle,
HANDLE hSourceHandle,
HANDLE hTargetProcessHandle,
PHANDLE phTargetHandle,
DWORD dwDesiredAccess,
BOOL bInheritHandle,
DWORD dwOptions);
简而言之,这个函数接受一个进程的句柄表中的一个记录,并将该记录复制到另一个进程的句柄表中。DuplicateHandle 函数的最通用用法涉及系统中运行的 3 个不同进程。
调用 DuplicateHandle 时,第一个和第三个参数是内核对象句柄。句柄本身必须与调用 DuplicateHandle 函数的进程相关。另外,这两个参数必须标识进程内核对象;如果传递其他类型的内核对象的句柄,该函数会调用失败。每当系统中启动一个新进程时都会创建一个进程内核对象。
第二个参数 hSourceHandle 是任一类型的内核对象的句柄。但是,该句柄值与调用 DuplicateHandle 的进程无关,反而必须与由 hSourceProcessHandle 句柄标识的进程相关。
第四个参数 phTargetHandle 是一个 HANDLE 变量的地址,该变量接收由 hTargetProcessHandle (源句柄信息复制到此处) 标识的进程的句柄表中的记录的 HANDLE。
DuplicateHandle 的最后三个参数指明访问掩码的值和继承标志,它们应使用在该内核对象句柄的目标记录中。dwOptions 参数可以为 0、或下面两个标志的任意组合:DUPLICATE_SAME_ACCESS 和 DUPLICATE_CLOSE_SOURCE。
指定 DUPLICATE_SAME_ACCESS 表示希望目标进程的句柄的访问掩码与源进程的句柄相同。使用此标志会使 Duplicate-Handle 忽略其 dwDesiredAccess 参数。
指定 DUPLICATE_CLOSE_SOURCE,可以关闭源进程中的句柄。该标志使得一个进程很容易地将内核对象移交给另一进程。使用此标志时,该内核对象的使用计数不受影响。
通过一个示例展示 DuplicateHandle 的工作方式。这个示例中,
- 进程 S 是当前可以访问某个内核对象的源进程,
- 进程 T 是将获取这个内核对象的访问权限的目标进程,
- 进程 C 是将执行 DuplicateHandle 调用的催化进程。
在演示时,使用硬编码的数字表示句柄值。
进程 C 的句柄表 (表3-4) 包含两个句柄值 1 和 2。句柄值 1 标识 S 的进程内核对象,句柄值 2 标识 T 的进程内核对象。
表3-4:进程 C 的句柄表
索引 | 指向内核对象内存块的指针 | 访问掩码 (标志位的 DWORD) | 标志 |
---|---|---|---|
1 | 0xF0000000 (进程S的内核对象) | 0x???????? | 0x00000000 |
2 | 0xF0000010 (进程T的内核对象) | 0x???????? | 0x00000000 |
表3-5是进程 S 的句柄表,其中包含一个句柄值为 2 的记录。此句柄可以标识任何类型的内核对象,而不必是进程内核对象。
表3-5:进程 S 的句柄表
索引 | 指向内核对象内存块的指针 | 访问掩码 (标志位的 DWORD) | 标志 |
---|---|---|---|
1 | 0x00000000 | (N/A) | (N/A) |
2 | 0xF0000020 (任一内核对象) | 0x???????? | 0x00000000 |
表3-6显示了在进程 C 调用 DuplicateHandle 函数之前,进程 T 的句柄表包含的内容。进程 T 的句柄表仅包含一个句柄值为 2 的记录。句柄记录 1 当前未使用。
表3-6:进程 T 的句柄表
索引 | 指向内核对象内存块的指针 | 访问掩码 (标志位的 DWORD) | 标志 |
---|---|---|---|
1 | 0x00000000 | (N/A) | (N/A) |
2 | 0xF0000030 (任一内核对象) | 0x???????? | 0x00000000 |
如果进程 C 现在使用下面代码调用 DuplicateHandle,则只有进程 T 的句柄表已更改,如表3-7所示:
DuplicateHandle(1, 2, 2, &hObj, 0, TRUE, DUPLICATE_SAME_ACCESS);
表3-7:调用 DuplicateHandle 后,进程 T 的句柄表
索引 | 指向内核对象内存块的指针 | 访问掩码 (标志位的 DWORD) | 标志 |
---|---|---|---|
1 | 0xF0000020 | 0x???????? | 0x00000001 |
2 | 0xF0000030 (任一内核对象) | 0x???????? | 0x00000000 |
进程 S 的句柄表中的第二个记录已复制到进程 T 的句柄表中的第一个记录。DuplicateHandle 还用值 1 填充了进程 C 的 hObj 变量,该变量是进程 T 的句柄表中放置新记录的索引。
因为 DUPLICATE_SAME_ACCESS 标志已传递给 DuplicateHandle,所以进程 T 句柄表中这个句柄的访问掩码与进程 S 句柄表记录中的访问掩码相同。
注意,继承位标志已打开,因为向 DuplicateHandle 的 bInheritHandle 参数传递了 TRUE。
与继承一样,DuplicateHandle 函数的有一奇怪之处,没有向目标进程提供任何有关现在可以访问新内核对象的通知。
因此,进程 C 必须以某种方式通知进程 T,它现在可以访问内核对象,并且 C 必须使用某种形式的进程间通信将 hObj 中的句柄值传递给进程 T。
显然,使用命令行参数或更改进程 T 的环境变量是不可能的,因为该进程已经启动并正在运行。
必须使用窗口消息或某种其他进程间通信 (IPC: interprocess communication) 机制。
上面解释的是 DuplicateHandle 的最通用用法。它是一个非常灵活的函数。但是,当涉及到 3 个不同的进程时,很少使用它,部分原因是进程 C 不太可能知道进程 S 正在使用的对象的句柄值。
通常,仅涉及 2 个进程时才调用 DuplicateHandle。比如,一个进程可以访问另一个进程想要访问的对象,或者一个进程想要将内核对象的访问权限赋予给另一个进程。
例如,假设进程 S 可以访问一个内核对象,并且希望赋予进程 T 对该对象的访问权限。为此,可以按以下方式调用 DuplicateHandle:
// All of the following code is executed by Process S.
// Create a mutex object accessible by Process S.
HANDLE hObjInProcessS = CreateMutex(NULL, FALSE, NULL);
// Get a handle to Process T's kernel object.
HANDLE hProcessT = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessIdT);
HANDLE hObjInProcessT; // An uninitialized handle relative to Process T.
// Give Process T access to our mutex object.
DuplicateHandle(GetCurrentProcess(), hObjInProcessS, hProcessT, &hObjInProcessT, 0, FALSE, DUPLICATE_SAME_ACCESS);
// Use some IPC mechanism to get the handle value of hObjInProcessS into Process T.
...
// We no longer need to communicate with Process T.
CloseHandle(hProcessT);
...
// When Process S no longer needs to use the mutex, it should close it.
CloseHandle(hObjInProcessS);
对 GetCurrentProcess 的调用返回一个伪句柄,该伪句柄总是标识调用进程 (calling process),在此示例中为进程 S。一旦 DuplicateHandle 返回,hObjInProcessT 就是与进程 T 相关的句柄,它标识的对象与 hObjInProcessS 的句柄标识的对象相同。
进程 S 永远不要执行以下代码:
// Process S should never attempt to close the duplicated handle.
CloseHandle(hObjInProcessT);
如果进程 S 执行了此代码,则调用可能失败也可能不会失败。但这不是问题。
如果进程 S 恰巧可以访问具有与 hObjInProcessT 相同的句柄值的内核对象,则调用成功。这个调用将关闭某个随机内核对象,这样进程 S 下次尝试访问它时,肯定会导致应用程序运行异常。
使用 DuplicateHandle 的另一种方式:假设一个进程拥有对一个文件映射对象的读写访问权限。在某个点,调用了一个函数,该函数应该只能通过读取文件映射对象来访问它。
为了使应用程序更健壮,可以使用 DuplicateHandle 为现有对象创建一个新的句柄,并确保该新句柄对其具有只读访问权限。然后,将这个只读句柄传递给上述函数;这样,这个函数中的代码将永远无法意外地对文件映射对象进行写入操作。以下代码说明了此示例:
int WINAPI _tWinMain(HINSTANCE hInstExe, HINSTANCE, LPTSTR szCmdLine, int nCmdShow) {
// Create a file-mapping object; the handle has read/write access.
HANDLE hFileMapRW = CreateFileMapping(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, 10240, NULL);
// Create another handle to the file-mapping object; the handle has read-only access.
HANDLE hFileMapRO;
DuplicateHandle(GetCurrentProcess(), hFileMapRW, GetCurrentProcess(), &hFileMapRO, FILE_MAP_READ, FALSE, 0);
// Call the function that should only read from the file mapping.
ReadFromTheFileMapping(hFileMapRO);
// Close the read-only file-mapping object.
CloseHandle(hFileMapRO);
// We can still read/write the file-mapping object using hFileMapRW.
...
// When the main code doesn't access the file mapping anymore, close it.
CloseHandle(hFileMapRW);
}