让我们与星光探索者一起,探索Windows吧!

Windows内核对象初步认识

Windows中有很多像访问令牌(access token)对象、事件(event)对象、进程(process)对象、线程(thread)对象、文件(file)对象、文件映射(filemapping)、可等待计时器(waitable timer)对象等等这样的对象,我们称之为Windows内核对象。内核对象是系统地址空间中的一个内存块,由系统创建并维护,这个内存对象是一个数据结构,维护着与对象相关的信息。微软提供了一些可以操作内核对象的API,可以使我们以适当的方式操作内核对象。关于内核对象的参考内容。

内核对象的所有者是操作系统,而非进程。如果我们的进程创建了一个内核对象,我们的进程终止之后,内核对象不一定被销毁, 因为可能别的进程使用我们的内核对象(如在下文中提到子进程继承父进程内核对象句柄,此时父进程终止之后,内核对象不一定被销毁)。

操作系统当然知道有多少进程使用同一个内核对象,因为每个内核对象都有一个叫引用计数(usage count)的共有数据成员。 初次创建一个内核对象时,他的引用计数为1,当一个内核对象的引用计数为0时,会被操作系统销毁,这样可以保证没有不被任何进程引用的内核对象。

内核对象用一个为安全描述符(security descriptor, SD)来保护。安全描述符描述了谁拥有对象,谁可以访问或使用对象,谁不可以访问对象。 在创建内核对象时,几乎所有的API都有一个指向SECURITY_ATTRIBUTES结构体的指针形参,这个结构体为安全描述符。默认情况下我们对于此参数都是传NULL,这样创建的内核对象有默认的安全性,具体包括哪些安全性,取决于当前进程的安全令牌(security token)。

许多老版本windows应用程序在visita之后不能正常工作,都在于不使用安全性。
例如,注册表操作 调用RegOpenKeyExA函数时 ,正确做法是传入KEY_QUERY_VALUE,从而指定查询子项数据的权限。传入KEY_ALL_ACCESS,就忽略了安全性。

1
2
3
4
5
typedef struct _SECURITY_ATTRIBUTES {
DWORD nLength; //结构体的大小,可用SIZEOF取得
LPVOID lpSecurityDescriptor; //安全描述符
BOOL bInheritHandle ; //安全描述的对象能否被新创建的进程继承
} SECURITY_ATTRIBUTES,* PSECURITY_ATTRIBUTES;

有关CreateFileMapping的内容

1
2
3
4
5
6
7
8
HANDLE CreateFileMappingA(
HANDLE hFile,
LPSECURITY_ATTRIBUTES lpFileMappingAttributes,
DWORD flProtect,
DWORD dwMaximumSizeHigh,
DWORD dwMaximumSizeLow,
LPCSTR lpName
);

**一个进程在初始化时,操作系统给他分配一个句柄表(handle table)**。创建任何内核对象,都会返回一个与进程相关的句柄。如果创建一个内核对象失败,返回的句柄值通常为NULL。注意是通常,也就是说有返回其他的表示失败,这取决于文档说明。失败原因可能是系统内存不足,或遇到了安全问题。

句柄表的结构类似这个样子:

索引 指向内核对象地址的指针 访问掩码(包含一个DWORD的ID) 可继承标志
1 0x???????? 0x???????? 0x????????
2 0x???????? 0x???????? 0x????????

我们使用ClsoeHandle函数来关闭一个内核对象。在CloaseHandle返回之前,她会清除进程句柄表中对应的记录项,无论内核对象是否当前销毁,这个过程均发生。

忘记了关闭内核对象会怎么样? 在进程运行期间,进程可能会发生资源泄露的情况,但是进程结束之后操作系统保证该进程使用的所有资源都被释放。 系统会保证我们的进程不留下任何东西。

我们可以使用Windows任务管理器查看每个进程的内核对象数。具体我们需要先打开Windows任务管理器(taskmgr.exe),然后点击Process(进程),点击菜单上的View(查看),然后点击Select Columns(选择列)菜单项。

https://s1.ax1x.com/2023/04/01/ppWWF5q.png

这样我们就可以查看进程的句柄数了。

很多时候不同进程中运行的进程需要共享内核对象。 例如:

  • 利用文件映射对象,可以在同一机器上运行两个不同进程之间的共享数据块。
  • 借助邮件槽和命名管道,在网络中的不同计算机运行的进程可以相互发送数据块。
  • 互斥量,和信号量和事件允许不同进程中的进程同步执行。

我们可以使用下面这三种机制来允许进程之间共享内核对象:使用对象句柄继承,为对象命名,复制对象句柄。

共享内核对象:使用对象句柄继承

当且仅当进程之间存在父子关系时,才可以使用对象句柄继承。对象句柄继承,继承的是对象的句柄,并不是继承父进程的内核对象。 为了使子进程能继承父进程的句柄,我们首先需要在创建内核对象时,分配并初始化一个SECURITY_ATTRIBUTES结构体。这个结构体是安全描述符,是我们自定义的。之后在调用创建内核对象的API时,传入其形参 PSECURITY_ATTRIBUTES位置;如果传NULL,表明这个句柄无法被继承。

当一个句柄可被继承时,它在句柄表位置的标志位被设为1,否则被设为0。标志位设为1的句柄才能被子进程继承,即“可被继承的对象句柄”
最后调用CreateProcess函数,并且使得bInheritHandles实参为TRUE,这样创建子进程。这种情况下,子进程就可以继承父线程中所有“可被继承的内核句柄”了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BOOL CreateProcess(
LPCTSTR lpApplicationName, // 应用程序名称
LPTSTR lpCommandLine, // 命令行参数
LPSECURITY_ATTRIBUTES lpProcessAttributes, // 默认进程的安全属性,可传NULL
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 默认进程的安全属性,可传NULL
BOOL bInheritHandles, // 当前进程内核对象能否被子进程继承
DWORD dwCreationFlags, // 创建进程的方式
LPVOID lpEnvironment, // 创建进程使用的环境变量,
// 传NULL表示使用父进程的
LPCSTR lpCurrentDirectory, // 创建进程的驱动器和目录,
// 传NULL表示使用父进程的
LPSTARTUPINFOA lpStartupInfo, // 传递给新进程的信息
LPPROCESS_INFORMATION lpProcessInformation // 新进程返回的信息
);

操作系统仍然会为子进程创建新的句柄表,只不过此时她多做一件事:遍历父进程的句柄表,对父进程所有任何有效的“可被继承的内核句柄”的句柄项,复制到子进程去,且和父进程的句柄表位置一模一样。 内核对象,访问掩码,标志这三个项的值完全一样。 这样就意味着,在父进程和子进程,对一个内核对象的标识的句柄值完全一样。除此之外,系统还会增加内核对象的引用计数。

对象句柄的继承只会发生在生成子进程那一刻发生,之后父进程后来又新建了其他内核对象且都可被继承,子进程是不会继承这些句柄的。子进程也不知道任何继承的句柄,这点非常重要。 因此,有必要在子进程的文档中表明打算继承哪些句柄。

为了使得子进程得到它想要的一个内核对象的句柄值,最常用的方式是将句柄值作为命令行参数传到子进程去,然后子进程的初始化代码解析命令行来提取句柄值,之后子进程就可以访问这个内核对象了。当然也可以通过父进程等待子进程完成初始化,父进程向子进程添加一个环境变量等。内核对象句柄的继承之所以会实现,原因是共享的内核对象的句柄值在父进程和子进程那都是一样的。

通过父进程等待子进程完成初始化,通常使用WaitForInputIdle函数,然后父进程发送一条消息到子进程的一个线程创建的窗口中。而通过父进程向子进程添加环境变量的方式,把句柄值写入进去,在子进程创建时会继承父进程的环境变量,就可以通过GetEnvironmentVariable得到环境变量。

如果在创建内核对象之后想修改它的可继承或其他属性,调用SetHandleInformation function函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
BOOL SetHandleInformation(
HANDLE hObject, // 要修改内核对象的句柄
DWORD dwMask, // 修改的属性
DWORD dwFlags // 修改的内容
);


HANDLE hObject; // 一个句柄

// 目前有下面常用用法
// 设置句柄值可继承:
SetHandleInformation(hObject, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
// 设置句柄不可继承:
SetHandleInformation(hObject, HANDLE_FLAG_INHERIT, 0);
// 设置句柄值不可关闭,受保护:
SetHandleInformation(hObject, HANDLE_FLAG_PROJECT_FROM_CLOSE, HANDLE_FLAG_PROJECT_FROM_CLOSE);
// 设置句柄值可关闭,不受保护:
SetHandleInformation(hObject, HANDLE_FLAG_PROJECT_FROM_CLOSE, 0);

共享内核对象:为对象命名

有一些内核对象在创建时需要传一个pszName参数,表明内核对象的名称(注意是有一些,并不是所有都是),我们可以传NULL表明这个内核对象是匿名的。如果不传NULL,这个字符串要求以0结尾,且字符长度最大为MAX_PATH(定义为260)。Microsoft没有任何机制保证这个内核对象的命名是唯一的,且所有内核对象共享一个命名空间,即使类型并不相同。例如创建一个Mutex命名为A,创建一个Semaphore也命名为A,这样会报错,并且我们也不能知道已经存在了叫A的内核对象。

当创建一个未命名的内核对象时,可以通过使用继承性或者DuplicateHandle函数(下面提到)来共享跨越进程的对象。当创建一个有名字的内核对象时,我们一般使用Create*的创建内核对象函数来获得这些对象。 Create*函数和Open*函数的主要差别: 如果对象不存在,Create*函数将创建该对象, 而Open*函数则运行失败。使用 GetLastError函数来获取函数运行的结果。使用为对象命名共享内核对象,最大优势即是共享内核对象的进程不一定就是创建内核对象的子进程。

因此,在一个窗口程序中,我们通常通过CreateMutex创建互斥量内核对象来保证同一个应用程序只有一个实例运行。为了确保名字的独立性,我们一般生成一个GUID字符串作为创建互斥量的名称。还有其他方式保证名字的独立性。例如下面这个win32程序入口。如果要限定程序同时可运行n个实例用信号量(Semaphore)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<windows.h>
#pragma comment(lib, "user32.lib")

INT APIENTRY WinMain(
HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
int nCmdShow)
{
HANDLE hMutex = CreateMutex(NULL, FALSE, TEXT("MyApplication"));
if (hMutex == NULL || ERROR_ALREADY_EXISTS == ::GetLastError())
{
MessageBox(NULL, TEXT("静止多开!"), TEXT("警告"), MB_ICONWARNING);
return FALSE;
}
MessageBox(NULL, TEXT("成功运行"), TEXT("提示"), MB_ICONINFORMATION);
return 0;
}

由于使用这些方式创造的内核对象,命名是在全局的。那么,如果其他应用程序也使用这个名字创建内核对象,我们无法防止发生“劫持”现象。任何进程,都可以创造任何名字的内核对象。以前面这个防止多开的例子为例,如果另外一个应用程序防多开也是用这套代码,“单实例”的程序很可能永远都开不了,因为它可能错误地认为有别的实例在同时运行。这即为大家熟知的DoS攻击,一种拒绝服务攻击。

未命名的内核对象不会遭受DoS攻击,并且不给内核对象命名是很普遍的。如果我们想确保我们的内核对象命名永远不会与其他的发生冲突,可以定义一个前缀作为自己专有命名空间使用。 负责创建内核对象的服务器将创建一个边界描述符(boundrary desciptor)对命名空间的名称进行保护。

创建专有命名空间步骤:

  1. 如何创建一个边界

  2. 如何将对应于本地管理员(Local Administrators)的一个安全描述符(security identifier, SID)和它关联起来

  3. 如何创建或者打开其名称被用途互斥量内核对象前缀的一个专有命名空间。

看下方代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
//边界句柄,这个句柄不需要被CloseHandle
HANDLE g_hBoundary = NULL;
//名字空间句柄
HANDLE g_hNamespace = NULL; 

// 为边界和名字空间取名
PCTSTR g_szBoundary = TEXT("3-Boundary");
PCTSTR g_szNamespace = TEXT("3-Namespace");

void CheckInstance()
{
//1. 创建边界,获得返回的边界句柄
// 实际上这个是伪句柄,不是内核对象句柄
g_hBoundary = CreateBoundaryDescriptor(g_szBoundary, 0);

// 创建指向本地Administrators的用户描述符
// 也可以用AllocateAndInitializeSID函数来获得SID值
BYTE localAdminSID[SECURITY_MAX_SID_SIZE];
PSID pLocalAdminSID = &localAdminSID;
DWORD cbSID = sizeof(localAdminSID);
if (!CreateWellKnownSid(
WinBuiltinAdministratorsSid, NULL, pLocalAdminSID, &cbSID))
{
// 创建指向本地Administrators的用户描述符失败时,将执行此块代码
return;
}

//2. 将一个特权用户组的SID与边界描述符关联起来
if (!AddSIDToBoundaryDescriptor(&g_hBoundary, pLocalAdminSID))
{
// 失败时,将执行此块代码
return;
}

SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(sa);
sa.bInheritHandle = FALSE;
// 指定SID中谁能进入边界并创建命名空间
if (!ConvertStringSecurityDescriptorToSecurityDescriptor(
TEXT("D:(A;;GA;;;BA)"),
SDDL_REVISION_1, &sa.lpSecurityDescriptor, NULL)) {
GetLastError();
return;
}
// 创建命名空间,如果已存在这个命名空间,GetLastError返回ERROR_ALREADY_EXISTS
g_hNamespace = CreatePrivateNamespace(&sa, g_hBoundary, g_szNamespace);
// 释放内存
LocalFree(sa.lpSecurityDescriptor);

// 现在可以使用“3-Namespace\yourName”之类的命名空间了

//完成后释放资源

// 释放命名空间时,我们可以给第二个参数传PRIVATE_NAMESPACE_FLAG_DESTROY
// 这样就可以使其关闭后不可见
ClosePrivateNamespace(g_hNamespace, 0);
// 释放边界资源用的是DeleteBoundaryDescriptor
DeleteBoundaryDescriptor(g_hBoundary);
}

这样,我们就可以使用OpenPrivateNamespace函数,来打开这个命名空间了。创建对象时,使用命名空间/对象名字的形式。

1
2
3
4
HANDLE OpenPrivateNamespaceA(
LPVOID lpBoundaryDescriptor, // 命名空间隔离方式的描述符
LPCSTR lpAliasPrefix // 命名空间的前缀
);

值得一提的是,在终端服务有多个命名空间,其中一个是全局命名空间。远程桌面和快速用户切换也是利用终端服务对话实现的。

共享内核对象:复制对象句柄

我们使用DuplicateHandle函数进行复制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BOOL DuplicateHandle(
HANDLE hSourceProcessHandle, // 拥有源内核对象句柄的进程
HANDLE hSourceHandle, // 源内核对象的句柄,也就是欲共享的内核对象的句柄
HANDLE hTargetProcessHandle, // 目的进程句柄
PHANDLE phTargetHandle, // 复制到目的进程句柄表中的内核对象句柄的地址
DWORD dwDesiredAccess, // 新句柄要求的安全访问级别。
// 若dwOptions已指定了DUPLICATE_SAME_ACCESS,
// 那么忽略这里的设置
BOOL bInheritHandle, // 新句柄是否能hTargetProcessHandle的子进程继承
DWORD dwOptions // 复制方式,以下两种方式的任意组合:
// 如果值为DUPLICATE_SAME_ACCESS,新句柄拥有与原始句柄相同安全访问特征
// 如果值为DUPLICATE_CLOSE_SOURCE,关闭源进程中的句柄,
// 该标识可以将一个内核对象传递给另外一个进程,即该内核对象的使用计数不会发生变化
);

可调用GetCurrentProcess函数获得当前进程句柄