第 4 章 对象管理 --- 4.6 对象的访问控制 / 4.7 句柄的遗传和继承
本节深入对象管理的两个核心安全机制:访问控制和句柄继承。访问控制决定了"谁能对对象做什么",句柄继承决定了"对象如何跨越进程边界传递"。两者共同构成了 Windows 对象安全模型的基石。
4.6 对象的访问控制
4.6.0 框架图
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 访问控制参与者 │
│ │
│ ┌────────────────────┐ │
│ │ Subject (调用方) │ = Token (用户 SID + 组 + 特权 + 完整性级别) │
│ │ - Primary Token │ │
│ │ - Impersonation │ │
│ └──────────┬─────────┘ │
│ │ │
│ │ 发起访问请求:DesiredAccess │
│ ▼ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ SeAccessCheck │◄──┤ SECURITY_DESCRIPTOR│ │
│ │ - 检查 Token │ │ - Owner SID │ │
│ │ - 检查 DACL │ │ - Group SID │ │
│ │ - 检查特权 │ │ - DACL │ │
│ │ - 完整性级别 │ │ - SACL │ │
│ └──────────┬─────────┘ └────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ GrantedAccess │ = 实际授予的访问掩码 │
│ │ (可能 < Desired) │ │
│ └────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 访问检查决策树 │
│ │
│ 1. AccessMode == KernelMode? │
│ ├─ Yes → 跳过检查,直接授权(除非 OBJ_FORCE_ACCESS_CHECK) │
│ └─ No → 继续 │
│ │
│ 2. Token == NULL (匿名用户)? │
│ └─ Yes → 拒绝(除非 AccessMode == KernelMode) │
│ │
│ 3. DACL == NULL? │
│ ├─ Yes → 全部允许 │
│ └─ No → 继续 │
│ │
│ 4. Owner SID 检查 │
│ ├─ 调用方是 Owner → 允许 WRITE_OWNER / READ_CONTROL │
│ └─ 否则继续 │
│ │
│ 5. DACL 遍历:检查每一条 ACE │
│ ├─ ACCESS_ALLOWED_ACE → 检查 SID 匹配 │
│ ├─ ACCESS_DENIED_ACE → 检查 SID 匹配 │
│ ├─ 找到匹配 → 允许 / 拒绝 │
│ └─ 遍历结束无匹配 → 拒绝 │
│ │
│ 6. SeAccessCheck 还需要检查特权: │
│ ├─ SeTcbPrivilege │
│ ├─ SeDebugPrivilege │
│ ├─ SeBackupPrivilege │
│ └─ SeRestorePrivilege │
└──────────────────────────────────────────────────────────────────────────────────┘
4.6.0.1 设计意图
核心问题
Windows 是一个多用户操作系统。不同用户(甚至同一用户的不同进程)对同一内核对象可能拥有不同的访问权限。例如:
- 进程 A 创建了一个文件,希望进程 B 可以读取但不能修改
- 进程 A 创建了一个互斥体,希望进程 B 和 C 都可以使用
- 系统服务(SYSTEM)需要访问普通用户的资源
如何用一套统一的机制来描述并执行"谁能对对象做什么"是内核安全子系统的根本问题。
设计哲学
Windows 采用「能力(Capability) + ACL(访问控制列表) + Token(身份令牌)」三件套模型:
- 能力 :每个句柄携带
GrantedAccess位掩码,定义"持句柄者能做什么" - ACL :每个对象携带
DACL(自主 ACL),定义"谁能访问" - Token :每个线程携带
Token,定义"我是谁"
访问检查时,内核做以下判断:
句柄.GrantedAccess & DesiredAccess == DesiredAccess?(能力检查)DACL 是否允许 Token 的 SID 访问?(ACL 检查)
本节定位
本节将从 ReactOS 源码出发,深入解析访问控制模型的三个核心组件:SECURITY_DESCRIPTOR、ACCESS_MASK、TOKEN。我们将以 SeAccessCheck(位于 ntoskrnl/se/accesschk.c:1994(file:///d:/reactos/ntoskrnl/se/accesschk.c#L1994))为核心,揭示 Windows 安全检查的完整流程。
4.6.1 访问控制模型概览
Windows 的安全模型建立在几个核心概念上:
4.6.1.1 主体(Subject)
「主体 」是访问请求的发起方,在 Windows 中用 TOKEN 结构表示:
c
typedef struct _TOKEN {
// ...
SID_AND_ATTRIBUTES User; // 用户的 SID + 属性
SID_AND_ATTRIBUTES Groups[32]; // 所属的组 SID + 属性
ULONG PrivilegeCount; // 启用的特权数量
LUID_AND_ATTRIBUTES Privileges[...];// 特权列表(如 SeShutdownPrivilege)
// ...
} TOKEN, *PTOKEN;
Token 的两种类型:
- 主 Token(Primary Token):进程级身份,描述"这个进程是谁"
- 模拟 Token(Impersonation Token):线程级身份,描述"这个线程当前在代表谁"
句柄检查时使用哪个?
- 如果线程在模拟(
ETHREAD->ActiveImpersonationInfo不为 NULL)→ 使用模拟 Token - 否则 → 使用进程的主 Token
4.6.1.2 客体(Object)
「客体 」是访问请求的目标,在 Windows 中用 SECURITY_DESCRIPTOR 表示。
每个对象都关联一个 SecurityDescriptor (在 OBJECT_HEADER 中存储,参见 4.1.3 节)。
4.6.1.3 访问请求(Access Request)
「访问请求 」是主体对客体的访问尝试,由 ACCESS_MASK 表示:
c
typedef ULONG ACCESS_MASK; // 32 位位掩码
#define DELETE (0x00010000L)
#define READ_CONTROL (0x00020000L)
#define WRITE_DAC (0x00040000L)
#define WRITE_OWNER (0x00080000L)
#define SYNCHRONIZE (0x00100000L)
#define STANDARD_RIGHTS_REQUIRED 0x000F0000L
// 类型特定权限
#define FILE_READ_DATA (0x0001)
#define FILE_WRITE_DATA (0x0002)
#define PROCESS_TERMINATE (0x0001)
// ... 等等
通用权限映射:
c
#define GENERIC_READ (0x80000000L)
#define GENERIC_WRITE (0x40000000L)
#define GENERIC_EXECUTE (0x20000000L)
#define GENERIC_ALL (0x10000000L)
GENERIC_* 是平台无关的"通用"权限,每个对象类型通过 GENERIC_MAPPING 映射到具体权限(参见 4.2 节)。
4.6.1.4 为什么需要三层模型?
为什么 Windows 设计了「主体(Token) + 客体(SD) + 访问请求(AccessMask)」三层结构?为什么不采用更简单的模型------比如 Unix 的「User/Group/Other + Mode Bits」?
原因 1:Unix 模型的不足
Unix 的 rwxrwxrwx 模式简洁高效,但表达力有限:
- 只能区分「文件所有者」「同组用户」「其他用户」三类
- 无法为"特定的非所有者用户"设置特殊权限
- 无法实现复杂的拒绝策略(如「允许所有人读,但拒绝某个特定用户」)
- 无法表达「管理员特权」与「普通用户权限」的差异
Windows 面对的场景更复杂:企业级多用户环境、域环境、服务隔离、多终端会话。Unix 模型在这些场景下会变得力不从心。
原因 2:能力与 ACL 的双重保证
「能力(Capability)」与「ACL」是两种互补的安全模型:
- 能力:持有者自身携带权限标识(如「我有这个文件的读权限」)
- ACL:对象自身声明访问规则(如「只有管理员可以写我」)
Windows 同时使用两者:句柄表携带 GrantedAccess(能力),对象携带 DACL(ACL)。每次访问时两者都检查,提供深度防御。
原因 3:可扩展性与中央策略
DACL 是"数据驱动"的------内核不需要知道"哪些用户属于什么角色",这些由系统管理员在活动目录中配置。这种设计将「策略」与「机制」分离:
- 机制 :内核实现
SeAccessCheck算法(在 ntoskrnl/se/accesschk.c(file:///d:/reactos/ntoskrnl/se/accesschk.c) 中,约 500 行) - 策略:由系统管理员通过组策略、安全策略编辑器配置
原因 4:特权(Privilege)与权限(Permission)的分层
Windows 区分「权限」和「特权」:
- 权限:针对特定对象的访问许可(如「对文件 C:\foo 有写权限」)
- 特权:系统级能力(如「可以关闭系统」「可以加载驱动」「可以调试任意进程」)
特权由 SePrivilegeCheck(ntoskrnl/se/token.c(file:///d:/reactos/ntoskrnl/se/token.c))独立检查,不通过 DACL。这种设计允许"即使对象拒绝你,你作为管理员仍可以通过特权绕过去"------这是企业级系统管理的必需能力。
4.6.1.5 为什么 Token 中需要组 SID?
Token 中不仅包含用户 SID,还包含用户所属的所有组 SID。为什么需要这种"多身份"模型?
原因 1:继承自本地组
用户可以属于多个组(如「Administrators」「Users」「Remote Desktop Users」)。每次访问检查都需要检查用户所有组对应的 ACE。
原因 2:拒绝优先
如果一个用户同时属于「Administrators(允许)」和「Guests(拒绝)」两个组,Windows 如何处理?答案是「拒绝优先」------ 只要有一个 DENY ACE 匹配,访问就被拒绝。这确保了管理员可以通过"把用户加入拒绝组"来即时撤销权限,不需要修改每个对象的 ACL。
原因 3:完整性级别(Vista 之后引入)
Windows Vista 引入的「强制完整性控制」(MIC)是 Token 的第四维度。进程运行在「低/中/高/系统」四个完整性级别之一。即使 DACL 允许访问,低完整性进程也无法写入高完整性对象。这提供了额外的安全层------浏览器以低完整性运行,即使被攻破也无法修改系统文件。
4.6.1.6 访问检查的输出
SeAccessCheck 的输出有两个:
GrantedAccess:实际授予的访问权限AccessStatus:成功 (STATUS_SUCCESS) 或失败 (如STATUS_ACCESS_DENIED)
为什么需要两个输出?
看似重复,实际上各司其职:
AccessStatus:是/否答案 --- 用于内核立即决定是否允许操作GrantedAccess:精确权限掩码 --- 用于后续优化(如「只检查一次权限,后续操作直接位掩码判断」)
典型用法:
c
SeAccessCheck(..., &GrantedAccess, &Status);
if (!NT_SUCCESS(Status)) return STATUS_ACCESS_DENIED;
// 后续多次使用 GrantedAccess 进行快速检查
if (!(GrantedAccess & FILE_WRITE_DATA)) {
// 不允许写操作
}
这种"先检查后缓存"模式在对象管理器中非常常见,避免每次操作都重新走一遍完整的 DACL 检查流程。
4.6.2 SECURITY_DESCRIPTOR 结构详解
c
typedef struct _SECURITY_DESCRIPTOR {
UCHAR Revision;
UCHAR Sbz1;
SECURITY_DESCRIPTOR_CONTROL Control;
PSID Owner; // 指向 Owner SID(在描述符之后)
PSID Group; // 指向 Group SID
PACL Sacl; // 指向 SACL(系统访问控制列表)
PACL Dacl; // 指向 DACL(自主访问控制列表)
} SECURITY_DESCRIPTOR, *PSECURITY_DESCRIPTOR;
Control 标志:
| 标志 | 含义 |
|---|---|
SE_OWNER_DEFAULTED |
Owner SID 来自默认机制 |
SE_GROUP_DEFAULTED |
Group SID 来自默认机制 |
SE_DACL_PRESENT |
DACL 存在 |
SE_DACL_DEFAULTED |
DACL 来自默认机制 |
SE_SACL_PRESENT |
SACL 存在 |
SE_SACL_DEFAULTED |
SACL 来自默认机制 |
SE_DACL_AUTO_INHERIT_REQ |
父对象 DACL 自动继承被请求 |
SE_SACL_AUTO_INHERIT_REQ |
父对象 SACL 自动继承被请求 |
SE_DACL_AUTO_INHERITED |
父对象 DACL 自动继承已应用 |
SE_SACL_AUTO_INHERITED |
父对象 SACL 自动继承已应用 |
SE_DACL_PROTECTED |
防止 DACL 被继承 |
SE_SACL_PROTECTED |
防止 SACL 被继承 |
SE_RM_CONTROL_VALID |
资源管理器控制位有效 |
SE_SELF_RELATIVE |
自相关格式(所有指针用偏移量) |
DACL(Discretionary ACL):
c
typedef struct _ACL {
UCHAR AclRevision;
UCHAR Sbz1;
USHORT AclSize;
USHORT AceCount;
USHORT Sbz2;
} ACL, *PACL;
ACE(Access Control Entry):
c
typedef struct _ACE_HEADER {
UCHAR AceType; // ACCESS_ALLOWED_ACE_TYPE / ACCESS_DENIED_ACE_TYPE / ...
UCHAR AceFlags; // OBJECT_INHERIT_ACE / CONTAINER_INHERIT_ACE / ...
USHORT AceSize; // 本 ACE 大小
} ACE_HEADER, *PACE_HEADER;
typedef struct _ACCESS_ALLOWED_ACE {
ACE_HEADER Header;
ACCESS_MASK Mask; // 允许的访问掩码
PSID SidStart; // 标识本 ACE 适用于哪个 SID
} ACCESS_ALLOWED_ACE;
ACE 类型:
ACCESS_ALLOWED_ACE_TYPE:允许指定 SID 访问ACCESS_DENIED_ACE_TYPE:拒绝指定 SID 访问SYSTEM_AUDIT_ACE_TYPE:审计指定 SID 访问SYSTEM_ALARM_ACE_TYPE:警报(已废弃)ACCESS_ALLOWED_OBJECT_ACE_TYPE:对象特定的允许ACCESS_DENIED_OBJECT_ACE_TYPE:对象特定的拒绝
4.6.2.1 为什么每个 ACE 是变长结构?
DACL 中的 ACE 不包含固定大小的 SID------SID 是变长的(SidLength 字段)。因此 ACE 也是变长的:Header(4 字节) + Mask(4 字节) + SID(变长)。
为什么这样设计?
- 节省空间:如果固定 SID 大小(如 64 字节),大量短 SID 会浪费空间
- 灵活扩展:未来可以引入新类型的 ACE(如 Object ACE),无需修改基本结构
- 对齐友好:ACE 以 4 字节对齐,便于在 32/64 位系统上处理
4.6.2.2 为什么需要两种"对象特定"ACE?
ACCESS_ALLOWED_OBJECT_ACE_TYPE 和 ACCESS_DENIED_OBJECT_ACE_TYPE 是 Windows 2000 引入的"对象特定 ACE",与普通 ACE 的区别是额外携带一个 GUID 字段标识"对象的哪个部分"。典型用途:
- 注册表:不同子键有不同权限(如 HKLM\Software\Microsoft\Windows 和 HKLM\Software\Microsoft\Office 可以单独控制)
- Active Directory:AD 对象的属性级权限控制(如"允许读取 userAccountControl 但不允许读取 pwdLastSet")
为什么不简单地为每个子对象创建独立的 SD?
- 性能:一个键有数千个属性,每个属性一个 SD 会是巨大的内存开销
- 继承简化:属性级权限可以从父对象继承,不需要在每个子对象上都显式设置
- 向后兼容:旧系统可以忽略 GUID,退化为普通 ACE 处理
4.6.2.3 为什么需要「Control」标志?
SECURITY_DESCRIPTOR_CONTROL 字段是 16 位标志,包含多达 12 个标志。为什么需要这么多?关键原因:
1. 区分"谁设置的"
SE_OWNER_DEFAULTED:Owner 来自默认机制(如继承自父对象),而非用户显式设置SE_DACL_DEFAULTED:DACL 来自默认机制
这允许系统在特定场景下"改写"默认值而不丢失用户的原始意图------如果用户显式设置了 Owner,系统就不应再改写它。
2. 继承传播控制
SE_DACL_AUTO_INHERIT_REQ:要求子对象从此 SD 继承 DACLSE_DACL_AUTO_INHERITED:已经应用了继承SE_DACL_PROTECTED:阻止继承(保护对象不被父对象的 ACL 覆盖)
这是 NTFS 文件系统和注册表最常用的标志------管理员在资源管理器中勾选「包括可从该对象的父项继承的权限」时,内核会设置这些标志。
3. 自相关格式指示
SE_SELF_RELATIVE:SD 的所有指针都以相对偏移量存在
这是最关键的标志。自相关 SD 可以被序列化(写入文件、跨进程传递、持久化到注册表)。绝对 SD 只存在于内核内存中,指针指向各个 SID 和 ACL 的真实地址。
4.6.3 ACCESS_MASK 与 GENERIC_MAPPING
ACCESS_MASK 是一个 32 位位掩码,结构如下:
31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
┌──────────────┐ ┌──────────────────────────────────────────────┐
│ Generic │ │ Object-Specific (类型特定) │
│ (4 位) │ │ │
└──────────────┴──────────────────────────────────────────────┴──────────────────────────────────────────────┘
┌──────┬──────┬──────┬──────┬──────────────────┐
│ G_R │ G_W │ G_E │ G_A │ Standard Rights │
└──────┴──────┴──────┴──────┴──────────────────┘
GENERIC_MAPPING 结构:
c
typedef struct _GENERIC_MAPPING {
ACCESS_MASK GenericRead; // GENERIC_READ 展开为 ?
ACCESS_MASK GenericWrite; // GENERIC_WRITE 展开为 ?
ACCESS_MASK GenericExecute; // GENERIC_EXECUTE 展开为 ?
ACCESS_MASK GenericAll; // GENERIC_ALL 展开为 ?
} GENERIC_MAPPING, *PGENERIC_MAPPING;
示例(File 类型):
c
GENERIC_MAPPING IopFileMapping = {
FILE_GENERIC_READ, // 0x00120089
FILE_GENERIC_WRITE, // 0x00120116
FILE_GENERIC_EXECUTE, // 0x001000A0
FILE_ALL_ACCESS // 0x001F01FF
};
转换函数:
c
VOID RtlMapGenericMask(PACCESS_MASK AccessMask, PGENERIC_MAPPING GenericMapping)
{
// 提取通用位
BOOLEAN IsRead = (*AccessMask & GENERIC_READ) != 0;
BOOLEAN IsWrite = (*AccessMask & GENERIC_WRITE) != 0;
BOOLEAN IsExecute = (*AccessMask & GENERIC_EXECUTE) != 0;
BOOLEAN IsAll = (*AccessMask & GENERIC_ALL) != 0;
// 清除通用位
*AccessMask &= ~(GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE | GENERIC_ALL);
// 添加类型特定位
if (IsRead) *AccessMask |= GenericMapping->GenericRead;
if (IsWrite) *AccessMask |= GenericMapping->GenericWrite;
if (IsExecute) *AccessMask |= GenericMapping->GenericExecute;
if (IsAll) *AccessMask |= GenericMapping->GenericAll;
}
4.6.3.1 MAXIMUM_ALLOWED 的特殊处理
MAXIMUM_ALLOWED(0x02000000)是一个特殊标志位,表示"请求所有我可以获得的权限"。SeAccessCheck 会遍历 DACL,累积所有允许的权限位,最终授予最大子集。
为什么需要这个特殊位?
- 简化用户态代码:打开对象时不需要预先知道自己有哪些权限位,系统会"尽量给"
- 探测权限 :
OpenProcess(MAXIMUM_ALLOWED, FALSE, pid)可以用来探测权限而不依赖具体权限位 - 性能:MAXIMUM_ALLOWED 只遍历 DACL 一次即可完成所有权限位的计算
4.6.3.2 ACCESS_MASK 的 32 位布局设计分析
ACCESS_MASK 是一个 32 位位掩码,位布局如下:
| 位 | 含义 | 典型值 |
|---|---|---|
| 31 | GENERIC_READ | 0x80000000 |
| 30 | GENERIC_WRITE | 0x40000000 |
| 29 | GENERIC_EXECUTE | 0x20000000 |
| 28 | GENERIC_ALL | 0x10000000 |
| 25 | MAXIMUM_ALLOWED | 0x02000000 |
| 24 | ACCESS_SYSTEM_SECURITY | 0x01000000 |
| 16-23 | Standard Rights | DELETE/READ_CONTROL/WRITE_DAC/WRITE_OWNER/SYNCHRONIZE |
| 0-15 | Object-Specific | 类型特定权限 |
为什么把通用权限放在高位,类型特定权限放在低位?
这是一个经过深思熟虑的设计决策:
-
避免位冲突:如果通用权限位在低位,不同对象类型的特定权限位可能与通用位重叠------例如 FILE_READ_DATA 与 PROCESS_TERMINATE 都使用位 0,但 GENERIC_READ 通过 GENERIC_MAPPING 可以映射到不同的类型权限。通用权限位在高位确保了"抽象层"和"实现层"在物理空间上的隔离。
-
快速识别请求类别:通过检测位 28-31 是否被设置,系统可以立即判断这是"通用权限请求"还是"类型特定权限请求"。这在调试日志和安全审计中非常有用。
-
防止意外授予 :用户态如果只写了
FILE_READ_DATA但忘记清除通用位,RtlMapGenericMask会自动清除------相当于"类型特定权限是最终形态,通用权限只是中间表示"。
4.6.3.3 为什么 STANDARD_RIGHTS_REQUIRED 是 0x000F0000?
STANDARD_RIGHTS_REQUIRED = DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER,这四个权限占用位 16-19。
为什么这些权限被认为是"标准"权限?
- READ_CONTROL:读取 SD 的 DACL------几乎所有对象操作都需要读取对象的安全信息
- WRITE_DAC:修改 DACL------管理权限的能力
- WRITE_OWNER:修改 Owner------所有权转换能力
- DELETE:删除对象------生命周期管理
这四个权限之所以被提升到"标准"级别,是因为它们不依赖对象的具体语义,而是描述"对对象本身的元操作"。无论对象是文件、进程、事件,这四个权限的含义都是相同的。
为什么 SYNCHRONIZE(位 20)是标准权限?
SYNCHRONIZE 权限允许调用者"等待对象"------例如在文件句柄上使用 WaitForSingleObject 等待 I/O 完成。对于同步对象(Event、Mutex、Semaphore),这是基本能力;对于文件和注册表项,SYNCHRONIZE 则控制是否可以在其上创建可等待的操作。
4.6.3.4 为什么每个对象类型都有自己的 GENERIC_MAPPING?
每个对象类型在 OBJECT_TYPE_INITIALIZER 中定义自己的 GenericMapping。为什么不使用统一的映射?
原因 1:不同对象对"读/写/执行"的理解完全不同
文件对象:
FILE_READ_DATA= 0x0001(读取文件内容)FILE_WRITE_DATA= 0x0002(写入文件内容)FILE_EXECUTE= 0x0020(作为程序执行)
事件对象:
EVENT_QUERY_STATE= 0x0001(查询事件状态)EVENT_MODIFY_STATE= 0x0002(设置/重置事件)
进程对象:
PROCESS_VM_READ= 0x0010(读取进程内存)PROCESS_TERMINATE= 0x0001(终止进程)
这种差异使得统一映射根本不可行------"写"对文件意味着修改内容,对进程意味着什么?必须由每个子系统自己定义。
原因 2:向后兼容
文件系统驱动、注册表子系统、对象管理器各自定义权限位后,通过 GENERIC_READ 这样的"通用语言"统一对外暴露。内核不需要修改就能支持新的对象类型------只需定义新的 GenericMapping。
4.6.3.5 为什么 RtlMapGenericMask 采用"先清除再添加"的策略?
RtlMapGenericMask 的工作方式是:
- 清除 GENERIC_READ/WRITE/EXECUTE/ALL 四个位(位 28-31)
- 检查原先是否设置了这些位
- 如果设置了,则添加相应的类型特定权限
为什么不能同时保留 GENERIC_READ 和 FILE_READ_DATA?
- 安全原因:如果同时保留,后续检查可能会有"双重授予"的问题------GENERIC_READ 已经授予了,FILE_READ_DATA 又授予一次,虽然结果相同,但增加了复杂性
- 清晰语义:清除后添加确保"访问掩码始终处于最终形式"------不存在"部分通用、部分特定"的混合状态
- 简化检查 :后续的
SeAccessCheck只需要检查类型特定权限位,不需要再考虑通用位
4.6.3.6 为什么 GENERIC_ALL 不展开为所有位?
一个常见的误解是 GENERIC_ALL 意味着"所有可能的权限"。实际上它只展开为 GenericMapping->GenericAll------这是由对象类型定义的"所有有意义的权限"集合。
为什么不简单地展开为 0x001FFFFF?
- 语义清晰:一个对象可能有 10 个权限位,但其中某些组合可能无意义(例如"对事件对象有 FILE_READ_DATA 权限"没有意义)。GenericAll 只定义有意义的组合
- 性能:较小的 GrantedAccess 在后续位运算检查时更快
- 安全:防止"溢出授予"------如果将来扩展权限位,旧代码使用 GenericAll 不会自动获得新权限位
4.6.4 SeAccessCheck 的实现流程
SeAccessCheck(accesschk.c:1994(file:///d:/reactos/ntoskrnl/se/accesschk.c#L1994))是访问控制的核心函数:
c
BOOLEAN
NTAPI
SeAccessCheck(
_In_ PSECURITY_DESCRIPTOR SecurityDescriptor,
_In_ PSECURITY_SUBJECT_CONTEXT SubjectSecurityContext,
_In_ BOOLEAN SubjectContextLocked,
_In_ ACCESS_MASK DesiredAccess,
_In_ ACCESS_MASK PreviouslyGrantedAccess,
_Out_ PPRIVILEGE_SET* Privileges,
_In_ PGENERIC_MAPPING GenericMapping,
_In_ KPROCESSOR_MODE AccessMode,
_Out_ PACCESS_MASK GrantedAccess,
_Out_ PNTSTATUS AccessStatus)
{
PAGED_CODE();
/* 1. 内核态快速路径:除非指定 OBJ_FORCE_ACCESS_CHECK,否则跳过 */
if (AccessMode == KernelMode) {
if (DesiredAccess & MAXIMUM_ALLOWED) {
*GrantedAccess = GenericMapping->GenericAll;
} else {
*GrantedAccess = DesiredAccess;
}
*GrantedAccess |= PreviouslyGrantedAccess;
*GrantedAccess |= (DesiredAccess & ~MAXIMUM_ALLOWED);
*AccessStatus = STATUS_SUCCESS;
return TRUE;
}
/* 2. 获取 Token */
Token = SeQuerySubjectContextToken(SubjectSecurityContext);
if (!Token) {
*AccessStatus = STATUS_ACCESS_DENIED;
return FALSE;
}
/* 3. 检查 SeTcbPrivilege(高特权绕过) */
if (SeTokenIsAdmin(Token) || SeSinglePrivilegeCheck(SeTcbPrivilege, ...)) {
*GrantedAccess = GenericMapping->GenericAll;
*AccessStatus = STATUS_SUCCESS;
return TRUE;
}
/* 4. 解析 SD */
SepDecodeSecurityDescriptor(SecurityDescriptor, &Dacl, ...);
/* 5. DACL 为空 → 全部允许 */
if (!SepDaclPresent || Dacl->AceCount == 0) {
*GrantedAccess = DesiredAccess;
*AccessStatus = STATUS_SUCCESS;
return TRUE;
}
/* 6. 遍历 DACL 的所有 ACE */
CurrentGranted = 0;
RequiredAccess = DesiredAccess & ~PreviouslyGrantedAccess;
if (DesiredAccess & MAXIMUM_ALLOWED) {
// 累积所有允许的访问
for (Ace in Dacl) {
if (Ace.Type == ACCESS_ALLOWED_ACE_TYPE && SepSidInToken(Token, Ace.Sid)) {
CurrentGranted |= Ace.Mask;
}
if (Ace.Type == ACCESS_DENIED_ACE_TYPE && SepSidInToken(Token, Ace.Sid)) {
if (RequiredAccess & Ace.Mask) {
*AccessStatus = STATUS_ACCESS_DENIED;
return FALSE;
}
}
}
*GrantedAccess = CurrentGranted | PreviouslyGrantedAccess;
*AccessStatus = STATUS_SUCCESS;
return TRUE;
} else {
// 检查 DesiredAccess 是否被完全允许
for (Ace in Dacl) {
if (Ace.Type == ACCESS_ALLOWED_ACE_TYPE && SepSidInToken(Token, Ace.Sid)) {
CurrentGranted |= Ace.Mask;
if ((CurrentGranted & RequiredAccess) == RequiredAccess) {
*GrantedAccess = CurrentGranted | PreviouslyGrantedAccess;
*AccessStatus = STATUS_SUCCESS;
return TRUE;
}
}
if (Ace.Type == ACCESS_DENIED_ACE_TYPE && SepSidInToken(Token, Ace.Sid)) {
if (Ace.Mask & RequiredAccess) {
*AccessStatus = STATUS_ACCESS_DENIED;
return FALSE;
}
}
}
*AccessStatus = STATUS_ACCESS_DENIED;
return FALSE;
}
}
关键设计决策分析:
1. Deny 优先于 Allow:遍历 DACL 时,DENY ACE 总是先生效
这是访问控制模型中最核心的设计决策。当一个 ACE 匹配并拒绝时,整个检查立即失败,不需要继续遍历。为什么?
- 快速失败:拒绝操作应该尽早完成,提高性能
- 安全优先:"明确拒绝"比"隐含允许"更重要------管理员通过拒绝组移除权限时,必须确保拒绝生效
- 避免竞态:拒绝是"绝对"的,一旦匹配就不可撤销------这防止了"先允许后拒绝"的逻辑漏洞
2. DACL 顺序重要:先 DENY 后 ALLOW 是最佳实践
DACL 中 ACE 的排列顺序直接影响安全检查结果:
- 错误顺序:先允许 Everyone 读 → 后拒绝 Guests 读 → Guests 仍可通过 Everyone 读(除非是明确的 Everyone 拒绝)
- 正确顺序:先拒绝 Guests → 后允许 Everyone
为什么这是一个"用户责任"而非"系统责任"?
因为内核只负责按顺序执行检查,不负责排序 ACE。如果把排序责任交给内核,会有两个问题:
- 性能开销:每次检查都需要排序,成本高
- 策略破坏:一些合法的 ACL 策略依赖特定顺序(例如"允许 Domain Users 读,但拒绝 Contractors"就依赖顺序)
3. OWNER 权限特殊 :Owner 自动拥有 READ_CONTROL 和 WRITE_OWNER
即使 DACL 完全禁止了某用户对对象的访问,如果他是对象的 Owner,仍然可以:
- 读取 SD(
READ_CONTROL)------查看谁有什么权限 - 写入 Owner(
WRITE_OWNER)------修改所有权 - 写入 DACL(
WRITE_DAC)------修改权限列表
为什么?
这是为了防止"对象被锁死"------如果用户创建了一个文件后忘记给自己权限,至少作为 Owner 可以重新获得访问权限。这是"所有权规则"的基本保障。
4. NULL DACL 表示全允许:但不推荐
DACL = NULL(零指针)表示"所有人可做任何事",这在旧代码中很常见。为什么不推荐?
- 安全漏洞:如果代码在高权限上下文中创建了 NULL DACL 的对象,普通用户可以通过该对象提权
- 被审计检测:现代安全工具会标记 NULL DACL 为高风险
- 性能考虑:看似快速,但后续缺少 SACL 无法审计关键操作
正确做法:使用显式的 DACL 定义最小权限集。
5. 为什么内核态跳过检查?
if (AccessMode == KernelMode) return TRUE;------这行代码在安全敏感代码中非常关键。为什么允许内核绕过所有检查?
- 信任边界:内核态代码已经通过了操作系统的信任验证
- 性能:内核中执行大量操作(如读写磁盘、调度线程),每次都通过完整 DACL 检查会严重影响性能
- 能力模型:内核代码有直接访问物理内存的能力,ACL 阻止不了它
- OBJ_FORCE_ACCESS_CHECK :如果内核代码确实需要检查权限,可以在
OBJECT_ATTRIBUTES中设置此标志
但是! 内核态的"跳过检查"只适用于SeAccessCheck------特权检查(如 SeTcbPrivilege)仍然执行。这意味着内核态代码依然无法绕过"谁可以做什么"的整体策略。
6. 为什么 SeTcbPrivilege 授予 GenericAll?
SeTcbPrivilege 即"作为操作系统的一部分",持有此特权的进程可以绕过几乎所有安全检查。为什么在 SeAccessCheck 中特殊处理?
- 设计目标:系统服务(如 LSASS、WINLOGON)需要对任意对象拥有完全访问权限
- 避免循环依赖:安全子系统自身需要访问对象来验证安全
- 性能:避免在系统服务热路径上反复执行 DACL 检查
代价 :恶意代码如果获得 SeTcbPrivilege,可以做任何事------这是 Windows 安全模型的"单点信任",也是攻击的核心目标。
7. 为什么 PreviouslyGrantedAccess 不需要重新检查?
PreviouslyGrantedAccess 是已经授予的权限位(如父对象继承来的),SeAccessCheck 直接使用它而不重新验证。
为什么?
- 性能:已经通过了安全检查的位不应重复检查
- 累积模型:权限是累积的------已经授予的位继续有效
- 简化调用者:调用者可以拆分访问检查------先查一部分权限,再查另一部分
示例:打开文件时可能先检查 READ_CONTROL(读取 SD),然后检查 FILE_READ_DATA------两次调用都累积权限位,最终合并为 GrantedAccess。
8. SepSidInToken 的复杂性
SepSidInToken 这个看似简单的函数实际上非常复杂------它需要遍历 Token 中的所有 SID(包括通过组间接获得的 SID),判断是否与 ACE 中的 SID 匹配。
为什么不直接比较?
- 组 SID:用户通过组成员资格获得的权限也需要检查
- 嵌套组:组可以包含其他组(例如 Domain Users ⊂ Authenticated Users ⊂ Everyone)
- 完整性级别:低完整性 Token 无法访问高完整性对象
9. DACL 遍历为什么一旦满足 DesiredAccess 就立即返回?
这是一个关键的性能优化------一旦累积的 CurrentGranted 覆盖了所有 RequiredAccess 位,就可以停止遍历。
示例:DesiredAccess = FILE_READ_DATA | FILE_WRITE_DATA,遍历过程:
- ACE 1:允许 FILE_READ_DATA → CurrentGranted = 0x0001 → 不够(缺少 0x0002)
- ACE 2:允许 FILE_WRITE_DATA → CurrentGranted = 0x0003 → 满足要求 → 返回成功
为什么不继续遍历?
- 性能:DACL 可能有几十个 ACE,提前退出节省时间
- 拒绝已经检查过:前面的 DENY ACE 已经处理过了,不会再出现新的拒绝
- 累积模型:后续的 ALLOW ACE 只会增加 CurrentGranted,不会减少------继续遍历的意义不大
4.6.5 NtAccessCheck 与 NtAccessCheckByType
NtAccessCheck(accesschk.c:2214(file:///d:/reactos/ntoskrnl/se/accesschk.c#L2214))是 SeAccessCheck 的用户态版本:
c
NTSTATUS
NTAPI
NtAccessCheck(
_In_ PSECURITY_DESCRIPTOR SecurityDescriptor,
_In_ HANDLE ClientToken,
_In_ ACCESS_MASK DesiredAccess,
_In_ PGENERIC_MAPPING GenericMapping,
_Out_ PPRIVILEGE_SET PrivilegeSet OPTIONAL,
_Inout_ PULONG PrivilegeSetLength,
_Out_ PACCESS_MASK GrantedAccess,
_Out_ PNTSTATUS AccessStatus)
{
/* 1. 捕获用户态参数 */
ProbeForRead(SecurityDescriptor, ...);
ProbeForRead(GenericMapping, sizeof(GENERIC_MAPPING), ...);
CapturedSD = SeCaptureSecurityDescriptor(SecurityDescriptor, ...);
/* 2. 获取 Token */
ObReferenceObjectByHandle(ClientToken, TOKEN_QUERY, SeTokenType, &Token);
/* 3. 构建 SubjectContext */
SeInitializeSubjectContext(&SubjectContext, Token, NULL);
/* 4. 调用 SeAccessCheck */
Result = SeAccessCheck(CapturedSD, &SubjectContext, FALSE, ...);
/* 5. 返回结果给用户态 */
*AccessStatus = Status;
*GrantedAccess = Granted;
return Result;
}
NtAccessCheckByType 与 NtAccessCheck 的差异:
NtAccessCheckByType 支持按对象特定 ACE(ACCESS_ALLOWED_OBJECT_ACE)的检查,而 NtAccessCheck 仅支持普通 ACE。
关键限制 :NtAccessCheck 只能用于没有挂上具体对象 的访问决策。换句话说,它只能判断"如果有一个这种 SD 的对象,主体能否访问它"。实际访问对象时,内核会调用 SeAccessCheck 而非 NtAccessCheck。
4.6.6 特权检查(SePrivilegeCheck)
SePrivilegeCheck(access.c(file:///d:/reactos/ntoskrnl/se/access.c))检查线程/进程 Token 是否启用了特定特权:
c
BOOLEAN
NTAPI
SePrivilegeCheck(
_Inout_ PPRIVILEGE_SET RequiredPrivileges,
_In_ PSECURITY_SUBJECT_CONTEXT SubjectSecurityContext,
_In_ KPROCESSOR_MODE AccessMode)
{
PTOKEN Token = SeQuerySubjectContextToken(SubjectSecurityContext);
PLUID_AND_ATTRIBUTES Privilege;
BOOLEAN Result = TRUE;
for (i = 0; i < RequiredPrivileges->PrivilegeCount; i++) {
Privilege = SepFindTokenPrivilege(Token, &RequiredPrivileges->Privilege[i].Luid);
if (!Privilege || !(Privilege->Attributes & SE_PRIVILEGE_ENABLED)) {
RequiredPrivileges->Privilege[i].Attributes = 0;
Result = FALSE;
} else {
RequiredPrivileges->Privilege[i].Attributes = Privilege->Attributes;
}
}
return Result;
}
常见特权:
| 特权 | 用途 |
|---|---|
SeTcbPrivilege |
进程是「受信任的计算机基础」的一部分(相当于 SYSTEM) |
SeDebugPrivilege |
调试任何进程(包括 SYSTEM 进程) |
SeBackupPrivilege |
绕过文件安全检查进行备份 |
SeRestorePrivilege |
绕过文件安全检查进行恢复 |
SeShutdownPrivilege |
关闭系统 |
SeLoadDriverPrivilege |
加载驱动 |
SeAssignPrimaryTokenPrivilege |
为进程分配主 Token |
特权与权限的区别:
- 权限(Permission):针对特定对象的访问许可(如对文件 X 有读权限)
- 特权(Privilege):针对系统级能力的全局开关(如关闭系统)
4.6.7 概念解释
4.6.7.1 Token
TOKEN 结构描述了"我是谁"------用户 SID、所属组 SID、启用的特权、完整性级别等。
4.6.7.2 DACL vs SACL
| 维度 | DACL | SACL |
|---|---|---|
| 全称 | Discretionary ACL | System ACL |
| 作用 | 控制访问 | 控制审计 |
| 检查时机 | 访问对象时 | 访问对象时(如果启用审计) |
| 执行者 | 内核 | 安全子系统 |
4.6.7.3 ACE
Access Control Entry,是 ACL 中的单个条目。可以是"允许"、"拒绝"或"审计"。
4.6.7.4 自相关 SD vs 绝对 SD
- 自相关 SD:所有 SID/ACL 用相对于 SD 起始的偏移量表示
- 绝对 SD:所有 SID/ACL 用内存地址表示
为什么需要两种格式?
- 自相关 SD:便于序列化(写入磁盘、跨进程传递)
- 绝对 SD:便于运行时访问(无需计算偏移量)
RtlSelfRelativeToAbsoluteSD 和 RtlAbsoluteToSelfRelativeSD 用于两种格式之间的转换。
4.6.7.5 完整性级别
Windows Vista 引入的「完整性级别」是 Token 中的一个新维度:
SECURITY_MANDATORY_LOW:低完整性(如 IE 保护模式)SECURITY_MANDATORY_MEDIUM:中完整性(普通进程)SECURITY_MANDATORY_HIGH:高完整性(管理员进程)SECURITY_MANDATORY_SYSTEM:系统完整性(SYSTEM)
规则:低完整性 Token 不能写入/读取高完整性对象(即使 DACL 允许)。
4.6.8 为什么要这样设计
4.6.8.1 问题 1:为什么需要 GENERIC_MAPPING?
不同对象类型有不同的权限位。例如:
- File:
FILE_READ_DATA(位 0) - Process:
PROCESS_TERMINATE(位 0) - Thread:
THREAD_TERMINATE(位 0)
这些权限位都"占用"了位 0,但语义完全不同。GENERIC_MAPPING 提供了平台无关的抽象层:用户态代码可以用 GENERIC_READ 写"我要读",但内核会把它翻译成对象特定权限。
4.6.8.2 问题 2:为什么 DACL 检查按顺序遍历?
按顺序遍历意味着"先 DENY 后 ALLOW"是关键。如果允许 ACE 在拒绝 ACE 之前,可能导致:
- 遍历到 ALLOW → 看起来允许
- 但 DENY 出现后 → 必须撤销
这会导致访问决策不一致。先 DENY 后 ALLOW 是 Microsoft 推荐的最佳实践(参见 MSDN ACE 顺序)。
4.6.8.3 问题 3:为什么特权是"绕过检查"而不是"权限位"?
SeTcbPrivilege 等特权的语义是"我是受信任的"。一个服务以 SYSTEM 身份运行,几乎可以做任何事。如果把特权当成普通权限位,需要在每个 DACL 检查中处理特权。
将特权视为"绕过机制"更简洁:
- DACL 检查失败 → 检查特权
- 特权启用 → 视为全允许
- 特权未启用 → 返回拒绝
4.6.8.4 问题 4:为什么 MAXIMUM_ALLOWED 是特殊位?
MAXIMUM_ALLOWED 允许用户态不指定具体权限,而是"我能得到多少就给我多少"。这大大简化了用户态代码:
c
// 不使用 MAXIMUM_ALLOWED
HANDLE hFile = CreateFile(L"foo", FILE_READ_DATA | FILE_WRITE_DATA, ...);
// 使用 MAXIMUM_ALLOWED
HANDLE hFile = CreateFile(L"foo", MAXIMUM_ALLOWED, ...);
但 MAXIMUM_ALLOWED 有安全风险:用户态代码不知道自己有"多少"权限。如果使用 FILE_READ_DATA,可以明确知道代码能读不能写。
4.6.8.5 问题 5:为什么 SD 自相关(Self-Relative)?
自相关 SD 便于复制和序列化:
- 用户态 → 内核态传递:用户态构造 SD,捕获到内核空间时地址改变,自相关格式无需修正
- 跨进程传递:每个进程的地址空间不同,自相关格式可序列化
- 写入注册表/文件:自相关格式可作为字节流存储
自相关 vs 绝对格式的本质区别:
- 绝对格式 :
SECURITY_DESCRIPTOR结构体中有 4 个指针,分别指向 Owner、Group、DACL、SACL。优点是易于修改(直接替换指针),缺点是不可复制(复制后指针指向旧地址) - 自相关格式 :所有数据紧接在
SECURITY_DESCRIPTOR之后,存储为相对偏移量。优点是整块可复制、可序列化、可作为字节流传递,缺点是修改需要重新构造整个块
为什么默认使用自相关格式?
内核与用户态之间的参数传递是"复制到内核空间"------绝对格式的 SD 无法直接使用,因为指针指向用户态地址,内核访问会崩溃。自相关格式是"可移植"的:无论在什么地址空间中,都能正确解析。
ReactOS 中的实际转换:
SepMakeSDAbsolute 和 SepMakeSDSelfRelative 在 ntoskrnl/se/sd.c 中实现------这两个函数是内核中频繁调用的基础工具。
4.6.8.6 问题 6:为什么访问掩码分三段(Generic/Standard/Specific)?
32 位访问掩码被严格划分为三段:
- 位 28-31:通用权限(GENERIC_READ/WRITE/EXECUTE/ALL)
- 位 16-27:保留
- 位 8-15:标准权限(DELETE/READ_CONTROL/WRITE_DAC/WRITE_OWNER/SYNCHRONIZE)
- 位 0-7:对象特定权限(FILE_READ_DATA/PROCESS_TERMINATE 等)
为什么不混用?
- 兼容性:未来增加新权限位时,不会与现有位冲突
- 快速识别:通过检查高位可以快速判断"请求了什么类型的权限"
- 映射清晰 :
RtlMapGenericMask只需要清零 28-31 位,不需要关心低位 - 审计友好:审计日志中可以分别记录"请求了通用权限"和"请求了标准权限"
与 Unix 的对比:
Unix 的权限模型是"rwx × 3"(owner/group/other)------只有 9 个有效位,且对所有对象类型语义相同。Windows 的 32 位划分模型提供了更细粒度的控制,但也增加了理解成本。
4.6.8.7 问题 7:为什么 Owner 自动拥有 WRITE_DAC?
即使 DACL 不明确允许 Owner 写 DACL,Owner 仍然可以修改 DACL。为什么?
设计目标:防止对象被锁死
假设 Alice 创建了一个文件,但不小心设置了"只有 Bob 能读写"------如果没有 Owner 权限规则,Alice 就无法重新获得对自己文件的控制。Owner 自动拥有 WRITE_OWNER 和 WRITE_DAC 就是为了解决这个问题。
代价:无法"完全剥夺"Owner
管理员可以通过 Owner 规则重新获得对任何对象的控制------这在企业环境中是必要的,但也意味着"用户无法完全阻止管理员访问"。这是 Windows 安全模型的一个基本假设:系统管理员拥有最终控制权。
4.6.8.8 问题 8:为什么访问检查不检查继承链?
访问检查时只检查对象自身的 DACL,不检查父容器的 DACL(例如打开文件时不检查父目录的 DACL)。为什么?
原因:性能 + 语义简单
- 性能:文件系统路径可能有 10 层嵌套,每层都检查 DACL 会导致 10 倍开销
- 语义简单:"你有对这个对象的权限"比"你对这个对象有权限,但对父目录没有权限"更容易理解
- 继承发生在创建时:父目录的 ACL 在对象创建时被"继承"为对象的 ACE------创建后对象的 DACL 已经包含了父目录的权限
但是有一个例外:
打开文件时,系统会检查父目录的 FILE_LIST_DIRECTORY 权限------这是"路径遍历权限",不是访问检查的一部分。这个检查由文件系统执行,不是由 SeAccessCheck 执行。
4.6.8.9 问题 9:为什么需要 SACL?DACL 不够吗?
DACL 决定"谁能做什么",SACL 决定"谁的操作被审计"。为什么需要分开?
- 安全与合规分离:安全策略(DACL)和审计策略(SACL)通常由不同的团队管理
- 审计开销:审计操作需要写入安全日志------如果 DACL 和 SACL 合并,每个允许的操作都会产生大量审计日志
- 独立配置:管理员可以配置"记录所有失败的访问",而不必改变"谁可以访问"的策略
典型场景:
- DACL:Alice 可以读,Bob 可以读写
- SACL:所有写操作都记录,所有失败的访问都记录
这样即使 Alice 有读权限,她的读取操作也可能被记录(如果 SACL 配置了读取审计)。
4.6.8.10 问题 10:为什么权限位数量不随时间增加?
Windows NT 3.x 定义了基础权限位,至今基本不变。为什么不不断增加新权限位?
兼容性约束:
- 应用兼容性:如果新权限位被添加,旧应用程序可能授予过多权限(应用请求 GENERIC_ALL,可能获得它不需要的新权限)
- 权限溢出:权限位是 32 位的------如果随意使用低位,未来可能耗尽
- 向后兼容:Windows 的核心设计原则是"10 年前的应用仍然能跑"
解决方案:
- 对象特定权限:每个对象类型定义自己的权限位------不需要修改系统基础
- 完整性级别:Vista 引入的新安全机制,不是通过权限位实现,而是通过 Token 属性
- 属性 ACE:Server 2003 引入的条件 ACE,允许在 ACE 中加入"动态条件"
4.6.9 增强子节:MaxAllowedAccess 语义
4.6.9.1 设计意图
核心问题 :用户态代码经常需要"尽可能多"的权限,但又不想精确指定。MAXIMUM_ALLOWED 提供了这个能力,但其语义容易误解。
设计哲学 :「最佳努力」------内核尽力授予主体能获得的所有权限,但不保证全部。
4.6.9.2 概念解释
- MaxAllowedAccess:请求"我能得到的所有权限"
- GrantedAccess:实际授予的权限(≤ MaxAllowedAccess)
4.6.9.3 累积式授权
SeAccessCheck 在处理 MAXIMUM_ALLOWED 时累积所有允许位:
c
if (DesiredAccess & MAXIMUM_ALLOWED) {
// 遍历所有 ACE
for (Ace in Dacl) {
if (Ace.Type == ACCESS_ALLOWED && SepSidInToken(Token, Ace.Sid)) {
CurrentGranted |= Ace.Mask; // 累积
}
if (Ace.Type == ACCESS_DENIED && SepSidInToken(Token, Ace.Sid)) {
if (Ace.Mask & RequiredAccess) {
return DENIED; // DENY 优先
}
}
}
}
4.6.9.4 与具体请求的对比
| 维度 | GENERIC_READ |
MAXIMUM_ALLOWED |
|---|---|---|
| 行为 | 只请求读 | 请求"所有能得到的" |
| 安全性 | 高(明确意图) | 中(取决于 DACL) |
| 可移植性 | 差(依赖 DACL) | 好(自动适配) |
4.6.9.5 为什么要这样设计
问题:为什么不强制用户态明确指定权限?
答案:
- 兼容性:Windows 早期版本就支持 MAXIMUM_ALLOWED
- 易用性:很多代码不需要精确权限
- 性能:一次请求可获得所有权限,无需多次调用
反例:在防病毒、EDR 等高安全代码中,应避免 MAXIMUM_ALLOWED,明确指定权限是最佳实践。
4.6.10 增强子节:内核态与用户态的权限检查差异
4.6.10.1 设计意图
核心问题 :内核是受信任的,用户态是"不可信的"。两者调用 SeAccessCheck 时,应有不同的语义。
4.6.10.2 概念解释
- KernelMode:CPU 处于特权级 0,可访问所有内存
- UserMode:CPU 处于特权级 3,受硬件保护
4.6.10.3 权限检查的差异
c
if (AccessMode == KernelMode) {
if (DesiredAccess & MAXIMUM_ALLOWED) {
*GrantedAccess = GenericMapping->GenericAll; // 全部权限
} else {
*GrantedAccess = DesiredAccess; // 按请求授予
}
return TRUE; // 总是成功
}
为什么内核态跳过权限检查?
- 内核是受信任的:内核不会被"恶意代码"劫持
- 简化代码:避免每次都进行无意义的检查
- 性能:在内核热路径上节省时间
例外 :OBJ_FORCE_ACCESS_CHECK 标志让内核态也必须检查。
4.6.10.4 与用户态 Token 的关系
内核态代码使用进程的主 Token进行访问检查。内核态没有"模拟 Token"的概念。
4.6.10.5 为什么要这样设计
问题:如果内核态可以绕过所有检查,恶意驱动是否可以滥用?
答案:
- 驱动必须被加载:Windows 强制驱动签名
- 驱动是受信任的:开发者已经通过认证
- 内核回调 :防病毒可以通过
ObRegisterCallbacks监控驱动操作
反例 :在内核态使用 OBJ_FORCE_ACCESS_CHECK 可用于"内核自检"场景。
4.6.11 小结
4.6.11.1 关键知识点
| 主题 | 关键点 |
|---|---|
| 访问控制参与者 | Subject (Token) + Object (SD) + Access Request (AccessMask) |
| SD 结构 | Owner + Group + DACL + SACL + Control |
| DACL 检查 | DENY 优先于 ALLOW,按顺序遍历 |
| MAXIMUM_ALLOWED | 累积所有允许位 |
| 特权检查 | SePrivilegeCheck,与权限检查是两条路径 |
| 完整性级别 | 阻止低完整性 Token 写入高完整性对象 |
4.6.11.2 设计原则
- 能力 + ACL + Token 三件套:主体、客体、决策
- DENY 优先:避免歧义
- 内核态绕过:性能与简化
- 完整性级别:纵深防御
- 特权作为兜底:受信任代码的最后手段
4.6.11.3 后续学习路径
- 4.7 节:句柄的遗传和继承
- 4.8 节:NtDuplicateObject
- 4.9 节:NtClose
- 第 5 章:进程与线程管理
- 第 8 章:安全子系统
4.7 句柄的遗传和继承
4.7.0 框架图
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 句柄继承模型 │
│ │
│ 父进程 │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ HANDLE Table │ │
│ │ [0] = NULL (free) │ │
│ │ [1] = ObjA (HandleAttributes: OBJ_INHERIT) │ │
│ │ [2] = ObjB (HandleAttributes: 0) │ │
│ │ [3] = ObjC (HandleAttributes: OBJ_INHERIT) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
│
│ CreateProcess(..., bInheritHandles = TRUE)
▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 子进程创建 │
│ │
│ 子进程 │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ HANDLE Table (新建) │ │
│ │ [0] = NULL (free) │ │
│ │ [1] = ObjA (从父进程复制) │ │
│ │ [2] = ObjC (从父进程复制) │ │
│ │ [3] = NULL (free) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ 注意:ObjB 因 OBJ_INHERIT 未设置,不会被复制 │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
4.7.0.1 设计意图
核心问题
进程间需要共享对象。父进程可能希望子进程访问某些内核对象(如 stdin/stdout),但不是全部。如何用统一机制表达"哪些句柄可以传递"?
设计哲学 :「句柄继承 」------在 CreateProcess 时显式声明继承意图,传递带特定属性的句柄。
本节定位
本节讨论两个相关概念:
- 句柄继承:父进程在创建子进程时选择性传递句柄
- 句柄遗传:「Inheritable Handles」的中文翻译,与继承本质相同
4.7.1 句柄继承基础概念
句柄继承 vs 共享内存 vs IPC
| 维度 | 句柄继承 | 共享内存 | 命名管道 |
|---|---|---|---|
| 粒度 | 句柄级(整对象) | 字节级(内存区域) | 字节流级(消息) |
| 方向 | 父 → 子(创建时) | 任意进程间 | 任意进程间(双向) |
| 权限 | 继承 GrantedAccess | 由映射句柄决定 | 由管道 SD 决定 |
| 生命周期 | 父子独立(独立 Close) | 共享区域直到 unmapped | 显式关闭才终止 |
| 内核参与 | 轻量(仅复制句柄表条目) | 重(需建立内存映射) | 重(需创建文件对象 + 缓存) |
| 性能 | 极快(O(n)遍历句柄表) | 较慢(需修改页表) | 最慢(需线程 + 缓存管理) |
为什么需要三种不同的共享机制?
- 句柄继承:用于"父进程传递已打开的资源给子进程"------最简单、最快速的机制,但仅在创建时刻有效
- 共享内存:用于"进程间共享大量数据"------内存映射文件/区域,提供最直接的数据共享
- 命名管道:用于"进程间通信"------基于消息的通信机制,提供数据传输和同步
三者并非互相替代,而是互补:Shell 启动子进程时同时使用三种机制------通过句柄继承传递 stdin/stdout,通过共享内存传递环境变量,通过命名管道实现进程间通信。
句柄继承的两种方式:
- 创建时声明 :
CreateProcess(..., bInheritHandles = TRUE)--- 一次性传递所有带 OBJ_INHERIT 的句柄 - 运行时复制 :
DuplicateHandle--- 任何时候都可以复制任意句柄
两种方式的深度对比:
| 维度 | CreateProcess 继承 | DuplicateHandle |
|---|---|---|
| 时机 | 进程创建时 | 任意时刻 |
| 数量 | 批量(所有 OBJ_INHERIT 句柄) | 单个句柄 |
| 权限限制 | 同父进程(完全相同 GrantedAccess) | 可限制(dwDesiredAccess 参数) |
| 跨会话 | 否(仅限于同会话) | 是(可跨会话、跨终端服务) |
| 目标进程 | 仅限即将创建的子进程 | 任意已存在进程 |
| 性能 | 快(创建时一次完成) | 较慢(每次需系统调用) |
| 安全性 | 简单但粗糙 | 灵活且精细 |
典型应用场景:
- Shell → 子进程:cmd.exe 创建子进程时,继承 stdin/stdout/stderr 三个句柄------子进程从父进程获得控制台输入输出
- 管道通信 :父进程
CreatePipe创建两个句柄,设置一个为继承,创建子进程后子进程用继承的句柄通信 - 调试器 :调试器通过
DebugActiveProcess获得被调试进程句柄,然后用DuplicateHandle传递给调试助手进程 - 服务控制:SCM(服务控制管理器)以 SYSTEM 身份启动服务,服务进程继承 SCM 的事件句柄用于状态通信
句柄继承的核心局限:
- 仅适用于子进程的创建时刻 :一旦子进程创建完成,就无法再通过继承方式传递新句柄------必须使用
DuplicateHandle - 不能动态添加/移除 :父进程创建子进程后,即使修改了句柄的
OBJ_INHERIT标志,也不会影响已创建的子进程 - 只能继承句柄:不能继承内存映射区域、线程状态、堆栈内容------这些是"进程内部状态",不通过句柄表暴露
- 继承的是句柄,不是引用:子进程关闭继承的句柄不会影响父进程的句柄------但两者都指向同一内核对象,都影响引用计数
为什么"创建时刻一次性"是正确的设计?
- 原子性 :子进程的初始状态由
CreateProcess一次性确定------不存在"子进程启动后逐步获得句柄"的竞态条件 - 可预测性:子进程启动时可以假定"已继承的句柄都有效"------不需要在启动后动态请求
- 性能 :在进程创建时一次性遍历父进程句柄表,比子进程启动后多次调用
DuplicateHandle更高效 - 安全模型:父进程在创建子进程时明确声明"我信任这个子进程,可以访问以下资源"------这是最小权限原则的体现
4.7.2 OBJ_INHERIT 标志位的意义
OBJ_INHERIT 是 HANDLE_TABLE_ENTRY.ObAttributes 中的一个标志位:
c
#define OBJ_INHERIT 0x00000002L
在 ObpCreateHandle 中设置:
c
if (ObjectAttributes->Attributes & OBJ_INHERIT) {
Entry->ObAttributes |= OBJ_INHERIT;
}
仅适用于用户态句柄 :内核态句柄(OBJ_KERNEL_HANDLE)不能被继承。
4.7.3 进程创建时的句柄继承流程
PspCreateProcess 中的句柄继承逻辑:
c
NTSTATUS PspCreateProcess(/* ... */, BOOLEAN InheritHandles)
{
if (InheritHandles) {
Status = KeStackAttachProcess(ParentProcess, &ApcState);
if (NT_SUCCESS(Status)) {
/* 复制父进程句柄表中带 OBJ_INHERIT 的句柄 */
Status = ExpCopyHandles(ParentProcess->HandleTable,
ChildProcess->HandleTable,
ChildProcess);
KeUnstackDetachProcess(&ApcState);
}
}
/* ... */
}
ExpCopyHandles 的简化实现:
c
NTSTATUS ExpCopyHandles(PHANDLE_TABLE ParentTable, PHANDLE_TABLE ChildTable, PEPROCESS Child)
{
/* 遍历父进程的句柄表 */
for (each entry in ParentTable) {
if (!(Entry->ObAttributes & OBJ_INHERIT)) continue;
/* 分配子进程中的新句柄 */
NewEntry = ExpAllocateHandleTableEntry(ChildTable);
/* 复制句柄内容(不复制引用计数) */
NewEntry->Object = Entry->Object; // 指向同一对象
NewEntry->GrantedAccess = Entry->GrantedAccess;
NewEntry->ObAttributes = Entry->ObAttributes;
/* 增加对象的引用计数(因为现在有两个引用:父子进程各一个) */
InterlockedIncrementSizeT(&OBJECT_TO_OBJECT_HEADER(Entry->Object)->PointerCount);
}
return STATUS_SUCCESS;
}
关键设计决策:
- 引用计数递增 :复制后
PointerCount += 1,因为有两个引用(父子进程各一个) - 不复制父进程的句柄值:子进程得到自己的句柄值(与父进程不同)
- GrantedAccess 复制:子进程的句柄权限与父进程相同
- ObjAttributes 复制 :子进程可以继续继承(如果 OBJ_INHERIT)
4.7.4 ObpDuplicateHandle 详解
ObpDuplicateHandle(位于 obhandle.c(file:///d:/reactos/ntoskrnl/ob/obhandle.c))是继承机制的核心:
c
NTSTATUS ObpDuplicateHandle(
PHANDLE_TABLE SourceTable,
PHANDLE_TABLE TargetTable,
HANDLE SourceHandle,
POBJECT_TYPE ObjectType,
ACCESS_MASK DesiredAccess,
ULONG HandleAttributes,
BOOLEAN InheritHandle,
ULONG Options,
PETHREAD InheritedFromProcess,
PHANDLE NewHandle)
{
/* 1. 查找源 Entry */
SourceEntry = ExpLookupHandleTableEntry(SourceTable, SourceHandle);
if (!SourceEntry) return STATUS_INVALID_HANDLE;
/* 2. 检查源句柄的有效性 */
ExpLockHandleTableEntry(SourceTable, SourceEntry);
SourceObject = ObpGetHandleObject(SourceEntry);
/* 3. 验证类型 */
if (ObjectType && ObjectType != OBJECT_TO_OBJECT_HEADER(SourceObject)->Type) {
return STATUS_OBJECT_TYPE_MISMATCH;
}
/* 4. 计算实际授予的访问权限 */
if (DesiredAccess & MAXIMUM_ALLOWED) {
GrantedAccess = SourceEntry->GrantedAccess; // 不能超过源
} else {
GrantedAccess = DesiredAccess & SourceEntry->GrantedAccess;
}
/* 5. 分配目标 Entry */
if (Options & DUPLICATE_SAME_ACCESS) {
GrantedAccess = SourceEntry->GrantedAccess;
}
NewEntry = ExpAllocateHandleTableEntry(TargetTable);
NewEntry->Object = SourceObject;
NewEntry->GrantedAccess = GrantedAccess;
NewEntry->ObAttributes = HandleAttributes;
/* 6. 递增引用计数 */
InterlockedIncrementSizeT(&OBJECT_TO_OBJECT_HEADER(SourceObject)->PointerCount);
return STATUS_SUCCESS;
}
重要标志位:
| 标志 | 含义 |
|---|---|
DUPLICATE_SAME_ACCESS |
目标句柄与源句柄权限完全相同 |
DUPLICATE_SAME_ATTRIBUTES |
目标句柄与源句柄属性完全相同 |
DUPLICATE_CLOSE_SOURCE |
复制后关闭源句柄(move 语义) |
4.7.5 继承的语义限制
句柄继承只能在以下情况下使用:
- 创建子进程时 :
CreateProcess(..., bInheritHandles = TRUE) - 指定 OBJ_INHERIT 标志 :在
CreateFile等调用时设置
不能用于:
- 任意进程之间(必须使用
DuplicateHandle) - 已存在的进程之间(必须用 DuplicateHandle)
- 内核对象之间(必须用 ObReference)
4.7.6 概念解释
4.7.6.1 bInheritHandles 参数
CreateProcess 的参数,指定是否继承句柄。
4.7.6.2 STARTUPINFO
STARTUPINFO 结构中的 hStdInput、hStdOutput、hStdError 字段可以包含带 OBJ_INHERIT 标志的句柄。
4.7.6.3 句柄传递(Handle Passing)
更通用的概念,包括继承和 DuplicateHandle。句柄传递后,两个进程都拥有指向同一对象的句柄。
4.7.7 为什么要这样设计
4.7.7.1 问题 1:为什么继承只能用于子进程?
继承是"创建时一次性传递",与子进程的生命周期密切相关。任意进程间传递需要显式的 DuplicateHandle,避免"幽灵句柄"。
4.7.7.2 问题 2:为什么需要 OBJ_INHERIT 标志位?
不是所有句柄都应被继承。例如:
- 用户登录句柄
- 加密密钥句柄
- 临时文件句柄
OBJ_INHERIT 强制开发者明确声明"这个句柄可以被子进程访问",避免意外泄露。
4.7.7.3 问题 3:为什么子进程的句柄值与父进程不同?
句柄值是相对当前进程的。如果父子进程的句柄值相同,反而是漏洞(一个进程可能修改另一个进程的句柄)。每个进程独立的句柄表是设计上的隔离。
为什么句柄值是进程相对的?
- 安全性:攻击者无法通过猜测句柄值来访问其他进程的资源------即使知道父进程的句柄值 0x48,在子进程中也无法使用(除非恰好有相同句柄值的条目)
- 引用计数正确:如果使用全局句柄值,父子进程都引用同一对象,需要额外的引用计数管理
- 可移植性:句柄值对应用程序来说是"不透明"的------程序只需要知道"句柄值有效"即可,不需要关心其具体含义
对比 Unix fd:
Unix 中 fork() 默认复制所有文件描述符,并且子进程通常获得与父进程相同的 fd 编号。这是因为 Unix 使用"文件描述符表"作为进程的本地索引,实际上 Windows 的句柄表也实现了相同的语义------只是 Windows 更强调"句柄值是本地的"。
实际差异:
- Unix :
fork()后子进程拥有与父进程相同的 fd 编号(除非用O_CLOEXEC标记)------这是因为子进程的 fd 表是父进程的完整复制 - Windows :
CreateProcess()继承后子进程获得不同的句柄值(即使指向同一内核对象)------这是因为句柄表是新分配的,旧条目被复制到新表
4.7.7.4 问题 4:为什么进程句柄继承默认关闭?
CreateProcess 的参数 bInheritHandles 默认是 FALSE------即使句柄设置了 OBJ_INHERIT,如果 bInheritHandles = FALSE 也不会继承。
为什么默认关闭?
- 最小权限原则:不传递不必要的句柄给子进程
- 性能考虑:遍历整个句柄表需要时间,尤其当进程有几千个句柄时
- 安全沙箱:子进程通常不应获得父进程的所有资源
何时需要打开继承?
- Shell execute:cmd.exe 启动子进程时需要继承 stdin/stdout/stderr
- 管道通信:父进程创建管道,将一端传递给子进程
- 调试器:调试进程需要获得被调试进程的各种句柄
4.7.7.5 问题 5:为什么继承时不复制对象?
继承只复制句柄表条目,不复制内核对象本身。为什么?
- 性能:复制内核对象(特别是大对象如 FILE_OBJECT)开销巨大
- 语义正确:子进程对文件的操作应该影响同一文件状态------如果是副本,父子进程会看到不同的文件指针位置
- 共享的目标:继承的设计目标就是"共享访问",不是"各自独立"
示例:父子进程通过管道通信------如果管道对象被复制,通信就无法进行。
4.7.7.6 问题 6:为什么句柄继承后 GrantedAccess 不改变?
继承后的句柄拥有与父进程句柄完全相同的权限。为什么不重新评估权限?
原因 1:性能 ------重新评估需要获取 Token、检查 DACL,成本高
原因 2:一致性 ------父进程打开文件获得了写权限,子进程继承后不应自动降级为只读
原因 3:安全模型 ------父进程已经被信任,其创建的子进程也被信任(直到子进程调用 ImpersonateLoggedOnUser 切换身份)
但是: 如果子进程的 Token 不同(例如以低权限运行),系统会在实际使用句柄时重新检查权限------这就是为什么 DuplicateHandle 在跨进程传递时可以"限制"权限。
4.7.8 增强子节:句柄继承的安全考量
4.7.8.1 设计意图
核心问题:句柄继承可能被恶意代码利用。例如:
- 父进程创建了一个特权句柄(如 SYSTEM 令牌)
- 子进程通过继承获得该句柄
- 子进程可以执行特权操作
设计哲学 :「最小特权」------只传递必要的句柄,不传递不必要的。
4.7.8.2 概念解释
- 特权继承(Privilege Inheritance):父进程的特权不传递给子进程(除 SeTcbPrivilege 外)
- 句柄继承:与特权继承是独立的
4.7.8.3 安全检查
虽然 OBJ_INHERIT 让句柄可继承,但内核会进行额外检查:
- 进程的完整性级别:高完整性进程创建的句柄,低完整性子进程不能继承
- AppContainer:现代 Windows 进一步隔离
4.7.8.4 最佳实践
c
// 不推荐:所有句柄都设 OBJ_INHERIT
HANDLE hFile = CreateFile(L"foo", GENERIC_READ, FILE_SHARE_READ,
&sa, OPEN_EXISTING, OBJ_INHERIT, NULL);
// 推荐:仅 stdin/stdout 设 OBJ_INHERIT
HANDLE hInput = CreateFile(L"CONIN$", GENERIC_READ, FILE_SHARE_READ,
&sa, OPEN_EXISTING, OBJ_INHERIT, NULL);
HANDLE hFile = CreateFile(L"foo", GENERIC_READ, FILE_SHARE_READ,
&sa, OPEN_EXISTING, 0, NULL);
4.7.9 增强子节:与 Unix fork() 的对比
4.7.9.1 设计意图
核心问题 :Unix 的 fork() 隐式继承所有资源,Windows 的 CreateProcess 显式控制。两种设计哲学各有优势。
4.7.9.2 概念解释
fork():复制整个进程空间,子进程几乎继承所有CreateProcess:完全新进程,仅按需传递
4.7.9.3 详细对比
| 维度 | Unix fork() |
Windows CreateProcess |
|---|---|---|
| 句柄继承 | 默认全部继承(O_CLOEXEC 才阻止) |
默认全部不继承(OBJ_INHERIT 才允许) |
| 内存空间 | 复制页表 + COW(写时复制) | 完全独立(新地址空间) |
| 代码段 | 共享同一物理页(COW 保护) | 重新加载 PE 文件到内存 |
| 数据段 | COW 直到子进程写入 | 完全新分配,通过 PE 初始化 |
| 文件描述符 | 所有 fd 复制(含指针、偏移量) | 仅 OBJ_INHERIT 句柄复制 |
| 性能 | 快(微秒级) | 慢(毫秒级,需加载 DLL、初始化运行时) |
| 安全性 | 全量继承可能泄露敏感资源 | 显式选择,最小权限 |
| 可扩展 | 依赖 clone() 系列系统调用 |
依赖 CreateProcess 的 10+ 参数 |
| 跨平台 | POSIX 标准 | Win32 专有 |
| 调试友好 | 简单(父子相同代码) | 复杂(需调试子进程附加) |
4.7.9.4 两种设计哲学的深度分析
Unix fork() 哲学:一切皆可复制
- 基本假设:子进程与父进程几乎相同,只是"从调用处继续执行"
- 核心优势 :API 极简------
fork()无参数,返回值区分父子进程 - 典型模式 :
fork()→ 子进程exec()→ 新程序替换内存空间 - 优点 :快速创建轻量子进程(如
bash执行ls) - 缺点 :如果子进程不执行
exec(),会浪费父进程所有已分配内存的复制成本
Windows CreateProcess 哲学:一切从新开始
- 基本假设:子进程是全新的独立实体,与父进程仅有显式共享
- 核心优势 :精确控制------
STARTUPINFO、bInheritHandles、lpEnvironment等 10+ 参数精确配置 - 典型模式 :
CreateProcess("cmd.exe", ...)→ 全新 cmd.exe 进程启动 - 优点:安全、清晰、无意外共享
- 缺点:启动成本高,每次都要加载 PE、解析 DLL、初始化堆、准备 stdin/stdout
为什么 Unix 选择 fork?
Unix 起源于 PDP-11 小型机,内存只有几千字节------轻量的 fork 是关键创新。"一切皆可复制"的简单模型让 shell 脚本可以轻易创建大量子进程来组合小工具。现代 Linux 继承了这一传统,但通过 clone() 提供了更细粒度的控制。
为什么 Windows 选择 CreateProcess?
Windows 起源于 DOS 和 OS/2,设计目标是"支持大型应用"------Word、Excel、数据库服务器。这些应用程序加载成本极高,不适合"fork-then-exec"模式。CreateProcess 设计为"一步到位"地启动全新程序,不需要先复制再丢弃父进程内存。
4.7.9.5 COW vs 完整加载:性能分析
Copy-On-Write 的优点:
- 快速创建:只需复制页表(几千字节),不复制实际数据(几 MB 到几 GB)
- 内存高效:如果子进程只读数据(如配置文件),与父进程共享物理页
- 按需分配:只有子进程真正写入时才分配新页面
COW 的缺点:
- 内存压力下的风暴:大量 fork 后,如果所有子进程同时开始写入,引发大量缺页异常
- TLB 刷新:fork 导致 MMU 缓存失效,影响性能
- 内存超配:Linux 允许"过量提交"------实际写入时可能触发 OOM killer
完整加载的优点:
- 可预测性:创建时间稳定,不依赖父进程状态
- 清晰语义:父子进程完全独立,易于调试
- 安全:没有意外共享的风险
完整加载的缺点:
- 启动慢:DLL 加载、重定位、IAT 解析都需要时间
- 冷启动成本:首次启动需要大量磁盘 I/O
- 小工具开销:启动记事本也要花几十毫秒初始化
4.7.9.6 现代趋势:两种模型的融合
现代操作系统正在融合两种模型的优点:
Unix 侧:
posix_spawn():在一次调用中完成 fork+exec,减少中间开销clone():细粒度控制共享哪些部分(VM、FD、命名空间)- VFORK:子进程直接使用父进程内存,直到 exec
Windows 侧:
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE:允许传递伪控制台PROC_THREAD_ATTRIBUTE_HANDLE_LIST:允许只传递特定句柄(替代 bInheritHandles 的全有或全无)CreateProcessWithTokenW:在不同安全上下文中启动进程(对应 Unix 的setuid)
4.7.9.7 安全考量的深度差异
| 安全问题 | Unix fork() | Windows CreateProcess |
|---|---|---|
| 文件描述符泄露 | 严重(所有 fd 默认继承) | 可控(仅 OBJ_INHERIT 标记) |
| 内存信息泄露 | 可能(子进程可读父进程的栈和堆) | 无(完全新地址空间) |
| 环境变量继承 | 默认继承全部 | 可控(lpEnvironment 参数) |
| 权限继承 | 通过 exec 时的 setuid 控制 |
通过 Token 和权限过滤精确控制 |
| 审计日志 | 弱(依赖内核配置) | 强(Audit Process Creation) |
典型攻击场景差异:
- Unix:CGI 脚本中 fork 子进程处理请求------如果未正确关闭文件描述符,子进程可能访问父进程的敏感文件
- Windows:Word 打开宏文件后启动子进程------子进程默认无法访问 Word 已打开的文档句柄(除非 Word 显式设置 OBJ_INHERIT)
4.7.9.8 为什么 Windows 最终没有采用 fork?
最终决策背后有多个技术和商业考虑:
- 历史原因:DOS/Windows 早期没有内存管理单元(MMU),无法实现 COW
- 应用模型:Windows 应用是"大型单体程序",不适合"小工具组合"模型
- 多任务模型:Windows 从开始就是多任务系统(每个应用一个进程),不依赖快速 fork
- 安全目标:Windows NT 的设计目标之一是"C2 级安全"------显式控制继承是关键要求
- 可调试性:完整创建的进程比 fork 创建的进程更容易调试(地址空间独立)
- COM/OLE 模型:Windows 大量使用 COM/OLE 对象,这些对象包含大量内部状态,在 fork 后会严重损坏
有趣的是,Windows 在 2016 年才通过 PssCaptureSnapshot + NtCreateProcessEx 添加了"进程快照"能力------这实际上等同于 fork 的语义,但仅用于调试目的,不作为常规 API。这从侧面证明:显式创建模型对 Windows 来说是更合适的默认选择。
4.7.10 小结
4.7.10.1 关键知识点
| 主题 | 关键点 |
|---|---|
| 继承时机 | CreateProcess 调用时 |
| 继承标记 | OBJ_INHERIT 标志 |
| 继承行为 | 复制句柄(不复制句柄值),递增引用计数 |
| 替代方案 | DuplicateHandle(更灵活) |
| 安全考量 | 仅继承显式标记的句柄 |
4.7.10.2 设计原则
- 显式优于隐式:OBJ_INHERIT 强制开发者声明
- 父子隔离:句柄值不共享
- 引用计数正确:复制后引用计数递增
- 最小特权:仅传递必要句柄
4.7.10.3 后续学习路径
- 4.8 节:NtDuplicateObject
- 4.9 节:NtClose
- 第 5 章:进程与线程管理