【Windows via C/C++】第3章 内核对象 (1)


Part I: Required Reading
Chapter 3: Kernel Objects


从检查内核对象及其句柄开始,理解 Microsoft Windows 应用程序编程接口(API: Application Programming Interface)。本章介绍一些相对抽象的概念——讨论适用于所有内核对象的特性。

系统和应用程序使用内核对象来管理众多的资源,如进程、线程和文件等。本章介绍的概念将贯穿本书其余大部分章节。

内核对象


3.1 什么是内核对象

作为Windows软件开发人员,需要经常创建、打开和操作内核对象。系统创建和操作几种类型的内核对象,如访问令牌(access token)对象、事件对象、文件对象、文件映射对象、I/O完成端口对象、作业对象、邮件槽(mailslot)对象、互斥对象、管道对象、进程对象、信号量(semaphore)对象、线程对象、可等待计时器(waitable timer)对象、线程池工厂(thread pool worker factory)对象等。
可以使用 Sysinternals 提供的免费WinObj工具查看所有内核对象类型的列表。注意,必须通过 Windows 资源管理器 (Windows Explorer) 以管理员身份运行它,才能看到 ObjectTypes 列表。
WinObj

这些对象是通过调用各种函数创建的,这些函数的名字不一定映射到内核级使用的对象类型。例如,CreateFileMapping 函数令系统创建一个对应于 Section 对象的文件映射。
每个内核对象只是一个由内核分配的内存块,且只能由内核访问。该内存块是一种数据结构,其成员维护对象的相关信息。某些成员(安全描述符、使用计数等)在所有对象类型中都是相同的,但大多数成员是特定的对象类型特有的。

由于内核对象数据结构只能由内核访问,因此应用程序不可能在内存中定位这些数据结构并直接更改其内容。
Microsoft 规定此限制,以确保内核对象结构保持一致的状态。此限制还允许 Microsoft 在不破坏任何应用程序的情况下添加、删除或更改这些结构中的成员。

应用程序如何操作这些内核对象?

  • Windows提供了一组函数,它们以定义良好的方式操作这些数据结构。可以通过这些函数访问这些内核对象。
    当调用创建内核对象的函数时,该函数返回一个标识该对象的句柄 (handle)。
    此句柄可视为一个不透明值,它可以被进程中的任何线程使用。
    句柄在32位Windows进程中是32位值,在64位Windows进程中是64位值。
    将此句柄传递给各种Windows函数,以便系统知道要操作哪个内核对象。

为了保证操作系统的健壮性,这些句柄值是与进程相关的。因此,如果要将这个句柄值传递给另一个进程中的线程 (使用某种形式的进程间通信),那么这另一个进程使用你的进程的句柄值进行的调用可能会失败,甚至更糟,它们将在进程句柄表的同一索引处创建完全不同的内核对象的引用。

使用计数

内核对象由内核拥有,而不是由进程拥有。换句话说,如果进程调用一个创建内核对象的函数,然后进程终止,那么内核对象不一定会被销毁。在大多数情况下,对象将被销毁;但是如果另一个进程正在使用原来进程创建的内核对象,那么内核知道在另一个进程停止使用它之前不要销毁该对象。

内核知道有多少个进程在使用一个特定的内核对象,因为每个对象都包含一个使用计数 (usage count)。使用计数是所有内核对象类型共有的数据成员之一。

  • 首次创建对象时,其使用计数设置为1。
  • 当另一个进程获得对现有内核对象的访问权限时,使用计数将递增。
  • 当进程终止时,内核会自动递减此进程中仍然打开的所有内核对象的使用计数。
  • 如果对象的使用计数变为0,内核将销毁该对象。这确保在没有进程引用内核对象时,该对象将不会保留在系统中。

安全性

可以使用安全描述符 (security descriptor) 保护内核对象。
安全描述符描述对象的拥有者 (通常是其创建者)、哪些组和用户可以访问或使用该对象,以及哪些组和用户被拒绝访问该对象。
安全描述符通常在编写服务器应用程序时使用。但是,在 Microsoft Windows Vista 中,对于具有私有命名空间的客户端应用程序,此特性更为明显。

几乎所有创建内核对象的函数都有一个实参是指向 SECURITY_ATTRIBUTES 结构的指针,如 CreateFileMapping 函数所示:

HANDLE CreateFileMapping(
	HANDLE hFile,
	PSECURITY_ATTRIBUTES psa,
	DWORD flProtect,
	DWORD dwMaximumSizeHigh,
	DWORD dwMaximumSizeLow,
	PCTSTR pszName); 

大多数应用程序只是传递 NULL 作为此实参,这样创建的对象具有默认安全性,此安全性是基于当前进程安全令牌构建的。

也可以分配一个 SECURITY_ATTRIBUTES 结构,对它进行初始化,并向这个形参传递该结构的地址。
SECURITY_ATTRIBUTES 结构如下所示:

typedef struct _SECURITY_ATTRIBUTES {
	DWORD nLength;
	LPVOID lpSecurityDescriptor;
	BOOL bInheritHandle;
} SECURITY_ATTRIBUTES; 

即使此结构称为 SECURITY_ATTRIBUTES,它实际上仅包含一个与安全性有关的成员:lpSecurityDescriptor。如果要限制对创建的内核对象的访问,必须创建安全描述符,然后按如下所示初始化 SECURITY_ATTRIBUTES 结构:

SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(sa);         // Used for versioning
sa.lpSecurityDescriptor = pSD;   // Address of an initialized SD
sa.bInheritHandle = FALSE;       // Discussed later
HANDLE hFileMapping = CreateFileMapping(INVALID_HANDLE_VALUE, &sa, PAGE_READWRITE, 0, 1024, TEXT("MyFileMapping")); 

若想要获得对现有内核对象的访问权限,必须指定要在该对象执行的操作。
例如,如果想访问现有的文件映射内核对象,以便从中读取数据,调用 OpenFileMapping 如下:

HANDLE hFileMapping = OpenFileMapping(FILE_MAP_READ, FALSE, TEXT("MyFileMapping")); 

通过将 FILE_MAP_READ 作为第一个参数传递给 OpenFileMapping,表示打算在获取文件映射的访问权限后,从中读取数据。
OpenFileMapping 函数首先执行安全检查,然后返回有效的句柄值。

  • 若允许已登录用户访问现有的文件映射内核对象,则 OpenFileMapping 返回一个有效的句柄。
  • 若已登录用户被拒绝此访问,OpenFileMapping 返回 NULL,且对 GetLastError 的调用将返回值 5 (ERROR_ACCESS_DENIED)。
    记住,如果返回的句柄用于调用一个API,且API需要不同于 FILE_MAP_READ 权限,则会发生相同的“拒绝访问”错误。

尽管许多应用程序不需要考虑安全性,但许多 Windows 函数要求传递所需的安全访问信息。
为 Windows 的早期版本设计的一些应用程序无法在 Windows Vista 上正常运行,可能是因为在实现应用程序时没有充分考虑安全性。

例如,假设有一个应用程序在启动时从注册表子项中读取一些数据。

  • 许多应用程序最初是为 Windows 2000 以前的操作系统开发的,没有考虑安全性。
    一些软件开发人员在调用 RegOpenKeyEx 时,传递 KEY_ALL_ACCESS 作为所需的访问权限。当这样的应用程序在 Windows Vista 上运行时,此调用会失败。因为注册表子项 (如 HKLM) 对于非管理员的用户可能是可读的,但不可写。
  • 如果开发人员稍微考虑一下安全性,将 KEY_ALL_ACCESS 改为 KEY_QUERY_VALUE,则该产品将可在所有操作系统平台上运行。

忽略正确的安全访问标志是开发人员犯的最大错误之一。
使用正确的安全访问标志无疑会令在 Windows 版本之间移植应用程序更加容易。
但还需要认识到,Windows 的每个新版本会带来一组以前版本中不存在的新约束。例如,在 Windows Vista 中,需要注意用户帐户控制 (UAC: User Account Control) 功能。

除了使用内核对象之外,应用程序还可以使用其他类型的对象,如菜单、窗口、鼠标光标、画笔和字体等。这些对象是用户对象或图形设备接口 (GDI: Graphical Device Interface) 对象,而不是内核对象。

确定对象是否为内核对象的最简单方法是检查创建该对象的函数。
几乎所有创建内核对象的函数都有一个形参,用于指定安全属性信息,就像 CreateFileMapping 函数一样。

创建用户对象或 GDI 对象的函数都没有 PSECURITY_ATTRIBUTES 形参。例如,CreateIcon 函数:

HICON CreateIcon(
	HINSTANCE hinst,
	int nWidth,
	int nHeight,
	BYTE cPlanes,
	BYTE cBitsPixel,
	CONST BYTE *pbANDbits,
	CONST BYTE *pbXORbits); 

3.2 进程的内核对象句柄表

当一个进程被初始化时,系统会为它分配一个句柄表。该句柄表仅用于内核对象,不用于用户对象或 GDI 对象。
句柄表是如何构造和管理的,这部分细节没有文档记录。不过,Windows 程序员需要了解如何管理进程的句柄表。

表3-1显示了进程的句柄表的样子。可以看到,它只是一个数据结构数组。每个结构都包含一个指向内核对象的指针、一个访问掩码和一些标志。

表3-1:进程句柄表的结构

索引指向内核对象内存块的指针访问掩码 (标志位的 DWORD)标志
10x????????0x????????0x????????
20x????????0x????????0x????????

创建内核对象

当进程首次初始化时,其句柄表是空的。
当进程中的线程调用创建内核对象的函数时,内核会为该对象分配一块内存,并对其初始化。
然后内核扫描进程的句柄表,寻找一个空的记录。因为表3-1中的句柄表是空的,所以内核找到索引 1 处的结构并初始化它。
指针成员将被设置为内核对象数据结构的内存地址,访问掩码将被设置为完全访问,并且标志将被设置。

下面是一些创建内核对象的函数:

HANDLE CreateThread(
	PSECURITY_ATTRIBUTES psa,
	size_t dwStackSize,
	LPTHREAD_START_ROUTINE pfnStartAddress,
	PVOID pvParam,
	DWORD dwCreationFlags,
	PDWORD pdwThreadId); 
 
HANDLE CreateFile(
	PCTSTR pszFileName,
	DWORD dwDesiredAccess,
	DWORD dwShareMode,
	PSECURITY_ATTRIBUTES psa,
	DWORD dwCreationDisposition,
	DWORD dwFlagsAndAttributes,
	HANDLE hTemplateFile); 

HANDLE CreateFileMapping(
	HANDLE hFile,
	PSECURITY_ATTRIBUTES psa,
	DWORD flProtect,
	DWORD dwMaximumSizeHigh,
	DWORD dwMaximumSizeLow,
	PCTSTR pszName); 
 
HANDLE CreateSemaphore(
	PSECURITY_ATTRIBUTES psa,
	LONG lInitialCount,
	LONG lMaximumCount,
	PCTSTR pszName); 

用于创建内核对象的所有函数都返回与进程相关的句柄,在同一进程中运行的任何线程都可以成功地使用这些句柄。这句柄值应除以 4 (或右移两位以忽略 Windows 内部使用的最后两位),获取放入进程句柄表中的索引,它标识了内核对象信息的存储位置。但记住,句柄的含义没有文档记录,可能会发生变化。

每当调用一个接受内核对象句柄作为实参的函数时,都会传递某个 Create* 函数返回的值。在内部,该函数查看进程的句柄表,获取要操作的内核对象的地址,然后以定义良好的方式操作该对象的数据结构。

若传递了无效的句柄,则该函数调用失败,且 GetLastError 将返回 6 (ERROR_INVALID_HANDLE)。因为句柄值实际上是作为进程的句柄表的索引来使用,所以这些句柄是与进程相关的,无法在其他进程中成功使用。

若调用一个函数创建内核对象,但调用失败,则返回的句柄值通常为 0 (NULL),这就是为什么第一个有效句柄值为 4 的原因。发生这种情况是因为系统缺少内存或遇到安全问题。
然而,一些函数在失败时返回的句柄值为 -1 (在 WinBase.h 中定义的 INVALID_HANDLE_VALUE)。例如,若 CreateFile 无法打开指定的文件,它将返回 INVALID_HANDLE_VALUE 而不是 NULL。
检查创建内核对象的函数的返回值时,必须非常小心。
具体来说,仅当调用 CreateFile 时,才可以将值与 INVALID_HANDLE_VALUE 进行比较。
下面的代码是不正确的:

HANDLE hMutex = CreateMutex();
if (hMutex == INVALID_HANDLE_VALUE) {
	// We will never execute this code because
	// CreateMutex returns NULL if it fails.
} 

同样,下面的代码也不正确:

HANDLE hFile = CreateFile();
if (hFile == NULL) {
	// We will never execute this code because CreateFile
	// returns INVALID_HANDLE_VALUE (-1) if it fails.
}

关闭内核对象

无论如何创建内核对象,都可以通过调用 CloseHandle 向系统指明已完成对该对象的操作:

BOOL CloseHandle(HANDLE hobject);

在内部,这个函数首先检查调用进程的句柄表,以确保传递给它的句柄值,标识该进程实际上可以访问的对象。
若该句柄有效,则系统将获取内核对象的数据结构的地址,并递减该结构中的使用计数成员。
若计数为零,则将销毁该内核对象并将其从内存中删除。

如果将无效的句柄传递给 CloseHandle,可能会发生以下两种情况之一。

  • 如果这个进程正常运行,CloseHandle 返回 FALSE,而 GetLastError 返回 ERROR_INVALID_HANDLE。
  • 如果正在调试这个进程,系统将抛出异常 0xC0000008(“指定了无效的句柄”),以便调试错误。

在 CloseHandle 返回之前,它将清除进程的句柄表中的相应记录——该句柄现在对此进程无效,不应再使用它。无论内核对象是否已销毁,清除都会发生!
调用 CloseHandle 之后,将不再具有访问内核对象的权限。但是,若对象的计数未减为零,则说明该对象尚未销毁。这意味着还有其他进程仍在使用该对象。

🔗注:通常,在创建内核对象时,会将相应的句柄存储在变量中。在使用该变量作为参数调用 CloseHandle 之后,还应将此变量重置为 NULL。如果错误地重新使用此变量来调用 Win32 函数,可能会发生两种意外情况:

  • 由于该变量引用的句柄表对应记录已被清除,因此Windows收到无效的参数,你会收到错误消息。
  • 还有另一种情况很难调试。当创建一个新的内核对象时,Windows 将在句柄表中寻找一个空的位置。如果在应用程序工作流程中构造了新的内核对象,则该变量引用的句柄表记录肯定会包含某个新的内核对象。因此,该调用操作的内核对象可能是错误的。然后,应用程序状态被破坏,无法恢复。

假如忘记调用 CloseHandle,是否会发生对象泄漏 (leak)?

  • 是。在进程运行时,进程可能会泄漏资源 (如内核对象)。
  • 否。当进程终止时,操作系统会确保释放该进程使用的所有资源,这是有保证的。对于内核对象,系统执行以下操作:当进程终止时,系统将自动扫描进程的句柄表;如果该表中包含有效的记录 (终止前未关闭的对象),系统会关闭这些对象句柄;若这些对象中有的对象使用计数变为零,则内核销毁该对象。

因此,应用程序在运行时可能会泄漏内核对象,但是当进程终止时,系统保证所有内容可以被正确地清除。这适用于所有对象、资源 (比如GDI对象) 和内存块。当进程终止时,系统确保进程不留下任何东西。
检测内核对象是否在应用程序运行时泄漏的简单方法是,使用 Windows 任务管理器 (Windows Task Manager)。

在Win10中,首先进入【详细信息】页面,在列表头那里右键弹出菜单栏,单击【选择列】,在弹出的对话框中选中【句柄】,然后,【详细信息】中就会显示句柄这一列。
“选择列”
“选择列”

然后,可以监视任何应用程序使用的内核对象的数量。
句柄

若【句柄】列中的数字不断增长,下一步确定哪些内核对象未关闭,利用 Sysinternals 提供的免费 Process Explorer 工具。
在 View 菜单上单击 Select Conlumns,在弹出的对话框中点击 Handle 窗格的标题。然后勾选所有选项。
Handle

完成上述操作后,在【View】菜单上将【Update Speed】改为【Paused】。在上方窗格中选择进程,然后按 F5 键获取内核对象的最新列表。执行需要对应用程序进行验证的工作流程,完成后,在 Process Explorer 中再次按 F5 键。每个新的内核对象都以绿色显示。
Process Explorer

注意,第一列提供了未关闭的内核对象的类型,第二列提供了内核对象的名字。使用表示内核对象名字的字符串可以在进程之间共享该对象。显然,根据类型和名字可以更容易地找出哪些对象没有关闭。如果泄漏大量对象,它们很有可能未命名,因为只能创建一个命名对象的实例,而其他尝试只是打开它。


【Windows via C/C++】目录

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值