Kenny Kerr
软件顾问
适用范围:
Microsoft® Windows® 安全授权
C++ 语言
摘要: 想了解扩展 Windows 操作系统丰富的安全功能以将其应用于自己的应用程序的方法吗?获取有关 Windows 安全授权以及创建自己的安全描述符的基础知识。
下载相关的 SecuringPrivateObjects.exe(英文)示例代码。
| 引言 | |
| 令牌及其概念 | |
| 安全描述符基础 | |
| 内存管理 | |
| 私有安全描述符 | |
| 权限 | |
| 访问控制列表 | |
| 访问控制编辑器 | |
| 结论 |
您是否考虑过扩展 Windows® 操作系统丰富的安全功能以将其应用于自己的应用程序的方法?您是否用过 Windows 文件系统安全编辑器,并希望能够为自己的业务对象提供这一级别的安全性?本文将首先介绍 Windows 安全授权的基础。然后介绍操纵安全描述符的步骤、创建自己的安全描述符的方法,以及如何使用不同的方法来编辑安全描述符。读过本文后,您将掌握足够的信息,使您能够将这些技术应用到自己的应用程序中。
撰写本文的目的之一,是希望有助于使安全编程切实可行,且便于访问。所以这里不会对特定的函数进行全面、深入的剖析,我将介绍若干 helper 函数和类,使用它们可以使您的安全代码更可靠、更便于管理。helper 函数和示例不仅说明了使用各种安全函数的方法,而且着重说明了在出现异常和错误的情况下如何安全、可靠地使用它们。
本文的内容全部是有关管理访问控制的,管理访问控制也称为授权。在讨论此问题之前,我们需要具备一种方法,用来识别尝试对采取了安全措施的资源进行访问的用户。这就是令牌的作用。令牌代表计算机中的登录会话。只要用户以交互方式或远程方式访问计算机,就会创建登录会话。令牌是一个处理程序,可用来对登录会话进行查询和操纵。如果具有令牌,您就可以得到登录会话所代表的用户,以及确定是否应授予该用户访问已采取安全措施的资源的权限。
因为所有应用程序都运行于登录会话的上下文中,所以总是可以使用某些类型的令牌来指示用户。在任何给定的时刻,可能有一个或多个不同的令牌或安全上下文,这会有些使人产生混淆。每一个登录会话都代表着不同的用户。至少有一个令牌附加到该进程。可以使用 OpenProcessToken 函数获取此令牌。
CHandle token;
Helpers::CheckError(::OpenProcessToken(::GetCurrentProcess(),
TOKEN_QUERY,
&token.m_h));
CHandle 是一个由活动模版库 (ATL) 提供的包装类,当令牌超出范围时,它用来确保能够通过调用 CloseHandle 函数关闭处理程序。CheckError 是我的 Helpers 命名空间中的一个 helper 函数。CheckError 抛出一个 HRESULT,用于说明所发生的错误。使用不同的方法可以在 Windows 的 C 语言编程中表示出错的情况,我倾向使 HRESULT 标准化,以保证一致性。如果下载本文,则可以使用 Helpers 命名空间。GetCurrentProcess 函数的返回值是一个伪处理程序,它代表当前的进程。因为不是真正的处理程序,所以不需要调用 CloseHandle 函数来释放返回的 HANDLE。
可以使用的另外一个令牌是线程令牌。可以通过调用 OpenThreadToken 函数来检索线程令牌。
CHandle token;
Helpers::CheckError(::OpenThreadToken(::GetCurrentThread(),
TOKEN_QUERY,
TRUE, // 打开自身
&token.m_h));
与 GetCurrentProcess 相同,GetCurrentThread 也返回一个伪处理程序,所以也不需要针对该程序调用 CloseHandle。与 OpenProcessToken 不同的是,如果没有任何令牌与当前线程相关联,那么可能无法成功调用 OpenThreadToken,这时函数返回 ERROR_NO_TOKEN。
在某些情况下,甚至存在第三令牌,该令牌代表其他安全上下文。例如,ASP.NET 允许关闭客户模拟,在这种情况下,可以通过 HttpContext 类获取客户标识。
获取令牌后,如果能够利用它执行一些有趣的操作,会有助于我们对它的理解。使用令牌能够执行的最简单的操作,就是对它进行查询,以获取登录会话的有关信息。可以使用 GetTokenInformation 函数执行此操作。因为 GetTokenInformation 函数可用来查询不同类的信息,调用方法相当复杂,所以我编写了一个包装函数模板,以减少调用时可能出现的错误。下面示例说明了查询令牌以获取令牌用户信息的方法。
CLocalMemoryT<PTOKEN_USER> tokenUser(Helpers::GetTokenInformation<TOKEN_USER>(token,
TokenUser));
CComBSTR string = Helpers::ConvertSidToStringSid(tokenUser->User.Sid);
ConvertSidToStringSid helper 函数用于将二进制安全标识符 (SID) 转换为用户易识别的字符串。使用 SID 表示用户帐户是计算机易识别的格式。如果您的兴趣只在包装函数的功能,可以下载并查看本文所附的源代码。有关 CLocalMemoryT 类模板的内容将在介绍了内存管理之后讨论。
了解了如何识别用户后,我们需要一种方法,用来管理不同用户所具有的不同的权限。这就是使用安全描述符的必要性。安全描述符包含许多不同类型的信息。其中最有趣的是所有者安全识别符 (SID) 和两个访问控制列表 (ACL)。所有者 SID 可标识拥有对象的用户。两个 ACL 分别是随机 ACL 和系统 ACL。因为系统 ACL 实际上与访问控制无关,所以本文集中讨论随机 ACL (DACL)。
安全描述符以 SECURITY_DESCRIPTOR 结构表示,因为没有关于此结构的说明,所以应避免直接对其进行操纵。Microsoft 提供了若干使用简便的函数,用于查询和操纵内置对象(比如,文件和注册表项)的安全描述符。下面示例说明了获取本地计算机中 Program Files 目录的所有者 SID 和 DACL 的方法。
CLocalMemory securityDescriptor;
PSID sid = 0;
PACL dacl = 0;
Helpers::CheckError(::GetNamedSecurityInfo(_T("C:\\Program Files"),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
&sid,
0, // 组
&dacl,
0, // sacl
&securityDescriptor.m_ptr));
GetNamedSecurityInfo 是一个具有多种用途的函数。它允许查询绝大多数(如果不要求全部查询的话)内置安全对象。第一个参数是要查询的对象的名称(或路径)。第二个参数指示对象的类型。本例查询的是文件系统对象。例如,若要查询注册表对象,可以将其更改为 SE_REGISTRY_KEY。下一步是使用枚举类型的参数 SECURITY_INFORMATION 指定感兴趣的信息。再后面的四个参数是分别指向安全描述符的四个主要部分的指针。这里的方便之处是,对于不感兴趣的部分,可以向相应的参数传递 0。最后一个参数是指向安全描述符本身的指针,它实际上是安全描述符的一个副本,必须使用 LocalFree 函数释放。
还可以使用名为 SetNamedSecurityInfo 的函数来更新内置对象的安全描述符。因为此函数的工作方式与上面所述相同,所以这里不对其进行深入的剖析。
在继续之前,我认为有必要讨论一下内存管理方面的内容。内存管理以及对资源的整体管理,是编写安全、可靠的代码的一个重要方面。编写内存管理代码的最佳方式就是根本不编写代码。首先需要了解将要调用的各种函数所使用的内存管理技术,然后应将这些函数包装到一个或两个类中,以确保能够在合适的时机正确清除。如果不进行这个工作,那么编写的代码将泄露资源,甚至出现更严重的情况,使程序出现漏洞,为存心不良的人访问采取了安全措施的资源提供可乘之机。
上面介绍了 CLocalMemoryT 类模板,但没有真正说明它的用途。绝大多数与安全描述符有关的安全函数都使用了本地内存。本地内存的使用可溯源至 16 位的 Windows 操作系统,在这类操作系统中,内存管理相当复杂。目前可供使用的本地内存函数(比如,LocalAlloc 和 LocalFree)主要是用来向后兼容 16 位的应用程序,以及兼容该类应用程序将其作为部分语义的以前的 API 函数。
为了使得处理本地内存更加容易,我编写了一个简单的类,该类用于包装本地内存指针。在重载成员选择运算符 (operator ->)时,可以将 CLocalMemoryT 看作一个具有智能的指针类。这样做是可能的,因为它是一个类模板,并且您可以通过模板参数指示所指向的内存的类型或结构。可以使用 CLocalMemoryT 创建新的本地内存块,但一般使用它附加到函数所返回的现有的内存块。然后析构函数通过调用 LocalFree 释放内存。安全函数所使用的某些数据结构是不透明的。为了能够更方便地使用这些结构,我在 CLocalMemory 头文件的末尾定义了以下类型定义。
typedef CLocalMemoryT<PVOID> CLocalMemory;
使用这些类型能够有效地管理 SECURITY_DESCRIPTOR 对象占用的内存单元,例如:
GetNamedSecurityInfo 和 SetNamedSecurityInfo 具有很强的处理内置对象的功能。但是对于私有对象(比如,在自己的应用程序业务逻辑中所使用的对象),它们具有什么功能呢?可以对这些函数的功能进行扩展,使其支持私有对象吗?很不幸,答案是否定的。因为每个资源(比如,文件系统或注册表)都定义了自己的保留安全描述符的方法,这些函数不可能了解查询或设置您所创建的私有对象的安全信息。万幸的是,我们有解决的方法。
首先我们需要一种创建私有安全描述符的方法。使用 CreatePrivateObjectSecurityEx 函数可以从头开始创建自己的安全描述符。这个函数的使用方法相当灵活,它的主要用途有两个:创建新的安全描述符以及更新现有的安全描述符的继承。它的原型如下。
BOOL CreatePrivateObjectSecurityEx(PSECURITY_DESCRIPTOR parentDescriptor,
PSECURITY_DESCRIPTOR defaultDescriptor,
PSECURITY_DESCRIPTOR* newDescriptor,
GUID* type,
BOOL isContainer,
ULONG autoInheritFlags,
HANDLE token,
PGENERIC_MAPPING genericMapping);
parentDescriptor 用于指示将继承其 ACL 的父对象。如果对象没有父对象,可以只向此参数传递 0。defaultDescriptor 的主要用途是,当 parentDescriptor 改变后,使用可继承的访问控制项 (ACE) 更新现有的安全描述符。实现这一目的的方法是,创建一个将由 newDescriptor 参数返回的新的安全描述符,然后释放原安全描述符。要传递对象 ACL 的显式项,请将现有的安全描述符作为 defaultDescriptor 参数传递。
在处理 Active Directory 对象的安全时,将使用 type。要指示安全描述符所表示的对象是否是其他安全对象的容器,可使用 isContainer。要影响各个可继承的 ACE 指向新建的安全描述符的方式,可使用 autoInheritFlags。可以只传递 SEF_DACL_AUTO_INHERIT 以继承任何可继承的 ACE。但在根据父安全描述符得到可继承 ACE 时,还应包含 SEF_AVOID_PRIVILEGE_CHECK 和 SEF_AVOID_OWNER_CHECK 标志,以避免出现针对用户的访问检查,因为在只更新继承时,没有必要这样做。有关管理 ACL 继承的详细信息,请参阅 Keith Brown 的著作 Programming Windows Security(英文)。
要识别为其创建对象的用户,可以使用令牌获取新创建的安全描述符的默认值(比如,所有者的 SID)。以显式传递用户令牌会为服务器应用程序的运行带来方便,因为不要求模拟。
最后,genericMapping 用来提供有关显式传递的信息,针对特定对象的权限可映射为四个通用权限,即读、写、执行以及全部。有关权限的内容将在下一部分中讨论。
有关安全描述符的工作完成后,必须通过调用 DestroyPrivateObjectSecurity 将其释放。
现在我们可以创建自己的安全描述符,我们需要找到一种能够对其进行查询和修改的方式。虽然 GetNamedSecurityInfo 和 SetNamedSecurityInfo 无法用来操作私有对象,但还是有能够达到此目的的函数。要修改私有安全描述符,需要使用 SetPrivateObjectSecurityEx 函数。该函数的原型如下。
BOOL SetPrivateObjectSecurityEx(SECURITY_INFORMATION securityInformation,
PSECURITY_DESCRIPTOR modificationDescriptor,
PSECURITY_DESCRIPTOR* securityDescriptor,
ULONG autoInheritFlags,
PGENERIC_MAPPING genericMapping,
HANDLE token);
有关该函数的文档有一处错误,即将 securityDescriptor 参数设为 [out],实际应将此参数设置为 [in, out],因为在输入时,此参数必须指向一个有效的安全描述符。如果必要,SetPrivateObjectSecurityEx 将调用 LocalReAlloc,以分配足够的内存单元,并向其中写入新信息。这就是要求一个指向安全描述符指针的指针的原因,调用 SetPrivateObjectSecurityEx 之后,securityDescriptor 指向的内存位置可能会改变。
正如您所见,SetPrivateObjectSecurityEx 不提供用于设置安全描述符各部分的单个参数,这一点与 SetNamedSecurityInfo 相同。SetPrivateObjectSecurityEx 要求提供现有的安全描述符,以供从中复制值。所幸在堆栈中创建安全描述对象以及使用可能会将其复制到私有安全描述符中的信息准备此安全描述符的操作相当容易。下面是一个示例:
CWellKnownSid adminSid = CWellKnownSid::Administrators();
SECURITY_DESCRIPTOR templateDescriptor = { 0 };
Helpers::CheckError(::InitializeSecurityDescriptor(&templateDescriptor,
SECURITY_DESCRIPTOR_REVISION));
Helpers::CheckError(::SetSecurityDescriptorOwner(&templateDescriptor,
&adminSid,
false));
Helpers::CheckError(::SetPrivateObjectSecurityEx(OWNER_SECURITY_INFORMATION,
&templateDescriptor,
&securityDescriptor,
SEF_AVOID_PRIVILEGE_CHECK,
&genericMapping,
0));
其中 templateDescriptor 是一个基于堆栈的安全描述符。请注意,在进行之前,确保清空内存单元。InitializeSecurityDescriptor 用来设置修订级别,否则将保持安全描述符为空。SetSecurityDescriptorOwner 将所有者 SID 设置为众所周知的本地管理员组。这是在 CWellKnownSid 类的帮助下实现的,可以在下载本文时得到此类。最后调用了 SetPrivateObjectSecurityEx,以将所有者信息从我们的模板描述符复制到 securityDescriptor 所包含的私有安全描述符中。
您可能感到奇怪,为什么不能直接使用这些函数来设置私有安全描述符的各部分。对于安全描述符来说,有两种不同的内存格式。绝对安全描述符包含指向它所包含的安全信息的指针。其内存单独分配,与安全描述符结构所占用的内存分配相分开。而相对安全描述符则将它的所有信息存储在连续的内存块中。它并不存储指针,而存储在内存块中的偏移量。
在了解了安全描述符在内存中存在的不同方式后,事情就很明白了。私有安全描述符通常是相对安全描述符。这就是处理这类安全描述符的函数更加复杂的原因。在堆栈中创建安全描述符比较容易,因为这类安全描述符是绝对安全描述符,诸如 SetSecurityDescriptorOwner 之类的函数只需要在基于堆栈的安全描述符中设置指针值即可。
幸运的是,查询私有安全描述符十分简单。可以使用标准函数(比如,GetSecurityDescriptorOwner 和 GetSecurityDescriptorDacl)获取不同部分的内容。还有一个名为 GetPrivateObjectSecurity 的函数,但它不常用于查询安全描述符。在使用访问控制编辑器时,使用该函数会很方便(这个问题将在后面讨论)。
权限也称为访问权限,已在前面提及过。有关它的话题与内存管理同样精彩。然而重要的是了解如何为您的私有对象设计权限。每次调用有可能访问安全资源的函数时,都会用到权限。例如,我们熟知的 CreateFile 函数具有一个采用访问位掩码的参数,该参数的每一位都代表一个特定的权限。
与文件系统定义特定的权限相同(比如,FILE_APPEND_DATA 和 FILE_TRAVERSE),您也必须为自己的对象定义特定的权限。对于 32 位的访问掩码,16 位只用于特定的权限。为对象定义好特定的权限后,需要将其分别归入四个通用权限类别中。通用权限是 GENERIC_READ、GENERIC_WRITE、GENERIC_EXECUTE 和 GENERIC_ALL。这样,编程者就可以简单声明需要读取访问以及应用逻辑上映射到读取访问类别的权限。但是因为任何一个安全函数都无法知道特定权限所映射到的通用权限类别,所以需要填入一个 GENERIC_MAPPING 结构,该结构将被传递给许多安全函数。
在对权限有了基本的了解之后,我们就可以为特定的 widget 资源定义以下权限。
namespace Permissions
{
const DWORD AddWidget = 0x0001;
const DWORD ListWidgets = 0x0002;
const DWORD RenameWidget = 0x0004;
const DWORD ReadWidgetData = 0x0008;
const DWORD WriteWidgetData = 0x0010;
const DWORD GenericRead = STANDARD_RIGHTS_READ |
ListWidgets |
ReadWidgetData;
const DWORD GenericWrite = STANDARD_RIGHTS_WRITE |
AddWidget |
RenameWidget |
WriteWidgetData;
const DWORD GenericExecute = STANDARD_RIGHTS_EXECUTE;
const DWORD GenericAll = STANDARD_RIGHTS_REQUIRED |
GenericRead |
GenericWrite |
GenericExecute;
}
这样,您就应该能够填充全局的 GENERIC_MAPPING 结构,并向它传递指针,也向所有需要此结构的函数传递指针。现在程序员就可以仅仅使用通用权限(比如,GENERIC_WRITE),而该通用权限会映射到我们的 widget 的Permissions::GenericRead。细心的程序员可能不会假定用户具有所有权限。在这种情况下,他可能会使用特定权限中的一种(比如,Permissions::RenameWidget)。当然,仍有可能使用一种标准权限(比如,DELETE,如果我们在 widget 中为其指定了特定含义的话)。
DACL 是安全描述符的核心。列表中的每个访问控制项 (ACE) 都定义了特定的用户或组对于某项资源的权限。ACE 既可以为正,也可以为负。正的 ACE 指示为用户或组授予了特定的权限,负的 ACE 指示该权限将被拒绝。如果 ACL 中未出现某用户(无论是单独出现还是作为组的成员出现),那么该用户的任何访问操作都将被拒绝。
ACL 存储在连续的内存块中,它由一个 ACL 结构和一个经过排序的 ACE 列表组成。下面的示例说明了创建一个简单的 ACL 的方法。
CLocalMemoryT<PACL> acl(100);
Helpers::CheckError(::InitializeAcl(acl.m_ptr,
100,
ACL_REVISION));
DWORD inheritFlags = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE;
CWellKnownSid everyoneSid = CWellKnownSid::Everyone();
Helpers::CheckError(::AddAccessAllowedAceEx(acl.m_ptr,
ACL_REVISION,
inheritFlags,
GENERIC_READ,
&everyoneSid));
CLocalMemoryT<PSID> userSid(Helpers::GetUserSid(token));
Helpers::CheckError(::AddAccessAllowedAceEx(acl.m_ptr,
ACL_REVISION,
inheritFlags,
GENERIC_ALL,
userSid.m_ptr));
因为 ACL 存储在连续的内存单元中,所以需要分配一块足够大的内存空间,以存放 ACL 标头和它的所有项。所分配的内存空间的大小并不重要,足够容纳所有的项即可。在创建 ACL 之后,一般要调用资源管理器,这样会创建该 ACL 的一份副本。我使用 AddAccessAllowedAceEx 函数为每位用户授予读取权限,为带有令牌的用户授予全部权限。
直接创建和编辑更实际和更复杂的 ACL 可能会很困难,且很容易出错。主要原因与列表中的 ACE 的顺序有关,在执行访问检查时,将在列表中从上向下进行。如果向列表末尾添加负的 ACE,且位于列表顶端的 ACE 允许用户进行访问,那么即使已经拒绝了此用户访问,访问检查仍然能够成功进行。访问检查采取这种取捷径的技术是为了提高效率。如果能够对 ACE 正确排序,程序就能够正常运行。更麻烦的是,Windows 2000 引入了新模型,用于实现 ACL 继承,该模型的功能要强大许多,但在实现时,要特别小心。我所能给出的最好的建议是决不要直接对 ACL 进行操纵。下一部分将告诉您其中的方法。
正如事实所说明的,程序员可以在他们自己的应用程序中使用 Windows shell 所使用的访问控制编辑器(请参见图 1)。访问控制编辑器是功能极其强大且使用灵活的编辑器,用来操纵安全描述符所有的不同部分。编辑器通过两个看上去简单的函数提供。CreateSecurityPage 用来创建我们熟悉的安全性属性页,可以向其中添加自己的属性表。EditSecurity 是一个 helper 函数,用于向属性表中添加并显示安全页。看起来很简单,但其中内容很多。这两个函数都要求具备一个相当奇怪的 COM 接口,称为 ISecurityInformation。之所以奇怪是因为它不遵循标准的 COM 内存管理规则。此外,它选择了继续使用许多安全函数所使用的内存管理函数和技术,依赖全局内存和 LocalAlloc。这使得在 C# 或其他托管语言中的实现极其困难。只需将 ISecurityInformation 看作辉煌一时的 C 语言的回调机制即可。

图 1:访问控制编辑器中的安全属性页
GetObjectInformation 方法允许您指示要使用的自定义访问控制编辑器的方法。例如,可以使用它来显示或隐藏高级按钮,高级按钮提供了比标准属性页更高级的安全描述符编辑器(请参见图 2)。其他可以控制的选项包括是否允许用户查看和更改安全描述符的所有者 SID 和系统 ACL。

图 2:高级安全设置对话框
当编辑器需要使用有关安全描述符的信息填充各种控件时,将在不同的时机调用 GetSecurity 方法。现在再使用我在本文前面的部分中提到的 GetPrivateObjectSecurity 函数就变得非常容易。GetSecurity 方法应返回正在编辑的安全描述符相应部分的副本。这正是 GetPrivateObjectSecurity 执行的操作。
用户在编辑器中进行了需要保存的更改后,将调用 SetSecurity 方法。有了 SetPrivateObjectSecurityEx 函数的帮助,实现 SetSecurity 将相当迅速(前面曾经讨论过 SetPrivateObjectSecurityEx 函数)。
调用 GetAccessRights 方法的目的是获取正在对其进行编辑的对象所属类型的特定权限和通用权限的列表。这一实现涉及到创建 SI_ACCESS 结构的数组。每个 SI_ACCESS 结构都标识一个特定的或通用的权限,还有其位掩码位、友好名称和其他标志。因为此信息描述安全描述符的所有实例,所以一般将此数组声明为静态类型。
在其余的方法中,真正有趣的是 GetInheritTypes。调用它的目的是允许编辑器确定如何设定 ACE 的属性值以进行继承。应创建 SI_INHERIT_TYPE 结构的静态数组,以描述要提供的不同继承标志的组合。如果对象支持继承,那么它一般会提供以下选项。
| 适用于: | 标志 |
对象和子对象 | CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE |
仅子对象 | CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE | INHERIT_ONLY_ACE |
仅对象 | 0 |
在刚开始,实现 ISecurityInformation 令人望而却步,但是通过练习并学习精彩的示例后,您会很快熟练起来。本文的下载文件包含若干有用的 helper 函数,以及我编写的 CSecurityDescriptor 类,该类能够使创建、编辑和管理 ACL 继承变得轻松。下载文件还包括实现 IsecurityInformation 的示例,其中的 C++ 项目提供了使用这些类的简单的示例。
将 Windows 安全模型扩展为私有对象需要从全局的角度对安全描述符有深刻的理解,尤其要对与管理私有安全描述符有关的函数有深刻的理解。能够使用 Windows 访问控制编辑器就能够编写出功能丰富的、安全的应用程序。到此您完全可以自己去体验有关安全描述符的内容,并考虑在自己的应用程序中将本文所介绍的技术用于以对象为中心的访问控制。
Kenny Kerr 将他的绝大部分时间都花在了设计和构建 Microsoft Windows 平台的分布式应用程序上,在安全编程方面,他也具有特殊的热情。Kenny 的电子邮件地址为 kennykerr@hotmail.com,也可以访问他的网站:http://www.kennyandkarin.com/Kenny/(英文)。
© 2004 Microsoft Corporation 版权所有。保留所有权利。使用规定。