第 5 章 进程与线程 --- 5.3 系统调用 NtCreateProcess()
5.3.0 框架图
┌──────────────────────────────────────────────────────────────────────────────────┐
│ NtCreateProcess 完整调用链 │
│ │
│ 用户态 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ kernel32!CreateProcessW │ │
│ │ │ │ │
│ │ ├─► CreateFile (打开可执行文件) │ │
│ │ ├─► NtCreateSection (创建 Section 对象) │ │
│ │ ├─► NtCreateProcess (创建进程) │ │
│ │ ├─► NtCreateThread (创建初始线程) │ │
│ │ ├─► NtResumeThread (启动线程) │ │
│ │ └─► 通知 CSRSS/Win32k │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ syscall │
│ 内核态 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ntdll!NtCreateProcess │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ntoskrnl!NtCreateProcess │ │
│ │ │ │ │
│ │ └─► NtCreateProcessEx (扩展版本) │ │
│ │ │ │ │
│ │ └─► PspCreateProcess (核心创建逻辑) │ │
│ │ │ │ │
│ │ ├─► 步骤 1: 参数校验 │ │
│ │ ├─► 步骤 2: 解析父进程句柄 │ │
│ │ ├─► 步骤 3: 解析 SectionHandle │ │
│ │ ├─► 步骤 4: 解析 DebugPort/ExceptionPort │ │
│ │ ├─► 步骤 5: 创建 EPROCESS 对象 │ │
│ │ ├─► 步骤 6: 初始化 EPROCESS 字段 │ │
│ │ ├─► 步骤 7: 创建地址空间 │ │
│ │ ├─► 步骤 8: 初始化 KPROCESS │ │
│ │ ├─► 步骤 9: 复制安全令牌 │ │
│ │ ├─► 步骤 10: 初始化对象管理器 │ │
│ │ ├─► 步骤 11: 初始化用户态地址空间 │ │
│ │ ├─► 步骤 12: 分配 PID │ │
│ │ ├─► 步骤 13: 插入 PsActiveProcessHead │ │
│ │ ├─► 步骤 14: 计算 Quantum/Priority │ │
│ │ ├─► 步骤 15: 创建 PEB │ │
│ │ ├─► 步骤 16: 插入对象目录 │ │
│ │ ├─► 步骤 17: 访问检查 │ │
│ │ └─► 步骤 18: 返回进程句柄 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
5.3.0.1 设计意图
核心问题
如何在内核态创建一个完整的进程对象?需要经过哪些步骤?每个步骤的目的是什么?
设计哲学 :「先建壳,再建芯;先建芯,再激活」
想象一下,创建一个进程就像建造一座新房子:
-
建壳(创建 EPROCESS 对象框架):先搭建房子的框架结构------地基、墙壁、屋顶。这就像创建 EPROCESS 对象,分配内存空间,建立基本的数据结构。
-
建芯(填充 EPROCESS 的各个字段):然后安装房子的核心设施------水电管道、门窗、电气系统。这就像初始化 EPROCESS 的各个字段:地址空间(水电)、安全令牌(门禁)、句柄表(窗户)、PEB(装修图纸)。
-
激活(插入全局链表和对象目录):最后办理入住手续------登记房产、发放钥匙、通知邻居。这就像将进程插入全局链表(PsActiveProcessHead)、分配 PID(房产证号)、创建句柄(钥匙)、通知子系统(邻居)。
本节定位
本节深入分析 NtCreateProcess 系统调用的完整实现,特别是核心函数 PspCreateProcess。读完本节后,读者应当能够:
- 理解进程创建的完整流程(18 个步骤)
- 掌握每个步骤的目的和实现细节
- 理解错误处理和回滚机制(事务性创建)
- 理解与其他子系统的交互(内存管理器、对象管理器、安全子系统)
5.3.1 NtCreateProcess 函数签名
NtCreateProcess:进程创建的"总指挥"
NtCreateProcess 是创建进程的核心系统调用。如果把创建进程比作申请开办一家新公司 ,那么 NtCreateProcess 就是向政府部门提交的公司注册申请表。
c
NTSTATUS NTAPI NtCreateProcess(
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN HANDLE ParentProcess,
IN BOOLEAN InheritObjectTable,
IN HANDLE SectionHandle OPTIONAL,
IN HANDLE DebugPort OPTIONAL,
IN HANDLE ExceptionPort OPTIONAL);
参数详解
| 参数 | 类型 | 说明 | 比喻 |
|---|---|---|---|
ProcessHandle |
OUT PHANDLE | 返回创建的进程句柄 | 公司营业执照(拿到后可以运营公司) |
DesiredAccess |
IN ACCESS_MASK | 请求的访问权限 | 申请的权限范围(能做什么) |
ObjectAttributes |
IN POBJECT_ATTRIBUTES | 对象属性(名称等) | 公司名称、地址等信息 |
ParentProcess |
IN HANDLE | 父进程句柄 | 母公司(子公司继承母公司属性) |
InheritObjectTable |
IN BOOLEAN | 是否继承父进程句柄表 | 是否继承母公司的业务关系 |
SectionHandle |
IN HANDLE | 可执行文件的 Section 句柄 | 公司章程(定义公司运营规则) |
DebugPort |
IN HANDLE | 调试端口(可选) | 监管部门(监督公司运营) |
ExceptionPort |
IN HANDLE | 异常端口(可选) | 紧急联络渠道(处理突发事件) |
返回值
| 返回值 | 说明 | 比喻 |
|---|---|---|
STATUS_SUCCESS |
创建成功 | 申请通过,公司成立 |
STATUS_INVALID_HANDLE |
无效句柄 | 提供的母公司信息无效 |
STATUS_ACCESS_DENIED |
访问被拒绝 | 权限不足,申请被驳回 |
STATUS_INSUFFICIENT_RESOURCES |
资源不足 | 办公场地不足,无法注册 |
参数之间的关系
-
ParentProcess 与 InheritObjectTable:如果 InheritObjectTable 为 TRUE,新进程会继承父进程的可继承句柄。就像子公司可以继承母公司的部分业务关系。
-
SectionHandle 与进程类型:如果 SectionHandle 为 NULL,创建的是系统进程(没有可执行文件);如果非 NULL,创建的是用户进程(有可执行文件)。就像有章程的公司是正规公司,没有章程的是特殊机构。
-
DebugPort 与 ExceptionPort:这两个参数是可选的,用于调试和异常处理。就像公司可以选择是否接受监管部门监督。
5.3.2 NtCreateProcessEx(新接口)
NtCreateProcessEx:升级版的"公司注册系统"
随着 Windows 系统的发展,进程创建的需求越来越复杂。NtCreateProcessEx 就像是升级版的公司注册系统,提供了更多的选项和更清晰的参数结构。
c
NTSTATUS NTAPI NtCreateProcessEx(
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN HANDLE ParentProcess,
IN BOOLEAN InheritObjectTable,
IN HANDLE SectionHandle OPTIONAL,
IN HANDLE DebugPort OPTIONAL,
IN HANDLE ExceptionPort OPTIONAL,
IN BOOLEAN InheritDebugPort,
IN PVOID Reserved);
新增参数
| 参数 | 说明 |
|---|---|
InheritDebugPort |
是否继承父进程的调试端口(新增) |
Reserved |
预留参数,供未来扩展使用 |
标志位(PROCESS_CREATE_FLAGS_*)
这些标志位就像是公司注册时的附加选项:
| 标志 | 说明 | 比喻 |
|---|---|---|
PROCESS_CREATE_FLAGS_BREAKAWAY |
脱离 Job | 子公司脱离母公司集团 |
PROCESS_CREATE_FLAGS_NO_DEBUG_INHERIT |
不继承调试端口 | 不接受母公司的监管 |
PROCESS_CREATE_FLAGS_INHERIT_HANDLES |
继承句柄 | 继承母公司的业务关系 |
PROCESS_CREATE_FLAGS_LARGE_PAGES |
使用大页面 | 申请大型办公场地 |
PROCESS_CREATE_FLAGS_PROTECTED_PROCESS |
受保护进程 | 特殊保护的公司(如银行) |
与 NtCreateProcess 的关系
NtCreateProcess 是 NtCreateProcessEx 的简化包装,就像快速注册通道 和完整注册流程的关系:
c
NTSTATUS NTAPI NtCreateProcess(...) {
return NtCreateProcessEx(ProcessHandle,
DesiredAccess,
ObjectAttributes,
ParentProcess,
InheritObjectTable,
SectionHandle,
DebugPort,
ExceptionPort,
FALSE, // InheritDebugPort(默认不继承)
NULL); // Reserved(预留)
}
这种设计保证了向后兼容性:旧代码可以继续使用 NtCreateProcess,新代码可以使用 NtCreateProcessEx 获得更多控制选项。
5.3.3 PspCreateProcess 深入分析
PspCreateProcess:进程创建的"施工队长"
PspCreateProcess 是进程创建的核心实现函数。如果把创建进程比作建造房子 ,那么 PspCreateProcess 就是负责具体施工的施工队长,他需要按照图纸一步步完成施工。
整个施工过程分为 18 个步骤,每个步骤都有明确的目的和依赖关系。如果任何一个步骤失败,都需要回滚之前已完成的工作,就像施工失败需要拆除已建的部分。
步骤详解
┌──────────────────────────────────────────────────────────────────────────────────┐
│ PspCreateProcess 执行流程 │
│ │
│ 步骤 1: 参数校验 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ if (Flags & ~PROCESS_CREATE_FLAGS_LEGAL_MASK) │ │
│ │ return STATUS_INVALID_PARAMETER; │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【施工前检查】就像施工前检查图纸是否合规。如果参数中有非法标志位, │
│ 立即返回错误,不开始任何施工工作。 │
│ │ │
│ ▼ │
│ 步骤 2: 解析父进程句柄 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Status = ObReferenceObjectByHandle(ParentProcess, │ │
│ │ PROCESS_CREATE_PROCESS, │ │
│ │ PsProcessType, │ │
│ │ &Parent); │ │
│ │ if (!NT_SUCCESS(Status)) return Status; │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【确认母公司】就像确认母公司是否存在、是否有权设立子公司。 │
│ 通过 ObReferenceObjectByHandle 获取父进程对象指针, │
│ 同时增加引用计数防止父进程被提前销毁。 │
│ │ │
│ ▼ │
│ 步骤 3: 解析 SectionHandle │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ if (SectionHandle != NULL) { │ │
│ │ Status = ObReferenceObjectByHandle(SectionHandle, │ │
│ │ SECTION_MAP_EXECUTE, │ │
│ │ NULL, │ │
│ │ &SectionObject); │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【获取公司章程】如果提供了 SectionHandle,获取可执行文件的 │
│ Section 对象。这个 Section 包含了程序的代码和数据, │
│ 就像公司章程定义了公司的运营规则。 │
│ │ │
│ ▼ │
│ 步骤 4: 解析 DebugPort/ExceptionPort │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ if (DebugPort != NULL) { │ │
│ │ Status = DbgkReferenceObjectByPointer(DebugPort, │ │
│ │ &DebugObject); │ │
│ │ } │ │
│ │ if (ExceptionPort != NULL) { │ │
│ │ Status = ObReferenceObjectByHandle(ExceptionPort, │ │
│ │ PORT_ALL_ACCESS, │ │
│ │ LpcPortObjectType, │ │
│ │ &ExceptionPortObject); │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【设置监管渠道】如果提供了调试端口或异常端口, │
│ 获取对应的对象引用。调试端口用于调试器监控进程, │
│ 异常端口用于接收进程的异常通知。 │
│ │ │
│ ▼ │
│ 步骤 5: 创建 EPROCESS 对象 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Status = ObCreateObject(KernelMode, │ │
│ │ PsProcessType, │ │
│ │ ObjectAttributes, │ │
│ │ KernelMode, │ │
│ │ NULL, │ │
│ │ sizeof(EPROCESS), │ │
│ │ 0, │ │
│ │ 0, │ │
│ │ &Process); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【搭建框架】调用 ObCreateObject 创建 EPROCESS 对象。 │
│ 这就像搭建房子的框架结构,分配内存空间, │
│ 添加 OBJECT_HEADER(对象管理器的头部信息)。 │
│ │ │
│ ▼ │
│ 步骤 6: 初始化 EPROCESS 字段 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ RtlZeroMemory(Process, sizeof(EPROCESS)); │ │
│ │ ExInitializeRundownProtection(&Process->RundownProtect); │ │
│ │ InitializeListHead(&Process->ThreadListHead); │ │
│ │ Process->InheritedFromUniqueProcessId = Parent->UniqueProcessId; │ │
│ │ // ... 其他字段初始化 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【清空并初始化】将 EPROCESS 结构清零, │
│ 然后初始化关键字段: │
│ - RundownProtect:防止进程被提前销毁的保护机制 │
│ - ThreadListHead:线程链表头(还没线程,先准备空链表) │
│ - InheritedFromUniqueProcessId:记录父进程 PID │
│ │ │
│ ▼ │
│ 步骤 7: 创建地址空间 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ if (SectionObject != NULL) { │ │
│ │ Status = MmCreateProcessAddressSpace(Process); │ │
│ │ } else { │ │
│ │ Status = MmInitializeHandBuiltProcess(Process); │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【铺设地基】创建进程的虚拟地址空间。 │
│ - 有 Section:调用 MmCreateProcessAddressSpace 创建完整地址空间 │
│ - 无 Section:调用 MmInitializeHandBuiltProcess 创建空地址空间 │
│ 这就像铺设房子的地基,后续所有建筑都建在这个地基上。 │
│ │ │
│ ▼ │
│ 步骤 8: 初始化 KPROCESS │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ KeInitializeProcess(&Process->Pcb, │ │
│ │ Parent->Pcb.Affinity, │ │
│ │ Parent->Pcb.BasePriority); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【安装调度系统】初始化 KPROCESS(EPROCESS 的第一个字段)。 │
│ 设置: │
│ - Affinity:CPU 亲和性(继承父进程) │
│ - BasePriority:基础优先级(继承父进程) │
│ - DirectoryTableBase:页目录基址(从地址空间获取) │
│ 这就像安装房子的电力系统,调度器会直接使用 KPROCESS。 │
│ │ │
│ ▼ │
│ 步骤 9: 复制安全令牌 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Status = PspInitializeProcessSecurity(Process, │ │
│ │ Parent); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【安装门禁系统】复制父进程的安全令牌到新进程。 │
│ 令牌决定了进程能访问哪些系统资源, │
│ 就像门禁卡决定了员工能进入哪些区域。 │
│ │ │
│ ▼ │
│ 步骤 10: 初始化对象管理器 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Status = ObInitProcess(Process, │ │
│ │ InheritObjectTable, │ │
│ │ Parent->ObjectTable); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【建立窗户系统】初始化进程的句柄表。 │
│ - 如果 InheritObjectTable 为 TRUE:继承父进程的可继承句柄 │
│ - 如果 FALSE:创建空的句柄表 │
│ 句柄就像窗户,进程通过句柄看到和操作外部资源。 │
│ │ │
│ ▼ │
│ 步骤 11: 初始化用户态地址空间 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Status = MmInitializeProcessAddressSpace(Process, │ │
│ │ SectionObject); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【装修内部空间】初始化用户态地址空间的具体内容。 │
│ 映射可执行文件、ntdll.dll 等到地址空间, │
│ 就像装修房子的内部空间,摆放家具和设备。 │
│ │ │
│ ▼ │
│ 步骤 12: 分配 PID │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Process->UniqueProcessId = ExCreateHandle(PspCidTable, │ │
│ │ &CidEntry); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【发放房产证】通过 ExCreateHandle 在 PspCidTable 中分配 PID。 │
│ PID 是进程的唯一标识符,就像房产证号唯一标识一栋房子。 │
│ PspCidTable 是全局句柄表,存储所有进程和线程对象。 │
│ │ │
│ ▼ │
│ 步骤 13: 插入 PsActiveProcessHead │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ExAcquireResourceExclusiveLite(&PspActiveProcessMutex, TRUE); │ │
│ │ InsertTailList(&PsActiveProcessHead, │ │
│ │ &Process->ActiveProcessLinks); │ │
│ │ ExReleaseResourceLite(&PspActiveProcessMutex); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【登记到房产名录】将进程插入全局活动进程链表 PsActiveProcessHead。 │
│ 使用 PspActiveProcessMutex 保护并发访问, │
│ 就像将新房子登记到城市的房产名录中。 │
│ │ │
│ ▼ │
│ 步骤 14: 计算 Quantum/Priority │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PspComputeQuantumAndPriority(Process); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【设置运营参数】计算进程的时间片(Quantum)和优先级(Priority)。 │
│ 这些参数决定了进程在调度时的行为, │
│ 就像设置公司的运营参数(营业时间、服务级别)。 │
│ │ │
│ ▼ │
│ 步骤 15: 创建 PEB │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Status = MmCreatePeb(Process, │ │
│ │ InitialPeb); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【制作装修图纸】在用户态地址空间中创建 PEB。 │
│ PEB 存储进程的用户态信息(模块列表、堆、参数等), │
│ 就像装修图纸记录了房子的内部布局和设施。 │
│ │ │
│ ▼ │
│ 步骤 16: 插入对象目录 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Status = ObInsertObject((PVOID)Process, │ │
│ │ NULL, │ │
│ │ DesiredAccess, │ │
│ │ 0, │ │
│ │ NULL, │ │
│ │ ProcessHandle); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【发放钥匙】将进程对象插入对象管理器的对象目录, │
│ 并创建进程句柄返回给调用者。 │
│ 这就像房子建好后发放钥匙,业主可以用钥匙进入房子。 │
│ │ │
│ ▼ │
│ 步骤 17: 访问检查 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Status = SeAccessCheck(Process->Token, │ │
│ │ &PsProcessType->GenericMapping, │ │
│ │ DesiredAccess, │ │
│ │ NULL); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【权限复核】检查调用者请求的访问权限是否合法。 │
│ 使用进程的安全令牌进行访问检查, │
│ 确保调用者有权访问新创建的进程。 │
│ │ │
│ ▼ │
│ 步骤 18: 返回进程句柄 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ return STATUS_SUCCESS; │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【交付完成】所有步骤成功完成,返回 STATUS_SUCCESS。 │
│ 进程句柄已经通过 ProcessHandle 参数返回给调用者, │
│ 调用者可以用这个句柄操作新进程。 │
└──────────────────────────────────────────────────────────────────────────────────┘
5.3.4 关键设计决策的深度分析
5.3.4.1 父进程句柄必须指定的原因
为什么必须指定父进程句柄?
想象一下,创建一个新进程就像成立一家子公司。在现实世界中,子公司必须有一个母公司,它从母公司继承资产、业务关系和管理模式。同样,Windows 要求每个新进程都必须有一个父进程。
三个核心原因:
-
继承关系:子进程需要继承父进程的某些属性(令牌、配额、设备映射)
- 就像子公司继承母公司的品牌授权、经营许可和业务资质
- 具体继承的内容包括:
- 安全令牌(Token):决定了进程能访问哪些资源,就像员工卡决定了能进入哪些区域
- 配额(Quota):限制了进程能使用的资源量,就像公司预算限制
- 设备映射(Device Map):DOS 设备名称到 NT 设备名称的映射,就像公司内部使用的缩写代号
-
权限检查:需要验证调用者对父进程有 PROCESS_CREATE_PROCESS 权限
- 就像成立子公司需要母公司董事会的批准
- 这个权限检查防止恶意进程随意创建子进程
- 例如,一个低权限的进程不能以高权限进程为父进程创建子进程
-
安全模型:确保创建者对父进程有足够权限
- Windows 的安全模型基于"谁创建谁负责"的原则
- 父进程对子进程有特殊的控制权(如可以终止子进程)
- 这建立了清晰的进程层次关系,便于系统管理和资源追踪
代码实现细节:
c
// 在 PspCreateProcess 中,父进程句柄的解析是第二步
Status = ObReferenceObjectByHandle(
ParentProcess, // 父进程句柄
PROCESS_CREATE_PROCESS, // 需要的权限
PsProcessType, // 对象类型
PreviousMode, // 调用者模式
(PVOID*)&Parent, // 输出:父进程对象指针
NULL
);
if (!NT_SUCCESS(Status)) {
// 如果父进程不存在或权限不足,立即返回错误
return Status;
}
为什么不能传 NULL?
有些人可能会问:"为什么不能传 NULL 表示创建一个'根进程'?"答案是:
- Windows 系统中只有一个真正的"根进程"------System 进程(PID 4),它在系统启动时由内核创建
- 所有用户进程都必须有父进程,形成一棵进程树
- 这种设计使得系统可以追踪进程的来源和关系,便于调试和安全管理
- 如果允许 NULL,会破坏这种清晰的层次结构
5.3.4.2 SectionHandle 与进程类型的关系
SectionHandle 决定进程类型:
SectionHandle 就像是公司的章程文件。有章程的公司是正规的、有明确业务的公司;没有章程的公司是特殊机构,执行特殊任务。
| SectionHandle | 进程类型 | 说明 | 比喻 |
|---|---|---|---|
| NULL | 系统进程 | 没有可执行文件,通常是系统服务 | 特殊机构(如情报局),没有公开的章程 |
| 非 NULL | 用户进程 | 有可执行文件,正常的应用程序 | 正规公司,有明确的章程和业务范围 |
深入理解 Section 对象:
Section 对象是内存管理器提供的核心对象,它代表了一个内存区域与文件的映射关系。对于进程创建,Section 对象有以下重要作用:
-
可执行文件的内存映射:
- 当你运行
notepad.exe时,系统首先打开这个文件 - 然后创建一个 Section 对象,将文件映射到内存
- 这个 Section 对象包含了程序的代码、数据、资源等
- 当你运行
-
进程地址空间的蓝图:
- Section 对象决定了进程地址空间的初始布局
- 它指定了代码段、数据段的位置和大小
- 它还包含了程序的入口点地址
-
写时复制(Copy-on-Write)优化:
- 多个进程可以共享同一个 Section 对象
- 当某个进程需要修改内存时,才复制相应的页面
- 这大大节省了内存,例如多个记事本进程共享同一份代码
系统进程的创建:
当 SectionHandle 为 NULL 时,创建的是系统进程。这种进程有以下特点:
c
// 系统进程的创建示例(如 System 进程、smss.exe)
Status = NtCreateProcess(
&ProcessHandle,
PROCESS_ALL_ACCESS,
NULL, // 无对象名称
ParentProcess,
FALSE, // 不继承句柄
NULL, // 无 Section ------ 这是关键!
NULL, // 无调试端口
NULL // 无异常端口
);
系统进程的典型用途:
- System 进程(PID 4):内核的"进程容器",内核线程在这里运行
- smss.exe:会话管理器,负责创建用户会话
- csrss.exe:Windows 子系统服务器进程
- 服务进程:某些系统服务可能以空进程方式创建,稍后再加载代码
SectionHandle 的生命周期:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ SectionHandle 的生命周期 │
│ │
│ 1. 打开可执行文件 │
│ CreateFile("notepad.exe", GENERIC_READ | GENERIC_EXECUTE, ...) │
│ │ │
│ ▼ │
│ 2. 创建 Section 对象 │
│ NtCreateSection(&SectionHandle, SECTION_ALL_ACCESS, ..., FileHandle) │
│ │ │
│ ▼ │
│ 3. 创建进程 │
│ NtCreateProcess(&ProcessHandle, ..., SectionHandle, ...) │
│ │ │
│ ▼ │
│ 4. 进程使用 Section │
│ - Section 被映射到进程地址空间 │
│ - 进程的代码、数据来自 Section │
│ - Section 引用计数增加 │
│ │ │
│ ▼ │
│ 5. 关闭句柄 │
│ NtClose(SectionHandle); // 进程仍持有 Section 的引用 │
│ NtClose(FileHandle); │
│ │
│ 注意:即使关闭 SectionHandle,Section 对象仍然存在, │
│ 因为进程持有它的引用。直到进程终止,Section 才会被释放。 │
└──────────────────────────────────────────────────────────────────────────────────┘
5.3.4.3 PID 分配机制
PID 实际上是句柄:
PID(进程标识符)看起来像是一个简单的整数,但实际上它是全局句柄表中的一个句柄值。这个设计非常巧妙,让我们深入理解它。
c
Process->UniqueProcessId = ExCreateHandle(PspCidTable, &CidEntry);
PspCidTable 的本质:
PspCidTable 是一个系统全局的句柄表,专门用于存储进程和线程对象:
- 名称含义:CID = Client ID,客户端标识符
- 存储内容:所有进程对象(EPROCESS)和线程对象(ETHREAD)
- 句柄值:PID 和 TID 都是这个表中的句柄
- 访问方式:通过 PID 可以直接找到对应的 EPROCESS 对象
为什么用句柄表而不是简单的计数器?
- 安全性:句柄表有访问控制,不是任何人都能通过 PID 获取进程对象
- 快速查找:句柄表是高效的哈希表,查找时间复杂度接近 O(1)
- 对象生命周期管理:句柄表管理对象的引用计数
- 统一接口:进程和线程使用相同的机制,代码简洁
PID 分配的具体过程:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ PID 分配流程 │
│ │
│ 步骤 1: 准备句柄表项 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CidEntry.Object = Process; // 指向 EPROCESS 对象 │ │
│ │ CidEntry.GrantedAccess = PROCESS_ALL_ACCESS; │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 步骤 2: 在句柄表中分配一个槽位 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PID = ExCreateHandle(PspCidTable, &CidEntry); │ │
│ │ │ │
│ │ // ExCreateHandle 内部: │ │
│ │ // 1. 在句柄表中找一个空闲槽位 │ │
│ │ // 2. 将 CidEntry 的内容写入该槽位 │ │
│ │ // 3. 返回槽位的索引作为句柄值(即 PID) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 步骤 3: 将 PID 存储到 EPROCESS │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Process->UniqueProcessId = PID; │ │
│ │ │ │
│ │ // 现在,可以通过 PID 找到这个进程对象 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ PID 的特性: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. 唯一性:同一时刻,系统中没有两个进程有相同的 PID │ │
│ │ 2. 可复用:进程终止后,PID 可以被新进程复用 │ │
│ │ 3. 顺序性:PID 通常递增分配,但可能跳过某些值 │ │
│ │ 4. 特殊值:PID=0 表示空闲进程(Idle),PID=4 表示 System 进程 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
PID 复用的安全性考虑:
当进程终止后,它的 PID 可以被新进程复用。这带来一个潜在问题:如果某个进程记住了另一个进程的 PID,然后那个进程终止了,新进程可能恰好分配到相同的 PID,导致误操作。
Windows 通过以下机制缓解这个问题:
- 句柄验证:通过 PID 获取进程对象时,会验证对象类型
- 延迟复用:PID 不会立即复用,而是等待一段时间
- 引用计数:即使进程终止,如果还有代码持有进程对象的引用,PID 也不会被复用
通过 PID 获取进程对象:
c
// PsLookupProcessByProcessId:通过 PID 获取 EPROCESS
NTSTATUS PsLookupProcessByProcessId(
IN HANDLE ProcessId, // PID(实际上是句柄值)
OUT PEPROCESS *Process // 输出:EPROCESS 指针
) {
// 在 PspCidTable 中查找
Status = ObReferenceObjectByHandle(
ProcessId, // PID 作为句柄
PROCESS_ALL_ACCESS,
PsProcessType,
KernelMode,
(PVOID*)Process,
NULL
);
return Status;
}
5.3.4.4 地址空间创建的时序
为什么先创建地址空间再初始化 KPROCESS?
这是一个关键的时序依赖问题。要理解它,我们需要深入了解 KPROCESS 的结构和地址空间的作用。
KPROCESS 的关键字段:
c
typedef struct _KPROCESS {
// ... 其他字段 ...
ULONG_PTR DirectoryTableBase; // 页目录基址 ------ 关键字段!
// ... 其他字段 ...
} KPROCESS;
DirectoryTableBase 是页目录的物理地址,它是虚拟地址空间的核心:
- 每个进程有独立的页目录
- 页目录包含了进程所有虚拟地址到物理地址的映射
- CPU 的 CR3 寄存器存储当前进程的 DirectoryTableBase
- 切换进程时,CR3 也随之切换
创建顺序的必要性:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 地址空间创建时序 │
│ │
│ 错误的顺序(假设): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. KeInitializeProcess(&Process->Pcb, ...) │ │
│ │ 问题:DirectoryTableBase 从哪里来? │ │
│ │ │ │
│ │ 2. MmCreateProcessAddressSpace(Process) │ │
│ │ 创建页目录,获取 DirectoryTableBase │ │
│ │ 但 KPROCESS 已经初始化了!需要重新设置 DirectoryTableBase │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 正确的顺序: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. MmCreateProcessAddressSpace(Process) │ │
│ │ 创建页目录结构 │ │
│ │ 分配物理页作为页目录 │ │
│ │ 设置 Process->Pcb.DirectoryTableBase = 页目录物理地址 │ │
│ │ │ │
│ │ 2. KeInitializeProcess(&Process->Pcb, ...) │ │
│ │ 读取 Process->Pcb.DirectoryTableBase(已设置) │ │
│ │ 初始化其他 KPROCESS 字段 │ │
│ │ 设置调度相关的参数 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ MmCreateProcessAddressSpace 的内部工作: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NTSTATUS MmCreateProcessAddressSpace(PEPROCESS Process) │ │
│ │ { │ │
│ │ // 1. 分配页目录物理页 │ │
│ │ PageDirectory = MmAllocPage(); │ │
│ │ │ │
│ │ // 2. 初始化页目录 │ │
│ │ // 复制内核地址空间映射(所有进程共享内核空间) │ │
│ │ RtlCopyMemory(PageDirectory, KernelPageDirectory, ...); │ │
│ │ │ │
│ │ // 3. 设置 DirectoryTableBase │ │
│ │ Process->Pcb.DirectoryTableBase = MmGetPhysicalAddress( │ │
│ │ PageDirectory │ │
│ │ ); │ │
│ │ │ │
│ │ // 4. 初始化用户态地址空间区域 │ │
│ │ // (如 PEB 区域、共享用户数据区域等) │ │
│ │ │ │
│ │ return STATUS_SUCCESS; │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
地址空间创建的详细步骤:
-
分配页目录:
- 页目录是一页(4KB)物理内存
- 包含 1024 个页目录项(PDE),每个 PDE 管理 4MB 虚拟地址空间
- 总共管理 4GB 虚拟地址空间(32 位系统)
-
复制内核映射:
- 所有进程共享内核地址空间(高 2GB)
- 新进程的页目录复制内核的页目录项
- 这样所有进程看到的内核空间是一样的
-
设置用户态空间:
- 用户态地址空间(低 2GB)初始为空
- 后续会映射可执行文件、DLL、堆、栈等
-
创建特殊区域:
- 共享用户数据区(KUSER_SHARED_DATA)
- PEB 区域(预留空间,稍后创建 PEB)
- TEB 区域(预留空间,线程创建时分配)
5.3.4.5 错误处理与回滚
事务性创建机制:
进程创建是一个复杂的多步骤操作,就像建造房子一样,任何一个步骤失败都需要回滚之前已完成的工作。Windows 采用事务性创建机制,确保失败时不会留下半成品。
c
// CleanupWithRef: 释放已分配的 EPROCESS 和引用
VOID CleanupWithRef(PEPROCESS Process, PEPROCESS Parent, ...) {
if (Process) {
ObDereferenceObject(Process);
}
if (Parent) {
ObDereferenceObject(Parent);
}
// ... 释放其他引用
}
// Cleanup: 只释放父进程引用
VOID Cleanup(PEPROCESS Parent, ...) {
if (Parent) {
ObDereferenceObject(Parent);
}
// ... 释放其他引用
}
回滚顺序:
- 如果 EPROCESS 创建失败 → 释放父进程引用
- 如果地址空间创建失败 → 释放 EPROCESS 和父进程引用
- 如果后续步骤失败 → 释放已分配的资源
为什么回滚如此重要?
想象一下,如果进程创建失败后不回滚:
- 内存泄漏:EPROCESS 对象占用的内存永远不会释放
- 句柄泄漏:父进程、Section 等对象的引用计数不会减少
- 系统不稳定:半成品进程可能导致系统状态不一致
- 安全风险:未完全初始化的进程可能被恶意利用
5.3.5 错误处理与回滚
错误处理:施工失败的"拆除方案"
进程创建是一个复杂的多步骤操作,就像建造房子一样,任何一个步骤失败都需要回滚之前已完成的工作。Windows 采用事务性创建机制,确保失败时不会留下半成品。
想象一下,如果在铺设地基(步骤 7)时发现土壤不适合建筑,那么之前搭建的框架(步骤 5-6)都需要拆除。同样,如果进程创建失败,需要释放已分配的资源。
┌──────────────────────────────────────────────────────────────────────────────────┐
│ PspCreateProcess 错误处理 │
│ │
│ 错误码列表: │
│ │
│ 错误码 说明 比喻 │
│ ──────────────────────────── ──────────────────────── ────────────────── │
│ STATUS_INVALID_HANDLE 无效句柄 母公司不存在 │
│ STATUS_ACCESS_DENIED 访问被拒绝 权限不足 │
│ STATUS_INSUFFICIENT_RESOURCES 资源不足 办公场地不足 │
│ STATUS_INVALID_PARAMETER 无效参数 申请表填写错误 │
│ STATUS_PROCESS_IS_TERMINATING 进程正在终止 母公司正在倒闭 │
│ STATUS_FILE_CORRUPT_ERROR 文件损坏 公司章程损坏 │
│ STATUS_IMAGE_FILE_CORRUPT 镜像文件损坏 公司章程格式错误 │
│ STATUS_NOT_SUPPORTED 不支持 不允许此类公司 │
│ │
│ 回滚路径: │
│ │
│ 步骤 1-4 失败(施工前准备阶段): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Cleanup(Parent, SectionObject, DebugObject, ExceptionPortObject); │ │
│ │ return Status; │ │
│ │ │ │
│ │ 【还没开始施工】只是获取了各种引用,释放这些引用即可。 │ │
│ │ 就像施工前检查发现图纸有问题,取消施工计划。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 步骤 5 失败(EPROCESS 创建失败): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Cleanup(Parent, SectionObject, DebugObject, ExceptionPortObject); │ │
│ │ return Status; │ │
│ │ │ │
│ │ 【框架搭建失败】ObCreateObject 失败,没有创建 EPROCESS。 │ │
│ │ 只需要释放之前获取的引用。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 步骤 6-15 失败(施工进行中): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CleanupWithRef(Process, Parent, SectionObject, DebugObject, │ │
│ │ ExceptionPortObject); │ │
│ │ return Status; │ │
│ │ │ │
│ │ 【施工中途失败】EPROCESS 已创建,需要释放 EPROCESS 和其他引用。 │ │
│ │ ObDereferenceObject(Process) 会触发 EPROCESS 的销毁流程。 │ │
│ │ 就像房子建了一半发现地基不稳,需要拆除已建部分。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 步骤 16-18 失败(即将完工): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ // 进程已经创建,需要特殊处理 │ │
│ │ NtTerminateProcess(ProcessHandle, Status); │ │
│ │ return Status; │ │
│ │ │ │
│ │ 【完工前失败】进程对象已插入对象目录,其他进程可能已经能看到它。 │ │
│ │ 不能简单释放,需要调用 NtTerminateProcess 正常终止进程。 │ │
│ │ 就像房子建好后发现验收不合格,需要正式拆除而不是半途废弃。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 回滚的重要性: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. 防止资源泄漏:如果不回滚,已分配的内存、句柄等会一直占用 │ │
│ │ 2. 保证系统稳定:半成品进程可能导致系统状态不一致 │ │
│ │ 3. 安全考虑:未完全初始化的进程可能被恶意利用 │ │
│ │ 4. 正确的错误处理:调用者能收到明确的错误码 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
5.3.6 概念解释
5.3.6.1 ParentProcess / InheritObjectTable
ParentProcess:父进程句柄
ParentProcess 是创建新进程时必须指定的父进程句柄。就像每个子公司都有一个母公司,每个新进程都有一个父进程。
父进程的作用:
-
属性继承:新进程从父进程继承以下属性:
- 安全令牌(Token):决定进程的权限级别
- 配额限制(Quota):限制进程能使用的资源量
- 设备映射(Device Map):DOS 设备名称映射
- 优先级(BasePriority):进程的基础调度优先级
- CPU 亲和性(Affinity):进程可以在哪些 CPU 上运行
-
进程树结构:所有进程形成一棵树,根节点是 System 进程(PID 4):
System (PID 4) │ ├── smss.exe (会话管理器) │ │ │ ├── csrss.exe (子系统服务器) │ └── winlogon.exe (登录管理器) │ │ │ ├── services.exe (服务控制管理器) │ │ │ │ │ ├── svchost.exe (服务宿主进程) │ │ └── ... (其他服务进程) │ │ │ └── lsass.exe (本地安全认证服务) │ └── ... (其他系统进程) -
权限检查:调用者必须对父进程有 PROCESS_CREATE_PROCESS 权限
InheritObjectTable:是否继承父进程句柄表
InheritObjectTable 是一个布尔值,决定新进程是否继承父进程的可继承句柄。就像子公司是否继承母公司的业务关系。
句柄继承的详细机制:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 句柄继承机制 │
│ │
│ 父进程的句柄表: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 句柄值 对象类型 是否可继承 说明 │ │
│ │ 0x0004 File TRUE 打开的配置文件(可继承) │ │
│ │ 0x0008 File FALSE 打开的临时文件(不可继承) │ │
│ │ 0x000C Event TRUE 同步事件(可继承) │ │
│ │ 0x0010 Mutex FALSE 互斥锁(不可继承) │ │
│ │ 0x0014 Process TRUE 子进程句柄(可继承) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 如果 InheritObjectTable = TRUE: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 新进程的句柄表会复制父进程的可继承句柄: │ │
│ │ │ │
│ │ 句柄值 对象类型 说明 │ │
│ │ 0x0004 File 继承的配置文件句柄 │ │
│ │ 0x000C Event 继承的同步事件句柄 │ │
│ │ 0x0014 Process 继承的子进程句柄 │ │
│ │ │ │
│ │ 注意:不可继承的句柄(0x0008, 0x0010)不会被复制 │ │
│ │ 句柄值可能不同,但指向相同的对象 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 如果 InheritObjectTable = FALSE: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 新进程的句柄表为空,不继承任何句柄 │ │
│ │ 新进程需要自己打开需要的资源 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 句柄继承的应用场景: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. 管道通信:父进程创建管道,子进程继承管道句柄进行通信 │ │
│ │ 2. 文件共享:父进程打开文件,子进程继承文件句柄共享访问 │ │
│ │ 3. 同步机制:父进程创建事件/互斥锁,子进程继承用于同步 │ │
│ │ 4. 进程控制:父进程可以创建子进程并保留其句柄用于控制 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
如何设置句柄为可继承?
在创建句柄时,通过 OBJECT_ATTRIBUTES 结构设置 OBJ_INHERIT 标志:
c
OBJECT_ATTRIBUTES ObjAttr;
InitializeObjectAttributes(&ObjAttr,
NULL, // 无名称
OBJ_INHERIT, // 可继承!
NULL, // 无根目录
NULL); // 无安全描述符
// 创建一个可继承的事件句柄
NtCreateEvent(&EventHandle,
EVENT_ALL_ACCESS,
&ObjAttr,
NotificationEvent,
FALSE);
5.3.6.2 SectionHandle / SECTION_MAP_EXECUTE
SectionHandle:可执行文件的 Section 对象句柄
SectionHandle 是一个指向 Section 对象的句柄,这个 Section 对象代表了可执行文件在内存中的映射。就像公司的章程文件,定义了公司的运营规则。
Section 对象的本质:
Section 对象是内存管理器提供的核心对象类型,它有以下用途:
- 文件映射(File Mapping):将文件内容映射到进程的地址空间
- 共享内存:多个进程可以共享同一个 Section 对象
- 可执行文件加载:将 .exe/.dll 文件映射为可执行的内存区域
SECTION_MAP_EXECUTE 权限:
SECTION_MAP_EXECUTE 是请求的访问权限之一,表示希望将 Section 映射为可执行的内存区域。
| 权限 | 说明 | 用途 |
|---|---|---|
| SECTION_MAP_READ | 可读映射 | 映射为只读内存,如数据文件 |
| SECTION_MAP_WRITE | 可写映射 | 映射为可写内存,如共享内存 |
| SECTION_MAP_EXECUTE | 可执行映射 | 映射为可执行内存,如 .exe/.dll |
| SECTION_ALL_ACCESS | 所有权限 | 完全控制 |
Section 对象的创建过程:
c
// 1. 打开可执行文件
HANDLE FileHandle;
Status = NtCreateFile(&FileHandle,
GENERIC_READ | GENERIC_EXECUTE | SYNCHRONIZE,
&ObjAttr,
&IoStatus,
NULL, // 分配大小
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ,
FILE_OPEN,
FILE_SYNCHRONOUS_IO_NONALERT,
NULL, // EaBuffer
0); // EaLength
// 2. 创建 Section 对象
HANDLE SectionHandle;
Status = NtCreateSection(&SectionHandle,
SECTION_ALL_ACCESS,
NULL, // 对象属性
NULL, // 最大大小(由文件决定)
PAGE_EXECUTE_READ, // 页面保护
SEC_IMAGE, // 可执行镜像
FileHandle);
// 3. 关闭文件句柄(Section 已持有文件引用)
NtClose(FileHandle);
SEC_IMAGE 标志的重要性:
SEC_IMAGE 标志告诉内存管理器这是一个可执行文件镜像,需要特殊处理:
- PE 文件解析:解析 PE 头,确定代码段、数据段的位置
- 节区映射:将 PE 文件的各个节区映射到正确的虚拟地址
- 入口点确定:记录程序的入口点地址
- 重定位处理:如果需要,处理地址重定位
5.3.6.3 DebugPort / DbgkDebugObjectType
DebugPort:调试对象句柄
DebugPort 是一个可选参数,用于进程调试。就像监管部门,可以监控进程的运行状态。
调试端口的作用:
当进程设置了 DebugPort,调试器可以接收以下事件:
| 事件类型 | 说明 | 触发时机 |
|---|---|---|
| CREATE_PROCESS_DEBUG_EVENT | 进程创建事件 | 进程启动时 |
| CREATE_THREAD_DEBUG_EVENT | 线程创建事件 | 新线程创建时 |
| LOAD_DLL_DEBUG_EVENT | DLL 加载事件 | 加载新 DLL 时 |
| UNLOAD_DLL_DEBUG_EVENT | DLL 卸载事件 | 卸载 DLL 时 |
| EXCEPTION_DEBUG_EVENT | 异常事件 | 发生异常时 |
| EXIT_PROCESS_DEBUG_EVENT | 进程退出事件 | 进程终止时 |
| EXIT_THREAD_DEBUG_EVENT | 线程退出事件 | 线程终止时 |
| OUTPUT_DEBUG_STRING_EVENT | 调试输出事件 | OutputDebugString 调用时 |
DbgkDebugObjectType:调试对象类型
DbgkDebugObjectType 是调试对象的类型定义,用于对象管理器的类型检查。
c
// 调试对象的创建
HANDLE DebugObjectHandle;
Status = DbgCreateDebugObject(&DebugObjectHandle,
DEBUG_ALL_ACCESS,
NULL);
// 将调试对象附加到进程
Status = NtCreateProcess(&ProcessHandle,
PROCESS_ALL_ACCESS,
NULL,
ParentProcess,
FALSE,
SectionHandle,
DebugObjectHandle, // 调试端口
NULL);
调试器的工作流程:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 调试器工作流程 │
│ │
│ 1. 创建调试对象 │
│ DbgCreateDebugObject(&DebugObject) │
│ │ │
│ ▼ │
│ 2. 创建被调试进程 │
│ NtCreateProcess(..., DebugObject, ...) │
│ │ │
│ ▼ │
│ 3. 等待调试事件 │
│ WaitForDebugEvent(&DebugEvent, INFINITE) │
│ │ │
│ ▼ │
│ 4. 处理调试事件 │
│ switch (DebugEvent.dwDebugEventCode) { │
│ case CREATE_PROCESS_DEBUG_EVENT: │
│ // 处理进程创建 │
│ case EXCEPTION_DEBUG_EVENT: │
│ // 处理异常(如断点) │
│ case LOAD_DLL_DEBUG_EVENT: │
│ // 处理 DLL 加载 │
│ ... │
│ } │
│ │ │
│ ▼ │
│ 5. 继续被调试进程 │
│ ContinueDebugEvent(DebugEvent.dwProcessId, │
│ DebugEvent.dwThreadId, │
│ DBG_CONTINUE) │
│ │ │
│ ▼ │
│ 6. 循环等待下一个事件 │
│ goto 步骤 3 │
│ │
│ 调试事件的处理示例(断点): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ case EXCEPTION_DEBUG_EVENT: │ │
│ │ if (DebugEvent.u.Exception.ExceptionRecord.ExceptionCode │ │
│ │ == EXCEPTION_BREAKPOINT) { │ │
│ │ // 这是断点异常 │ │
│ │ // 调试器可以: │ │
│ │ // 1. 显示当前变量值 │ │
│ │ // 2. 显示调用栈 │ │
│ │ // 3. 让用户选择继续或单步执行 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
5.3.6.4 PspCidTable / PspActiveProcessMutex
PspCidTable:全局 PID/TID 句柄表
PspCidTable 是系统全局的句柄表,专门用于存储进程和线程对象。就像城市的户籍登记系统,记录所有居民(进程和线程)的信息。
PspCidTable 的特点:
- 全局唯一:整个系统只有一个 PspCidTable
- 存储对象:所有 EPROCESS 和 ETHREAD 对象
- 句柄值:PID 和 TID 都是这个表中的句柄值
- 快速查找:通过 PID/TID 可以快速找到对应的对象
PspCidTable 的结构:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ PspCidTable 结构 │
│ │
│ PspCidTable 是一个动态扩展的句柄表: │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 句柄值 (PID/TID) 对象指针 对象类型 │ │
│ │ 4 EPROCESS System 进程 │ │
│ │ 8 EPROCESS smss.exe 进程 │ │
│ │ 12 ETHREAD System 进程的线程 │ │
│ │ 16 EPROCESS csrss.exe 进程 │ │
│ │ 20 ETHREAD csrss.exe 的线程 1 │ │
│ │ 24 ETHREAD csrss.exe 的线程 2 │ │
│ │ ... ... ... │ │
│ │ 1234 EPROCESS 用户启动的 notepad.exe │ │
│ │ 1238 ETHREAD notepad.exe 的主线程 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 通过 PID 查找进程对象: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PsLookupProcessByProcessId(PID, &Process) │ │
│ │ │ │
│ │ // 内部实现: │ │
│ │ // 1. 将 PID 作为句柄值 │ │
│ │ // 2. 在 PspCidTable 中查找 │ │
│ │ // 3. 验证对象类型是 PsProcessType │ │
│ │ // 4. 返回 EPROCESS 指针 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 通过 TID 查找线程对象: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PsLookupThreadByThreadId(TID, &Thread) │ │
│ │ │ │
│ │ // 内部实现类似,验证对象类型是 PsThreadType │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
PspActiveProcessMutex:保护全局进程链表的互斥锁
PspActiveProcessMutex 是一个互斥锁,用于保护全局进程链表 PsActiveProcessHead 的并发访问。就像房产登记处的排队系统,防止多人同时登记造成混乱。
为什么需要互斥锁?
在多线程环境中,多个线程可能同时创建或终止进程,如果不加保护:
- 链表损坏:并发插入/删除可能导致链表结构损坏
- 数据丢失:一个线程的修改可能被另一个线程覆盖
- 系统崩溃:链表损坏可能导致遍历时崩溃
互斥锁的使用:
c
// 插入进程到全局链表
ExAcquireResourceExclusiveLite(&PspActiveProcessMutex, TRUE);
InsertTailList(&PsActiveProcessHead, &Process->ActiveProcessLinks);
ExReleaseResourceLite(&PspActiveProcessMutex);
// 从全局链表移除进程
ExAcquireResourceExclusiveLite(&PspActiveProcessMutex, TRUE);
RemoveEntryList(&Process->ActiveProcessLinks);
ExReleaseResourceLite(&PspActiveProcessMutex);
PsActiveProcessHead 的作用:
PsActiveProcessHead 是全局活动进程链表的头部,所有活动进程都链接在这个链表上:
- 遍历所有进程:系统可以通过这个链表遍历所有活动进程
- 进程管理:任务管理器、性能监控等工具使用这个链表获取进程信息
- 资源统计:系统统计进程数量、资源使用等
c
// 遍历所有活动进程
PLIST_ENTRY Current = PsActiveProcessHead.Flink;
while (Current != &PsActiveProcessHead) {
PEPROCESS Process = CONTAINING_RECORD(Current, EPROCESS, ActiveProcessLinks);
// 处理每个进程
printf("PID: %d, Name: %s\n", Process->UniqueProcessId, Process->ImageFileName);
Current = Current->Flink;
}
5.3.7 为什么要这样设计
本节深入探讨 NtCreateProcess 设计背后的原因,每个问题都涉及重要的设计决策。理解这些设计原因,有助于我们更好地理解 Windows 进程管理的精髓。
5.3.7.1 为什么 NtCreateProcess 不直接创建进程,而是先创建地址空间?
问题背景:
有些人可能会认为,创建进程应该是一个"一步到位"的操作------直接创建完整的进程对象。但实际上,NtCreateProcess 分为多个步骤,其中地址空间的创建是最早的关键步骤之一。
答案:地址空间是进程的核心资源,必须先创建。
详细解释:
-
进程的本质是地址空间隔离:
- 进程的核心特征是拥有独立的虚拟地址空间
- 不同进程的虚拟地址空间相互隔离,一个进程不能直接访问另一个进程的内存
- 地址空间是进程存在的"物理基础",就像房子的地基是房子存在的基础
-
地址空间创建需要分配关键资源:
- 页目录(Page Directory):存储虚拟地址到物理地址的映射关系
- 页表(Page Tables):更细粒度的地址映射
- 物理内存:页目录和页表本身需要物理内存存储
- 这些资源必须在初始化进程结构之前准备好
-
KPROCESS 需要 DirectoryTableBase:
- KPROCESS 是 EPROCESS 的第一个字段,包含调度器需要的信息
- DirectoryTableBase(页目录基址)是 KPROCESS 的关键字段
- CPU 的 CR3 寄存器存储当前进程的 DirectoryTableBase
- 进程切换时,调度器会将新进程的 DirectoryTableBase 加载到 CR3
- 如果地址空间还没创建,DirectoryTableBase 就不存在,无法初始化 KPROCESS
比喻理解:
想象一下建造房子:
- 地址空间 = 地基:地基是房子的基础,必须先铺设
- KPROCESS = 房屋结构:房屋结构建在地基之上,地基没准备好,结构无法搭建
- EPROCESS = 房屋档案:房屋档案记录了房子的信息,但档案本身不决定房子能否存在
如果先创建 EPROCESS(房屋档案)再创建地址空间(地基),就像先写房屋档案再铺设地基------档案里的信息(如地基位置)还没有实际数据,只能填空值或等待后续更新,这会导致设计复杂化。
代码证据:
c
// PspCreateProcess 中的顺序(步骤 7-8)
// 步骤 7:创建地址空间
Status = MmCreateProcessAddressSpace(Process);
if (!NT_SUCCESS(Status)) {
CleanupWithRef(Process, Parent, ...);
return Status;
}
// 步骤 8:初始化 KPROCESS
// 此时 Process->Pcb.DirectoryTableBase 已经被 MmCreateProcessAddressSpace 设置
KeInitializeProcess(&Process->Pcb,
Parent->Pcb.Affinity,
Parent->Pcb.BasePriority);
5.3.7.2 为什么必须指定父进程句柄(不能 NULL)?
问题背景:
在 Unix/Linux 系统中,fork() 系统调用总是以调用进程为父进程。但在 Windows 中,NtCreateProcess 要求显式指定父进程句柄,而且不能为 NULL。为什么这样设计?
答案:继承和权限检查的需要。
详细解释:
-
属性继承的需要:
- Windows 进程需要从父进程继承多种属性:
- 安全令牌(Token):决定了进程的权限级别和访问能力
- 配额限制(Quota):限制进程能使用的 CPU 时间、内存等
- 设备映射(Device Map):DOS 设备名称到 NT 设备名称的映射
- 优先级和亲和性:调度相关的参数
- 如果允许 NULL,新进程就没有这些属性的来源,需要额外设计"默认属性"机制
- Windows 进程需要从父进程继承多种属性:
-
权限检查的需要:
- 创建进程需要验证调用者有足够的权限
- 具体来说,调用者对父进程必须有 PROCESS_CREATE_PROCESS 权限
- 这个权限检查防止恶意进程随意创建子进程
- 例如,一个低权限进程不能以高权限进程为父进程创建子进程(否则会提升权限)
-
进程树结构的维护:
- Windows 系统中的所有进程形成一棵树
- 树的根是 System 进程(PID 4),由内核在启动时创建
- 每个进程都有唯一的父进程,形成清晰的层次关系
- 这种结构便于:
- 追踪进程来源:调试时可以查看进程的创建链
- 资源归属:确定进程的资源来源和责任归属
- 权限传递:理解权限如何从父进程传递到子进程
-
安全模型的要求:
- Windows 安全模型基于"谁创建谁负责"的原则
- 父进程对子进程有特殊的控制权:
- 可以终止子进程
- 可以读取子进程的内存
- 可以调试子进程
- 如果允许 NULL,会破坏这种责任关系
比喻理解:
想象一下公司注册:
- 父进程 = 母公司:新公司(子公司)必须有母公司
- 属性继承 = 业务继承:子公司继承母公司的资质、许可、品牌
- 权限检查 = 董事会批准:成立子公司需要母公司董事会批准
- 进程树 = 企业集团:所有公司形成企业集团结构
如果允许 NULL(无母公司),就像允许成立"无来源公司"------这样的公司没有资质继承,没有责任归属,没有监管主体,会破坏整个企业体系的管理结构。
Unix/Linux 的对比:
Unix/Linux 的 fork() 系统调用隐式以调用进程为父进程:
c
// Unix/Linux
pid_t child_pid = fork(); // 父进程隐式为调用进程
Windows 的 NtCreateProcess 显式指定父进程:
c
// Windows
NtCreateProcess(&ProcessHandle,
PROCESS_ALL_ACCESS,
NULL,
NtCurrentProcess(), // 显式指定父进程为当前进程
FALSE,
SectionHandle,
NULL,
NULL);
两种设计各有优劣:
- Unix/Linux:简单,但灵活性较低
- Windows:灵活(可以以任意进程为父进程),但需要显式指定
5.3.7.3 为什么 SectionHandle 决定是真实进程还是空壳?
问题背景:
NtCreateProcess 的 SectionHandle 参数可以为 NULL 或非 NULL。为什么这个参数决定了进程是"真实进程"还是"空壳进程"?
答案:Section 包含可执行文件信息,决定了进程的"身份"。
详细解释:
-
Section 对象的本质:
- Section 对象是内存管理器提供的对象,代表文件与内存的映射
- 对于进程创建,Section 对象包含可执行文件(.exe)的内容:
- 代码段:程序的机器指令
- 数据段:程序的静态数据
- 资源:图标、字符串、对话框模板等
- PE 头:程序的组织结构信息
- Section 对象就像程序的"灵魂",决定了进程运行什么代码
-
有 SectionHandle = 真实进程:
- 进程有明确的可执行文件
- 进程地址空间会映射可执行文件的内容
- 进程有明确的入口点(程序的 main 或 WinMain)
- 进程有明确的名称(来自可执行文件的文件名)
- 例如:
notepad.exe、explorer.exe、cmd.exe等
-
无 SectionHandle = 空壳进程(系统进程):
- 进程没有可执行文件
- 进程地址空间初始为空(只有内核映射)
- 进程没有明确的入口点
- 进程的代码来自其他来源:
- 内核线程:System 进程中的线程运行内核代码
- 动态加载:某些进程稍后通过其他机制加载代码
- 例如:System 进程(PID 4)、某些服务进程
比喻理解:
想象一下公司注册:
- SectionHandle = 公司章程:章程定义了公司的业务范围、运营规则
- 有章程 = 正规公司:有明确的业务,如"某某科技有限公司"
- 无章程 = 特殊机构:如情报局、特勤处,没有公开的业务范围
空壳进程就像特殊机构------它们存在,但不像普通公司有明确的"营业执照"(可执行文件)。它们的"业务"(代码)来自特殊渠道(内核代码或动态加载)。
代码示例:
c
// 创建真实进程(有 Section)
HANDLE SectionHandle;
NtCreateSection(&SectionHandle, SECTION_ALL_ACCESS, NULL, NULL,
PAGE_EXECUTE_READ, SEC_IMAGE, FileHandle);
NtCreateProcess(&ProcessHandle, PROCESS_ALL_ACCESS, NULL,
NtCurrentProcess(), FALSE,
SectionHandle, // 有 Section!
NULL, NULL);
// 创建空壳进程(无 Section)
NtCreateProcess(&ProcessHandle, PROCESS_ALL_ACCESS, NULL,
NtCurrentProcess(), FALSE,
NULL, // 无 Section!
NULL, NULL);
空壳进程的应用:
空壳进程虽然看起来"空",但有重要的用途:
-
System 进程(PID 4):
- 内核线程的"容器"
- 内核代码不在任何用户态进程中运行
- System 进程提供了内核线程的进程上下文
-
服务进程:
- 某些服务可能以空壳方式创建
- 稍后通过 ServiceMain 函数加载服务代码
-
进程注入:
- 恶意软件可能创建空壳进程
- 然后注入代码到进程中运行
- 这是安全研究的重要话题
5.3.7.4 为什么 PspCidTable 是系统全局的?
问题背景:
PspCidTable 是存储 PID/TID 的全局句柄表。为什么这个表必须是全局的,而不是每个进程有自己的 PID 表?
答案:全局唯一性和跨进程访问的需要。
详细解释:
-
PID/TID 的全局唯一性:
- PID(进程标识符)和 TID(线程标识符)必须在系统范围内唯一
- 如果每个进程有自己的 PID 表,不同进程可能看到相同的 PID 值指向不同的进程
- 全局表确保:PID=1234 在任何进程中都指向同一个进程对象
-
跨进程访问的需要:
- 进程经常需要访问其他进程:
- 调试器:调试器进程需要访问被调试进程
- 任务管理器:需要列出和操作所有进程
- 进程间通信:需要通过 PID 打开其他进程的句柄
- 如果 PID 表不是全局的,跨进程访问会非常复杂
- 进程经常需要访问其他进程:
-
API 设计的简洁性:
- OpenProcess(pid) 可以直接通过 PID 打开进程句柄
- 如果 PID 不是全局唯一,需要额外的参数指定"哪个进程的 PID"
- 全局表使得 API 设计简洁明了
-
调试和监控的需要:
- 系统管理员需要查看系统中所有进程
- 调试器需要通过 PID/TID 定位进程和线程
- 性能监控工具需要统计所有进程的资源使用
- 全局表提供了统一的访问入口
比喻理解:
想象一下身份证系统:
- PspCidTable = 全国身份证数据库:存储所有人的身份证信息
- PID = 身份证号:每个人的唯一标识
- 全局唯一 = 全国唯一:身份证号在全国范围内唯一
- 跨进程访问 = 跨地区查询:任何地方都可以通过身份证号查到同一个人
如果每个地区有自己的身份证数据库(每个进程有自己的 PID 表),身份证号可能在不同地区指向不同的人,跨地区查询会非常混乱。
PspCidTable 的实现:
c
// PspCidTable 的全局定义
PHANDLE_TABLE PspCidTable = NULL;
// 初始化(系统启动时)
PspCidTable = ExCreateHandleTable(NULL);
// 分配 PID
HANDLE_TABLE_ENTRY CidEntry;
CidEntry.Object = Process;
CidEntry.GrantedAccess = PROCESS_ALL_ACCESS;
Process->UniqueProcessId = ExCreateHandle(PspCidTable, &CidEntry);
// 通过 PID 查找进程
NTSTATUS PsLookupProcessByProcessId(HANDLE ProcessId, PEPROCESS *Process) {
return ObReferenceObjectByHandle(ProcessId, PROCESS_ALL_ACCESS,
PsProcessType, KernelMode,
(PVOID*)Process, NULL);
}
5.3.7.5 为什么先 MmCreateProcessAddressSpace 再 KeInitializeProcess?
问题背景:
在 PspCreateProcess 的步骤 7 和步骤 8 中,先调用 MmCreateProcessAddressSpace 创建地址空间,再调用 KeInitializeProcess 初始化 KPROCESS。为什么这个顺序不能颠倒?
答案:KPROCESS 需要 DirectoryTableBase,而它来自地址空间。
详细解释:
-
KPROCESS 的关键字段:
ctypedef struct _KPROCESS { DISPATCHER_HEADER Header; LIST_ENTRY ProfileListHead; ULONG_PTR DirectoryTableBase; // 页目录基址 ------ 关键! // ... 其他字段 ... } KPROCESS;- DirectoryTableBase 是页目录的物理地址
- 它是虚拟地址空间的核心数据
-
地址空间创建提供 DirectoryTableBase:
cNTSTATUS MmCreateProcessAddressSpace(PEPROCESS Process) { // 分配页目录 PageDirectory = MmAllocPage(); // 设置 DirectoryTableBase Process->Pcb.DirectoryTableBase = MmGetPhysicalAddress(PageDirectory); return STATUS_SUCCESS; } -
KeInitializeProcess 使用 DirectoryTableBase:
cVOID KeInitializeProcess(PKPROCESS Process, KAFFINITY Affinity, KPRIORITY BasePriority) { // 使用 Process->DirectoryTableBase(已由 MmCreateProcessAddressSpace 设置) // 初始化调度相关字段 Process->Affinity = Affinity; Process->BasePriority = BasePriority; // ... } -
如果顺序颠倒会怎样:
- 如果先调用 KeInitializeProcess,DirectoryTableBase 还不存在
- KeInitializeProcess 需要设置 DirectoryTableBase,但不知道应该设置什么值
- 后续调用 MmCreateProcessAddressSpace 时,需要重新设置 DirectoryTableBase
- 这会导致代码复杂化,可能出现不一致的状态
比喻理解:
想象一下盖房子:
- 地址空间 = 地基:地基决定了房子的位置和基础结构
- KPROCESS = 房屋框架:房屋框架建在地基之上
- DirectoryTableBase = 地基坐标:房屋框架需要知道地基的位置才能正确搭建
如果先搭建框架再铺设地基,框架不知道应该建在哪里,只能随意放置。后续铺设地基时,需要移动框架到正确位置,这会导致结构不稳定。
代码顺序:
c
// 正确的顺序(PspCreateProcess 中)
// 步骤 7:创建地址空间(设置 DirectoryTableBase)
Status = MmCreateProcessAddressSpace(Process);
// 步骤 8:初始化 KPROCESS(使用已设置的 DirectoryTableBase)
KeInitializeProcess(&Process->Pcb,
Parent->Pcb.Affinity,
Parent->Pcb.BasePriority);
5.3.7.6 为什么需要 PspInitializeProcessSecurity 复制令牌?
问题背景:
进程创建时,需要调用 PspInitializeProcessSecurity 复制父进程的安全令牌。为什么每个进程需要独立的令牌,而不是直接共享父进程的令牌?
答案:安全隔离和独立权限控制的需要。
详细解释:
-
令牌的本质:
- 安全令牌(Token)是进程的"身份证"
- 包含进程的用户身份、组身份、权限列表
- 决定了进程能访问哪些资源、能执行哪些操作
-
独立令牌的必要性:
- 权限修改 :子进程可能需要不同的权限
- 例如,父进程是管理员权限,但子进程应该以普通用户权限运行
- 如果共享令牌,修改子进程权限会影响父进程
- 安全隔离 :子进程的权限变化不应影响父进程
- 如果子进程被攻击者利用,攻击者获得的权限不应扩展到父进程
- 生命周期独立 :父进程终止后,子进程应该继续运行
- 如果共享令牌,父进程终止可能导致令牌失效,子进程无法继续运行
- 权限修改 :子进程可能需要不同的权限
-
令牌复制的实现:
cNTSTATUS PspInitializeProcessSecurity(PEPROCESS Process, PEPROCESS Parent) { // 复制父进程的令牌 Status = SeCopyToken(&Process->Token, Parent->Token, FALSE); // 不继承特权 // 如果复制失败,使用默认令牌 if (!NT_SUCCESS(Status)) { Process->Token = SeGetDefaultToken(); } return Status; } -
令牌继承与修改:
- 子进程默认继承父进程的令牌
- 但可以通过 CreateProcessAsUser 等函数指定不同的令牌
- 子进程运行后,也可以通过 AdjustTokenPrivileges 等函数修改自己的权限
- 这些修改只影响子进程,不影响父进程
比喻理解:
想象一下员工卡系统:
- 令牌 = 员工卡:员工卡决定了员工能进入哪些区域
- 父进程 = 高级经理:高级经理有高级员工卡,能进入所有区域
- 子进程 = 新员工:新员工应该有独立的员工卡
- 复制令牌 = 发放新卡:新员工获得新卡,权限可以与经理相同或不同
如果新员工直接使用经理的员工卡(共享令牌),会有问题:
- 新员工丢失卡片,经理也无法进入
- 新员工权限被修改,经理的权限也跟着变化
- 新员工离职后,经理的卡片可能失效
独立令牌确保每个进程有独立的"员工卡",互不影响。
5.3.7.7 PEB 创建的时机为什么在 KeInitializeProcess 之后?
问题背景:
PEB(进程环境块)在用户态地址空间中创建,这个操作发生在 KeInitializeProcess 之后。为什么 PEB 不能更早创建?
答案:PEB 需要进程的地址空间,而地址空间需要先创建和初始化。
详细解释:
-
PEB 的位置:
- PEB 位于用户态地址空间(低 2GB)
- 具体地址通常是 0x7FFDF000(32 位系统)
- PEB 是用户态数据结构,用户态代码可以直接访问
-
PEB 创建的前提条件:
- 地址空间存在:PEB 需要映射到地址空间中
- 地址空间初始化:地址空间需要有基本的映射(如共享用户数据区)
- KPROCESS 初始化:调度器需要知道进程的地址空间信息
-
PEB 创建的时机:
- PspCreateProcess 的步骤 15 创建 PEB
- 此时:
- 地址空间已创建(步骤 7)
- KPROCESS 已初始化(步骤 8)
- 安全令牌已设置(步骤 9)
- 句柄表已初始化(步骤 10)
- 用户态地址空间已初始化(步骤 11)
- PID 已分配(步骤 12)
- 进程已插入全局链表(步骤 13)
-
PEB 创建的内容:
cNTSTATUS MmCreatePeb(PEPROCESS Process, PPEB InitialPeb) { // 在用户态地址空间中分配 PEB PebAddress = 0x7FFDF000; // 映射 PEB 页面 Status = MmMapViewInProcessSpace(Process, PebAddress, sizeof(PEB)); // 初始化 PEB 内容 Peb->ImageBaseAddress = Process->SectionBaseAddress; Peb->ProcessParameters = InitialPeb; // ... return Status; }
比喻理解:
想象一下装修房子:
- 地址空间 = 房子:房子需要先建成
- KPROCESS = 房屋结构:房屋结构需要先搭建
- PEB = 装修图纸:装修图纸放在房子里
装修图纸(PEB)不能在房子建成之前放置,因为还没有地方放。必须先建房子(地址空间),搭建结构(KPROCESS),然后才能放置装修图纸(PEB)。
5.3.7.8 为什么需要 PspActiveProcessMutex 保护插入?
问题背景:
将进程插入全局链表 PsActiveProcessHead 时,需要使用 PspActiveProcessMutex 互斥锁保护。为什么需要这个保护?
答案:线程安全,防止并发访问导致链表损坏。
详细解释:
-
多线程环境:
- Windows 是多线程操作系统
- 多个线程可能同时创建进程
- 多个线程可能同时终止进程
- 如果不加保护,并发操作可能导致链表损坏
-
链表操作的风险:
- 插入操作:InsertTailList 修改链表指针
- 删除操作:RemoveEntryList 修改链表指针
- 遍历操作:遍历链表时,如果链表被修改,可能访问无效节点
-
竞态条件示例:
线程 A:正在插入进程 P1 Current = PsActiveProcessHead.Flink; PsActiveProcessHead.Flink = &P1->ActiveProcessLinks; P1->ActiveProcessLinks.Flink = Current; // 还没完成,Current->Blink 还没更新 线程 B:同时插入进程 P2 Current = PsActiveProcessHead.Flink; // 拿到的是 P1(部分插入) PsActiveProcessHead.Flink = &P2->ActiveProcessLinks; P2->ActiveProcessLinks.Flink = Current; // P1 的插入被中断,链表结构损坏 -
互斥锁的作用:
c// 插入进程(加锁) ExAcquireResourceExclusiveLite(&PspActiveProcessMutex, TRUE); InsertTailList(&PsActiveProcessHead, &Process->ActiveProcessLinks); ExReleaseResourceLite(&PspActiveProcessMutex); // 遍历进程(加锁) ExAcquireResourceSharedLite(&PspActiveProcessMutex, TRUE); PLIST_ENTRY Current = PsActiveProcessHead.Flink; while (Current != &PsActiveProcessHead) { PEPROCESS Process = CONTAINING_RECORD(Current, EPROCESS, ActiveProcessLinks); // 处理进程 Current = Current->Flink; } ExReleaseResourceLite(&PspActiveProcessMutex);
比喻理解:
想象一下排队系统:
- PsActiveProcessHead = 排队队伍:所有人排队等待服务
- 插入进程 = 新人加入队伍:新人需要找到队伍末尾加入
- PspActiveProcessMutex = 排队号码机:只有拿到号码的人才能操作队伍
如果没有排队号码机(互斥锁),多人可能同时尝试加入队伍,导致队伍混乱:
- 两人都认为自己是队伍末尾
- 两人都插入,导致队伍分叉
- 后面的人不知道应该跟谁
互斥锁确保同一时间只有一个人操作队伍,保证队伍结构正确。
5.3.7.9 进程对象插入对象目录的时机为什么不在最开始?
问题背景:
进程对象通过 ObInsertObject 插入对象目录,这个操作发生在步骤 16,接近创建流程的末尾。为什么不一开始就插入对象目录?
答案:确保进程完全初始化后再暴露给其他进程。
详细解释:
-
对象目录的作用:
- 对象目录是对象管理器提供的命名空间
- 进程可以有自己的名称(如 "\Sessions\1\Processes\1234")
- 其他进程可以通过名称打开进程句柄
-
过早插入的风险:
- 如果进程在步骤 5(创建 EPROCESS)后就插入对象目录
- 其他进程可能立即打开这个进程的句柄
- 但此时进程还没完全初始化:
- 地址空间可能还没创建
- 安全令牌可能还没设置
- PEB 可能还没创建
- 其他进程看到的进程状态是不完整的,可能导致错误
-
正确时机的保证:
- 步骤 16 插入对象目录时,进程已经:
- 地址空间已创建并初始化
- KPROCESS 已初始化
- 安全令牌已设置
- 句柄表已初始化
- PID 已分配
- 进程已插入全局链表
- PEB 已创建
- 此时进程状态完整,其他进程可以安全地访问
- 步骤 16 插入对象目录时,进程已经:
-
对象可见性的控制:
c// 步骤 16:插入对象目录 Status = ObInsertObject((PVOID)Process, NULL, DesiredAccess, 0, NULL, ProcessHandle); // ObInsertObject 内部: // 1. 将进程对象插入对象目录(如果有名称) // 2. 创建进程句柄 // 3. 返回句柄给调用者
比喻理解:
想象一下开店:
- 进程创建 = 店铺装修:装修需要多个步骤
- 插入对象目录 = 正式开业:开业后顾客可以进入
- 过早插入 = 装修期间开业:顾客进入时店铺还没准备好
如果店铺在装修期间就开业(过早插入对象目录),顾客进入时可能看到:
- 地面还没铺设
- 货架还没安装
- 商品还没上架
顾客可能因为店铺状态不完整而感到困惑或遇到问题。正确的做法是装修完成后才开业,确保顾客看到的是完整的店铺。
5.3.7.10 为什么需要单独的 NtCreateProcessEx?
问题背景:
Windows 有两个创建进程的系统调用:NtCreateProcess 和 NtCreateProcessEx。为什么需要两个版本?
答案:兼容性和功能扩展的需要。
详细解释:
-
历史背景:
- NtCreateProcess 是早期版本的系统调用(Windows NT 3.1/3.5/4.0)
- NtCreateProcessEx 是扩展版本(Windows XP/2003)
- 新版本增加了更多参数和功能
-
向后兼容性:
- 旧代码使用 NtCreateProcess
- 如果直接修改 NtCreateProcess 的参数,会破坏旧代码
- 保持 NtCreateProcess 的接口不变,通过 NtCreateProcessEx 提供新功能
-
NtCreateProcessEx 的新功能:
- InheritDebugPort 参数:是否继承父进程的调试端口
- Reserved 参数:预留参数,供未来扩展
- 更多标志位:PROCESS_CREATE_FLAGS_* 系列标志
-
包装实现:
cNTSTATUS NtCreateProcess(OUT PHANDLE ProcessHandle, IN ACCESS_MASK DesiredAccess, IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL, IN HANDLE ParentProcess, IN BOOLEAN InheritObjectTable, IN HANDLE SectionHandle OPTIONAL, IN HANDLE DebugPort OPTIONAL, IN HANDLE ExceptionPort OPTIONAL) { // NtCreateProcess 只是 NtCreateProcessEx 的包装 return NtCreateProcessEx(ProcessHandle, DesiredAccess, ObjectAttributes, ParentProcess, InheritObjectTable, SectionHandle, DebugPort, ExceptionPort, FALSE, // InheritDebugPort(默认不继承) NULL); // Reserved(预留) } -
标志位扩展:
c// NtCreateProcessEx 支持的标志位 #define PROCESS_CREATE_FLAGS_BREAKAWAY 0x01 // 脱离 Job #define PROCESS_CREATE_FLAGS_NO_DEBUG_INHERIT 0x02 // 不继承调试端口 #define PROCESS_CREATE_FLAGS_INHERIT_HANDLES 0x04 // 继承句柄 #define PROCESS_CREATE_FLAGS_LARGE_PAGES 0x08 // 使用大页面 #define PROCESS_CREATE_FLAGS_PROTECTED_PROCESS 0x10 // 受保护进程
比喻理解:
想象一下 API 版本:
- NtCreateProcess = V1.0 API:早期版本,功能有限
- NtCreateProcessEx = V2.0 API:新版本,功能扩展
- 包装实现 = 兼容层:V1.0 API 内部调用 V2.0 API
软件开发中常见的模式:
- 保持旧 API 不变,确保旧代码继续工作
- 提供新 API,提供更多功能
- 旧 API 包装新 API,减少代码重复
这种设计模式在 Windows 中很常见:
- NtCreateFile / NtCreateFileEx
- NtOpenProcess / NtOpenProcessEx
- 等等
5.3.8 增强子节 1:NtCreateProcess 与 NtCreateProcessEx 的差异
API 演进的历史背景
Windows 系统调用 API 的演进是一个有趣的话题。NtCreateProcess 和 NtCreateProcessEx 的差异展示了 Windows 如何在保持向后兼容性的同时扩展功能。
版本演进时间线:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ NtCreateProcess API 演进时间线 │
│ │
│ Windows NT 3.1 (1993) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NtCreateProcess 首次引入 │ │
│ │ - 8 个参数 │ │
│ │ - 基本的进程创建功能 │ │
│ │ - 标志位编码在参数低位 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Windows 2000 (1999) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NtCreateProcess 继续使用 │ │
│ │ - 标志位编码方式暴露出扩展性问题 │ │
│ │ - 需要更多控制选项 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Windows XP/2003 (2001-2003) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NtCreateProcessEx 引入 │ │
│ │ - 10 个参数(新增 InheritDebugPort 和 Reserved) │ │
│ │ - 清晰的参数结构 │ │
│ │ - 更好的扩展性 │ │
│ │ - NtCreateProcess 包装 NtCreateProcessEx │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Windows Vista+ (2006+) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NtCreateProcessEx2 等更多版本 │ │
│ │ - 支持更多进程类型(如受保护进程) │ │
│ │ - 更复杂的安全模型 │ │
│ │ - 更多标志位和选项 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
旧接口的标志位编码问题:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ NtCreateProcess vs NtCreateProcessEx │
│ │
│ 历史背景: │
│ │
│ Windows 2000: NtCreateProcess (原始版本) │
│ Windows XP/2003: NtCreateProcessEx (扩展版本) │
│ Windows Vista+: NtCreateProcessEx2 (更多扩展) │
│ │
│ NtCreateProcess 的旧标志位编码: │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 旧接口使用 SectionHandle 和 DebugPort 的低位来编码标志: │ │
│ │ │ │
│ │ SectionHandle = (PVOID)((ULONG_PTR)RealSectionHandle | Flags); │ │
│ │ DebugPort = (PVOID)((ULONG_PTR)RealDebugPort | InheritDebugPort); │ │
│ │ │ │
│ │ 问题: 标志位空间有限,扩展性差 │ │
│ │ │ │
│ │ 【比喻】就像在电话号码后面加备注信息: │ │
│ │ 电话号码 = 12345678 | 0x01(表示紧急联系人) │ │
│ │ 问题:电话号码可能被误读,备注信息空间有限 │ │
│ │ 更好的做法:电话号码和备注分开存储 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ NtCreateProcessEx 的新接口: │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 新增独立参数: │ │
│ │ - InheritDebugPort: BOOLEAN │ │
│ │ - Reserved: PVOID (预留,供未来扩展) │ │
│ │ │ │
│ │ 优点: 清晰的参数,更好的扩展性 │ │
│ │ │ │
│ │ 【比喻】就像把电话号码和备注分开存储: │ │
│ │ 电话号码:12345678 │ │
│ │ 备注:紧急联系人 │ │
│ │ 优点:清晰明了,可以添加更多备注字段 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 兼容性保证: │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NtCreateProcess 内部调用 NtCreateProcessEx: │ │
│ │ │ │
│ │ NTSTATUS NtCreateProcess(...) { │ │
│ │ return NtCreateProcessEx(..., FALSE, NULL); │ │
│ │ } │ │
│ │ │ │
│ │ 这样旧代码可以继续使用 NtCreateProcess │ │
│ │ │ │
│ │ 【比喻】就像旧版 API 是新版 API 的"简化版": │ │
│ │ - 旧版:快速通道,默认选项 │ │
│ │ - 新版:完整通道,自定义选项 │ │
│ │ - 旧版内部调用新版,传入默认参数 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
标志位的详细说明:
| 标志位 | 值 | 说明 | 应用场景 |
|---|---|---|---|
PROCESS_CREATE_FLAGS_BREAKAWAY |
0x01 | 脱离 Job 对象 | 进程不想受 Job 限制时使用 |
PROCESS_CREATE_FLAGS_NO_DEBUG_INHERIT |
0x02 | 不继承调试端口 | 子进程不应该被调试器监控 |
PROCESS_CREATE_FLAGS_INHERIT_HANDLES |
0x04 | 继承句柄 | 子进程需要访问父进程的资源 |
PROCESS_CREATE_FLAGS_LARGE_PAGES |
0x08 | 使用大页面 | 需要优化内存性能的进程 |
PROCESS_CREATE_FLAGS_PROTECTED_PROCESS |
0x10 | 受保护进程 | 防止被其他进程干扰(如 DRM) |
受保护进程的特殊性:
Windows 8.1 引入了受保护进程(Protected Process)概念:
- 目的:防止恶意进程注入代码、读取内存、终止进程
- 应用:DRM 系统、安全软件、系统关键进程
- 限制:即使是管理员权限,也无法完全控制受保护进程
- 实现:通过 PROCESS_CREATE_FLAGS_PROTECTED_PROCESS 标志创建
API 设计的最佳实践:
从 NtCreateProcess 到 NtCreateProcessEx 的演进,展示了 API 设计的最佳实践:
- 向后兼容性:旧 API 继续工作,新 API 提供新功能
- 清晰的参数:避免在参数中编码额外信息
- 预留扩展空间:Reserved 参数为未来预留
- 包装模式:旧 API 包装新 API,减少代码重复
5.3.9 增强子节 2:CreateProcess (kernel32) 的完整流程
CreateProcess:用户态的"一站式服务窗口"
CreateProcess 是 kernel32.dll 提供的用户态 API,它封装了整个进程创建流程。如果把 NtCreateProcess 比作向政府部门提交的公司注册申请 ,那么 CreateProcess 就像是一站式服务窗口,它会帮你完成所有准备工作,包括:
-
准备申请材料(解析参数、验证文件)
-
提交申请(调用 NtCreateProcess)
-
招聘员工(创建初始线程)
-
通知相关部门(通知 CSRSS/Win32k)
-
启动运营(启动线程执行)
┌──────────────────────────────────────────────────────────────────────────────────┐
│ CreateProcess (kernel32) 完整流程 │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. 参数解析和验证 │ │
│ │ - 解析命令行参数 │ │
│ │ - 检查可执行文件路径 │ │
│ │ - 验证安全策略(Badapp/WinSafer) │ │
│ │ │ │
│ │ 【准备申请材料】就像准备公司注册申请表, │ │
│ │ 检查公司名称是否合法、经营范围是否合规、 │ │
│ │ 是否有安全限制(如某些行业需要特殊许可)。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. 打开可执行文件 │ │
│ │ - CreateFile(ImageName, GENERIC_READ | GENERIC_EXECUTE, ...) │ │
│ │ - 获取文件句柄 │ │
│ │ │ │
│ │ 【获取公司章程】打开可执行文件,获取文件句柄。 │ │
│ │ 这个文件包含了程序的代码和数据, │ │
│ │ 就像公司章程定义了公司的运营规则。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. 创建 Section 对象 │ │
│ │ - NtCreateSection(&SectionHandle, │ │
│ │ SECTION_ALL_ACCESS, │ │
│ │ NULL, │ │
│ │ &SectionSize, │ │
│ │ PAGE_EXECUTE_READ, │ │
│ │ SEC_IMAGE, │ │
│ │ FileHandle); │ │
│ │ │ │
│ │ 【制作章程副本】将可执行文件映射为 Section 对象。 │ │
│ │ Section 是内存管理器的对象,代表文件在内存中的映射。 │ │
│ │ SEC_IMAGE 标志表示这是可执行文件镜像。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 4. 创建进程 │ │
│ │ - NtCreateProcess(&ProcessHandle, │ │
│ │ PROCESS_ALL_ACCESS, │ │
│ │ NULL, │ │
│ │ NtCurrentProcess(), │ │
│ │ InheritHandles, │ │
│ │ SectionHandle, │ │
│ │ NULL, │ │
│ │ NULL); │ │
│ │ │ │
│ │ 【提交注册申请】调用内核系统调用创建进程。 │ │
│ │ 这一步完成了进程对象的创建(见 5.3.3 的 18 个步骤)。 │ │
│ │ 此时进程已经存在,但还没有线程运行。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 5. 创建初始线程 │ │
│ │ - NtCreateThread(&ThreadHandle, │ │
│ │ THREAD_ALL_ACCESS, │ │
│ │ NULL, │ │
│ │ ProcessHandle, │ │
│ │ &ClientId, │ │
│ │ &ThreadContext, │ │
│ │ NULL, │ │
│ │ FALSE); │ │
│ │ - 设置线程入口为 BaseThreadStart │ │
│ │ │ │
│ │ 【招聘首位员工】创建进程的第一个线程。 │ │
│ │ 线程入口设置为 BaseThreadStart(kernel32 的内部函数), │ │
│ │ 这个函数会调用 LdrInitializeThunk 初始化进程, │ │
│ │ 然后跳转到程序的真正入口点(main 或 WinMain)。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 6. 通知 CSRSS │ │
│ │ - CsrClientCallServer(CsrCreateProcess, ...) │ │
│ │ - 注册进程到 CSRSS 进程列表 │ │
│ │ │ │
│ │ 【通知行业协会】CSRSS(Client/Server Runtime Subsystem) │ │
│ │ 是 Windows 子系统的核心进程,负责管理所有用户进程。 │ │
│ │ 新进程需要向 CSRSS 注册,就像新公司需要向行业协会登记。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 7. 通知 Win32k │ │
│ │ - 如果是 GUI 进程,通知 win32k.sys │ │
│ │ - 初始化 GDI/USER 资源 │ │
│ │ │ │
│ │ 【通知监管部门】如果进程会使用图形界面(GUI), │ │
│ │ 需要通知 win32k.sys(窗口管理器)初始化图形资源。 │ │
│ │ 就像某些公司需要通知特定监管部门(如餐饮公司通知卫生部门)。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 8. 启动线程 │ │
│ │ - NtResumeThread(ThreadHandle, NULL) │ │
│ │ - 线程开始执行 BaseThreadStart → LdrInitializeThunk │ │
│ │ - 最终进入用户态入口点 │ │
│ │ │ │
│ │ 【正式开业】调用 NtResumeThread 启动线程。 │ │
│ │ 线程开始执行: │ │
│ │ 1. BaseThreadStart(kernel32 入口) │ │
│ │ 2. LdrInitializeThunk(加载器初始化,加载 DLL) │ │
│ │ 3. 程序入口点(main/WinMain) │ │
│ │ 就像公司正式开业,员工开始工作。 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 线程启动后的执行流程: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ BaseThreadStart │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ LdrInitializeThunk │ │
│ │ │ 加载所有依赖的 DLL │ │
│ │ │ 调用每个 DLL 的 DllMain │ │
│ │ │ 初始化进程堆 │ │
│ │ ▼ │ │
│ │ 程序入口点 (main 或 WinMain) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 用户程序开始执行 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
5.3.10 小结
本章总结
本章深入分析了 NtCreateProcess 系统调用的完整实现,从用户态 API 到内核态核心函数,涵盖了进程创建的每一个细节。通过本章的学习,读者应该对 Windows 进程创建机制有了全面的理解。
5.3.10.1 关键知识点
核心概念回顾:
| 主题 | 关键点 | 详细说明 |
|---|---|---|
| NtCreateProcess | 创建进程的系统调用 | 用户态通过 ntdll.dll 调用,进入内核态执行实际创建操作 |
| NtCreateProcessEx | 扩展版本,支持更多选项 | 新增 InheritDebugPort 和 Reserved 参数,提供更好的扩展性 |
| PspCreateProcess | 核心创建函数,18 个步骤 | 内核态的核心实现,完成所有进程创建的实际工作 |
| 地址空间创建 | MmCreateProcessAddressSpace | 创建进程的虚拟地址空间,分配页目录和页表 |
| PID 分配 | ExCreateHandle(PspCidTable) | 在全局句柄表中分配唯一的进程标识符 |
| PEB 创建 | MmCreatePeb | 在用户态地址空间中创建进程环境块 |
| 错误处理 | Cleanup/CleanupWithRef 回滚 | 事务性创建机制,失败时回滚所有已分配资源 |
18 步骤流程回顾:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ PspCreateProcess 18 步骤总结 │
│ │
│ 阶段 1: 准备阶段(步骤 1-4) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 步骤 1: 参数校验 ------ 检查标志位是否合法 │ │
│ │ 步骤 2: 解析父进程句柄 ------ 获取父进程对象 │ │
│ │ 步骤 3: 解析 SectionHandle ------ 获取可执行文件映射 │ │
│ │ 步骤 4: 解析 DebugPort/ExceptionPort ------ 设置调试和异常端口 │ │
│ │ │ │
│ │ 【比喻】施工前准备:检查图纸、确认母公司、获取章程、设置监管 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 阶段 2: 创建阶段(步骤 5-11) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 步骤 5: 创建 EPROCESS 对象 ------ 分配进程结构 │ │
│ │ 步骤 6: 初始化 EPROCESS 字段 ------ 设置基本属性 │ │
│ │ 步骤 7: 创建地址空间 ------ 分配页目录 │ │
│ │ 步骤 8: 初始化 KPROCESS ------ 设置调度参数 │ │
│ │ 步骤 9: 复制安全令牌 ------ 继承父进程权限 │ │
│ │ 步骤 10: 初始化对象管理器 ------ 创建句柄表 │ │
│ │ 步骤 11: 初始化用户态地址空间 ------ 映射可执行文件 │ │
│ │ │ │
│ │ 【比喻】施工进行:搭建框架、铺设地基、安装系统、装修内部 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 阶段 3: 注册阶段(步骤 12-15) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 步骤 12: 分配 PID ------ 在全局句柄表中分配唯一标识 │ │
│ │ 步骤 13: 插入 PsActiveProcessHead ------ 加入全局进程链表 │ │
│ │ 步骤 14: 计算 Quantum/Priority ------ 设置调度参数 │ │
│ │ 步骤 15: 创建 PEB ------ 在用户态创建进程环境块 │ │
│ │ │ │
│ │ 【比喻】登记注册:发放房产证、登记名录、设置参数、制作图纸 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 阶段 4: 完成阶段(步骤 16-18) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 步骤 16: 插入对象目录 ------ 创建进程句柄 │ │
│ │ 步骤 17: 访问检查 ------ 验证调用者权限 │ │
│ │ 步骤 18: 返回进程句柄 ------ 完成创建 │ │
│ │ │ │
│ │ 【比喻】交付完成:发放钥匙、权限复核、交付使用 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
5.3.10.2 设计原则
Windows 进程创建的设计哲学:
-
分层设计:用户态 API → 系统调用 → 内部函数
- 比喻:就像公司的管理层级:员工(用户态)→ 经理(系统调用)→ 总裁(内核态)
- 每层有明确的职责,上层依赖下层,下层不知道上层的存在
- 这种设计使得系统模块化,便于维护和扩展
-
事务性创建:失败时回滚所有已分配资源
- 比喻:就像建造房子,如果中途失败,需要拆除已建部分
- 确保系统不会留下半成品进程,避免资源泄漏和状态不一致
- 每个步骤都有对应的回滚操作,保证原子性
-
权限检查:多个检查点验证权限
- 比喻:就像公司注册需要多个部门审批
- 步骤 2:检查对父进程的权限
- 步骤 17:检查对新进程的请求权限
- 多层检查确保安全性,防止权限提升攻击
-
模块化:各个子系统独立负责自己的部分
- 比喻:就像建造房子涉及多个专业团队
- 内存管理器负责地址空间
- 对象管理器负责对象创建和句柄
- 安全子系统负责令牌和权限
- 进程管理器负责协调所有子系统
5.3.10.3 常见陷阱
开发中需要注意的问题:
-
忘记继承句柄:需要显式设置 InheritObjectTable
- 问题:子进程无法访问父进程的资源
- 解决:如果需要继承,设置 InheritObjectTable = TRUE
- 注意:只有标记为 OBJ_INHERIT 的句柄才会被继承
-
无效的 SectionHandle:必须有正确的可执行权限
- 问题:创建进程失败,返回 STATUS_ACCESS_DENIED
- 解决:创建 Section 时请求 SECTION_MAP_EXECUTE 权限
- 注意:Section 必须用 SEC_IMAGE 标志创建
-
父进程权限不足:需要 PROCESS_CREATE_PROCESS 权限
- 问题:无法以指定进程为父进程创建子进程
- 解决:打开父进程时请求 PROCESS_CREATE_PROCESS 权限
- 注意:低权限进程不能以高权限进程为父进程
-
资源泄漏:创建失败时需要正确释放资源
- 问题:创建失败后,已分配的资源没有释放
- 解决:使用 Cleanup/CleanupWithRef 函数正确回滚
- 注意:Windows 内核已经处理了这个问题,但用户态代码需要注意
调试技巧:
- 使用 Process Monitor 工具监控进程创建过程
- 检查返回的错误码,根据错误码定位问题
- 使用调试器跟踪 NtCreateProcess 的执行
- 查看进程的 PEB 和 TEB 内容,验证初始化是否正确
5.3.10.4 后续学习路径
下一步学习建议:
-
线程创建:NtCreateThread 系统调用
- 进程创建后需要创建线程才能执行代码
- 理解线程的创建流程和初始化过程
- 学习 TEB(线程环境块)的结构和作用
-
进程终止:NtTerminateProcess 系统调用
- 进程如何正常终止
- 资源清理和回滚机制
- 进程终止通知(CSRSS、父进程)
-
线程调度:第 6 章
- 理解调度器如何选择线程运行
- 优先级、时间片、CPU 亲和性
- 调度算法和调度策略
-
进程间通信:
- 管道、共享内存、消息队列
- LPC(本地过程调用)
- RPC(远程过程调用)
-
进程安全:
- 安全令牌的详细结构
- ACL(访问控制列表)
- 特权和权限管理
推荐阅读源码:
- ntoskrnl/ps/process.c(file:///d:/reactos/ntoskrnl/ps/process.c):进程创建的核心实现
- dll/kernel32/client/proc.c(file:///d:/reactos/dll/kernel32/client/proc.c):CreateProcess 的用户态实现
- sdk/include/ndk/pstypes.h(file:///d:/reactos/sdk/include/ndk/pstypes.h):EPROCESS/ETHREAD 结构定义
- sdk/include/ndk/peb_teb.h(file:///d:/reactos/sdk/include/ndk/peb_teb.h):PEB/TEB 结构定义
实践建议:
- 使用 WinDbg 调试器跟踪进程创建过程
- 编写简单的程序调用 CreateProcess,观察进程创建
- 使用 Process Explorer 查看进程的详细信息
- 阅读 ReactOS 源码,理解具体实现细节
源码位置:ntoskrnl/ps/process.c(file:///d:/reactos/ntoskrnl/ps/process.c)、dll/kernel32/client/proc.c(file:///d:/reactos/dll/kernel32/client/proc.c)