CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • Windows 10系统编程 引言
  • 第1章:基础
  • 第2章:对象和句柄
  • 第3章:进程
  • 第4章:作业(Jobs)
  • 第5章:线程基础
  • 第6章:线程调度
  • 第7章:进程内线程同步
  • 第8章:进程间线程同步
  • 第9章:线程池
  • 第10章:高级线程
  • 第11章:文件和设备输入输出
  • 第12章:内存管理基础
  • 第13章:内存操作
  • 第14章:内存映射文件
  • 第15章:动态链接库
  • 第16章:安全性
    • 简介
      • WinLogon
      • LogonUI
      • LSASS
      • LsaIso
      • 安全引用监视器(Security Reference Monitor,SRM)
      • 事件日志记录器(Event Logger)
    • 安全标识符(SIDs)
    • 令牌(Tokens)
      • 二次登录服务
      • 模拟
      • 客户端/服务器中的模拟
    • 特权
      • 超级特权
    • 访问掩码
    • 安全描述符(Security Descriptors)
      • 默认安全描述符
      • 构建安全描述符
    • 用户访问控制
      • 提权(Elevation)
      • 要求以管理员身份运行(Running As Admin Required)
      • UAC虚拟化(UAC Virtualization)
    • 完整性级别(Integrity Levels)
      • 用户界面特权隔离(UIPI)
    • 专用安全机制
      • 控制流防护(Control Flow Guard)
      • 进程缓解措施
    • 总结
  • 第17章:注册表
目录

第16章:安全性

# 第16章:安全性

Windows NT从最初设计时就考虑到了安全性。这与不支持安全性的Windows 95/98系列操作系统形成了鲜明对比。Windows遵循美国国防部定义的C2级安全标准。C2级是通用操作系统能够达到的最高安全级别。

C2级安全标准的一些要求如下:

  • 登录系统必须需要某种形式的身份验证。
  • 一个已终止进程的内存绝不能泄露给其他进程。
  • 必须有一个能够对文件系统对象设置权限的文件系统。

在本章中,我们将探讨Windows安全性的基础,并研究许多用于操作安全性的应用程序编程接口(API)。

本章内容

  • 简介
  • 安全标识符(SIDs)
  • 令牌(Tokens)
  • 访问掩码(Access Masks)
  • 特权(Privileges)
  • 安全描述符(Security Descriptors)
  • 用户账户控制(User Account Control)
  • 完整性级别(Integrity Levels)
  • 专用安全机制(Specialized Security Mechanisms)

# 简介

Windows中有几个与安全性相关的组件。图16-1展示了其中的大部分组件。

img

图16-1:与安全相关的组件

以下是对图16-1中主要组件的快速概述:

# WinLogon

登录进程(Winlogon.exe)负责交互式登录。它还负责响应安全注意序列(SAS,默认是Ctrl+Alt+Del组合键),切换到Winlogon桌面,在该桌面上显示我们熟悉的选项(锁定、切换用户、注销等)。

Winlogon通过一个辅助进程LogonUI.exe(见下一节)获取用户的凭据,并将其发送到Lsass.exe进行身份验证。如果身份验证成功,Lsass会创建一个登录会话和一个访问令牌(本章后面会讨论),该访问令牌代表用户的安全上下文。然后它会创建启动进程(默认是userinit.exe,从注册表中读取),而userinit.exe又会创建Explorer.exe(同样,这只是注册表中的默认设置)。访问令牌会为每个新创建的进程进行复制。图16-2展示了一个系统初始化进程树的一部分,该图由Sysinternals的进程监视器(ProcMon.exe)工具生成。请注意WinLogon、UserInit和Explorer之间的关联。

img

图16-2:登录进程树

用户初始化(Userinit)还会运行启动脚本,然后终止(这就是为什么Explorer通常没有父进程)。上述设置所在的注册表项是HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon。

# LogonUI

Windows支持多种登录方式:用户名/密码、使用面部识别和指纹识别的 “Windows Hello” 等。LogonUI.exe进程由Winlogon启动,用于显示所选的身份验证方法的用户界面。在过去(Windows Vista之前),这一功能由Winlogon负责(当时LogonUI不存在)。问题在于,如果用户界面组件崩溃,Winlogon也会随之崩溃。从Vista开始,如果用户界面崩溃,LogonUI会被终止,Winlogon允许用户选择其他身份验证方法。

LogonUI显示由凭据提供程序(Credential Provider)提供的用户界面,凭据提供程序是一个COM动态链接库(DLL),为其相关的身份验证机制实现特定的用户界面。任何需要自定义身份验证方法的人都可以开发凭据提供程序。

# LSASS

本地安全认证服务(Local Security Authentication Service,Lsass.exe)是身份验证管理的基石。它最基本的作用是对用户进行身份验证。在常见的用户名/密码组合登录情况下,Lsass会检查本地注册表(本地登录时),或者与域控制器通信(域登录时)来验证用户身份。然后它将结果返回给Winlogon。如果身份验证成功,它会为登录用户创建并填充访问令牌。

# LsaIso

LsaIso.exe进程存在于启用了基于虚拟化的安全性(Virtualization Based Security,VBS)且开启了凭据防护(Credential Guard)功能的Windows 10(或更高版本)系统中。LsaIso被称为信任小程序(trustlet),是一个运行在虚拟信任级别(Virtual Trust Level,VTL)1的用户模式进程,安全内核和隔离用户模式(isolated user mode,IUM)也位于该级别。作为一个IUM进程,LsaIso的访问受到Hyper-V虚拟机管理程序的保护,因此即使是内核也无法访问它。LsaIso的目的是为Lsass保管机密信息,这样可以减轻某些类型的攻击,比如 “传递哈希” 攻击,因为任何进程(即使是管理员级别的进程)甚至内核都无法窥视LsaIso的地址空间。

上一段中有许多新术语在本书中未作解释,因为它们与系统编程关联不大。有关VBS、Hyper-V、VTL及相关概念的更多信息,请查阅《Windows Internals 7th edition Part 1》这本书和 / 或在线资源。

# 安全引用监视器(Security Reference Monitor,SRM)

安全引用监视器是执行体的一部分,负责在某些操作发生时进行访问检查。例如,尝试打开现有内核对象的句柄时,就需要由安全引用监视器执行访问检查。

# 事件日志记录器(Event Logger)

事件日志记录器服务是Windows提供的标准服务之一。它并非专门用于安全性,但安全事件会通过该服务进行记录。查看日志信息的常用方法是使用内置的事件查看器应用程序,如图16-3所示(选中了 “安全性” 节点)。

img

图16-3:事件查看器应用程序

# 安全标识符(SIDs)

主体(principal)这个术语描述了在安全上下文中可以被引用的实体。例如,可以对主体授予或拒绝权限。主体可以代表用户、组、计算机等。主体由安全标识符(Security ID,SID)唯一标识,安全标识符是一种大小可变的结构,包含几个部分,如图16-4所示。

img

图16-4:安全标识符(SID)

当以字符串形式显示时,一个安全标识符看起来如下:S-R-A-SA-SA-…-SA。

“S” 是字面意义上的S,修订版本(R)始终为1(修订版本从未更改过),“A” 是一个6字节的颁发机构标识符,用于生成该安全标识符,“SA” 是一个由4字节的子颁发机构(也称为相对标识符 - RID)组成的数组,这些子颁发机构对于生成它们的颁发机构来说是唯一的。虽然安全标识符的大小取决于子颁发机构的数量,但目前子颁发机构的最大数量为15,这就限制了安全标识符的大小。在需要分配一个缓冲区来存储安全标识符的情况下,这很有用,因为你可以静态分配缓冲区,而不是动态分配。以下是来自winnt.h的定义:

#define SID_MAX_SUB_AUTHORITIES                    (15)
#define SECURITY_MAX_SID_SIZE    \
(sizeof(SID)-sizeof(DWORD)+(SID_MAX_SUB_AUTHORITIES*sizeof(DWORD)))  // 68字节
1
2
3

安全标识符通常以字符串形式显示(通常将它们以字符串形式持久化也更容易)。二进制形式和字符串形式之间的转换可以使用以下函数完成(在<sddl.h>中声明):

BOOL ConvertSidToStringSid(
    _In_ PSID Sid,
    _Outptr_ LPTSTR* StringSid);

BOOL ConvertStringSidToSid(
    _In_ LPCTSTR StringSid,
    _Outptr_ PSID* Sid);
1
2
3
4
5
6
7

这两个函数返回的结果稍后必须由调用者使用LocalFree释放。

组和别名:由安全标识符表示的组是主体的集合。属于该组的每个主体在其安全上下文中都会有该组的安全标识符(见本章后面的 “令牌” 部分)。这有助于在事先不指定单个主体的情况下对组织结构进行建模。 别名(也称为本地组)是另一种容器主体,它可以包含一组组和其他主体(但不能包含其他别名)。例如,本地管理员组实际上就是一个别名,它可以包含单个安全标识符和组安全标识符,这些都属于该别名的一部分。别名始终是本地计算机特有的,不能与其他计算机共享。 在实践中,组和别名之间的区别并不是很重要,但仍然值得牢记。

在表示不同主体时,安全标识符保证在统计意义上是唯一的。有些安全标识符被称为 “知名(Well-known)” 安全标识符,它们在每台计算机上都代表相同的主体。例如,“S-1-1-0”(Everyone组)和S-1-5-32-544(本地管理员别名)。winnt.h定义了一个枚举类型WELL_KNOWN_SID_TYPE,其中包含知名安全标识符的列表。这些安全标识符的存在使得在任何计算机上都能轻松引用相同的主体组。(想象一下,如果每台计算机都有自己的本地管理员安全标识符会发生什么情况。)

创建一个知名安全标识符可以通过CreateWellKnwonSid函数完成:

BOOL CreateWellKnownSid(
    _In_ WELL_KNOWN_SID_TYPE WellKnownSidType,
    _In_opt_ PSID DomainSid,
    _Out_ PSID pSid,
    _Inout_ DWORD* cbSid);
1
2
3
4
5

对于某些类型的知名安全标识符,需要DomainSid参数。对于大多数情况,NULL是可接受的值。返回的安全标识符会被放置在调用者分配的缓冲区中。cbSid应该包含调用者提供的缓冲区大小,返回时会存储安全标识符的实际大小。

我们可以结合CreateWellKnownSid和ConvertSidToStringSid来列出所有不需要域安全标识符参数的知名安全标识符:

BYTE buffer[SECURITY_MAX_SID_SIZE];
PWSTR name;
for (int i = 0; i < 120; i++) {
    DWORD size = sizeof(buffer);
    if  (!::CreateWellKnownSid((WELL_KNOWN_SID_TYPE)i, nullptr , (PSID)buffer, &size))
        continue ;
    ::ConvertSidToStringSid((PSID)buffer, &name);
    printf("Well known sid %3d: %ws\n", i, name);
    ::LocalFree(name);
}
1
2
3
4
5
6
7
8
9
10

安全标识符缓冲区以最大安全标识符大小进行静态分配。以下是简要的输出:

Well known sid  0: S-1-0-0
Well known sid  1: S-1-1-0
Well known sid  2: S-1-2-0
Well... known sid  3: S-1-3-0
Well known sid 20: S-1-5-14
Well known sid 22: S-1-5-18
Well... known sid 23: S-1-5-19
Well known sid 36: S-1-5-32-555
Well known sid 37: S-1-5-32-556
Well known sid 51: S-1-5-64-10
Well known sid 52: S-1-5-64-21
Well... known sid 53: S-1-5-64-14
Well known sid 118: S-1-18-3
Well known sid 119: S-1-5-32-583
1
2
3
4
5
6
7
8
9
10
11
12
13
14

知名安全标识符WinLogonIdsSid(21)无法创建。

可以使用IsWellKnownSid函数来检查安全标识符(Security Identifier,SID)是否与特定的知名安全标识符相匹配:

BOOL IsWellKnownSid(
    _In_ PSID pSid,
    _In_ WELL_KNOWN_SID_TYPE WellKnownSidType
);
1
2
3
4

我们还可以通过LookupAccountSid函数获取知名安全标识符的名称:

BOOL LookupAccountSid(
    _In_opt_ LPCTSTR lpSystemName,
    _In_ PSID Sid,
    _Out_ LPWSTR Name,
    _Inout_ LPDWORD cchName,
    _Out_ LPWSTR ReferencedDomainName,
    _Inout_ LPDWORD cchReferencedDomainName,
    _Out_ PSID_NAME_USE peUse
);
1
2
3
4
5
6
7
8
9

lpSystemName是用于安全标识符查找的计算机名称,NULL表示本地计算机,Sid是要查找的安全标识符。Name是返回的账户名称,cchName指向名称可接受的最大字符数。函数返回时,它会存储复制到缓冲区的实际字符数。ReferencedDomainName和cchReferencedDomainName用于域名(或者更准确地说,账户所在的授权机构),作用与上述类似。最后,peUse根据SID_NAME_USE枚举返回相关安全标识符的类型:

typedef enum _SID_NAME_USE {
    SidTypeUser = 1,
    SidTypeGroup,
    SidTypeDomain,
    SidTypeAlias,
    SidTypeWellKnownGroup,
    SidTypeDeletedAccount,
    SidTypeInvalid,
    SidTypeUnknown,
    SidTypeComputer,
    SidTypeLabel,
    SidTypeLogonSession
} SID_NAME_USE, *PSID_NAME_USE;
1
2
3
4
5
6
7
8
9
10
11
12
13

在知名安全标识符迭代中添加对LookupAccountSid函数的调用,代码如下:

WCHAR accountName[64] = { 0 }, domainName[64] = { 0 };
SID_NAME_USE use;
for (int  i = 0; i < 120; i++) {
    DWORD size = sizeof(buffer);
    if (!::CreateWellKnownSid((WELL_KNOWN_SID_TYPE)i, nullptr , (PSID)buffer, &size))
        continue;
    
    ::ConvertSidToStringSid((PSID)buffer, &name);
    DWORD accountNameSize = _countof(accountName);
    DWORD domainNameSize = _countof(domainName);
    ::LookupAccountSid(nullptr , (PSID)buffer, accountName, &accountNameSize,
        domainName, &domainNameSize, &use);

    printf("Well known sid %3d: %-20ws %ws\\%ws (%s)\n", i,
        name, domainName, accountName, SidNameUseToString(use));
    ::LocalFree(name);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

该循环遍历所有已定义的知名安全标识符索引(目前有120个值)。C++中(目前)还没有静态反射机制,所以使用数字是比较可行的办法。

SidNameUseToString函数只是将SID_NAME_USE枚举转换为字符串。运行这段代码会得到如下输出:

Well known sid  0: S-1-0-0                                          \NULL SID (Well Known Group)
Well known sid  1: S-1-1-0                                          \Everyone (Well Known Group)
Well known sid  2: S-1-2-0                                          \LOCAL (Well Known Group)
Well known sid  3: S-1-3-0                                          \CREATOR OWNER (Well Known Group)
Well known sid  4: S-1-3-1                                          \CREATOR GROUP (Well Known Group)
...
Well known sid  22: S-1-5-18                                         NT AUTHORITY\SYSTEM (Well Known Group)
Well known sid  23: S-1-5-19                                         NT AUTHORITY\LOCAL SERVICE (Well Known Group)
Well known sid  24: S-1-5-20                                         NT AUTHORITY\NETWORK SERVICE (Well Known Group)
Well known sid  25: S-1-5-32                                         BUILTIN\BUILTIN (Domain)
Well known sid  26: S-1-5-32-544                                      BUILTIN\Administrators (Alias)
Well known sid  27: S-1-5-32-545                                      BUILTIN\Users (Alias)
Well known sid  28: S-1-5-32-546                                      BUILTIN\Guests (Alias)
...
Well known sid 65: S-1-16-0                                         Mandatory Label\Untrusted Mandatory Level (Label)
Well known sid 66: S-1-16-4096                                       Mandatory Label\Low Mandatory Level (Label)
Well known sid 67: S-1-16-8192                                       Mandatory Label\Medium Mandatory Level (Label)
...
Well known sid 84: S-1-15-2-1                                       \Key property attestation (Well Known Group)  
Well known sid 85: S-1-15-3-1                                       \Fresh public key identity (Well Known Group) 
...
Well known sid 117: S-1-18-6                                        BUILTIN\Device Owners (Alias)
Well known sid 118: S-1-18-3                                        
Well known sid 119: S-1-5-32-583                                     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

输出结果清晰地显示了哪些安全标识符是别名(而非组)。

wellknownsids项目包含完整代码。它还有用于检索域安全标识符的代码,这样就能创建更多知名安全标识符。

LookupAccountSid有一个对应的函数LookupAccountName:

BOOL LookupAccountName(
    _In_opt_ LPCTSTR lpSystemName,
    _In_     LPCTSTR lpAccountName,
    _Out_    PSID Sid,
    _Inout_  LPDWORD cbSid,
    _Out_    LPTSTR ReferencedDomainName,
    _Inout_  LPDWORD cchReferencedDomainName,
    _Out_    PSID_NAME_USE peUse
);
1
2
3
4
5
6
7
8
9

LookupAccountName函数功能强大,它尝试定位一个名称并返回其安全标识符。它首先会检查知名名称。lpAccountName可以是像“joe”这样的简单名称,也可以包含域名(从性能角度考虑,“mydomain\joe”这种格式更好)。它还接受其他格式,比如“joe@mydomain.com”。它在Sid参数中返回安全标识符,在ReferencedDomainName中返回域名,同时也会返回一个SID_NAME_USE,这点和LookupAccountSid函数一样。

安全标识符相关的应用程序编程接口(API)包含一些功能明确、易于理解的函数,比如IsValidSid、CopySid、EqualSid、GetLengthSid、AllocatedAndInitializeSid、InitializeSid、FreeSid、GetSidIdentifierAuthority、GetSidSubAuthorityCount和GetSidSubAuthority 。

# 令牌(Tokens)

当用户成功登录时,无论是否为交互式登录,系统都会在后台创建一个登录会话对象,该对象存储与已登录主体相关的信息。通常情况下,登录会话隐藏在访问令牌(access token,简称令牌)之后,访问令牌是一个维护多种信息且指向登录会话的对象。

令牌是开发人员在一定程度上可以与之交互和操作的对象。每个进程都有一个与之关联的令牌,称为主令牌(primary token)。在执行需要安全上下文的操作时,进程中的所有线程默认使用该主令牌。线程可以通过使用不同的令牌(称为模拟令牌,impersonation token)来进行模拟操作。一旦线程完成模拟,它会恢复使用进程的主令牌。登录会话、令牌和进程之间的关系如图16-6所示。

图16-6:登录会话、令牌和进程

Windows中有三个内置用户——本地服务(Local Service)、网络服务(Network Service)和本地系统(Local System,也称为SYSTEM),它们始终对应三个登录会话。我们将在第19章“服务”中更深入地探讨这些账户,开发人员主要在服务场景中使用它们。

你可能会惊讶于在任何给定时间存在的登录会话数量。在提升权限的命令窗口中运行Sysinternals工具中的pslogonsessions.exe。它使用LsaEnumerateLogonSessions应用程序编程接口,该接口返回一个登录会话ID数组。对于每个ID,它会调用LsaGetLogonSessionData来检索登录会话的详细信息。以下是部分输出内容:

[0] Logon session 00000000:000003e7:
User name:   WORKGROUP\PAVEL7540$
Auth package: NTLM
Logon type:   (none)
Session:     0
Sid:          S-1-5-18
Logon time:  19-May-20 19:29:32
Logon server:
DNS Domain:
UPN:
...
[2] Logon session 00000000:0001964a:
User name:    Font Driver Host\UMFD-0
Auth package: Negotiate
Logon type:   Interactive
Session:     0
Sid:          S-1-5-96-0-0
Logon time:  19-May-20 19:29:32
...
[3] Logon session 00000000:000003e5:
User name:    NT AUTHORITY\LOCAL SERVICE
Auth package: Negotiate
Logon type:   Service
Session:     0
Sid:          S-1-5-19
Logon time:  19-May-20 19:29:32
...
[5] Logon session 00000000:000003e4:
User name:   WORKGROUP\PAVEL7540$
Auth package: Negotiate
Logon type:   Service
Session:     0
Sid:          S-1-5-20
Logon time:  19-May-20 19:29:32
...
[7] Logon session 00000000:00023051:
User name:   Window Manager\DWM-1
Auth package: Negotiate
Logon type:   Interactive
Session:     1
Sid:          S-1-5-90-0-1
Logon time:  19-May-20 19:29:32
...
[17] Logon session 00000000:02edfae1:
User name:    NT VIRTUAL MACHINE\47E3D5AD-77C2-4BCE-AC4F-252E2A6935DA
Auth package: Negotiate
Logon type:   Service
Session:     0
Sid:          S-1-5-83-1-1206113709-1271822274-774197164-3660933418
Logon time:   19-May-20 20:14:35
...
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

通常情况下,当用户成功登录时,本地安全授权子系统服务(Lsass,Local Security Authority Subsystem Service)会创建一个令牌。Windows登录进程(Winlogon)会将该令牌附加到用户会话中创建的第一个进程上,然后令牌会被复制并从该进程传播到子进程,依此类推。

一个令牌(token)包含若干信息,其中部分信息如下:

  • 用户安全标识符(Security Identifier,SID)
  • 主组(primary group)
  • 用户所属的组
  • 用户拥有的特权(稍后讨论)
  • 新创建对象的默认安全描述符
  • 令牌类型(主令牌或模拟令牌)

主组是为与POSIX子系统配合使用而创建的,因为这是类Unix(*NIX)安全权限的一部分,所以大多情况下未被使用。

进程资源管理器(Process Explorer)允许在进程属性对话框的“安全”选项卡中查看进程主令牌的信息(图16-7)。

img 图16-7:进程资源管理器中的“安全”选项卡

让我们从了解如何获取令牌开始探索令牌。由于每个进程都必须关联一个令牌(其主令牌),因此可以使用OpenProcessToken为进程打开一个令牌句柄:

BOOL OpenProcessToken(
    _In_ HANDLE ProcessHandle,
    _In_ DWORD DesiredAccess,
    _Outptr_ PHANDLE TokenHandle
);
1
2
3
4
5

在尝试获取令牌句柄之前,必须获取一个打开的进程句柄(OpenProcess),且至少具有PROCESS_QUERY_INFORMATION访问掩码(当然,GetCurrentProcess始终可用)。DesiredAccess是请求对令牌对象的访问掩码。常见的值包括TOKEN_QUERY(查询信息)、TOKEN_ADJUST_PRIVILEGES(启用/禁用特权)、TOKEN_ADJUST_DEFAULT(调整各种默认值)、TOKEN_DUPLICATE(复制令牌)和TOKEN_IMPERSONATE(模拟令牌)。还有其他更强大的访问掩码受支持,但通常无法授予这些权限,因为它们需要一些强大的特权(在“特权”部分描述)。最后,如果调用成功,TokenHandle将返回实际的句柄。

如果需要线程的令牌(很可能该线程正在模拟与它所属进程不同的令牌),则可以调用OpenThreadToken:

BOOL OpenThreadToken(
    _In_ HANDLE ThreadHandle,
    _In_ DWORD DesiredAccess,
    _In_ BOOL OpenAsSelf,
    _Outptr_ PHANDLE TokenHandle
);
1
2
3
4
5
6

ThreadHandle是指向相关线程的句柄,该句柄必须具有THREAD_QUERY_LIMITED_INFORMATION访问掩码。可以使用OpenThread打开这样的句柄(GetCurrentThread始终可行)。DesiredAccess是返回的令牌句柄所需的访问权限。OpenAsSelf指示应根据哪个令牌进行令牌检索的访问检查。如果OpenAsSelf为TRUE,则访问检查针对进程令牌;否则针对当前线程的令牌。当然,只有在当前线程正在模拟时,这一点才重要。

有了具有TOKEN_QUERY访问掩码的令牌句柄后,GetTokenInformation可以提供存储在令牌内部的大量数据:

BOOL GetTokenInformation(
    _In_ HANDLE TokenHandle,
    _In_ TOKEN_INFORMATION_CLASS TokenInformationClass,
    _Out_ LPVOID TokenInformation,
    _In_ DWORD TokenInformationLength,
    _Out_ PDWORD ReturnLength
);
1
2
3
4
5
6
7

该函数非常通用,使用TOKEN_INFORMATION_CLASS枚举来指示请求的信息类型。对于枚举中的每个值,都需要指定正确的缓冲区大小。最后一个参数指示成功完成操作使用或需要的字节数。枚举列表很长。让我们看几个示例。

要获取与令牌关联的用户安全标识符(SID),可以像这样使用TokenUser枚举值:

BYTE buffer[1 << 12];  //  4KB  - should be  large  enough  for anything
if  (::GetTokenInformation(hToken, TokenUser, buffer, sizeof(buffer), &len)) {
    auto  data = (TOKEN_USER*)buffer;
    printf("User SID: %ws\n", SidToString(data->User.Sid).c_str());
}
1
2
3
4
5

SidToString使用了前面讨论过的ConvertSidToStringSid函数:

std::wstring SidToString(const  PSID sid) {
    PWSTR ssid;
    std::wstring result;
    if  (::ConvertSidToStringSid(sid, &ssid)) {
        result = ssid;
        ::LocalFree(ssid);
    }
    return  result;
}
1
2
3
4
5
6
7
8
9

下面是另一个使用TokenStatistics令牌信息类的示例,它检索令牌的一些有用统计信息:

TOKEN_STATISTICS stats;
if (::GetTokenInformation(hToken, TokenStatistics, &stats, sizeof(stats), &len)) {
    printf("Token ID: 0x%08llX\n", LuidToNum(stats.TokenId));
    printf("Logon Session ID: 0x%08llX\n", LuidToNum(stats.AuthenticationId));
    printf("Token Type: %s\n", stats.TokenType == TokenPrimary ?
        "Primary" : "Impersonation");
    if (stats.TokenType == TokenImpersonation)
        printf("Impersonation level: %s\n",
            ImpersonationLevelToString(stats.ImpersonationLevel));

    printf("Dynamic charged (bytes): %lu\n", stats.DynamicCharged);
    printf("Dynamic available (bytes): %lu\n", stats.DynamicAvailable);
    printf("Group count: %lu\n", stats.GroupCount);
    printf("Privilege count: %lu\n", stats.PrivilegeCount);
    printf("Modified ID: %08llX\n\n", LuidToNum(stats.ModifiedId));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

上面使用了两个辅助函数:

ULONGLONG LuidToNum(const  LUID& luid) {
    return  *(const  ULONGLONG*)&luid;
}

const  char* ImpersonationLevelToString(SECURITY_IMPERSONATION_LEVEL level) {
    switch  (level) {
    case  SecurityAnonymous: return  "Anonymous";
    case  SecurityIdentification: return  "Identification";
    case  SecurityImpersonation: return  "Impersonation";
    case  SecurityDelegation: return  "Delegation";
    }
    return  "Unknown";
}
1
2
3
4
5
6
7
8
9
10
11
12
13

TOKEN_STATISTICS结构的定义如下:

typedef  struct  _TOKEN_STATISTICS {
    LUID TokenId;
    LUID AuthenticationId;
    LARGE_INTEGER ExpirationTime;
    TOKEN_TYPE TokenType;
    SECURITY_IMPERSONATION_LEVEL ImpersonationLevel;
    DWORD DynamicCharged;
    DWORD DynamicAvailable;
    DWORD GroupCount;
    DWORD PrivilegeCount;
    LUID ModifiedId;
} TOKEN_STATISTICS, *PTOKEN_STATISTICS;
1
2
3
4
5
6
7
8
9
10
11
12

其中一些成员需要解释一下。首先是LUID类型,我们之前没有遇到过。这是一个64位数字,在特定系统运行时保证是唯一的。它的定义如下:

typedef  struct  _LUID {
    DWORD LowPart;
    LONG HighPart;
} LUID, *PLUID;
1
2
3
4

LUID在Windows的多个地方使用,不一定与安全相关。这只是在每台机器上获取唯一值的一种方式。如果需要生成一个LUID,可以调用AllocateLocallyUniqueId。

TokenId成员是此令牌实例(对象,而非句柄)的唯一标识符,因此比较令牌的一种简单方法是比较TokenId值。AuthenticationId是登录会话ID,唯一标识登录会话本身。

三个内置登录会话的登录会话ID有已知的值:系统(SYSTEM)为999(0x3e7),本地服务(Local Service)为997(0x3e5),网络服务(Network Service)为996(0x3e4)。

TOKEN_STATISTICS中的TokenType要么是PrimaryToken(进程令牌),要么是ImpersonationToken(线程令牌)。如果令牌是模拟令牌,则ImpersonationLevel成员有意义:

typedef  enum  _SECURITY_IMPERSONATION_LEVEL {
    SecurityAnonymous,   // not really used
    SecurityIdentification,
    SecurityImpersonation,
    SecurityDelegation
} SECURITY_IMPERSONATION_LEVEL;
1
2
3
4
5
6

模拟级别指示此模拟令牌具有何种“权限”——从仅识别用户(SecurityIdentification)及其属性,到仅在服务器进程所在机器上模拟用户(SecurityImpersonation),再到在远程机器(相对于服务器进程所在机器)上模拟客户端。

令牌中存储的信息还有很多。我们将在后续章节中研究更多细节。token.exe应用程序展示了通过调用GetTokenInformation获取的更多令牌信息。

令牌中的某些信息也可以更改。一种通用方法是使用SetTokenInformation,它与GetTokenInformation使用相同的TOKEN_INFORMATION_CLASS枚举:

BOOL SetTokenInformation(
    _In_ HANDLE TokenHandle,
    _In_ TOKEN_INFORMATION_CLASS TokenInformationClass,
    _In_ LPVOID TokenInformation,
    _In_ DWORD TokenInformationLength
);
1
2
3
4
5
6

遗憾的是,文档并未说明哪些信息类是有效的,因为只有一部分是有效的。使用不同的应用程序编程接口(API)可以对令牌进行一些更改。表16-1描述了SetTokenInformation的有效信息类以及它们所需的特权和访问掩码(如果需要)。

表16-1:SetTokenInformation的有效值

TOKEN_INFORMATION_CLASS 所需访问掩码 所需特权
TokenOwner TOKEN_ADJUST_DEFAULT 无
TokenPrimaryGroup TOKEN_ADJUST_DEFAULT 无
TokenDefaultDacl TOKEN_ADJUST_DEFAULT 无
TokenSessionId TOKEN_ADJUST_SESSIONID SeTcbPrivilege
TokenVirtualizationAllowed 无 SeCreateTokenPrivilege
TokenVirtualizationEnabled TOKEN_ADJUST_DEFAULT 无
TokenOrigin 无 SeTcbPrivilege
TokenMandatoryPolicy 无 SeCreateTokenPrivilege

例如,下面是通过操作进程的令牌来更改用户账户控制(User Access Control,UAC)虚拟化状态的代码。本章后面的“用户账户控制”部分将讨论UAC虚拟化。目前,我们将重点关注SetTokenInformation的机制。对于每个信息类,文档都说明了应提供的相关结构。在这种情况下,对于TokenVirtualizationEnabled,值存储在一个简单的ULONG中,1表示启用,0表示禁用。下面是代码(省略错误处理):

// hProcess is an open process handle  with PROCESS_QUERY_INFORMATION
HANDLE hToken;
::OpenProcessToken(hProcess, TOKEN_ADJUST_DEFAULT, &hToken);

ULONG enable = 1;  // enable
::SetTokenInformation(hToken, TokenVirtualizationEnabled, &enable, sizeof(enable));
1
2
3
4
5
6

根据表16-1,TokenVirtualizationEnabled需要TOKEN_ADJUST_DEFAULT访问掩码。setvirt.exe是一个命令行应用程序(来自本章的示例),它允许启用或禁用进程的UAC虚拟化(当然,该状态存储在其主令牌中)。下面是一个为进程6912启用UAC虚拟化的示例运行:

c:\>setvirt 6912 on
1

你可以使用任务管理器(添加UAC虚拟化列,图16-8)或在进程资源管理器的安全选项卡(图16-9)中查看UAC虚拟化状态。

img 图16-8:任务管理器中的UAC虚拟化

img 图16-9:进程资源管理器中的UAC虚拟化

# 二次登录服务

二次登录服务(Secondary Logon service,seclogon)是一项内置服务,它允许以与调用者不同的用户身份启动进程。它是与内置命令行工具runas.exe配合使用的服务。通过调用CreateProcessWithLogonW来调用该服务:

BOOL CreateProcessWithLogonW(
    _In_        LPCWSTR lpUsername,
    _In_opt_    LPCWSTR lpDomain,
    _In_        LPCWSTR lpPassword,
    _In_        DWORD dwLogonFlags,
    _In_opt_    LPCWSTR lpApplicationName,
    _Inout_opt_ LPWSTR lpCommandLine,
    _In_        DWORD dwCreationFlags,
    _In_opt_    LPVOID lpEnvironment,
    _In_opt_    LPCWSTR lpCurrentDirectory,
    _In_        LPSTARTUPINFOW lpStartupInfo,
    _Out_       LPPROCESS_INFORMATION lpProcessInformation
);
1
2
3
4
5
6
7
8
9
10
11
12
13

请注意,CreateProcessWithLogonW没有ANSI版本。

CreateProcessWithLogonW的一些参数可能看起来很眼熟,因为它们通常也会提供给标准的CreateProcess调用。实际上,CreateProcessWithLogonW是两个独立调用的组合:LogonUser和CreateProcessAsUser。LogonUser用于在提供正确凭据的情况下获取用户的令牌:

BOOL LogonUser(
    _In_     LPCTSTR lpszUsername,
    _In_opt_ LPCTSTR lpszDomain,
    _In_opt_ LPCTSTR lpszPassword,
    _In_     DWORD dwLogonType,
    _In_     DWORD dwLogonProvider,
    _Outptr_ PHANDLE phToken
);
1
2
3
4
5
6
7
8

lpszUsername是用户名,可以是 “普通” 名称或用户主体名称(User Principal Name,UPN),例如 “user@domanin.com”。如果用户名是UPN名称,lpszDomain必须为NULL。否则,lpszDomain应该是域名,其中 “.” 表示本地计算机(用于本地登录)。lpszPassword是用户的明文密码。

dwLogonType是登录类型。以下是常见的值(完整列表请查看文档):

  • LOGON32_LOGON_INTERACTIVE:适用于将以交互方式使用计算机的用户。它会缓存登录信息以用于离线操作,这意味着它比其他登录类型的开销更大。
  • LOGON32_LOGON_BATCH:适用于代表用户执行操作而无需用户干预的情况。这种登录类型不会缓存凭据。
  • LOGON32_LOGON_NETWORK:与批处理类似,但返回的令牌是模拟令牌(impersonation token)而非主令牌(primary token)。这样的令牌不能与CreateProcessAsUser一起使用,但可以使用DuplicateTokenEx转换为主令牌(请参阅 “模拟” 部分)。这是最快的登录类型。

要成功登录,登录类型必须已授予登录的账户。

dwLogonProvider用于选择登录提供程序,可以是LOGON32_PROVIDER_WINNT50(Kerberos,也称为 “协商”)或LOGON32_PROVIDER_WINNT40(NTLM - NT局域网管理器)。指定LOGON32_PROVIDER_DEFAULT将选择NTLM。

如果函数成功,phToken将返回令牌句柄。有了令牌后,调用CreateProcessAsUser就很简单了:

BOOL CreateProcessAsUser(
    _In_opt_ HANDLE hToken,
    _In_opt_ LPCTSTR lpApplicationName,
    _Inout_opt_ LPTSTR lpCommandLine,
    _In_opt_ LPSECURITY_ATTRIBUTES lpProcessAttributes,
    _In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes,
    _In_ BOOL bInheritHandles,
    _In_ DWORD dwCreationFlags,
    _In_opt_ LPVOID lpEnvironment,
    _In_opt_ LPCTSTR lpCurrentDirectory,
    _In_ LPSTARTUPINFO lpStartupInfo,
    _Out_ LPPROCESS_INFORMATION lpProcessInformation
);
1
2
3
4
5
6
7
8
9
10
11
12
13

该函数与CreateProcess看起来相同,只是多了第一个参数:新进程应在其下执行的主令牌。虽然看起来足够简单,但至少有一个问题:调用CreateProcessAsUser需要SeAssignPrimaryTokenPrivilege权限。此权限通常授予运行服务的账户(本地服务、网络服务和本地系统),但不授予标准用户,甚至不授予本地管理员别名。这意味着从服务中调用CreateProcessAsUser是可行的(请参阅第18章),否则会有问题。

这就是二次登录服务的用武之地。由于它在本地系统账户下运行,因此可以毫无问题地调用CreateProcessAsUser。CreateProcessWithLogonW是它的驱动程序。

实际上,调用CreateProcessAsUser并不那么简单,因为需要为进程成功启动配置一些安全设置。

CreateProcessWithLogonW的dwLogonFlags参数可以是以下值之一:

  • LOGON_WITH_PROFILE:使CreateProcessWithLogonW加载用户配置文件。如果新进程需要访问HKEY_CURRENT_USER注册表项,这一点很重要。
  • 零(0):不加载用户配置文件。
  • LOGON_NETCREDENTIALS_ONLY:新进程将在调用者的用户身份下执行(而不是指定的用户),但会使用指定用户创建一个新的网络登录会话。这意味着新进程的任何网络访问都将使用其他用户的令牌。

接下来的两个参数lpApplicationName和lpCommandLine与它们在CreateProcess中的作用相同(如有需要,请参阅第3章进行复习)。dwCreationFlags是传递给CreateProcessAsUser的标志组合,大多数在CreateProcess中有效的标志在此处也有效。lpEnvironment与它在CreateProcess中的含义相同,只是默认环境是其他用户的默认环境,而不是调用者的。

最后,lpStartupInfo和lpProcessInfo与它们在CreateProcess中的含义相同。

你可能会认为,通过调用LogonUser,然后模拟新用户(ImpersonateLoggedOnUser),最后正常调用CreateProcess,就可以以另一个用户身份创建进程。然而,这是行不通的,因为CreateProcess总是使用调用者的主(进程)令牌,而不是活动的模拟令牌(如果有的话)。

还有另一个函数可以调用二次登录服务 —— CreateProcessWithTokenW:

BOOL CreateProcessWithTokenW(
    _In_        HANDLE hToken,
    _In_        DWORD dwLogonFlags,
    _In_opt_    LPCWSTR lpApplicationName,
    _Inout_opt_ LPWSTR lpCommandLine,
    _In_        DWORD dwCreationFlags,
    _In_opt_    LPVOID lpEnvironment,
    _In_opt_    LPCWSTR lpCurrentDirectory,
    _In_        LPSTARTUPINFOW lpStartupInfo,
    _Out_       LPPROCESS_INFORMATION lpProcessInformation
);
1
2
3
4
5
6
7
8
9
10
11

此函数需要SeImpersonatePrivilege权限,该权限通常授予本地管理员别名和服务账户。它使用现有令牌(例如,来自成功的LogonUser调用),但确实需要一些额外的工作,以确保新进程初始化不会失败。

使用CreateProcessWithLogonW是目前最简单的方法,与CreateProcessWithLogonW和CreateProcessAsUser相比,它的灵活性有所降低。

# 模拟

通常,线程执行的任何操作都是使用进程的令牌完成的。如果进程中的某个线程在执行某些操作之前想要临时更改令牌会怎样呢?对令牌的任何更改都会反映在进程级别,影响进程中的所有线程。该线程可能希望拥有自己的私有令牌。

使用类似DuplicateHandle的函数可能不会像预期的那样工作,因为新句柄仍然引用相同的对象。相反,可以使用DuplicateTokenEx函数:

BOOL DuplicateTokenEx(
    _In_ HANDLE hExistingToken,
    _In_ DWORD dwDesiredAccess,
    _In_opt_ LPSECURITY_ATTRIBUTES lpTokenAttributes,
    _In_ SECURITY_IMPERSONATION_LEVEL ImpersonationLevel,
    _In_ TOKEN_TYPE TokenType,
    _Outptr_ PHANDLE phNewToken
);
1
2
3
4
5
6
7
8

DuplicateTokenEx的独特之处在于它是唯一可以复制对象的函数。例如,对于互斥锁(mutexes)或信号量(semaphores),就没有这样的函数。

hExistingToken是要复制的现有令牌。它必须具有TOKEN_DUPLICATE访问掩码。dwDesiredAccess是对新令牌请求的访问掩码。指定零将导致与原始令牌相同的访问掩码。如果此新令牌要用于模拟,则需要TOKEN_IMPERSONATE。

lpTokenAttributes是标准的SECURITY_ATTRIBUTES(在 “安全描述符” 部分讨论过),通常设置为NULL。ImpersonationLevel指示新令牌中固有的模拟信息级别(本节后面会详细介绍)。TokenType可以是TokenPrimary(用于附加到进程)或TokenImpersonation(用于附加到线程)。最后,如果调用成功,phNewToken将接收新令牌句柄。

现在可以对新令牌进行操作(启用/禁用权限、更改UAC虚拟化状态等),而不会影响原始令牌。为了使新令牌发挥作用,需要通过调用SetThreadToken将其附加到当前线程(假设它是作为模拟令牌复制的):

BOOL SetThreadToken(
    _In_opt_ PHANDLE Thread,
    _In_opt_ HANDLE Token
);
1
2
3
4

Thread是指向线程句柄的指针,NULL表示当前线程。

这是一种相当不寻常的指定线程句柄的方式;通常会使用直接句柄,使用GetCurrentThread表示当前线程。

Token是要使用的模拟令牌。NULL可以接受,用于停止使用任何现有令牌。或者,如果模拟是在当前线程上进行的,也可以调用RevertToSelf函数:

BOOL RevertToSelf();
1

以下示例复制进程令牌以进行模拟,并对模拟令牌进行 “本地” 更改(省略错误处理):

HANDLE hProcToken;
::OpenProcessToken(GetCurrentProcess(), TOKEN_DUPLICATE, &hProcToken);

HANDLE hImpToken;
::DuplicateTokenEx(hProcToken, MAXIMUM_ALLOWED, nullptr ,
    SecurityIdentification, TokenImpersonation, &hImpToken);
::CloseHandle(hProcToken);

// 启用新令牌上的UAC虚拟化
ULONG virt = 1;
::SetTokenInformation(hImpToken, TokenVirtualizationEnabled,
    &virt, sizeof(virt));

// 模拟
::SetThreadToken(nullptr, hImpToken);

// 执行操作...

::RevertToSelf();
::CloseHandle(hImpToken);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

获取当前进程令牌、将其复制为模拟令牌并将其附加到当前线程的整个过程可以一步完成:

BOOL ImpersonateSelf(_In_ SECURITY_IMPERSONATION_LEVEL ImpersonationLevel);
1

ImpersonateSelf复制进程令牌以创建模拟令牌,然后调用SetThreadToken。

当在当前线程上使用某个令牌(不一定是进程令牌)进行模拟时,可以使用另一个简化函数:

BOOL ImpersonateLoggedOnUser(_In_ HANDLE hToken);
1

ImpersonateLoggedOnUser接受主令牌或模拟令牌,必要时复制令牌,并在当前线程上调用SetThreadToken。

在以下示例中,在LogonUser之后使用ImpersonateLoggedOnUser:

HANDLE hToken;
::LogonUser(L"alice", L".", L"alicesecretpassword",
    LOGON32_LOGON_BATCH, LOGON32_PROVIDER_DEFAULT, &hToken);

// 模拟alice
::ImpersonateLoggedOnUser(hToken);

// 以alice的身份执行操作...

::RevertToSelf();
::CloseHandle(hToken);
1
2
3
4
5
6
7
8
9
10
11

# 客户端/服务器中的模拟

模拟的经典用法是在客户端/服务器场景中。假设有一个在用户A下运行的服务器进程。多个客户端使用某种通信机制(COM、命名管道、RPC等)连接到该服务器进程,请求服务器进程代表他们执行某些操作(图16-10)。

img 图16-10:客户端/服务器

如果服务器进程使用自己的身份(A)执行请求的操作,这是不正确的。例如,客户端B可能请求对一个B没有访问权限但A有访问权限的文件执行操作。相反,服务器应该在尝试执行操作之前模拟请求的客户端。

当复制令牌时,SECURITY_IMPERSONATION_LEVEL模拟级别指示当生成的令牌发送到另一台计算机上的服务器时,该令牌所具有的 “权限”:

typedef enum _SECURITY_IMPERSONATION_LEVEL {
    SecurityAnonymous,
    SecurityIdentification,
    SecurityImpersonation,
    SecurityDelegation
} SECURITY_IMPERSONATION_LEVEL;
1
2
3
4
5
6

使用SecurityAnonymous时,服务器不知道客户端是谁,因此无法模拟它。对于服务器被要求执行的某些类型的操作,这可能没问题。SecurityIdentification是下一个级别,在这个级别服务器可以查询客户端的属性,但仍然无法模拟它(除非服务器进程与客户端进程在同一台计算机上)。使用SecurityImpersonation时,服务器只能在自己的计算机上模拟客户端(不能进一步模拟)。最后一个(也是最宽松的)模拟级别SecurityDelegation,允许服务器调用另一台计算机上的另一个服务器并传播令牌,以便另一个服务器可以模拟原始客户端,并且这种模拟可以进行任意数量的跳转。

令牌传播机制取决于所使用的通信机制。表16-2显示了其中一些机制的模拟和还原API。有关更多详细信息,请查看文档(命名管道在第18章讨论,COM在第21章讨论)。 表16-2:远程客户端模拟的API

通信机制 模拟 还原
命名管道 ImpersonateNamedPipeClient RevertToSelf
RPC RpcImpersonateClient RpcRevertToSelf
COM CoImpersonateClient CoRevertToSelf

# 特权

特权是执行某些系统级操作的权利(或被拒绝的权利),它与特定对象无关。特权的示例包括:加载设备驱动程序、调试其他用户的进程、获取对象的所有权等。完整的列表可以在本地安全策略管理单元中查看(图16-11)。对于每一项特权(“策略”列),该工具会列出被授予该特权的账户。

图16-11:本地安全策略编辑器中的特权

从技术上讲,图16-11展示了特权和用户权限。它们的区别如下:用户权限适用于账户,即存储在用户数据库中的数据。用户权限始终是关于允许或拒绝某种形式的登录。而特权(同样作为静态数据存储在账户数据库中),只有在用户登录后才适用。特权存储在用户的访问令牌(access token)中,而用户权限则不存储在其中,因为用户权限仅在用户登录前有意义。

用户权限的示例包括:“拒绝作为批处理作业登录”、“允许本地登录”和“允许通过远程桌面服务登录”。

一旦创建(或复制)了一个令牌,就不能向该令牌添加新的特权。管理员可以向账户(数据库)本身添加特权,但这对现有令牌没有影响。用户注销并再次登录后,新的特权将在其令牌中可用。

大多数特权在默认情况下是禁用的。这可以防止意外(无意)使用这些特权。图16-12展示了Explorer.exe进程的特权列表(在进程资源管理器中显示)。唯一启用的特权(并且默认启用)是SeChangeNotifyPrivilege,其余的都被禁用。

图16-12:资源管理器令牌中的特权

奇怪的是,名为SeChangeNotifyPrivilege的特权默认授予并启用给所有用户。它的描述性名称是“绕过遍历检查”。它允许用户访问某个目录中的文件,即使该用户无法访问该文件的某些父目录。例如,如果文件c:\A\B\c.txt的安全描述符允许,即使目录A不可访问,用户也可以访问该文件。遍历所有父目录的安全描述符成本很高,这就是为什么此特权默认启用。

使用我们之前遇到的GetTokenInformation函数可以获取令牌中的特权列表。要启用、禁用或删除特权,则需要调用AdjustTokenPrivileges函数:

BOOL AdjustTokenPrivileges(
    _In_ HANDLE TokenHandle,
    _In_ BOOL DisableAllPrivileges,
    _In_opt_ PTOKEN_PRIVILEGES NewState,
    _In_ DWORD BufferLength,
    _Out_opt_ PTOKEN_PRIVILEGES PreviousState,
    _Out_opt_ PDWORD ReturnLength
);
1
2
3
4
5
6
7
8

TokenHandle必须具有TOKEN_ADJUST_PRIVILEGES访问掩码,调用才有可能成功。如果DisableAllPrivileges为TRUE,该函数将禁用令牌中的所有特权,接下来的两个参数将被忽略。要更改的特权由TOKEN_PRIVILEGES结构提供,其定义如下:

typedef struct _LUID_AND_ATTRIBUTES {
    LUID Luid;
    DWORD Attributes;
} LUID_AND_ATTRIBUTES;

typedef struct _TOKEN_PRIVILEGES {
    DWORD PrivilegeCount;
    LUID_AND_ATTRIBUTES Privileges[ANYSIZE_ARRAY];
} TOKEN_PRIVILEGES, *PTOKEN_PRIVILEGES;
1
2
3
4
5
6
7
8
9

在Windows API中,特权用字符串表示。例如,SE_DEBUG_NAME被定义为TEXT("SeDebugPrivilege")。然而,系统启动时,每个特权都会被赋予一个本地唯一标识符(LUID,Locally Unique Identifier),每次系统重启时LUID都不同。AdjustTokenPrivileges函数需要操作的特权是LUID,而不是字符串。因此,我们必须通过LookupPrivilegeValue函数来获取特权的LUID:

BOOL LookupPrivilegeValue(
    _In_opt_ LPCTSTR lpSystemName,
    _In_ LPCTSTR lpName,
    _Out_ PLUID lpLuid
);
1
2
3
4
5

该函数接受一个计算机名称(对于本地计算机,NULL即可)和一个特权名称,并返回其LUID。

回到AdjustTokenPrivileges函数,TOKEN_PRIVILEGES需要一个LUID_AND_ATTRIBUTES结构数组,每个结构都包含一个LUID和要使用的属性。可能的值有SE_PRIVILEGE_ENABLED用于启用特权,0用于禁用特权,SE_PRIVILEGE_REMOVED用于删除特权。

NewState是指向TOKEN_PRIVILEGES的指针,BufferLength是数据的大小,因为可以同时修改多个特权。最后,PreviousState和ReturnedLength是可选参数,可以返回修改后的特权的先前状态。大多数调用者通常将这两个参数都指定为NULL。

AdjustTokenPrivileges函数的返回值有点棘手。它在任何成功的情况下都返回TRUE,即使只有部分特权更改成功。如果调用返回TRUE,正确的做法是调用GetLastError函数。如果返回0,则表示一切正常,否则可能返回ERROR_NOT_ALL_ASSIGNED,这表明出现了问题。如果只请求更改一个特权,这实际上表示操作失败。

我们在第13章以及第一部分的其他几个章节中多次使用了AdjustTokenPrivileges函数,但没有进行完整的解释。现在,我们可以利用AdjustTokenPrivileges和LookupPrivilegeValue函数编写一个通用函数,用于启用或禁用调用者令牌中的任何特权:

bool EnablePrivilege(PCWSTR privName, bool enable) {
    HANDLE hToken;
    if (!::OpenProcessToken(::GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &hToken))
        return false;

    bool result = false;
    TOKEN_PRIVILEGES tp;
    tp.PrivilegeCount = 1;
    tp.Privileges[0].Attributes = enable ? SE_PRIVILEGE_ENABLED : 0;

    if (::LookupPrivilegeValue(nullptr, privName, &tp.Privileges[0].Luid)) {
        if (::AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), nullptr, nullptr))
            result = ::GetLastError() == ERROR_SUCCESS;
    }

    ::CloseHandle(hToken);
    return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

使用单个特权使得对AdjustTokenPrivileges函数的调用变得简单,因为TOKEN_PRIVILEGES结构中恰好可以容纳一个特权,无需额外分配内存。

# 超级特权

详尽讨论所有可用的特权超出了本书的范围。然而,有些特权确实值得特别提及。有一组特权非常强大,拥有其中任何一项都几乎可以完全控制系统(有些甚至可以实现完全控制)。它们有时被亲切地称为“超级特权”,尽管这不是一个官方术语。

获取所有权

对象的所有者始终可以设置谁能对该对象执行何种操作(有关详细信息,请参阅“安全描述符”部分)。SeTakeOwnershipPrivilege(SE_TAKE_OWNERSHIP_NAME)特权允许其持有者将自己设置为任何内核对象(文件、互斥体、进程等)的所有者。作为所有者,用户现在可以给自己授予对该对象的完全访问权限。

此特权通常授予管理员,这是合理的,因为管理员在需要时应该能够控制任何对象。例如,假设一名员工离开公司,他/她拥有的一些文件/文件夹现在无法访问。管理员可以行使获取所有权的特权,将管理员设置为所有者,从而可以对该对象进行完全访问。

一种查看此操作的方法是使用对象(如文件)的安全描述符(图16-13)。单击“高级”按钮会显示图16 - 14中的对话框,其中显示了所有者信息。

图16-13:内核对象的安全设置

图16-14:高级安全设置对话框

单击“更改”按钮会使对话框处理程序启用获取所有权特权(如果调用者的令牌中存在此特权,在这种情况下是运行资源管理器的用户)并更改所有者。

备份

备份特权(SE_BACKUP_NAME)允许用户读取任何对象,无论其安全描述符如何。通常,这应该授予执行某种备份操作的应用程序。管理员和备份操作员组默认拥有此特权。要在文件操作中使用它,在使用CreateFile函数打开文件时需要指定特定的FILE_BACKUP_SEMANTICS。

还原

还原特权(SE_RESTORE_NAME)与备份特权相反,它提供对任何内核对象的写入访问权限。

调试

调试特权(SE_DEBUG_NAME)允许调试和操作任何进程的内存。这包括调用CreateRemoteThread函数向任何进程注入线程,但受保护的进程和基于保护模式的进程(PPL,Protected Process Light)除外。

可信计算基础(TCB)

TCB特权(可信计算基础,SE_TCB_NAME)被描述为“作为操作系统的一部分运行”,是最强大的特权之一。默认情况下,它不会授予任何用户或组,这一事实证明了它的强大。拥有此特权允许用户模拟任何其他用户,并且通常拥有与内核相同的访问权限。

创建令牌

创建令牌特权(SE_CREATE_TOKEN_NAME)允许创建一个令牌,从而在其中填充任何特权或组。默认情况下,此特权不会授予任何用户。然而,本地安全授权子系统服务(Lsass,Local Security Authority Subsystem Service)进程必须拥有此特权,因为在成功登录后需要它。如果在进程资源管理器中检查Lsass进程的安全属性,你会发现它拥有此特权。如果该特权没有授予任何用户,这是如何实现的呢?我们将在第19章回答这个问题。

# 访问掩码

我们之前多次遇到过访问掩码。起初,各种访问位的值可能看起来是随机的,但实际上这些位有逻辑分组,如图16-15所示。

图16-15:访问掩码的组成部分

“特定权限”部分(低16位)表示的就是针对每种对象类型不同的特定权限。例如,PROCESS_TERMNINATE和PROCESS_CREATE_THREAD就是进程对象的两种特定权限。

接下来是标准权限,适用于多种类型的对象。例如,SYNCHRONIZE、DELETE和WRITE_DAC就是标准权限的示例。但它们并非对所有对象类型都有意义。例如,SYNCHRONIZE仅对调度(可等待)对象有意义。

下一位(第24位)是ACCESS_SYSTEM_SECURITY访问权限,它允许访问系统访问控制列表(将在下一节讨论)。MAXIMUM_ALLOWED(第25位)是一个特殊值,如果使用它,将提供客户端可以获得的最大访问掩码。例如:

HANDLE hProcess = ::OpenProcess(MAXIMUM_ALLOWED, FALSE, pid);
1

如果能够获取到句柄(handle),那么调用者所能得到的访问掩码(access mask)将是其权限范围内最高的。上述示例在实际应用中用处不大,因为调用者通常清楚完成任务所需的访问掩码。

第28 - 31位代表通用权限(generic rights)。这些权限(如果使用)必须进行转换或映射,以对应具体的访问权限。例如,指定GENERIC_WRITE必须映射为针对特定对象类型的 “写入” 含义。这一转换在内部通过以下结构完成:

typedef  struct  _GENERIC_MAPPING {
    ACCESS MASK GenericRead;
    ACCESS MASK GenericWrite;
    ACCESS MASK GenericExecute;
    ACCESS MASK GenericAll;
} GENERIC_MAPPING;
1
2
3
4
5
6

默认映射可以通过对象资源管理器工具(图16-13)查看,它们的含义相当直观,部分映射在头文件中也有间接定义。例如,FILE_GENERIC_READ是文件的GENERIC_READ映射。

img 图16-16:对象资源管理器中的通用映射

# 安全描述符(Security Descriptors)

安全描述符是一种长度可变的结构,包含了关于哪些主体可以对其所附加对象执行哪些操作的信息。一个安全描述符包含以下信息:

  • 所有者安全标识符(Owner SID)—— 对象的所有者。
  • 主要组安全标识符(Primary Group SID)—— 过去在POSIX子系统应用程序的组安全中使用。
  • 自由访问控制列表(Discretionary Access Control List,DACL)—— 一个访问控制项(Access Control Entry,ACE)列表,用于指定哪些主体可以对对象执行哪些操作。
  • 系统访问控制列表(System Access Control List,SACL)—— 一个ACE列表,用于指示哪些操作应导致在安全日志中写入审核条目。

对象的所有者始终拥有WRITE_DAC(以及READ_CONTROL)标准访问权限,这意味着所有者可以读取和更改对象的DACL。这一点非常重要,否则一个不小心的调用可能会使对象完全无法访问。为所有者赋予WRITE_DAC权限可确保无论发生什么情况,所有者都能更改DACL。

如果对某个内核对象拥有打开的句柄,可使用GetKernelObjectSecurity函数获取其安全描述符:

BOOL GetKernelObjectSecurity(
    _In_ HANDLE Handle,
    _In_ SECURITY_INFORMATION RequestedInformation,
    _Out_ PSECURITY_DESCRIPTOR pSecurityDescriptor,
    _In_ DWORD nLength,
    _Out_ LPDWORD lpnLengthNeeded
);
1
2
3
4
5
6
7

句柄必须具有READ_CONTROL标准访问掩码。SECURITY_INFORMATION是一个枚举类型,用于指定在结果安全描述符中返回何种信息(可使用按位或运算符指定多个信息)。最常见的取值是OWNER_SECURITY_INFORMATION和DACL_SECURITY_INFORMATION。结果存储在PSECURITY_DESCRIPTOR中。SECURITY_DESCRIPTOR结构已定义,但应将其视为不透明类型,这就是为什么PSECURITY_DESCRIPTOR(指向该结构的指针)被定义为PVOID。GetKernelObjectSecurity要求调用者分配一个足够大的缓冲区,在nLength参数中指定其长度,并在lpnLengthNeeded中获取实际所需长度。

使用SACL_SECURITY_INFORMATION请求SACL需要SeSecurityPrivilege权限,该权限通常授予管理员。

获取到PSECURITY_DESCRIPTOR后,有几个函数可用于提取其中存储的数据:

DWORD GetSecurityDescriptorLength(_In_ PSECURITY_DESCRIPTOR pSecurityDescriptor);
BOOL GetSecurityDescriptorControl(     // 控制标志
    _In_ PSECURITY_DESCRIPTOR pSecurityDescriptor,
    _Out_ PSECURITY_DESCRIPTOR_CONTROL pControl,
    _Out_ LPDWORD lpdwRevision
);
BOOL GetSecurityDescriptorOwner(       // 所有者
    _In_ PSECURITY_DESCRIPTOR pSecurityDescriptor,
    _Outptr_ PSID* pOwner,
    _Out_ LPBOOL lpbOwnerDefaulted
);
BOOL GetSecurityDescriptorGroup(        // 主要组(大多无用)
    _In_ PSECURITY_DESCRIPTOR pSecurityDescriptor,
    _Outptr_ PSID* pGroup,
    _Out_ LPBOOL lpbGroupDefaulted
);
BOOL GetSecurityDescriptorDacl(        // DACL
    _In_ PSECURITY_DESCRIPTOR pSecurityDescriptor,
    _Out_ LPBOOL lpbDaclPresent,
    _Outptr_ PACL* pDacl,
    _Out_ LPBOOL lpbDaclDefaulted
);
BOOL GetSecurityDescriptorSacl(        // SACL
    _In_ PSECURITY_DESCRIPTOR pSecurityDescriptor,
    _Out_ LPBOOL lpbSaclPresent,
    _Outptr_ PACL* pSacl,
    _Out_ LPBOOL lpbSaclDefaulted
);
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

例如,以下代码展示了如何根据进程ID显示进程的所有者:

bool DisplayProcessOwner(DWORD pid) {
    HANDLE hProcess = ::OpenProcess(READ_CONTROL, FALSE, pid);
    if (!hProcess)
        return false;

    BYTE buffer[1 << 10];
    auto sd = (PSECURITY_DESCRIPTOR)buffer;
    DWORD len;
    BOOL success = ::GetKernelObjectSecurity(hProcess,
        OWNER_SECURITY_INFORMATION,
        sd, sizeof(buffer), &len);
    ::CloseHandle(hProcess);

    if (!success)
        return false;

    PSID owner;
    BOOL isDefault;
    if (!::GetSecurityDescriptorOwner(sd, &owner, &isDefault))
        return false;

    printf("Owner: %ws (%ws)\n", GetUserNameFromSid(owner).c_str(),
        SidToString(owner).c_str());
    return true;
}
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

另一个用于检索对象安全描述符的函数是GetNamedSecurityInfo(包含头文件<AclAPI.h>):

DWORD GetNamedSecurityInfo(
    _In_  LPCTSTR pObjectName,
    _In_  SE_OBJECT_TYPE ObjectType,
    _In_  SECURITY_INFORMATION SecurityInfo,
    _Out_opt_       PSID * ppsidOwner,
    _Out_opt_       PSID * ppsidGroup,
    _Out_opt_       PACL * ppDacl,
    _Out_opt_       PACL * ppSacl,
    _Out_  PSECURITY_DESCRIPTOR * ppSecurityDescriptor
);
1
2
3
4
5
6
7
8
9
10

此函数仅适用于命名对象(互斥体、事件、信号量、节对象)以及具有某种 “路径” 的对象(文件和注册表项)。它不需要对象的打开句柄,仅需对象的名称和类型。当然,如果调用者无法获得READ_CONTROL访问掩码,该函数将失败。

pObjectName是对象的名称,其格式应与SE_OBJECT_TYPE枚举指定的对象类型相匹配。该函数可以返回整个安全描述符,也可以仅返回选定的部分。函数的返回值本身就是错误代码,其中ERROR_SUCCESS(0)表示一切正常(此时调用GetLastError没有意义)。

以下示例展示了如何显示给定文件的所有者:

bool DisplayFileOwner(PCWSTR filename) {
    PSID owner;
    DWORD error = ::GetNamedSecurityInfo(filename, SE_FILE_OBJECT,
        OWNER_SECURITY_INFORMATION, &owner,
        nullptr, nullptr, nullptr, nullptr);
    if (error != ERROR_SUCCESS)
        return false;

    printf("Owner: %ws (%ws)\n", GetUserNameFromSid(owner).c_str(),
        SidToString(owner).c_str());
    return true;
}
1
2
3
4
5
6
7
8
9
10
11
12

请注意,不要释放GetNamedSecurityInfo返回的信息,否则会引发异常。

对于文件,还有一个专门用于返回文件安全描述符的函数GetFileSecurity。之后,需要使用前面提到的函数(如GetSecurityDescriptorOwner)之一来检索其各个部分。此外,对于桌面(desktop)和窗口站(window station)对象,还有一个便捷函数GetUserObjectSecurity。

安全描述符中最重要的部分是DACL。它直接影响哪些主体被允许以何种方式访问对象。最常见的DACL视图是各种工具中可用的安全属性对话框,例如资源管理器中文件和目录的安全属性对话框(图16-17)。

img 图16-17:安全属性对话框

图16-17中的对话框展示了DACL。对于每个用户或组,它显示了允许或拒绝的操作。DACL中的每个条目都是一个访问控制项(ACE),包含以下信息:

  • 此ACE所应用的安全标识符(SID)(例如,某个用户或组)。
  • 此ACE控制的访问掩码(例如,对于进程对象的PROCESS_TERMINATE)(可能包含多个位)。
  • ACE类型,最常见的是允许(Allow)或拒绝(Deny)。

当调用者尝试获取对文件的特定访问权限时,内核中的安全引用监视器必须检查基于访问掩码请求的访问权限是否允许调用者使用。它通过遍历DACL中的ACE来查找明确的结果,一旦找到,遍历就会终止。图16-18展示了某个文件对象的DACL示例。

img 图16-18:一个安全描述符示例

假设我们有两个用户USER1和USER2,分别属于两个组TEAM1和TEAM2,还有另一个用户USER3,仅属于组TEAM2。我们可以提出以下问题:

  1. 如果USER1想要以读访问权限打开文件,是否会成功?
  2. 如果USER1想要以写访问权限打开文件,是否会成功?
  3. 如果USER2想要以写访问权限打开文件,是否会成功?
  4. 如果USER2想要以执行访问权限打开文件,是否会成功?
  5. 如果USER3想要以读访问权限打开文件,是否会成功?

ACE按顺序遍历。如果没有明确的答案,则会查看下一个ACE。如果在查看所有ACE后仍没有明确的结果,则最终判定为拒绝访问。

如果安全描述符本身为NULL,这意味着对象没有保护,所有访问都被允许。如果安全描述符存在,但DACL本身为NULL,含义也是一样的——没有保护。另一方面,如果DACL为空(即没有ACE),则意味着除了所有者之外,没有人可以访问该对象。

下面我们来回答上述问题:

  1. USER1的读访问被拒绝,因为第二个ACE拒绝了读访问。第一个ACE未提及读访问。
  2. USER1的写访问被允许——ACE的顺序很重要。
  3. USER2的写访问被拒绝。
  4. USER2的执行访问被允许,因为它属于Everyone组,且之前的ACE未提及执行权限。
  5. USER3的读访问被拒绝,因为没有ACE提供明确答案,所以最终判定为拒绝访问。

还有其他因素会影响访问检查,包括其他类型的ACE,如继承ACE。如需详细信息,请查阅相关文档。

如果使用资源管理器显示的安全对话框编辑DACL,该对话框构建的ACE总是将拒绝ACE置于允许ACE之前。图16-18中的示例永远不会以这种顺序构建(但通过编程可以构建成这种顺序),因为ACE 2是拒绝ACE,它会被排在首位。

如果拒绝ACE置于允许ACE之前,上述问题的答案会有何变化?

可以使用EditSecurity API以编程方式显示安全属性对话框。这并不容易,因为需要提供ISecurityInformation COM接口的实现,以便为对话框的操作返回适当的信息。可以在我的对象资源管理器工具的源代码或其他在线资源中找到示例实现。

DACL(以及SACL)由ACL结构表示,使用GetSecurityDescriptorDacl或GetNamedSecurityInfo检索DACL时,会返回指向该结构的指针:

typedef struct _ACL {
    BYTE AclRevision;
    BYTE  Sbz1;
    WORD AclSize;
    WORD AceCount;
    WORD  Sbz2;
} ACL;
typedef ACL *PACL;
1
2
3
4
5
6
7
8

唯一有用的成员是AceCount。ACL对象后面紧接着是一个ACE数组。每个ACE的大小可能不同(取决于ACE的类型),但每个ACE始终以ACE_HEADER开头:

typedef  struct  _ACE_HEADER {
    BYTE AceType;
    BYTE AceFlags;
    WORD AceSize;
} ACE_HEADER;
typedef ACE_HEADER *PACE_HEADER;
1
2
3
4
5
6

无需手动计算每个ACE的起始位置,只需调用GetAce函数:

BOOL GetAce(
    _In_ PACL pAcl,
    _In_ DWORD dwAceIndex,
    _Outptr_ LPVOID* pAce
);
1
2
3
4
5

pAcl是DACL指针,dwAceIndex是ACE索引(从0开始),返回的指针指向ACE本身,该ACE始终以ACE_HEADER开头。获取ACE的类型(从头部获取)后,可将GetAce返回的指针强制转换为特定的ACE结构。以下是两种最常见的ACE类型:允许和拒绝:

typedef struct _ACCESS_ALLOWED_ACE {
    ACE_HEADER Header;
    ACCESS_MASK Mask;
    DWORD SidStart;
} ACCESS_ALLOWED_ACE;

typedef struct _ACCESS_DENIED_ACE {
    ACE_HEADER Header;
    ACCESS_MASK Mask;
    DWORD SidStart;
} ACCESS_DENIED_ACE;
1
2
3
4
5
6
7
8
9
10
11

在这里,你可以看到访问控制项(ACE,Access Control Entry)的三个组成部分:它的类型、访问掩码和安全标识符(SID,Security Identifier)。安全标识符紧跟在访问掩码之后,所以 SidStart 实际上是一个虚拟值——只有它的地址有意义。下面的函数用于显示两种常见访问控制项类型的信息:

void DisplayAce(PACE_HEADER header, int index) {
    printf("ACE %2d: Size: %2d bytes, Flags: 0x%02X Type: %s\n",
        index, header->AceSize, header->AceFlags,
        AceTypeToString(header->AceType)); 		// 简单的枚举转字符串
    
    switch (header->AceType) {
        case ACCESS_ALLOWED_ACE_TYPE:
        case ACCESS_DENIED_ACE_TYPE:   		  	// 二进制布局相同
        {
            auto data = (ACCESS_ALLOWED_ACE*)header;
            printf("\tAccess: 0x%08X %ws (%ws)\n", data->Mask,
                GetUserNameFromSid((PSID)&data->SidStart).c_str(),
                SidToString((PSID)&data->SidStart).c_str());
        }
        break;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

sd.exe 应用程序可用于查看线程、进程、文件、注册表项以及其他命名对象(互斥体、事件等)的安全描述符。上述代码就是从该应用程序中节选的。以下是运行 sd.exe 的一些示例:

c:\>sd.exe
Usage: sd [[-p <pid>] | [-t <tid>] | [-f <filename>] | [-k <regkey>] | [objectname]]
If no arguments specified, shows the current process security descriptor
SD Length: 116 bytes
SD: O:BAD:(A;;0x1fffff;;;BA)(A;;0x1fffff;;;SY)(A;;0x121411;;;S-1-5-5-0-687579)
Control: DACL Present, Self Relative
Owner: BUILTIN\Administrators (S-1-5-32-544)
DACL: ACE count: 3
ACE    0: Size: 24 bytes, Flags: 0x00 Type: ALLOW
Access: 0x001FFFFF  BUILTIN\Administrators (S-1-5-32-544)
ACE    1: Size: 20 bytes, Flags: 0x00 Type: ALLOW
Access: 0x001FFFFF  NT AUTHORITY\SYSTEM (S-1-5-18)
ACE    2: Size: 28 bytes, Flags: 0x00 Type: ALLOW
Access: 0x00121411  NT AUTHORITY\LogonSessionId  0  687579 (S-1-5-5-0-687579)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
c:\>sd -p 4936
SD Length: 100 bytes
SD: O:S-1-5-5-0-340923D:(A;;0x1fffff;;;S-1-5-5-0-340923)(A;;0x1400;;;BA)
Control: DACL Present, Self Relative
Owner: NT AUTHORITY\LogonSessionId  0  340923 (S-1-5-5-0-340923)
DACL: ACE count: 2
ACE    0: Size: 28 bytes, Flags: 0x00 Type: ALLOW
Access: 0x001FFFFF  NT AUTHORITY\LogonSessionId  0  340923 (S-1-5-5-0-340923)
ACE    1: Size: 24 bytes, Flags: 0x00 Type: ALLOW
Access: 0x00001400  BUILTIN\Administrators (S-1-5-32-544)
1
2
3
4
5
6
7
8
9
10
c:\>sd -f c:\temp\test.txt
SD Length: 180 bytes
SD: O:S-1-5-21-2575492975-396570422-1775383339-1001D:AI(D;;CCDCLCSWRPWPLOCRSDRC;;;S-
1-5-21-2575492975-396570422-1775383339-1009)(A;ID;FA;;;BA)(A;ID;FA;;;SY)(A;ID;0x1200
a9;;;BU)(A;ID;0x1301bf;;;AU)
Control: DACL Present, DACL Auto Inherited, Self Relative
Owner: PAVEL7540\pavel (S-1-5-21-2575492975-396570422-1775383339-1001)
DACL: ACE count: 5
ACE    0: Size: 36 bytes, Flags: 0x00 Type: DENY
Access: 0x000301BF  PAVEL7540\alice (S-1-5-21-2575492975-396570422-1775383339-1009)
ACE    1: Size: 24 bytes, Flags: 0x10 Type: ALLOW
Access: 0x001F01FF  BUILTIN\Administrators (S-1-5-32-544)
ACE    2: Size: 20 bytes, Flags: 0x10 Type: ALLOW
Access: 0x001F01FF  NT AUTHORITY\SYSTEM (S-1-5-18)
ACE    3: Size: 24 bytes, Flags: 0x10 Type: ALLOW
Access: 0x001200A9  BUILTIN\Users (S-1-5-32-545)
ACE    4: Size: 20 bytes, Flags: 0x10 Type: ALLOW
Access: 0x001301BF  NT AUTHORITY\Authenticated Users (S-1-5-11)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

上述部分输出内容需要解释一下。安全描述符有基于安全描述符定义语言(SDDL,Security Descriptor Definition Language)的字符串表示形式。有函数可以在二进制表示和字符串表示之间进行转换:ConvertSecurityDescriptorToStringSecurityDescriptor 和 ConvertStringSecurityDescriptorToSecurityDescriptor 。

安全描述符有两种格式:自相关(Self-relative)格式和绝对(Absolute)格式。使用自相关格式时,安全描述符的各个部分被打包到一个便于移动的结构中。绝对格式则包含指向安全描述符各部分的内部指针,所以如果不修改内部指针,就无法移动它。在大多数情况下,实际使用哪种格式并不重要,并且可以使用 MakeAbsoluteSD 和 MakeSelfRelativeSD 在两种格式之间进行转换。

# 默认安全描述符

几乎每个内核对象创建函数都有一个 SECURITY_ATTRIBUTES 结构作为参数。提醒一下,该结构如下所示:

typedef struct _SECURITY_ATTRIBUTES {
    DWORD nLength;
    LPVOID lpSecurityDescriptor;
    BOOL bInheritHandle;
} SECURITY_ATTRIBUTES;
1
2
3
4
5

我们将 bInheritHandle 用作实现句柄继承的一种方式,但 lpSecurityDescriptor 始终为 NULL。如果没有提供整个结构,这意味着 lpSecurityDescriptor 为 NULL。这是否意味着对象没有保护呢?不一定。

我们可以在对象创建后,使用 GetKernelObjectSecurity 来检查附加到该对象的安全描述符,如下所示:

BYTE buffer[1 << 10];
DWORD len;
HANDLE hEvent = ::CreateEvent(nullptr, FALSE, FALSE, nullptr);
::GetKernelObjectSecurity(hEvent,
    DACL_SECURITY_INFORMATION | OWNER_SECURITY_INFORMATION,
    (PSECURITY_DESCRIPTOR)buffer, sizeof(buffer), &len);
1
2
3
4
5
6

现在我们可以检查生成的安全描述符。事实证明,未命名对象(互斥体、事件、信号量、文件映射对象)的安全描述符没有自由访问控制列表(DACL,Discretionary Access Control List)。然而,命名对象(包括文件和注册表项)确实有一个带有默认自由访问控制列表的安全描述符。这个默认自由访问控制列表来自访问令牌,所以我们可以查看它,甚至修改它。

为什么未命名对象没有保护呢?原因可能是由于句柄是私有的,无法从进程外部访问。只有注入的代码才能访问这些句柄。而且由于这些句柄没有任何 “标识标记”,恶意主体不太可能知道它们的用途。另一方面,命名对象是可见的。其他进程可以尝试通过名称打开它们,所以采取某种形式的保护是谨慎的做法。

查询默认自由访问控制列表只需使用正确的值调用 GetTokenInformation 即可:

HANDLE hToken;
::OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken);
::GetTokenInformation(hToken, TokenDefaultDacl, buffer, sizeof(buffer), &len);
auto dacl = ((TOKEN_DEFAULT_DACL*)buffer)->DefaultDacl;
1
2
3
4

你可以使用进程资源管理器(Process Explorer)的句柄视图,或者使用我的对象资源管理器(Object Explorer)的句柄和对象视图来查看内核对象的自由访问控制列表。

# 构建安全描述符

默认安全描述符通常就足够了,但有时你可能希望加强安全性,或者为特定用户或组提供额外权限。为此,你需要在将安全描述符应用到对象之前,构建一个新的安全描述符或修改现有的安全描述符。

以下示例为一个事件对象构建了一个安全描述符,其所有者为管理员别名,自由访问控制列表中有两个访问控制项:

  • 第一个访问控制项允许管理员别名对该事件进行所有可能的访问。
  • 第二个访问控制项仅允许对该事件进行同步(SYNCHRONIZE)访问。

代码不是很美观,但下面是一种实现方法(省略了错误处理):

BYTE sdBuffer[SECURITY_DESCRIPTOR_MIN_LENGTH];
auto sd = (PSECURITY_DESCRIPTOR)sdBuffer;
// 初始化一个空的安全描述符
::InitializeSecurityDescriptor(sd, SECURITY_DESCRIPTOR_REVISION);

// 构建所有者安全标识符
BYTE ownerSid[SECURITY_MAX_SID_SIZE];
DWORD size;
::CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, (PSID)ownerSid, &size);
// 设置所有者
::SetSecurityDescriptorOwner(sd, (PSID)ownerSid, FALSE);

//  Everyone 安全标识符
BYTE everyoneSid[SECURITY_MAX_SID_SIZE];
b = ::CreateWellKnownSid(WinWorldSid, nullptr, (PSID)everyoneSid, &size);

// 构建自由访问控制列表
EXPLICIT_ACCESS ea[2];
ea[0].grfAccessPermissions = EVENT_ALL_ACCESS; // 所有访问权限
ea[0].grfAccessMode = SET_ACCESS;
ea[0].grfInheritance = NO_INHERITANCE;
ea[0].Trustee.ptstrName = (PWSTR)ownerSid;
ea[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[0].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;

ea[1].grfAccessPermissions = SYNCHRONIZE;  	 // 仅同步访问权限
ea[1].grfAccessMode = SET_ACCESS;
ea[1].grfInheritance = NO_INHERITANCE;
ea[1].Trustee.ptstrName = (PWSTR)everyoneSid;
ea[1].Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea[1].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;

PACL dacl;
// 创建包含2个条目的自由访问控制列表
::SetEntriesInAcl(_countof(ea), ea, nullptr, &dacl);
// 在安全描述符中设置自由访问控制列表
::SetSecurityDescriptorDacl(sd, TRUE, dacl, FALSE);

// 最后,使用创建的安全描述符创建对象
SECURITY_ATTRIBUTES sa = { sizeof(sa) };
sa.lpSecurityDescriptor = sd;

HANDLE hEvent = ::CreateEvent(&sa, FALSE, FALSE, nullptr);

// 自由访问控制列表由 SetEntriesInAcl 分配
::LocalFree(dacl);
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

此示例中使用的应用程序编程接口(API,Application Programming Interface)并不是构建自由访问控制列表和安全标识符的唯一可用接口。一种简单的方法(如果你了解安全描述符定义语言)是将所需的安全描述符创建为字符串,然后调用 ConvertStringSecurityDescriptorToSecurityDescriptor ,将其转换为可直接使用的 “真正” 安全描述符。

安全描述符定义语言在微软文档中有完整的说明。

上述代码创建了一个在创建内核对象时使用的安全描述符。如果对象已经存在,则有几个应用程序编程接口可用于更改现有值(详细信息请阅读文档):

BOOL SetKernelObjectSecurity(
    _In_ HANDLE Handle,
    _In_ SECURITY_INFORMATION,
    _In_ PSECURITY_DESCRIPTOR
);

DWORD SetSecurityInfo(
    // 最通用的
    HANDLE handle,
    SE_OBJECT_TYPE ObjectType,
    SECURITY_INFORMATION SecurityInfo,
    _In_opt_ PSID psidOwner,
    _In_opt_ PSID psidGroup,
    _In_opt_ PACL pDacl,
    _In_opt_ PACL pSacl
);

BOOL SetFileSecurity(
    _In_ LPCTSTR lpFileName,
    _In_ SECURITY_INFORMATION,
    _In_ PSECURITY_DESCRIPTOR
);

DWORD SetNamedSecurityInfo(
    _In_ LPTSTR pObjectName,
    _In_ SE_OBJECT_TYPE ObjectType,
    _In_ SECURITY_INFORMATION SecurityInfo,
    _In_opt_ PSID psidOwner,
    _In_opt_ PSID psidGroup,
    _In_opt_ PACL pDacl,
    _In_opt_ PACL pSacl
);
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

# 用户访问控制

用户访问控制(UAC,User Access Control)是Windows Vista中引入的一项功能,它给用户和开发人员都带来了不少麻烦。在Windows Vista之前,用户被创建为本地管理员,从安全角度来看,这是不好的。任何正在执行的代码都具有管理员权限,这意味着任何恶意代码都可以控制系统。

以管理员权限运行很方便,因为大多数操作都能直接执行,但这可能会因写入敏感文件或 HKEY_LOCAL_MACHINE 注册表配置单元而造成损害。

Windows Vista改变了这种情况。新创建的用户不一定是管理员,甚至第一个必须是本地管理员的用户,默认情况下也不会以管理员权限运行。实际上,本地安全授权子系统服务(Lsass,Local Security Authority Subsystem Service)在用户成功登录时,会为本地管理员用户创建两个访问令牌——一个是完整的管理员令牌,另一个是标准用户权限令牌。默认情况下,进程使用标准用户权限令牌运行。

大多数进程不需要以管理员权限执行。像记事本、Word或Visual Studio这样的应用程序就是例子。在大多数情况下,它们使用标准用户权限就能完美运行。然而,在某些情况下,可能希望它们以管理员权限运行。这可以通过提升权限(稍后讨论)来实现。

Windows Vista通过放宽一些操作的要求,使以标准用户权限运行变得更容易:

  • “更改时间” 特权被拆分为两个特权:“更改时间” 和 “更改时区”。“更改时区” 权限授予所有用户,而 “更改时间” 权限仅授予管理员(这是合理的,因为更改时区只是更改时间显示,而不是更改时间本身)。
  • 一些以前仅允许管理员用户进行的配置,现在标准用户也可以进行(例如无线设置、一些电源选项)。
  • 虚拟化(稍后讨论)。

如果一个进程需要以管理员权限运行,它会请求提升权限。这会显示两个对话框之一——如果用户是真正的管理员,则显示 “是/否” 确认对话框;如果用户没有管理员权限,则显示要求输入用户名/密码的对话框(此时用户应呼叫IT部门的管理员,或者该计算机上的其他管理员用户)。对话框的颜色表示允许应用程序提升到管理员权限的危险程度:

  • 如果二进制文件由微软签名,对话框显示为浅蓝色(蓝色被认为是一种让人放松的颜色)。
  • 如果二进制文件由除微软之外的其他实体签名,对话框显示为浅灰色。
  • 如果二进制文件未签名,对话框显示为明亮的橙色/黄色,以引起用户注意,因为这可能是一个危险的可执行文件。

控制面板中的用户账户控制(User Account Control,UAC)对话框有4个级别,用于指示在哪些情况下应弹出提权对话框(图16-19)。

img

图16-19:用户账户控制对话框

虽然看起来有4个选项,但实际上与提权对话框相关的只有3个:

  • 始终通知(Always notify)——任何提权操作都会弹出相应的对话框进行提示。
  • 从不通知(Never notify)——如果用户是真正的管理员,则自动执行提权操作。否则,显示一个用于输入管理员用户名/密码的对话框(也称为管理员批准模式——Administrator Approval Mode,AAM)。
  • 中间选项——如果用户是真正的管理员,对于Windows组件(Windows components),不会弹出同意对话框(是/否)。

“Windows组件”是什么意思呢?它指的是由内核团队创建或与内核紧密相关的应用程序。示例应用程序包括任务管理器(Task Manager)、任务计划程序(Task Scheduler)、设备管理器(Device Manager)和性能监视器(Performance Monitor)。不包含在内的应用程序示例(这些是由外部团队创建的微软组件)有cmd.exe、notepad.exe和regedit.exe。如果选择中间级别,后面这些应用程序会弹出同意对话框。

两个中间选项的区别在于,上面那个(默认选项)会在一个备用桌面(背景设置为原始壁纸的淡化位图)中显示提权对话框,而下面那个则使用默认桌面。

在Windows 8之前的Windows版本中,“从不通知”会使系统使用Vista之前的模式,即只有一个管理员令牌(如果用户是真正的管理员)。从Windows 8开始,UAC无法完全关闭,因为通用Windows平台(Universal Windows Platform,UWP)进程始终使用标准令牌运行。

# 提权(Elevation)

提权是指以管理员权限运行可执行文件的操作。提权过程如图16-20所示。

img

图16-20:提权

启动提权进程的唯一有文档记录的方法是使用Shell函数ShellExecute或ShellExecuteEx(包含<shellapi.h>头文件):

HINSTANCE ShellExecute(
    _In_opt_ HWND hwnd,
    _In_opt_ LPCTSTR lpOperation,
    _In_ LPCTSTR lpFile,
    _In_opt_ LPCTSTR lpParameters,
    _In_opt_ LPCTSTR lpDirectory,
    _In_ INT nShowCmd);

typedef struct _SHELLEXECUTEINFO {
    DWORD cbSize;
    ULONG fMask;
    HWND hwnd;
    LPTSTR lpVerb;
    LPTSTR lpFile;
    LPTSTR lpParameters;
    LPTSTR lpDirectory;
    int nShow;
    HINSTANCE hInstApp;
    void *lpIDList;
    LPCTSTR lpClass;
    HKEY hkeyClass;
    DWORD dwHotKey;
    union {
        HANDLE hIcon;
        HANDLE hMonitor;
    };
    HANDLE hProcess;
} SHELLEXECUTEINFO, *LPSHELLEXECUTEINFO;

BOOL ShellExecuteExW(_Inout_ SHELLEXECUTEINFOW *pExecInfo);
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

这些函数由Shell(资源管理器)和其他应用程序使用,用于基于可执行文件路径以外的信息启动可执行文件(CreateProcess也能很好地完成此操作)。这些函数可以接受任何文件,在注册表中查找其扩展名,并启动相关的可执行文件。例如,使用txt文件扩展名调用ShellExecute(Ex),会在后台使用正确的值调用CreateProcess来启动记事本(如果用户没有更改默认设置)。用于提权的关键参数是lpVerb,它必须设置为“runas”。以下是一个以管理员身份启动记事本的示例:

::ShellExecute(nullptr, L"runas", L"notepad.exe", nullptr, nullptr, SW_SHOWDEFAULT);
1

提权过程(图16-20)会向应用程序信息服务(AppInfo service,托管在标准的Svchost.exe中)发送一条消息。该服务会调用一个辅助可执行文件consent.exe,它会显示相关的提权对话框。如果一切顺利(提权获得批准),应用程序信息服务会调用CreateProcessAsUser,使用提升后的令牌启动可执行文件,然后新进程会被“重新指定父进程”,这样它看起来就像是原始进程创建的(图16-20中是资源管理器,但也可以是调用ShellExecute(Ex)的任何程序)。

这一“重新指定父进程”的操作相当独特。例如,第3章讨论的UWP进程始终由DCOM启动服务(也托管在标准的Svchost.exe实例中)启动,但不会尝试重新指定父进程。

一些应用程序提供“以管理员身份运行”选项(例如,可参考Sysinternals的WinObj)。虽然看起来进程似乎突然获得了提升的权限,但实际情况并非如此。当前进程会退出,然后使用提升的权限启动一个新进程。无法在原位提升令牌权限(如果可以,UAC就毫无用处了)。

# 要求以管理员身份运行(Running As Admin Required)

有些应用程序在使用标准用户权限运行时无法正常工作。这类可执行文件必须以某种方式通知系统,无论如何它们都需要提权。这可以通过使用清单文件(在第1章中讨论过)来实现。其中一部分内容涉及提权要求。以下是相关部分:

<trustInfo xmlns="urn:schema-microsoft-com:asm.v3">
    <security>
        <requestedPrivileges>
            <requestedExecutionLevel Level="requireAdministrator" />
        </requestedPrivileges>
    </security>
</trustInfo>
1
2
3
4
5
6
7

Level值指示请求的提权类型。可能的值有:

  • asInvoker——如果未指定值,则为默认值。表示如果可执行文件的父进程以提升的权限运行,则该可执行文件应提升权限运行,否则以标准用户权限运行。
  • requireAdministrator——表示需要管理员提权。如果没有提权,进程将无法启动。
  • highestAvailable——中间值。表示如果启动用户是真正的管理员,则尝试提权。否则,以标准用户权限运行。

注册表编辑器(regedit.exe)就是highestAvailable的一个示例。如果用户是本地管理员,它会请求提权。否则,它仍然可以运行。regedit的某些功能将无法使用,例如在HKEY_LOCAL_MACHINE中进行更改,这是标准用户不允许的;但该应用程序仍然可用。

在Visual Studio中,无需手动创建XML文件并将其指定为清单,就可以轻松设置这些选项之一(图16-21)。

img

图16-21:Visual Studio中的提权要求

# UAC虚拟化(UAC Virtualization)

许多Vista之前的应用程序(有意或无意地)假定用户是本地管理员。其中一些应用程序执行的操作只有在具有管理员权限时才能成功,例如写入系统目录或写入HKEY_LOCAL_MACHINE\Software注册表项。当这些应用程序在Vista或更高版本上运行时,默认使用的是标准用户令牌,这时会发生什么呢?

最简单的做法是向应用程序返回“访问被拒绝”错误,但这会导致这些应用程序出现故障,因为它们没有针对UAC进行更新。微软的解决方案是UAC虚拟化,在这种情况下,这些应用程序会被重定向到文件系统/注册表中用户文件/HKEY_CURRENT_USER配置单元下的一个私有区域,这样调用就不会失败。

然而,这是一把双刃剑。如果这样的应用程序对系统文件进行更改(或者认为它进行了更改),实际上这些文件并没有真正被更改,因此其他未虚拟化的应用程序将无法获取这些更改。该应用程序会看到自己的更改——系统首先会查看私有存储区,如果未找到,则会在重新分配的区域中查找。

对于文件系统,私有存储区位于C:\Users<用户名>\AppData\Local\VirtualStore。你可以通过在网上搜索“UAC Virtualization”来找到更多详细信息。

UAC虚拟化会自动应用于被标记为“旧版”的可执行文件,这里的“旧版”是指32位可执行文件,并且没有清单表明它是适用于Vista及更高版本的应用程序。无论如何,通过在访问令牌中启用UAC虚拟化,也可以为其他进程启用它。你可以在任务管理器中查看UAC虚拟化列。可能的值有3个:

  • 不允许(Not Allowed)——虚拟化被禁用且无法启用。这是系统进程和服务的设置。
  • 已禁用(Disabled)——虚拟化未激活。
  • 已启用(Enabled)——虚拟化已激活。

你可以进行一个简单的实验来观察UAC虚拟化的实际效果。

  • 打开记事本,写入一些内容并将文件保存到System32目录。这将失败,因为该进程没有权限写入该目录。
  • 打开任务管理器,右键单击记事本进程并启用UAC虚拟化。
  • 现在再次尝试在记事本中保存文件。这次会成功。
  • 打开资源管理器并导航到System32目录。注意,你保存的文件并不在那里。由于虚拟化,它被保存到了虚拟存储区中。
  • 为记事本禁用虚拟化,然后尝试从System32目录打开该文件。文件不会在那里。

# 完整性级别(Integrity Levels)

完整性级别是Windows Vista中引入的另一个功能(正式名称为强制完整性控制,Mandatory Integrity Control)。引入它的一个原因是为了在同一用户下,将使用标准权限令牌运行的进程与使用提升权限令牌运行的进程区分开来。显然,使用提升权限令牌运行的进程功能更强大,也是攻击者更青睐的目标。区分它们的方法是使用完整性级别。

完整性级别由安全标识符(Security Identifier,SID)表示,对于进程来说,它存储在进程的令牌中。表16-3显示了定义的完整性级别。

表16-3:标准完整性级别

完整性级别 安全标识符(SID) 备注
系统(System) S - 1 - 16-16384 最高级别,用于系统进程和服务
高(High) S - 1 - 16-12288 用于使用提升权限令牌运行的进程
中加(Medium Plus) S - 1 - 16 - 8448
中(Medium) S - 1 - 16 - 8192 用于使用标准用户权限运行的进程
低(Low) S - 1 - 16 - 4096 用于UWP进程和大多数浏览器

进程资源管理器(Process Explorer)有一个“完整性级别”列,用于显示进程的完整性级别(图16-22)。

img

图16-22:进程资源管理器中的完整性级别

图16-22中显示的完整性级别的“应用容器(AppContainer)”值等同于“低”,但“应用容器”一词用于UWP进程所在的沙盒环境。

与完整性级别相关的一个术语是强制策略(Mandatory Policy),它指示完整性级别的差异实际上如何影响操作。默认值是“禁止向上写入(No Write Up)”,这意味着当一个进程试图访问具有更高完整性级别的对象时,不允许进行写入类型的访问。例如,完整性级别为“中”的进程A想要打开一个指向完整性级别为“高”的进程的句柄,只能被授予以下访问掩码:PROCESS_QUERY_LIMITED_INFORMATION、SYNCHRONIZE和PROCESS_TERMINATE。

那么非进程对象呢?所有对象(包括文件)的完整性级别默认为“中”,除非通过添加类型为“强制标签(Mandatory Label)”且具有不同值的访问控制项(Access Control Entry,ACE)来显式更改。总是可以将完整性级别设置为低于调用者令牌的级别,但只有在调用者具有SeRelabelPrivilege(通常不会授予任何人)时,才有可能将完整性级别设置为更高。

再举个例子,UWP进程以低完整性级别运行意味着它们甚至无法访问用户文档或图片等常见文件位置,因为这些位置具有中等完整性级别。如今,大多数浏览器也以低完整性级别运行其进程。这样,如果恶意文件被此类浏览器下载并执行,它将以低完整性级别执行,从而限制了其造成损害的能力。

当启动一个可执行文件时,新进程的完整性级别是可执行文件的完整性级别和调用者进程令牌的完整性级别的最小值。

你可以使用GetTokenInformation函数并传入TokenIntegrityLevel枚举值来读取进程的完整性级别,使用SetTokenInformation函数来设置完整性级别。

完整性级别与自主访问控制列表(Discretionary Access Control List,DACL)有什么关系呢?完整性级别优先。如果调用者的完整性级别等于或高于目标对象的完整性级别,则使用DACL进行正常的访问检查。否则,“禁止向上写入”策略优先。

有关完整性级别及相关术语的更多信息,请查阅官方文档https://docs.microsoft.com/en-us/windows/win32/secauthz/mandatory-integrity-control (opens new window)。

# 用户界面特权隔离(UIPI)

用户界面特权隔离(User Interface Privilege Isolation,UIPI)是一种基于完整性级别(integrity levels)的功能。假设有一个具有高完整性级别的进程,它拥有窗口(图形用户界面,GUI)。如果其他进程(可能具有较低的完整性级别)向这些窗口发送消息,会发生什么情况呢?向窗口发送不受控制的消息,可能会导致创建该窗口的线程执行一些调用进程通常不被允许执行的操作。

UIPI是一种用于防止此类情况发生的机制。一个进程不能向由另一个具有更高完整性级别的进程所拥有的窗口发送消息,但少数良性消息除外(例如:WM_NULL、WM_GETTEXT和WM_GETICON)。

具有较高完整性级别的进程可以通过调用ChangeWindowMessageFilter或ChangeWindowMessageFilterEx函数,来允许某些消息通过:

BOOL ChangeWindowMessageFilter(
    _In_ UINT message,
    _In_ DWORD dwFlag
);

BOOL ChangeWindowMessageFilterEx(
    _In_ HWND hwnd,
    _In_ UINT message,
    _In_ DWORD action,
    _Inout_opt_ PCHANGEFILTERSTRUCT pChangeFilterStruct
);
1
2
3
4
5
6
7
8
9
10
11

ChangeWindowMessageFilter函数用于允许或阻止某个消息通过,dwFlag参数可以取值为MSGFLT_ADD(允许)或MSGFLT_REMOVE(阻止)。这个调用会影响进程中的所有窗口。

Windows 7添加了ChangeWindowMessageFilterEx函数,以便对每个单独的窗口进行更精细的控制。action参数可以取值为MSGFLT_ALLOW(允许)、MSGFLT_DISALLOW(阻止,除非进程范围的筛选器允许)或MSGFLT_RESET(重置为进程范围的设置)。pChangeFilterStruct是一个指向可选结构的指针,该结构返回调用这两个函数的详细效果信息。如需更多信息,请查阅相关文档。

# 专用安全机制

基本的安全机制,如安全描述符(security descriptors)和特权(privileges),自Windows NT的第一个版本起就已存在。随着时间的推移,Windows中又添加了更多的安全机制,例如强制完整性控制(mandatory integrity control)。如今,恶意行为者的攻击比以往任何时候都更强大,Windows试图通过引入新的防御机制来应对。其中许多机制超出了本书的范围(例如基于虚拟化的安全性,Virtualization Based Security)。在本节中,我们将探讨一些可以通过编程方式利用的机制。

# 控制流防护(Control Flow Guard)

控制流防护(Control Flow Guard,CFG)于Windows 10和Server 2016中引入,用于缓解与间接调用相关的特定类型的攻击。例如,C++中的虚函数调用是通过一个虚表指针(virtual table pointer)来完成的,该指针指向一个虚表(virtual table),实际的目标函数存储在虚表中。图16-23展示了一个具有虚函数的C++对象在内存中的样子。

img

图16-23:具有虚函数的C++对象

每个对象都以一个虚表指针(vptr)开头,该指针指向类A的虚表。注入到进程中的恶意代理可以覆盖vptr(因为它所在的内存是可读/写的),并将vptr重定向到它选择的备用虚表(图16-24)。

COM类也使用虚表机制,因此CFG对这类对象也同样适用。

img

图16-24:虚表重定向

CFG在进行任何间接调用之前会进行额外的检查。如果间接调用的目标不在进程中的任何一个模块(动态链接库,DLL和可执行文件,EXE)中,那么它很可能是被注入到进程中的某些shellcode重定向了,在这种情况下,进程将终止。这个过程如图16-25所示。

img

图16-25:正在工作的CFG

在Visual Studio中,通过在项目属性中选择CFG选项,就可以相当轻松地获得CFG支持(图16-26)。请注意,CFG与“编辑并继续的调试信息”存在冲突,因此后者必须更改为“程序数据库”。

img

图16-26:Visual Studio中的CFG选项

以下是一些C++代码示例(可在CfgDemo应用程序中找到):

class A {
public:
    virtual ~A() = default;
    virtual void DoWork(int x) {
        printf("A::DoWork %d\n", x);
    }
};

class B : public A {
public:
    void DoWork(int x) override {
        printf("B::DoWork %d\n", x);
    }
};

void main() {
    A a;

    a.DoWork(10);
    B b;
    b.DoWork(20);

    A* pA = new B;
    pA->DoWork(30);

    delete pA;
}
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

类A定义了两个虚方法——析构函数和DoWork,因此它的虚表中有两个函数指针,顺序如上。

对a.DoWork和b.DoWork的调用不必以多态方式进行。而pA->DoWork的调用必须以多态方式进行。以下是在应用CFG之前,pA->DoWork调用的汇编输出(x64,调试版本):

; 29      :          pA->DoWork(30);
000a5  mov   rax, QWORD PTR pA$[rsp]
000aa  mov   rax, QWORD PTR [rax]
000ad  mov   edx, 30
000b2  mov   rcx, QWORD PTR pA$[rsp]
000b7  call  QWORD PTR [rax+8]     ;  normal  call
1
2
3
4
5
6

可以看到,值30被存入EDX寄存器。RCX是this指针(这都是x64调用约定的一部分)。RAX指向虚表,调用本身是间接调用虚表中偏移8字节的位置,因为DoWork是第二个函数(在64位进程中,每个函数指针占8字节)。

如果汇编语言不是你的专长,你可以放心跳过这部分内容,只需知道CFG是起作用的即可。

应用CFG之后,pA->DoWork调用的结果代码如下:

; 29      :          pA->DoWork(30);
000a5  mov rax, QWORD PTR pA$[rsp]
000aa   mov  rax, QWORD PTR [rax]
000ad   mov  rax, QWORD PTR [rax+8]
000b1  mov QWORD PTR tv70[rsp], rax ;  tv70=128
000b9  mov edx, 30
000be  mov rcx, QWORD PTR pA$[rsp]
000c3  mov rax, QWORD PTR tv70[rsp]
000cb  call QWORD PTR   guard_dispatch_icall_fptr
1
2
3
4
5
6
7
8
9

最后一行是关键所在。它调用了NtDll.dll中的一个函数,该函数会检查调用目标是否有效。如果有效,就进行调用;否则,终止进程。

请验证delete pA调用(它会调用析构函数)也是通过CFG进行调用的。

支持CFG的二进制文件在其可移植可执行文件(Portable Executable,PE)中包含额外的信息,这些信息列出了二进制文件中的有效函数。这不仅包括导出函数,还包括所有函数。你可以使用dumpbin.exe查看这些信息:

C:\>dumpbin /loadconfig cfgdemo.exe
Microsoft (R) COFF/PE Dumper Version 14.27.28826.0
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file c:\dev\temp\ConsoleApplication6\x64\Debug\cfgdemo.exe

File Type: EXECUTABLE IMAGE

Section contains the following load config:
...
000000014000F008 Security Cookie
0000000140015000 Guard CF address of check-function pointer
0000000140015020 Guard CF address of dispatch-function pointer
0000000140015010 Guard XFG address of check-function pointer
0000000140015030 Guard XFG address of dispatch-function pointer
0000000140015040 Guard XFG address of dispatch-table-function pointer
0000000140013000 Guard CF function table
1C Guard CF function count
00014500 Guard Flags
CF instrumented
FID table present
Export suppression info present
Long jump target table present
0000 Code Integrity Flags
0000 Code Integrity Catalog
00000000 Code Integrity Catalog Offset
00000000 Code Integrity Reserved
0000000000000000 Guard CF address taken IAT entry table
0 Guard CF address taken IAT entry count
0000000000000000 Guard CF long jump target table
0 Guard CF long jump target count
0000000000000000 Guard EH continuation table
0 Guard EH continuation count
0000000000000000 Dynamic value relocation table
0000000000000000 Hybrid metadata pointer
0000000000000000 Guard RF address of failure-function
0000000000000000 Guard RF address of failure-function pointer
00000000 Dynamic value relocation table offset
0000 Dynamic value relocation table section
0000 Reserved2
0000000000000000 Guard RF address of stack pointer verification function pointer
00000000 Hot patching table offset
0000 Reserved3
0000000000000000 Enclave configuration pointer
0000000000000000 Volatile metadata pointer

Guard CF Function Table

Address
--------
0000000140001040 @ILT+48(??_EB@@UEAAPEAXI@Z)
0000000140001050 @ILT+64(mainCRTStartup)
0000000140001100 @ILT+240(??_Ebad_array_new_length@std@@UEAAPEAXI@Z)
0000000140001110 @ILT+256(?DoWork@A@@UEAAXH@Z)
...
00000001400013F0 @ILT+992(?DoWork@B@@UEAAXH@Z)
...
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

注意两个被混淆处理的DoWork函数以及各种控制流防护(Control Flow Guard,CFG)信息。

控制流防护(CFG)是如何工作的呢?加载程序会创建一个较大的保留位图,每个有效的函数会在这个大位图中“打”一个“1”位。检查一个函数是否有效是一个时间复杂度为O(1)的操作,即函数指针会快速右移,以找到位图中代表它的位。如果该位是“1”,则函数有效。如果该位是“0”或者内存未提交,那么这个地址就是无效的,进程就会终止。

上述解释并不完全准确,但就本书的目的而言已经足够了。如需了解确切细节,请查阅《Windows Internals, 7th edition, part 1》这本书。

进程资源管理器(Process Explorer)为进程和模块提供了一个控制流防护(CFG)列。对于进程而言,你可以看到64位进程的虚拟大小(Virtual Size)列大约是2TB。其中大部分内存是控制流防护(CFG)位图,并且大部分内存是保留的。你可以在VMMap Sysinternals工具中打开进程来验证这一点。图16-27展示了一个记事本(Notepad)实例的VMMap及其控制流防护(CFG)位图。

img

图16-27:VMMap中的控制流防护(CFG)位图

# 进程缓解措施

Windows 8引入了进程缓解措施,即能够以单向方式为进程设置各种与安全相关的属性;一旦设置了某项缓解措施,就无法撤销(如果可以撤销,恶意代码就可能会关闭这些缓解措施)。几乎每次Windows发布新版本,缓解措施的列表都会增加。

有四种设置进程缓解措施的方法:

  • 使用由组织中的管理员控制的组策略设置。
  • 使用基于可执行文件名称(而非完整路径)的“映像文件执行选项”(Image File Execution Options)注册表项。
  • 通过调用CreateProcess函数并使用进程属性为创建的进程设置缓解措施。
  • 在进程内部调用SetProcessMitigationPolicy函数。

就本书的目的而言,使用组策略设置并不值得关注。我们在第13章中提到过“映像文件执行选项”(IFEO)注册表项。其完整的键路径是HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options。这是一个加载程序在进程启动时读取的键,用于为进程设置各种属性,其中之一就与进程缓解措施相关。使用我的GFlagsX工具可以方便地对其中许多缓解措施进行试验。图16-28展示了打开GFlagsX工具并选择“映像”选项卡的界面。在这里,你可以看到当前在其“映像文件执行选项”(IFEO)键中有一些设置的可执行文件列表。你可以点击“新建映像...”为某个可执行文件(示例中为Notepad.exe)创建一个键(扩展名是必需的)。

img

图16-28:GFlagsX工具

窗口左侧与NT全局标志(NT Global Flags)相关,这不在本章讨论范围内。其中一些内容将在第20章中讨论。窗口右侧显示了缓解选项列表。详细研究所有缓解措施类型超出了本书的范围(如需完整详细信息,请查看文档)。以下是对其中一些缓解措施的简要解释:

  • 严格句柄检查(Strict Handle Checks)——如果使用了无效句柄,则终止进程,而不仅仅返回错误。无效句柄可能是注入的恶意代码关闭句柄或滥用句柄导致的。
  • 禁用Win32K调用(Disable Win32K calls)——如果进行任何user32.dll或gdi32.dll调用,就会引发异常。Win32k.sys是Windows子系统的内核组件,过去(可能现在和将来也是如此)被用于各种攻击。如果进程只是一个不需要图形用户界面(GUI)的工作进程,使用此缓解措施可以防止该进程受到Win32k相关的攻击。
  • 控制流防护(Control Flow Guard)——要求加载的所有动态链接库(DLL)都支持控制流防护(CFG)。如果不支持,非控制流防护(CFG)的动态链接库(DLL)将正常加载,在这种情况下,必须将其整个内存设置为控制流防护(CFG)的有效目标。
  • 优先使用系统映像(Prefer System Images)——确保加载的任何存在于System32目录中的动态链接库(DLL)优先于其他任何位置的同名动态链接库(DLL)(这不包括始终从System32目录获取的已知动态链接库(Known DLLs))。

作为一个简单的实验,为Notepad.exe选择“禁用Win32K调用”并将其设置为“始终开启”,然后点击“应用设置”。现在尝试启动记事本(Notepad),它应该会启动失败,因为记事本(Notepad)需要user32.dll和gdi32.dll。在这种情况下,GFlagsX写入注册表的值如图16-29所示。

img

图16-29:缓解选项的“映像文件执行选项”(IFEO)值设置

确保你为记事本(Notepad)移除了这个缓解措施(或者完全删除该键),以便记事本(Notepad)能够正常执行。

父进程可以通过使用PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY进程属性为子进程设置进程缓解措施(进程属性在第3章中讨论过)。

以下是一个使用CreateProcess启动带有控制流防护(CFG)缓解措施的可执行文件的示例:

HANDLE LaunchWithCfgMitigation(PWSTR exePath) {
    PROCESS_INFORMATION pi;
    STARTUPINFOEX si = { sizeof(si) };
    SIZE_T size;

    // 缓解措施
    DWORD64 mitigation = 
        PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_ON;

    // 获取一个属性所需的大小
    ::InitializeProcThreadAttributeList(nullptr, 1, 0, &size);
    // 分配内存
    si.lpAttributeList = (PPROC_THREAD_ATTRIBUTE_LIST)::malloc(size);

    // 用一个属性初始化
    ::InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &size);

    // 添加我们想要的属性
    ::UpdateProcThreadAttribute(si.lpAttributeList, 0,
        PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY,
        &mitigation, sizeof(mitigation), nullptr, nullptr);

    // 创建进程
    BOOL created = ::CreateProcess(nullptr, exePath, nullptr, nullptr, FALSE,
        EXTENDED_STARTUPINFO_PRESENT, nullptr, nullptr, (STARTUPINFO*)&si, &pi);

    // 释放资源
    ::DeleteProcThreadAttributeList(si.lpAttributeList);
    ::free(si.lpAttributeList);
    ::CloseHandle(pi.hThread);

    return created? pi.hProcess : nullptr;
}
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

子进程对应用于它的缓解措施没有“发言权”,它必须能够应对这些措施。

设置缓解措施的最后一种方法是在进程内部通过调用SetProcessMitigationPolicy函数来实现。然而,并非所有缓解选项都可以通过这种方式设置(请查看每个缓解措施的文档以获取详细信息)。

BOOL SetProcessMitigationPolicy(
    _In_ PROCESS_MITIGATION_POLICY MitigationPolicy,
    _In_ PVOID lpBuffer,
    _In_ SIZE_T dwLength);
1
2
3
4

PROCESS_MITIGATION_POLICY是一个包含各种支持的缓解措施的枚举类型。lpBuffer是一个指向相关结构的指针,具体取决于缓解措施的类型。最后,dwLength是缓冲区的大小。

以下示例展示了如何设置加载映像策略缓解选项:

PROCESS_MITIGATION_IMAGE_LOAD_POLICY policy = { 0 };
policy.NoRemoteImages = true;
policy.NoLowMandatoryLabelImages = true;

::SetProcessMitigationPolicy(ProcessImageLoadPolicy,
    &policy, sizeof(policy));
1
2
3
4
5
6

# 总结

Windows中的安全是一个很大的话题,可能需要专门写一本书来阐述。在本章中,我们探讨了Windows安全系统中的主要概念,并研究了各种用于处理安全问题的应用程序编程接口(API)。更多信息可以在官方文档和各种在线资源中找到。

在下一章中,我们将把注意力转向Windows中最著名的数据库——注册表。

第15章:动态链接库
第17章:注册表

← 第15章:动态链接库 第17章:注册表→

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式