Reactos 第 4 章 对象管理 — 4.6 对象的访问控制 / 4.7 句柄的遗传和继承

第 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(身份令牌)」三件套模型:

  1. 能力 :每个句柄携带 GrantedAccess 位掩码,定义"持句柄者能做什么"
  2. ACL :每个对象携带 DACL(自主 ACL),定义"谁能访问"
  3. Token :每个线程携带 Token,定义"我是谁"

访问检查时,内核做以下判断:

  • 句柄.GrantedAccess & DesiredAccess == DesiredAccess?(能力检查)
  • DACL 是否允许 Token 的 SID 访问?(ACL 检查)

本节定位

本节将从 ReactOS 源码出发,深入解析访问控制模型的三个核心组件:SECURITY_DESCRIPTORACCESS_MASKTOKEN。我们将以 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 有写权限」)
  • 特权:系统级能力(如「可以关闭系统」「可以加载驱动」「可以调试任意进程」)

特权由 SePrivilegeCheckntoskrnl/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_TYPEACCESS_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 继承 DACL
  • SE_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 类型特定权限

为什么把通用权限放在高位,类型特定权限放在低位?

这是一个经过深思熟虑的设计决策:

  1. 避免位冲突:如果通用权限位在低位,不同对象类型的特定权限位可能与通用位重叠------例如 FILE_READ_DATA 与 PROCESS_TERMINATE 都使用位 0,但 GENERIC_READ 通过 GENERIC_MAPPING 可以映射到不同的类型权限。通用权限位在高位确保了"抽象层"和"实现层"在物理空间上的隔离。

  2. 快速识别请求类别:通过检测位 28-31 是否被设置,系统可以立即判断这是"通用权限请求"还是"类型特定权限请求"。这在调试日志和安全审计中非常有用。

  3. 防止意外授予 :用户态如果只写了 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 的工作方式是:

  1. 清除 GENERIC_READ/WRITE/EXECUTE/ALL 四个位(位 28-31)
  2. 检查原先是否设置了这些位
  3. 如果设置了,则添加相应的类型特定权限

为什么不能同时保留 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 的实现流程

SeAccessCheckaccesschk.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_CONTROLWRITE_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

NtAccessCheckaccesschk.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;
}

NtAccessCheckByTypeNtAccessCheck 的差异

NtAccessCheckByType 支持按对象特定 ACE(ACCESS_ALLOWED_OBJECT_ACE)的检查,而 NtAccessCheck 仅支持普通 ACE。

关键限制NtAccessCheck 只能用于没有挂上具体对象 的访问决策。换句话说,它只能判断"如果有一个这种 SD 的对象,主体能否访问它"。实际访问对象时,内核会调用 SeAccessCheck 而非 NtAccessCheck

4.6.6 特权检查(SePrivilegeCheck)

SePrivilegeCheckaccess.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:便于运行时访问(无需计算偏移量)

RtlSelfRelativeToAbsoluteSDRtlAbsoluteToSelfRelativeSD 用于两种格式之间的转换。

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 中的实际转换:

SepMakeSDAbsoluteSepMakeSDSelfRelativentoskrnl/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_OWNERWRITE_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 为什么要这样设计

问题:为什么不强制用户态明确指定权限?

答案

  1. 兼容性:Windows 早期版本就支持 MAXIMUM_ALLOWED
  2. 易用性:很多代码不需要精确权限
  3. 性能:一次请求可获得所有权限,无需多次调用

反例:在防病毒、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;  // 总是成功
}

为什么内核态跳过权限检查?

  1. 内核是受信任的:内核不会被"恶意代码"劫持
  2. 简化代码:避免每次都进行无意义的检查
  3. 性能:在内核热路径上节省时间

例外OBJ_FORCE_ACCESS_CHECK 标志让内核态也必须检查。

4.6.10.4 与用户态 Token 的关系

内核态代码使用进程的主 Token进行访问检查。内核态没有"模拟 Token"的概念。

4.6.10.5 为什么要这样设计

问题:如果内核态可以绕过所有检查,恶意驱动是否可以滥用?

答案

  1. 驱动必须被加载:Windows 强制驱动签名
  2. 驱动是受信任的:开发者已经通过认证
  3. 内核回调 :防病毒可以通过 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 设计原则
  1. 能力 + ACL + Token 三件套:主体、客体、决策
  2. DENY 优先:避免歧义
  3. 内核态绕过:性能与简化
  4. 完整性级别:纵深防御
  5. 特权作为兜底:受信任代码的最后手段
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 时显式声明继承意图,传递带特定属性的句柄。

本节定位

本节讨论两个相关概念:

  1. 句柄继承:父进程在创建子进程时选择性传递句柄
  2. 句柄遗传:「Inheritable Handles」的中文翻译,与继承本质相同

4.7.1 句柄继承基础概念

句柄继承 vs 共享内存 vs IPC

维度 句柄继承 共享内存 命名管道
粒度 句柄级(整对象) 字节级(内存区域) 字节流级(消息)
方向 父 → 子(创建时) 任意进程间 任意进程间(双向)
权限 继承 GrantedAccess 由映射句柄决定 由管道 SD 决定
生命周期 父子独立(独立 Close) 共享区域直到 unmapped 显式关闭才终止
内核参与 轻量(仅复制句柄表条目) 重(需建立内存映射) 重(需创建文件对象 + 缓存)
性能 极快(O(n)遍历句柄表) 较慢(需修改页表) 最慢(需线程 + 缓存管理)

为什么需要三种不同的共享机制?

  • 句柄继承:用于"父进程传递已打开的资源给子进程"------最简单、最快速的机制,但仅在创建时刻有效
  • 共享内存:用于"进程间共享大量数据"------内存映射文件/区域,提供最直接的数据共享
  • 命名管道:用于"进程间通信"------基于消息的通信机制,提供数据传输和同步

三者并非互相替代,而是互补:Shell 启动子进程时同时使用三种机制------通过句柄继承传递 stdin/stdout,通过共享内存传递环境变量,通过命名管道实现进程间通信。

句柄继承的两种方式

  1. 创建时声明CreateProcess(..., bInheritHandles = TRUE) --- 一次性传递所有带 OBJ_INHERIT 的句柄
  2. 运行时复制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_INHERITHANDLE_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;
}

关键设计决策

  1. 引用计数递增 :复制后 PointerCount += 1,因为有两个引用(父子进程各一个)
  2. 不复制父进程的句柄值:子进程得到自己的句柄值(与父进程不同)
  3. GrantedAccess 复制:子进程的句柄权限与父进程相同
  4. 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 继承的语义限制

句柄继承只能在以下情况下使用

  1. 创建子进程时CreateProcess(..., bInheritHandles = TRUE)
  2. 指定 OBJ_INHERIT 标志 :在 CreateFile 等调用时设置

不能用于

  • 任意进程之间(必须使用 DuplicateHandle
  • 已存在的进程之间(必须用 DuplicateHandle)
  • 内核对象之间(必须用 ObReference)

4.7.6 概念解释

4.7.6.1 bInheritHandles 参数

CreateProcess 的参数,指定是否继承句柄。

4.7.6.2 STARTUPINFO

STARTUPINFO 结构中的 hStdInputhStdOutputhStdError 字段可以包含带 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 更强调"句柄值是本地的"。

实际差异:

  • Unixfork() 后子进程拥有与父进程相同的 fd 编号(除非用 O_CLOEXEC 标记)------这是因为子进程的 fd 表是父进程的完整复制
  • WindowsCreateProcess() 继承后子进程获得不同的句柄值(即使指向同一内核对象)------这是因为句柄表是新分配的,旧条目被复制到新表
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 哲学:一切从新开始

  • 基本假设:子进程是全新的独立实体,与父进程仅有显式共享
  • 核心优势 :精确控制------STARTUPINFObInheritHandleslpEnvironment 等 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 的优点:

  1. 快速创建:只需复制页表(几千字节),不复制实际数据(几 MB 到几 GB)
  2. 内存高效:如果子进程只读数据(如配置文件),与父进程共享物理页
  3. 按需分配:只有子进程真正写入时才分配新页面

COW 的缺点:

  1. 内存压力下的风暴:大量 fork 后,如果所有子进程同时开始写入,引发大量缺页异常
  2. TLB 刷新:fork 导致 MMU 缓存失效,影响性能
  3. 内存超配:Linux 允许"过量提交"------实际写入时可能触发 OOM killer

完整加载的优点:

  1. 可预测性:创建时间稳定,不依赖父进程状态
  2. 清晰语义:父子进程完全独立,易于调试
  3. 安全:没有意外共享的风险

完整加载的缺点:

  1. 启动慢:DLL 加载、重定位、IAT 解析都需要时间
  2. 冷启动成本:首次启动需要大量磁盘 I/O
  3. 小工具开销:启动记事本也要花几十毫秒初始化
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?

最终决策背后有多个技术和商业考虑:

  1. 历史原因:DOS/Windows 早期没有内存管理单元(MMU),无法实现 COW
  2. 应用模型:Windows 应用是"大型单体程序",不适合"小工具组合"模型
  3. 多任务模型:Windows 从开始就是多任务系统(每个应用一个进程),不依赖快速 fork
  4. 安全目标:Windows NT 的设计目标之一是"C2 级安全"------显式控制继承是关键要求
  5. 可调试性:完整创建的进程比 fork 创建的进程更容易调试(地址空间独立)
  6. 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 设计原则
  1. 显式优于隐式:OBJ_INHERIT 强制开发者声明
  2. 父子隔离:句柄值不共享
  3. 引用计数正确:复制后引用计数递增
  4. 最小特权:仅传递必要句柄
4.7.10.3 后续学习路径
  • 4.8 节:NtDuplicateObject
  • 4.9 节:NtClose
  • 第 5 章:进程与线程管理
相关推荐
huangdong_1 小时前
京东整店商品图片视频批量下载技术:从商品列表到自动分类
开发语言·python·音视频
摇滚侠1 小时前
JavaWeb 全套教程 Filter 107-111
java·开发语言·servlet
聆风吟º1 小时前
【C标准库】深入理解C语言 atoi 函数:字符串转换为整数
c语言·开发语言·库函数·atoi
凤山老林1 小时前
81-Java Scanner 类
java·开发语言
j_xxx404_1 小时前
MySQL数据库基础硬核解析:从 C/S 网络服务到磁盘文件与存储引擎
linux·运维·服务器·开发语言·数据库·mysql·ai
艾莉丝努力练剑1 小时前
【QT】系统相关:QT文件
linux·服务器·开发语言·网络·qt·tcp/ip·计算机网络
沐苏瑶1 小时前
深入浅出 Java 文件操作与 IO:从文件系统到数据流实战
java·开发语言
海鸥-w1 小时前
用python (fastapi)做项目第二天实现新闻列表和新闻详情接口
开发语言·python·fastapi
Cloud_Shy6181 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第四章 Item 25 - 26)
开发语言·人工智能·经验分享·笔记·python·学习方法