Windows Internals学习:CreateProcess 完整流程详解
一、总览:创建一个进程到底要经过哪些系统组件
前面几节讲的都是"进程相关的数据结构长什么样"(EPROCESS、PEB、TEB 之类),以及怎么用工具去查看这些结构。这一节要讲的是这些数据结构究竟是在什么时候、被谁、怎么一步步填出来的——也就是进程从"无"到"能跑起来"的完整生命周期。
所有对外公开文档的进程创建函数,最终都会走到同一个内部函数:CreateProcessInternalW,所以这就是分析的起点。
1.1 三个参与方
创建一个 Windows 进程,实际上要经过操作系统里三个不同的部分分工协作:
- Windows 客户端库
Kernel32.dll——真正的工作从CreateProcessInternalW开始; - Windows 执行体(Executive)——内核层面真正创建"执行体进程对象",这是一个所有子系统都能复用的通用对象;
- Windows 子系统进程(
Csrss.exe)——因为 Windows 支持多环境子系统架构,"创建一个执行体进程对象"这件通用的事,和"把它包装成一个 Windows 子系统进程"这件专属的事,是分开处理的。
正因为有这个分层,虽然接下来讲的CreateProcess流程看起来很复杂,但你要记住一个核心区分:有一部分工作是 Windows 子系统特有的语义,另一部分才是创建执行体进程对象所必需的核心工作。
1.2 七个主要阶段
用一句话概括这七个阶段:
- 验证参数:转换子系统标志/选项为原生格式;解析、验证、转换属性列表;
- 打开镜像文件(.exe),准备执行;
- 创建 Windows 执行体进程对象;
- 创建初始线程(栈、上下文、执行体线程对象);
- 执行 Windows 子系统特有的创建后初始化;
- 启动初始线程执行(除非指定了
CREATE_SUSPENDED挂起标志); - 在新进程/线程的上下文中,完成地址空间的最终初始化(比如加载所需 DLL),然后跳到程序入口点开始执行。
提示:这一节很多步骤会涉及"进程虚拟地址空间"的搭建,涉及不少内存管理相关的术语和结构(比如 VAD、工作集),如果对这块不熟悉,可以先有个大概印象,后面讲内存管理时再回过头来对照理解。
下面用图来梳理这七个阶段之间的调用关系:
这张图对应的正是 Figure 3-10:注意箭头方向——阶段 5 会主动向 Csrss 发消息请求它做子系统层面的设置,Csrss 处理完再返回,然后阶段 6 才真正恢复(resume)初始线程的执行;而线程真正开始跑起来之后,阶段 7 是在新进程自己的上下文里完成的,跟前面几个阶段不在同一个执行环境下运行。
二、阶段1:转换并验证参数和标志
在真正打开可执行镜像文件之前,CreateProcessInternalW 要先做下面这些事:
2.1 优先级类的选择
新进程的优先级类是通过 CreateProcess* 系列函数的 CreationFlags 参数里的独立比特位来指定的。这意味着理论上一次调用可以同时设置多个优先级类的标志位——遇到这种情况,Windows 会选择其中优先级最低的那个类。
Windows 一共定义了六种进程优先级类,每种对应一个数值(这个数值会被用作该进程内新建线程的基础优先级):
| 优先级类(任务管理器显示名) | 对应数值 |
|---|---|
| Idle 或 Low | 4 |
| Below Normal | 6 |
| Normal | 8 |
| Above Normal | 10 |
| High | 13 |
| Real-time | 24 |
要注意,这个值本身并不直接影响进程,它只是被用作这个进程内新创建线程的基础优先级——线程调度的细节会在讲线程内部结构时详细讲。
2.2 默认优先级类与权限检查
如果调用时没有指定任何优先级类,默认使用 Normal。
如果指定了 Real-time 优先级类,但调用方没有"提升调度优先级"权限(SE_INC_BASE_PRIORITY_NAME),系统会自动改用 High 优先级类代替。也就是说,权限不够并不会导致进程创建失败,只是新进程拿不到那么高的优先级而已——这是一种"降级而非拒绝"的设计。
2.3 调试相关初始化
如果创建标志里指定了"这个进程要被调试",Kernel32 会调用 DbgUiConnectToDbg,向 Ntdll.dll 里的原生调试代码发起连接,并从当前线程环境块(TEB)里取到一个调试对象的句柄。
2.4 硬错误处理模式
如果创建标志里指定了默认硬错误处理模式,Kernel32.dll 会据此设置。
2.5 属性列表转换
用户指定的属性列表会从 Win32 子系统格式转换为原生格式,同时还会追加一些内部专用的属性。下面这张大表列出了所有可能出现在属性列表里的原生属性:
补充说明:
CreateProcess*调用传入的这份属性列表,不只是单向传参数进去,还可以把信息传回给调用方——比如初始线程的 TEB 地址、镜像 Section 的相关信息。这一点对保护进程尤其重要,因为父进程在子进程创建完成之后,没法再反过来查询这些信息(前面讲过保护进程会拒绝大部分查询类访问权限)。
| 原生属性 | 对应的 Win32 属性 | 输入/输出 | 说明 |
|---|---|---|---|
PS_CP_PARENT_PROCESS |
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS(提权时也用) |
输入 | 指定父进程的句柄 |
PS_CP_DEBUG_OBJECT |
无(DEBUG_PROCESS 标志触发) |
输入 | 如果进程要以被调试状态启动,指定调试对象 |
PS_CP_PRIMARY_TOKEN |
无(CreateProcessAsUser/WithTokenW 触发) |
输入 | 如果用了 CreateProcessAsUser,指定进程令牌 |
PS_CP_CLIENT_ID |
无(通过 PROCESS_INFORMATION 参数返回) |
输出 | 返回初始线程的 TID 和进程的 PID |
PS_CP_TEB_ADDRESS |
无(内部使用,不对外暴露) | 输出 | 返回初始线程 TEB 的地址 |
PS_CP_FILENAME |
无(CreateProcess API 的参数) |
输入 | 要创建的进程的名称 |
PS_CP_IMAGE_INFO |
无(内部使用,不对外暴露) | 输出 | 返回 SECTION_IMAGE_INFORMATION,包含可执行文件的版本、标志、子系统信息,以及栈大小和入口点 |
PS_CP_MEM_RESERVE |
无(SMSS 和 CSRSS 内部使用) | 输入 | 一组虚拟内存预留请求,会在初始地址空间创建期间执行,确保这些地址范围一定可用(因为此时还没有其他分配发生) |
PS_CP_PRIORITY_CLASS |
无(作为 CreateProcess 参数传入) |
输入 | 进程应被赋予的优先级类 |
PS_CP_ERROR_MODE |
无(通过 CREATE_DEFAULT_ERROR_MODE 标志传入) |
输入 | 进程的硬错误处理模式 |
PS_CP_STD_HANDLE_INFO |
无,内部使用 | 输入 | 指定标准句柄是应该被复制还是新建 |
PS_CP_HANDLE_LIST |
PROC_THREAD_ATTRIBUTE_HANDLE_LIST |
输入 | 父进程中一批应被新进程继承的句柄列表 |
PS_CP_GROUP_AFFINITY |
PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY |
输入 | 线程被允许运行的处理器组 |
PS_CP_PREFERRED_NODE |
PROC_THREAD_ATTRIBUTES_PRFERRED_NODE |
输入 | 进程应关联的首选(理想)NUMA 节点,影响初始堆和线程栈会被创建在哪个节点上 |
PS_CP_IDEAL_PROCESSOR |
PROC_THREAD_ATTTRIBUTE_IDEAL_PROCESSOR |
输入 | 线程应被调度到的首选(理想)处理器 |
PS_CP_UMS_THREAD |
PROC_THREAD_ATTRIBUTE_UMS_THREAD |
输入 | 包含用户态调度(UMS)属性、完成列表和上下文 |
PS_CP_MITIGATION_OPTIONS |
PROC_THREAD_MITIGATION_POLICY |
输入 | 包含应为该进程启用/禁用哪些缓解措施的信息(SEHOP、ATL 模拟、NX 等) |
PS_CP_PROTECTION_LEVEL |
PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL |
输入 | 必须指向前面讲过的合法保护值之一,或者用 PROTECT_LEVEL_SAME 表示和父进程保护等级相同 |
PS_CP_SECURE_PROCESS |
无,内部使用 | 输入 | 表示这个进程要作为 IUM Trustlet 运行 |
PS_CP_JOB_LIST |
无,内部使用 | 输入 | 把进程分配到一组 Job 对象里 |
PS_CP_CHILD_PROCESS_POLICY |
PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY |
输入 | 指定新进程是否允许创建子进程(无论是直接创建,还是通过 WMI 这种间接方式) |
PS_CP_ALL_APPLICATION_PACKAGES_POLICY |
PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY |
输入 | 指定 AppContainer 令牌是否应从包含 ALL APPLICATION PACKAGES 组的 ACL 检查中排除(改用 ALL RESTRICTED APPLICATION PACKAGES 组代替) |
PS_CP_WIN32K_FILTER |
PROC_THREAD_ATTRIBUTE_WIN32K_FILTER |
输入 | 指定该进程调用 Win32k.sys 的 GDI/USER 系统调用是被过滤(拦截)还是被放行但记录审计日志;这是 Microsoft Edge 用来缩小攻击面的机制 |
PS_CP_SAFE_OPEN_PROMPT_ORIGIN_CLAIM |
无,内部使用 | 输入 | Mark of the Web 功能用来标记这个文件来自不受信任来源 |
PS_CP_BNO_ISOLATION |
PROC_THREAD_ATTRIBUTE_BNO_ISOLATION |
输入 | 使进程的主令牌关联到一个隔离的 BaseNamedObjects 目录 |
PS_CP_DESKTOP_APP_POLICY |
PROC_THREAD_ATTRIBUTE_DESKTOP_APP_POLICY |
输入 | 指定现代应用是否允许启动传统桌面应用,以及以何种方式启动 |
| 无,内部使用 | PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES |
输入 | 指向 SECURITY_CAPABILITIES 结构体的指针,在调用 NtCreateUserProcess 之前用来创建进程的 AppContainer 令牌 |
这张表看着密密麻麻,但可以按用途归成几类来记:安全隔离类(Protection Level、Secure Process、BNO Isolation、AppContainer 相关)、调度资源类(Group Affinity、Preferred Node、Ideal Processor、UMS)、兼容性/防护类(Mitigation Options、Win32k Filter)、父子关系与句柄传递类(Parent Process、Handle List、Child Process Policy)、信息回传类(Client ID、TEB Address、Image Info)。
2.6 后续小步骤
- 如果进程属于某个 Job 对象,但创建标志又要求单独的虚拟 DOS 机(VDM),这个标志会被直接忽略。
- 调用方传给
CreateProcess的安全属性会被转换成内部表示(OBJECT_ATTRIBUTES结构体,WDK 中有文档)。 CreateProcessInternalW判断这个进程是否应该以"现代应用(modern/UWP)"方式创建——判断依据是:要么显式指定了PROC_THREAD_ATTRIBUTE_PACKAGE_FULL_NAME属性带上了完整包名,要么创建者本身就是现代应用(且没有通过PROC_THREAD_ATTRIBUTE_PARENT_PROCESS显式指定别的父进程)。如果确认是,会调用内部函数BasepAppXExtension去收集更多上下文信息,填到一个叫APPX_PROCESS_CONTEXT的结构体里,里面包含包名(内部叫 package moniker)、应用具备的能力、当前目录、以及这个应用是否应拥有完全信任。创建"完全信任的现代应用"这个选项并不对外公开,是留给那些"外观是现代应用,但要执行系统级操作"的场景用的——典型例子就是 Windows 10 的"设置"应用(SystemSettings.exe)。- 如果确定要创建现代应用,且提供了
PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES属性,会调用内部函数BasepCreateLowBox把这些安全能力记录下来供后续令牌创建使用。LowBox 这个术语指的正是这个进程要运行在的沙箱(AppContainer)。虽然不支持直接调用CreateProcess来创建现代应用(应该用前面讲过的 COM 接口),但 Windows SDK 和 MSDN 确实公开记录了通过传递这个属性来创建 AppContainer 传统桌面应用的方法。 - 如果要创建现代应用,会设置一个标志告诉内核跳过内嵌清单(embedded manifest)检测——现代应用永远不该有内嵌清单,因为压根用不上(现代应用有自己的清单,跟这里说的内嵌清单是两回事)。
- 如果指定了调试标志(
DEBUG_PROCESS),那么这次调用会标记跳过这个可执行文件在 Image File Execution Options(IFEO)注册表键下的Debugger值检查。不这样做的话,调试器永远无法创建自己要调试的目标进程——因为创建过程会陷入无限循环(不停地试图创建"调试器进程"本身)。 - 所有窗口都关联到"桌面(Desktop)"——工作区的图形化表示。如果
STARTUPINFO结构体里没有指定桌面,新进程会关联到调用方当前的桌面。补充说明:Windows 10 的虚拟桌面功能,其实并没有用到多个内核对象意义上的"桌面对象"。系统始终只有一个桌面对象,只是窗口按需被显示/隐藏来模拟出"多个桌面"的效果。这和 Sysinternals 的
desktops.exe工具形成鲜明对比——那个工具是真的创建了最多 4 个独立的桌面对象。这个差异会体现在一个很具体的场景上:用desktops.exe创建的桌面之间没法把一个窗口从一个桌面"移动"到另一个(因为 Windows 本身不支持这种操作),而 Windows 10 的虚拟桌面却可以做到,原因就是它压根没有真的在"移动"任何东西,只是切换了这个窗口在同一个桌面上的可见性。 - 分析传给
CreateProcessInternalW的应用程序路径和命令行参数。可执行文件的路径名会被转换成内部的 NT 名称格式(比如c:\temp\a.exe会变成类似\device\harddiskvolume1\temp\a.exe这样的形式),因为有些函数要求这种格式的输入。 - 收集到的绝大部分信息,会被打包进一个巨大的结构体
RTL_USER_PROCESS_PARAMETERS。
以上步骤都做完之后,CreateProcessInternalW会第一次调用NtCreateUserProcess来尝试创建进程。因为此时Kernel32.dll其实并不知道这个可执行文件到底是个真正的 Windows 应用,还是批处理文件(.bat/.cmd)、16 位程序,还是 DOS 程序,所以这次调用有可能失败——一旦失败,CreateProcessInternalW会检查失败原因,并尝试相应地纠正(比如换成Ntvdm.exe或Cmd.exe重新来一次)。
三、阶段2:打开待执行的镜像文件
到了这一步,创建线程已经切换到内核态,继续在 NtCreateUserProcess 这个系统调用的实现内部工作。
NtCreateUserProcess首先重新验证参数,并建立一个内部结构体来存放全部创建信息。之所以要"重新验证"(前面阶段1已经验证过一次),是为了防止有人通过某种手段"伪造"了从Ntdll.dll到内核这段过渡调用,用恶意或伪造的参数直接闯进来。- 如图 Figure 3-11 所示,
NtCreateUserProcess的下一步是找到合适的 Windows 镜像来运行调用方指定的可执行文件,并创建一个 Section 对象,为后续把它映射进新进程的地址空间做准备。如果这一步因为任何原因失败,会带着失败状态码返回给CreateProcessInternalW(对照后面的表 3-8),促使CreateProcessInternalW尝试重新执行。 - 如果要创建的是保护进程,还需要检查签名策略。
- 如果要创建的是现代应用,会做一次授权(许可证)检查,确保它已获得授权且被允许运行。如果这个应用是 inbox(随 Windows 预装的),则无论许可证如何都允许运行;如果系统开启了"侧载(sideload)应用"(通过设置应用配置),那么任何签名过的应用都可以运行,不局限于来自商店的应用。
- 如果要创建的是 Trustlet,Section 对象在创建时必须带上一个特殊标志,让 Secure Kernel(安全内核)能够使用它。
- 如果指定的可执行文件是一个 Windows EXE,
NtCreateUserProcess会尝试打开这个文件并为它创建一个 Section 对象——注意,这个对象此时还没有被映射进内存,只是打开了而已。而且,即便 Section 对象成功创建,也不代表这个文件一定是合法的 Windows 镜像——它有可能其实是个 DLL,或者是个 POSIX 可执行文件。如果是 POSIX 可执行文件,调用会失败(因为 POSIX 支持已经被移除);如果是 DLL,CreateProcessInternalW同样会失败。 - 找到合法的 Windows 可执行镜像之后,作为进程创建代码的一部分(下一节详细讲),系统会去查注册表
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options,看看下面是否存在一个和这个可执行镜像文件名+扩展名(不带目录路径,比如Notepad.exe)同名的子键。如果存在,PspAllocateProcess会去找这个子键下名为Debugger的值。如果这个值存在,那么实际要运行的镜像就变成了这个值里指定的字符串,CreateProcessInternalW会回到阶段1重新开始。小技巧:可以利用这个进程创建行为,在 Windows 服务进程启动之前就调试它的启动代码,而不是等服务先启动了再挂上调试器(那样就没法调试启动阶段的代码了)。
- 反过来,如果镜像不是 Windows EXE(比如是 MS-DOS 或 Win16 程序),
CreateProcessInternalW会走一系列步骤,去找一个能运行它的 Windows 支持镜像。这是必须的,因为非 Windows 应用不能被直接运行——Windows 会用几个特殊的支持镜像之一去代为运行这些非 Windows 程序。例如,如果试图运行一个 MS-DOS 或 Win16 可执行文件(仅限 32 位 Windows),实际要运行的镜像会变成Ntvdm.exe。简单说:你没办法直接创建一个"不是 Windows 进程"的进程。如果 Windows 找不到办法把被激活的镜像解析成 Windows 进程(对照下面表 3-8),CreateProcessInternalW会失败。
3.1 阶段1的判定决策表
| 镜像类型 | 创建状态码 | 将要运行的镜像 | 随后发生的事 |
|---|---|---|---|
| 带 .exe/.com/.pif 扩展名的 MS-DOS 应用 | PsCreateFailOnSectionCreate |
Ntvdm.exe | CreateProcessInternalW 重新回到阶段1 |
| Win16 应用 | PsCreateFailOnSectionCreate |
Ntvdm.exe | CreateProcessInternalW 重新回到阶段1 |
| 32 位系统上的 Win64 应用(或 PPC/MIPS/Alpha 二进制) | PsCreateFailMachineMismatch |
无 | CreateProcessInternalW 将失败 |
| 带有指向另一个镜像名的 Debugger 值 | PsCreateFailExeName |
Debugger 值里指定的名称 | CreateProcessInternalW 重新回到阶段1 |
| 无效或损坏的 Windows EXE | PsCreateFailExeFormat |
无 | CreateProcessInternalW 将失败 |
| 无法打开 | PsCreateFailOnFileOpen |
无 | CreateProcessInternalW 将失败 |
| 带 .bat/.cmd 扩展名的命令过程(批处理文件) | PsCreateFailOnSectionCreate |
Cmd.exe | CreateProcessInternalW 重新回到阶段1 |
具体来说,CreateProcessInternalW 判定该如何运行一个镜像的决策逻辑是这样的:
- 如果是 x86 32 位 Windows,且镜像是带
.exe、.com或.pif扩展名的 MS-DOS 应用:先向 Windows 子系统发消息,检查当前会话是否已经创建过 MS-DOS 支持进程(Ntvdm.exe,具体路径在注册表HKLM\SYSTEM\CurrentControlSet\Control\WOW\cmdline值里指定)。如果已经创建过,就直接用它来运行这个 MS-DOS 应用(Windows 子系统会向虚拟 DOS 机 [VDM] 进程发消息,让它去运行新镜像),然后CreateProcessInternalW直接返回。如果还没创建过支持进程,实际要运行的镜像就换成Ntvdm.exe,CreateProcessInternalW回到阶段1重新开始。 - 如果待运行的文件带
.bat或.cmd扩展名:实际要运行的镜像变成Cmd.exe(Windows 命令提示符),CreateProcessInternalW回到阶段1重新开始(批处理文件的名字会作为Cmd.exe的第二个参数,跟在/c开关后面传入)。 - 对于 x86 Windows 系统,如果镜像是一个 Win16(Windows 3.1)可执行文件:
CreateProcessInternalW必须决定是创建一个新的 VDM 进程来运行它,还是使用默认的、整个会话共享的 VDM 进程(这个共享进程可能还没被创建)。这个决策受CreateProcess的CREATE_SEPARATE_WOW_VDM和CREATE_SHARED_WOW_VDM标志控制;如果没有指定这两个标志,默认行为由注册表值HKLM\SYSTEM\CurrentControlSet\Control\WOW\DefaultSeparateVDM决定。如果应用要跑在独立 VDM 中,实际要运行的镜像变成Ntvdm.exe(后面跟着一些配置参数和这个 16 位进程的名称),CreateProcessInternalW回到阶段1重新开始。否则,Windows 子系统会发消息去检查共享的 VDM 进程是否存在且可用(如果 VDM 进程运行在不同的桌面上,或者不是以调用方相同的安全上下文运行的,就不能用,必须创建一个新的)。如果共享 VDM 进程可用,Windows 子系统发消息让它运行新镜像,CreateProcessInternalW返回。如果 VDM 进程还没创建(或者存在但不能用),实际要运行的镜像就换成 VDM 支持镜像,CreateProcessInternalW回到阶段1重新开始。
四、阶段3:创建 Windows 执行体进程对象
到这一步,NtCreateUserProcess 已经打开了一个合法的 Windows 可执行文件,并为它创建了一个 Section 对象,准备把它映射进新进程的地址空间。接下来,它会调用内部系统函数 PspAllocateProcess 来创建一个 Windows 执行体进程对象,用于运行这个镜像。
创建执行体进程对象(由创建线程本身来完成)包含以下几个子阶段:
- 3A:搭建 EPROCESS 对象
- 3B:创建初始进程地址空间
- 3C:初始化内核进程结构(KPROCESS)
- 3D:完成进程地址空间的设置收尾
- 3E:搭建 PEB
- 3F:完成执行体进程对象的最终设置
补充说明:唯一没有父进程的情况,是系统初始化阶段(也就是 System 进程被创建的那一刻)。在那之后,任何新进程都必须有一个父进程来提供安全上下文。
4.1 阶段3A:搭建 EPROCESS 对象
这个子阶段包括以下步骤:
- 继承父进程的处理器亲和性(affinity),除非在创建时通过属性列表显式设置了。
- 如果属性列表里指定了理想 NUMA 节点,就选用它。
- 继承父进程的 I/O 优先级和页面优先级。如果没有父进程,使用默认页面优先级(5)和 I/O 优先级(Normal)。
- 把新进程的退出状态设置为
STATUS_PENDING(“待定”)。 - 选用属性列表里指定的硬错误处理模式;如果没指定,继承父进程的处理模式;如果没有父进程,使用默认处理模式(显示所有错误)。
- 把父进程的 ID 存入新进程对象的
InheritedFromUniqueProcessId字段。 - 查询 IFEO(Image File Execution Options)键,检查这个进程是否应以大页(large pages)方式映射(对应 IFEO 键下的
UseLargePages值)——除非这个进程要跑在 Wow64 之下,那种情况下不会用大页。同时还会检查NTDLL是否被列为需要以大页方式映射的 DLL。 - 查询 IFEO 下的性能选项键(
PerfOptions,如果存在的话),它可能包含以下任意组合的值:IoPriority、PagePriority、CpuPriorityClass、WorkingSetLimitInKB。 - 如果这个进程要跑在 Wow64 之下,就分配 Wow64 辅助结构体(
EWOW64PROCESS),存入EPROCESS结构体的WoW64Process成员。 - 如果这个进程要创建在一个 AppContainer 里(多数情况下是现代应用),要验证令牌是否是带 LowBox 创建的。
- 尝试获取创建该进程所需的全部权限——选择 Real-time 优先级类、给新进程分配令牌、以大页方式映射进程、在新会话里创建进程,这些操作都需要相应的权限。
- 创建进程的主访问令牌(父进程主令牌的一个副本)。新进程默认继承父进程的安全配置。如果使用了
CreateProcessAsUser来给新进程指定不同的访问令牌,令牌会相应地被更改——但这个更改只有在父令牌的完整性级别支配访问令牌的完整性级别、且访问令牌确实是父令牌的真正子级或同级令牌时才会发生。注意,如果父进程拥有SeAssignPrimaryToken权限,这些检查会被跳过。 - 检查新进程令牌的会话 ID,判断这是否是一次"跨会话创建"。如果是,父进程会临时"附加"到目标会话,以便正确处理配额和地址空间的创建。
- 把新进程的配额块(quota block)指向父进程配额块的地址,并递增父进程配额块的引用计数。如果进程是通过
CreateProcessAsUser创建的,这一步不会发生——取而代之的是创建默认配额,或者选用匹配用户配置文件的配额。 - 进程的最小/最大工作集大小分别被设为
PspMinimumWorkingSet和PspMaximumWorkingSet的值。如果 IFEO 的PerfOptions键里指定了性能选项,这些值可以被覆盖,此时最大工作集会从那里读取。注意:默认的工作集限制只是软限制(本质上是一种建议),而PerfOptions里指定的最大工作集是硬限制(也就是说,工作集绝对不允许超过这个数字)。 - 初始化进程的地址空间(对应下面的阶段3B)。然后,如果之前附加到了不同的会话,现在把它分离(detach)掉。
- 如果没有使用组亲和性继承,现在选择进程的组亲和性(group affinity)。默认的组亲和性,要么继承自父进程(如果之前设置了 NUMA 节点传播,会使用拥有该 NUMA 节点的组),要么按轮询方式分配。如果系统处于强制分组感知模式,并且选择算法选中了 0 号组,则改用 1 号组(前提是它存在)。
- 初始化进程对象里的 KPROCESS 部分(对应下面的阶段3C)。
- 现在设置进程的令牌。
- 进程的优先级类被设为 Normal,除非父进程正在使用 Idle 或 Below Normal 优先级类,这种情况下会继承父进程的优先级。
- 初始化进程句柄表。如果为父进程设置了句柄继承标志,父进程对象句柄表中所有"可继承"的句柄,会被复制进新进程。(关于对象句柄表的更多信息,参见第二部分第8章。)也可以用一个进程属性来指定"只继承一部分句柄",这在使用
CreateProcessAsUser限制子进程能继承哪些对象时很有用。 - 如果通过
PerfOptions键指定了性能选项,现在应用这些选项——它包含对工作集限制、I/O 优先级、页面优先级、以及进程 CPU 优先级类的覆盖设置。 - 计算并设置最终的进程优先级类,以及它下属线程的默认时间片(quantum)。
- 读取并设置 IFEO 键里提供的各种缓解选项(作为一个名为
Mitigation的单个 64 位数值提供)。如果这个进程处于 AppContainer 之下,追加TreatAsAppContainer缓解标志。 - 应用所有其他的缓解标志。
4.2 阶段3B:创建初始进程地址空间
初始进程地址空间由以下几种页面构成:
- 页目录(对于超过两级页表的系统——比如 PAE 模式下的 x86 系统,或 64 位系统——可能会有不止一个页目录)
- Hyperspace 页
- VAD 位图页
- 工作集列表
创建这些页面的具体步骤:
- 在适当的页表里创建页表项,映射上述初始页面。
- 把用掉的页面数量从内核变量
MmTotalCommittedPages里扣除,加到MmProcessCommit里。 - 从
MmResidentAvailablePages里扣除系统级默认的进程最小工作集大小(PsMinimumWorkingSet)。 - 创建全局系统空间的页表页面(也就是除了上面这几个进程专属页面之外、且不属于会话专属内存的那部分)。
4.3 阶段3C:创建内核进程结构
PspAllocateProcess 的下一步是初始化 KPROCESS 结构体(这是 EPROCESS 的 Pcb 成员),由 KeInitializeProcess 完成,具体工作:
- 初始化一个双向链表,用于连接这个进程下属的所有线程(初始为空)。
- 进程默认时间片(quantum,第4章"线程调度"会详细讲)的初始值(也叫重置值)先被硬编码设为 6,之后再由
PspComputeQuantumAndPriority重新初始化。补充说明:Windows 客户端系统和服务器系统的默认初始时间片是不一样的。
- 进程的基础优先级根据阶段3A里计算出的结果来设置。
- 设置该进程下线程的默认处理器亲和性,以及组亲和性——组亲和性是在阶段3A里计算出来的,或者继承自父进程。
- 进程的换页状态被设为常驻(resident)。
- **线程种子(thread seed)**基于内核为这个进程选中的理想处理器来确定(这个理想处理器又是基于上一个被创建进程的理想处理器算出来的,本质上是以轮询方式实现随机化)。创建一个新进程会更新
KeNodeBlock(初始 NUMA 节点块)里的种子,这样下一个新进程就会拿到不同的理想处理器种子。 - 如果这个进程是安全进程(Windows 10 和 Server 2016 上),现在会调用
HvlCreateSecureProcess来创建它的安全 ID。
4.4 阶段3D:完成进程地址空间的设置收尾
搭建新进程地址空间这件事相对比较复杂,下面一步步来看它涉及了什么(这部分内容涉及不少内存管理器的内部机制,如果对第5章的内容不太熟悉,可以先了解个大概)。
真正完成大部分地址空间搭建工作的例程是 MmInitializeProcessAddressSpace,它同时也支持"从另一个进程克隆地址空间"这项能力——这在实现 POSIX 的 fork 系统调用时曾经派上用场,将来也可能被用于支持其他类 Unix 的 fork(这正是 Redstone 1 版本里 WSL 实现 fork 的方式)。下面的步骤不描述"克隆地址空间"这条分支,而是专注于正常的进程地址空间初始化:
- 虚拟内存管理器把进程的"最后一次修剪时间"设为当前时间。工作集管理器(运行在平衡集管理器系统线程的上下文里)会用这个值来判断什么时候该启动工作集修剪。
- 内存管理器初始化进程的工作集列表。从这一刻起,进程可以承受页面错误(page fault)了。
- 前面打开镜像文件时创建的 Section 对象,现在被映射进新进程的地址空间,进程的 Section 基址被设为镜像的基址。
- 创建并初始化进程环境块(PEB)(详见下面的阶段3E)。
Ntdll.dll被映射进这个进程。如果这是一个 Wow64 进程,32 位版本的Ntdll.dll也会一并被映射。- 如果有请求,现在为这个进程创建一个新会话。这个特殊步骤主要是为了照顾会话管理器(Smss)在初始化一个新会话时的需要。
- 标准句柄被复制,新的值被写入进程参数结构体。
- 处理属性列表里列出的所有内存预留请求。另外,还有两个标志允许批量预留地址空间的前 1MB 或前 16MB——这些标志是内部使用的,比如用于映射实模式向量表和 ROM 代码(这些内容必须位于虚拟地址空间的低范围区域,而这个区域通常是堆或其他进程结构可能占用的地方)。
- 用户进程参数被写入进程、复制,并做修正(也就是从绝对地址形式转换为相对形式,这样只需要一块内存就能容纳)。
- 亲和性信息被写入 PEB。
- MinWin API 重定向集被映射进进程,其指针存入 PEB。
- 现在确定并存储进程的唯一 ID。内核并不区分"唯一进程 ID/线程 ID"和"句柄"——进程和线程的 ID(句柄)被存放在一个全局句柄表(
PspCidTable)里,这个表不属于任何具体进程。 - 如果这个进程是安全进程(即运行在 IUM 里),此时会初始化安全进程,并将其与内核进程对象关联起来。
4.5 阶段3E:搭建 PEB
NtCreateUserProcess 调用 MmCreatePeb,这个函数首先把系统级的国家语言支持(NLS)表映射进进程的地址空间。接着它调用 MiCreatePebOrTeb 为 PEB 分配一个页面,然后初始化一系列字段——这些字段中的大多数,值来自通过注册表配置的内部变量,比如 MmHeap* 系列值、MmCriticalSectionTimeout、MmMinimumStackCommitInBytes。其中有些字段还可以被链接的可执行镜像本身的设置覆盖,比如 PE 头里的 Windows 版本信息,或者 PE 头加载配置目录里的亲和性掩码。
如果镜像头的特征标志里设置了 IMAGE_FILE_UP_SYSTEM_ONLY(表示该镜像只能在单处理器系统上运行),系统会为这个新进程里的所有线程选定唯一一个 CPU(记录在 MmRotatingUniprocessorNumber 里)来运行。这个选择过程只是简单地循环遍历可用处理器——每次运行这类镜像时,就切换到下一个处理器。这样一来,这类镜像就能被均匀地分散到各个处理器上。
4.6 阶段3F:完成执行体进程对象的最终设置
在把新进程的句柄返回给调用方之前,还需要完成几个收尾步骤,由 PspInsertProcess 及其辅助函数完成:
- 如果启用了系统级的进程审计(可能来自本地策略设置,也可能来自域控制器下发的组策略设置),这次进程创建会被写入安全事件日志。
- 如果父进程隶属于某个 Job,会从父进程的 Job 层级集合里取回这个 Job,并将其绑定到新创建进程所在的会话,最后把新进程加入这个 Job。
- 新进程对象被插入到 Windows 活跃进程列表(
PsActiveProcessHead)的末尾。从这一刻起,进程就可以被EnumProcesses和OpenProcess之类的函数访问到。 - 除非设置了
NoDebugInherit标志(这是创建进程时可以指定的选项),否则父进程的调试端口会被复制到新的子进程。如果指定了调试端口,它会被附加到新进程上。 - Job 对象可以对"隶属于该 Job 的进程内的线程可以运行在哪个/哪些组"设置限制。因此,
PspInsertProcess必须确保和进程关联的组亲和性不会违反与 Job 关联的组亲和性限制。这里还有一个值得留意的次要问题:Job 的权限是否允许修改进程的亲和性权限——因为一个权限较低的 Job 对象,理论上可能会干扰到一个权限更高的进程本应具有的亲和性要求。 - 最后,
PspInsertProcess调用ObOpenObjectByPointer为新进程创建一个句柄,然后把这个句柄返回给调用方。注意:在这个进程内的第一个线程被创建之前,不会发送任何"进程创建"回调,而且代码总是先发送进程回调,再发送基于对象管理器的回调。
五、阶段4:创建初始线程及其栈和上下文
到这一步,Windows 执行体进程对象已经完全搭建好了。但它还没有任何线程,所以还什么都做不了——现在轮到创建线程了。
正常情况下,创建一个新线程时(NtCreateThread 调用),负责全部工作的是 PspCreateThread 例程。但因为初始线程是内核内部创建的,不涉及任何用户态输入,所以这里改用 PspCreateThread 内部依赖的两个辅助例程:
PspAllocateThread:负责执行体线程对象本身的实际创建和初始化。PspInsertThread:负责创建线程句柄和安全属性,并调用KeStartThread把这个执行体对象转变为系统上一个"可调度"的线程。
不过,此时线程还不会做任何事情——它是以挂起状态被创建的,要等到进程完全初始化完毕(对应阶段5)之后才会被恢复(resume)执行。
补充说明:这里有个参数(无法在
CreateProcess里指定,但可以在CreateThread里指定)——PEB 的地址。这个参数会被跑在这个新线程上下文里的初始化代码用到(对应阶段6会详述)。
5.1 PspAllocateThread 执行的步骤
- 阻止 Wow64 进程里创建用户态调度(UMS)线程,同时阻止用户态调用方在 System 进程里创建线程。
- 创建并初始化一个执行体线程对象。
- 如果系统启用了能耗估算(XBOX 上永远禁用),则分配并初始化一个
THREAD_ENERGY_VALUES结构体,由ETHREAD对象指向它。 - 初始化 LPC、I/O 管理,以及执行体所使用的各种链表。
- 设置线程的创建时间,并生成它的线程 ID(TID)。
- 线程真正开始执行之前,需要一个栈和一个运行上下文,因此现在把这些搭建好。初始线程的栈大小取自镜像本身——没有办法另行指定别的大小。如果这是 Wow64 进程,Wow64 线程上下文也会被一并初始化。
- 为这个新线程分配线程环境块(TEB)。
- 用户态的线程起始地址存入
ETHREAD的StartAddress字段——这个地址实际上是Ntdll.dll里由系统提供的线程启动函数(RtlUserThreadStart)。而用户指定的 Windows 启动地址,则存放在ETHREAD的另一个字段(Win32StartAddress),这样 Process Explorer 这类调试工具就能展示出这个信息(也就是说,你在 Process Explorer 里看到的"线程起始地址",其实是从Win32StartAddress读出来的,跟内核实际让线程从哪里开始跑,是两个不同的字段)。 - 调用
KeInitThread来搭建KTHREAD结构体。线程的初始及当前基础优先级被设为进程的基础优先级,它的亲和性和时间片也被设为跟进程一致。接下来KeInitThread会为线程分配一个内核栈,并初始化与机器相关的硬件上下文,包括上下文、陷阱帧和异常帧。线程的上下文被设置成"将从内核态的KiThreadStartup开始执行"。最后,KeInitThread把线程状态设为"已初始化",返回给PspAllocateThread。 - 如果这是一个 UMS 线程,会调用
PspUmsInitThread来初始化 UMS 状态。
以上工作完成后,NtCreateUserProcess调用PspInsertThread来执行以下步骤: - 如果通过属性指定了,初始化线程的理想处理器。
- 如果通过属性指定了,初始化线程的组亲和性。
- 如果进程隶属于某个 Job,检查线程的组亲和性是否违反了 Job 的限制(前面已经讲过)。
- 检查确认进程还没被终止、线程还没被终止、线程还没能开始运行——只要满足以上任意一条,线程创建就会失败。
- 如果线程隶属于一个安全进程(IUM),此时会创建并初始化对应的安全线程对象。
- 通过调用
KeStartThread来初始化线程对象里的 KTHREAD 部分,涉及:从所属进程那里继承调度器设置、设置理想节点和处理器、更新组亲和性、设置基础和动态优先级(从进程那里复制)、设置线程时间片、并把线程插入到KPROCESS维护的进程线程列表(注意,这个列表和EPROCESS里的线程列表是两个不同的列表)。 - 如果进程处于"深度冻结(deep freeze)"状态(意味着不允许任何线程运行,包括新线程),这个线程也会被冻结。
- 在非 x86 系统上,如果这个线程是进程里的第一个线程(且这个进程不是 Idle 进程),进程会被插入到另一个全局变量
KiProcessListHead所维护的系统级进程列表中。 - 进程对象里的线程计数递增,同时线程继承所属进程的 I/O 优先级和页面优先级。如果这是该进程有史以来线程数最多的一次,"线程数历史最高水位"也会一并更新。如果这是进程里的第二个线程,进程的主令牌会被冻结(也就是说,此后不能再被更改)。
- 线程被插入到进程的线程列表中;如果创建进程时要求线程以挂起状态创建,此时会被挂起。
- 线程对象被插入到进程句柄表。
- 如果这是这个进程创建的第一个线程(也就是说,这是作为一次
CreateProcess*调用的一部分发生的),会调用所有已注册的进程创建回调,然后再调用所有已注册的线程回调。如果任何一个回调否决了这次创建,创建会失败,并向调用方返回相应的状态码。 - 如果通过属性提供了 Job 列表,且这是进程里的第一个线程,那么这个进程会被分配到 Job 列表中的所有 Job。
- 调用
KeReadyThread让线程进入就绪状态准备执行——它会进入"延迟就绪(deferred ready)"状态(更多线程状态相关内容,参见第4章)。
六、阶段5:执行 Windows 子系统特有的初始化
一旦 NtCreateUserProcess 带着成功状态码返回,就说明必要的执行体进程和线程对象都已经创建完成。接下来,CreateProcessInternalW 会执行一系列与 Windows 子系统相关的操作,来完成进程初始化的最后一步:
- 做各种检查,确认 Windows 是否应该允许这个可执行文件运行——包括验证镜像头里的版本信息,以及检查这个进程是否被 Windows 应用认证机制(通过组策略)阻止。在 Windows Server 2012 R2 的某些特殊版本上(比如 Windows Storage Server 2012 R2),还会额外检查这个应用是否导入了不被允许使用的 API。
- 如果软件限制策略要求,会为新进程创建一个受限令牌。之后,会查询应用兼容性数据库,看注册表或系统应用数据库里是否存在这个进程的相关条目。注意:此时还不会应用兼容性垫片(compatibility shim)——相关信息会在初始线程真正开始执行时(阶段6)才存入 PEB。
CreateProcessInternalW调用一些内部函数(针对非保护进程)去获取 SxS(Side-by-Side)信息(关于 SxS 的更多内容,参见本章后面"DLL 名称解析与重定向"一节),比如清单文件和 DLL 重定向路径,以及其他信息——比如 EXE 所在的介质是否可移动、安装程序检测标志。对于沉浸式(immersive/现代应用)进程,还会返回其包清单中的版本信息和目标平台。- 基于收集到的信息,构造一条要发送给 Csrss 的消息。这条消息包含:
- 路径名和 SxS 路径名;
- 进程和线程句柄;
- Section 句柄;
- 访问令牌句柄;
- 介质信息;
- 应用兼容性和垫片数据;
- 沉浸式进程相关信息;
- PEB 地址;
- 各种标志(比如是否为保护进程,是否要求以提权方式运行);
- 一个标志,指示这个进程是否属于 Windows 应用(好让 Csrss 决定是否要显示启动光标);
- UI 语言信息;
- DLL 重定向和
.local标志(本章后面"镜像加载器"一节会讲); - 清单文件信息。
收到这条消息后,Windows 子系统会执行以下步骤:
CsrCreateProcess复制一份进程和线程的句柄——这一步会把进程和线程的引用计数,从创建时设的 1,递增到 2。- 分配 Csrss 的进程结构体(
CSR_PROCESS)。 - 新进程的异常端口被设置为 Windows 子系统的通用功能端口,这样一旦这个进程发生"二次机会异常(second-chance exception)",Windows 子系统就能收到消息(更多异常处理相关内容,参见第二部分第8章)。
- 如果要以新进程为根节点创建一个新的进程组(
CreateProcess的CREATE_NEW_PROCESS_GROUP标志),会在CSR_PROCESS里设置相应标志。进程组在向一组共享同一控制台的进程发送控制事件时很有用——更多信息可以参考 Windows SDK 中CreateProcess和GenerateConsoleCtrlEvent的文档。 - 分配并初始化 Csrss 的线程结构体(
CSR_THREAD)。 CsrCreateThread把这个线程插入到进程的线程列表中。- 该会话中的进程计数递增。
- 进程的关闭级别被设为
0x280,这是默认的进程关闭级别(更多信息参考 Windows SDK 中SetProcessShutdownParameters的文档)。 - 新的 Csrss 进程结构体被插入到 Windows 子系统级别的进程列表中。
Csrss 完成上述步骤之后,CreateProcessInternalW会检查这个进程是否是以提权方式运行的(也就是说,它是通过ShellExecute执行的,在向用户展示同意对话框之后,由 AppInfo 服务完成提权的)。这也包括检查这个进程是否是安装程序。如果是,会打开这个进程的令牌,并打开虚拟化标志,使得这个应用会被虚拟化(更多关于 UAC 和虚拟化的信息,参见第7章)。如果应用包含提权相关的兼容性垫片,或者在清单中要求了提权级别,进程会被销毁,同时向 AppInfo 服务发送一个提权请求。
需要注意的是,大多数这类检查都不会对保护进程执行。因为保护进程必须是为 Windows Vista 或更高版本设计的,它们没有理由需要提权、虚拟化或应用兼容性检查和处理。此外,如果允许类似垫片引擎那样的机制,用它常规的钩子和内存补丁技术去动一个保护进程,那如果有人能想办法插入任意垫片来篡改保护进程的行为,就会造成安全漏洞。而且,因为垫片引擎是由父进程安装的,父进程可能根本没有权限访问它的子保护进程,所以哪怕是合法的垫片操作在这里也无法工作。
七、阶段6:启动初始线程执行
到这一步,进程的环境已经确定,供它的线程使用的资源已经分配好,进程已经拥有了一个线程,而且 Windows 子系统也已经知晓这个新进程的存在。除非调用方指定了 CREATE_SUSPENDED 标志,否则初始线程现在会被恢复(resume),开始运行,并执行剩余的、发生在新进程自己的上下文里的进程初始化工作(这正是阶段7要讲的)。
八、阶段7:在新进程上下文中执行进程初始化
新线程的"人生"始于运行内核态的线程启动例程 KiStartUserThread。这个例程把线程的 IRQL 级别从延迟过程调用(DPC)级别降到 APC 级别,然后调用系统的初始线程例程 PspUserThreadStartup,把用户指定的线程起始地址作为参数传给它。PspUserThreadStartup 执行以下动作:
- 在 x86 架构上安装一条异常链(其他架构在这方面的做法不同,详见第二部分第8章)。
- 把 IRQL 降到
PASSIVE_LEVEL(0,也是唯一允许用户代码运行的 IRQL 级别)。 - 禁用运行时替换主进程令牌的能力。
- 如果这个线程在启动阶段就(因为某种原因)被杀死了,它会被终止,不再做后续任何事。
- 基于内核态数据结构中已有的信息,在 TEB 中设置区域设置 ID(locale ID)和理想处理器,然后检查线程创建是否真的失败了。
- 调用
DbgkCreateThread,检查是否已经为这个新进程发送过镜像通知。如果还没有,且通知功能是开启的,会先为这个进程发送一次镜像通知,再为Ntdll.dll的镜像加载发送一次。补充说明:这一步之所以放在这个阶段,而不是镜像刚被映射的时候,是因为那个时候进程 ID(内核回调需要用到)还没有被分配。
- 完成上面的检查后,还会检查这个进程是否是一个"被调试对象(debuggee)"。如果是,且还没发送过调试器通知,会通过调试对象(如果存在的话)发送一条创建进程消息,把进程启动调试事件(
CREATE_PROCESS_DEBUG_INFO)发给对应的调试器进程。紧接着会发送一个类似的线程启动调试事件,以及另一个针对Ntdll.dll镜像加载的调试事件。然后DbgkCreateThread会等待调试器的回复(通过ContinueDebugEvent函数)。 - 检查系统是否启用了应用预取(prefetch),如果启用,会调用预取器(以及 Superfetch)去处理该应用对应的预取指令文件(如果存在),把上次运行该进程时头 10 秒内引用过的页面预先加载进来(关于预取器和 Superfetch 的详细内容,参见第5章)。
- 检查
SharedUserData结构体里的系统级 Cookie 是否已经生成。如果没有,会基于系统信息的哈希值(比如已处理的中断数量、DPC 投递次数、页面错误次数、中断时间,加上一个随机数)来生成它。这个系统级 Cookie 会被用于内部指针的编解码——比如堆管理器用它来防范某些类型的利用攻击(更多堆管理器安全相关内容,参见第5章)。 - 如果这是一个安全进程(IUM 进程),会调用
HvlStartSecureThread,把控制权转交给 Secure Kernel 去启动线程执行——这个函数只有在线程退出时才会返回。 - 搭建初始的跳转(thunk)上下文,用来运行镜像加载器的初始化例程(
Ntdll.dll里的LdrInitializeThunk),以及系统级的线程启动桩函数(Ntdll.dll里的RtlUserThreadStart)。这些步骤是通过原地编辑线程的上下文,然后发出一次"从系统服务退出"的操作来完成的——这个操作会加载精心构造好的用户上下文。LdrInitializeThunk例程负责初始化加载器、堆管理器、NLS 表、线程本地存储(TLS)和纤程本地存储(FLS)数组,以及临界区结构体。然后它会加载所有需要的 DLL,并以DLL_PROCESS_ATTACH功能码调用这些 DLL 的入口点。这个函数返回之后,NtContinue会恢复新的用户上下文,返回到用户态——此时线程才真正开始执行。RtlUserThreadStart使用实际镜像入口点的地址和启动参数,去调用应用程序的入口点。这两个参数其实早就已经被内核压入了栈中。这一整套看起来颇为绕的流程,实际上是为了达成两个目的:
- 让
Ntdll.dll内部的镜像加载器能在幕后把进程内部搭建好,这样其他用户态代码才能正常运行(否则它连堆、线程本地存储这些基本设施都没有); - 让所有线程都从同一个通用例程开始运行,这样就可以把它们统一包裹在异常处理机制里——如果线程崩溃了,
Ntdll.dll能感知到,并调用Kernel32.dll内部的"未处理异常过滤器"。这套机制还能协调线程从启动例程返回时的退出流程,并执行各种清理工作。应用程序开发者也可以调用SetUnhandledExceptionFilter来添加自己的未处理异常处理代码。
九、动手实验:跟踪进程启动全过程
前面详细讲完了进程启动涉及的各种操作之后,现在用 Process Monitor 来实际看看这个过程中都访问了哪些文件 I/O 和注册表键。
这个实验不会展示前面讲过的所有内部步骤的完整画面,但你能看到系统里几个重要部分实际运作的样子,特别是预取(prefetch)和 Superfetch、镜像文件执行选项及其他兼容性检查,以及镜像加载器的 DLL 映射过程。
实验对象是一个很简单的可执行文件——Notepad.exe,从命令提示符窗口(Cmd.exe)里启动它。这里的关键是要同时看 Cmd.exe 内部和 Notepad.exe 内部发生的事情——回想一下,很多用户态的工作是由 CreateProcessInternalW 完成的,而这个函数是在内核创建新进程对象之前,由父进程调用的。
9.1 实验步骤
第一步:设置过滤器。
给 Process Monitor 添加两个过滤器:一个针对 Cmd.exe,一个针对 Notepad.exe——这应该是唯一被包含的两个进程。要确保这两个进程当前没有正在运行的实例,这样才能确保看到的是正确的事件。截图里可以看到过滤器窗口的样子:一系列"进程名 是 xxx 则 包含/排除"的规则,其中 cmd.exe 和 notepad.exe 被设为"包含(Include)“,而 Procexp.exe、Autoruns.exe、Procmon64.exe 这些工具本身则被设为"排除(Exclude)”,避免这些监控工具自己的活动干扰观察结果。
第二步:先关闭事件捕获,启动命令提示符。
确认事件日志记录当前是关闭的(打开 File 菜单,取消勾选 Capture Events),然后启动命令提示符。
第三步:开启事件捕获,输入命令。
开启事件日志记录(打开 File 菜单选择 Event Logging,或按 Ctrl+E,或点击工具栏上的放大镜图标),然后输入 Notepad.exe 并回车。在一台典型的 Windows 系统上,你应该会看到大约 500 到 3500 个事件出现。
从截图(Image 4)里可以看到 cmd.exe 依次做了一系列 CreateFile/QueryDirectory 操作——它在按顺序尝试不同的路径和扩展名去查找 notepad:先查当前用户目录、再查一些"Microsoft Online Services"相关的公共目录、最后才在 C:\Windows\System32 下依次尝试 notepad.*、notepad.COM、notepad.EXE。这其实反映了 Windows 命令行解析可执行文件时的标准查找顺序:按 PATHEXT 环境变量列出的扩展名顺序,逐个目录逐个扩展名去试探,直到找到为止。最终它找到了 C:\Windows\System32\notepad.exe,紧接着出现了一次 RegOpenKey,路径正是 HKLM\SOFTWARE\MICROSOFT\WINDOWS NT\CURRENTVERSION\...(对应我们前面讲的 IFEO 键查询),然后是 Process Create 和 Process Start 事件。
第四步:停止捕获,聚焦关注的列。
停止捕获,隐藏 Sequence 和 Time of Day 这两列,把注意力集中在关心的信息上。
9.2 从 Stack(调用栈)确认这是内核态还是用户态
跟 Process Monitor 日志里其他任何事件一样,你可以通过双击 RegOpenKey 事件、切换到 Stack 标签页,来查看这个操作到底是在用户态还是内核态完成的,以及具体是哪些例程完成的。
从 Image 5 展示的调用栈来看(这是 64 位 Windows 10 上的标准调用栈),从下往上(也就是调用顺序从早到晚)依次是:
U 23 ntdll.dll RtlUserThreadStart + 0x34
U 22 KERNEL32.DLL BaseThreadInitThunk + 0x22
U 21 cmd.exe _delayLoadHelper2 + 0x2dc
U 20 cmd.exe CmpType + 0x21a1
U 19 cmd.exe Dispatch + 0xa5
U 18 cmd.exe FindFixAndRun + 0x36d
U 17 cmd.exe ECWork + 0xa3
U 16 cmd.exe ExecPgm + 0x228
U 15 KERNEL32.DLL CreateProcessWStub + 0x53
U 14 KERNELBASE.dll CreateProcessW + 0x66
U 13 KERNELBASE.dll CreateProcessInternalW + 0x14e1
U 12 ntdll.dll NtCreateUserProcess + 0x14
─────────────────用户态/内核态分界────────────────
K 11 ntoskml.exe KiSystemServiceCopyEnd + 0x13
K 10 ntoskml.exe NtCreateUserProcess + 0x620
K 9 ntoskml.exe PspAllocateProcess + 0x4e1
K 8 ntoskml.exe RtlpOpenImageFileOptionsKey + 0xb0
K 7 ntoskml.exe KiServiceLinkage
K 6 ntoskml.exe KiSystemServiceCopyEnd + 0x13
K 5 ntoskml.exe NtOpenKey + 0x12
K 4 ntoskml.exe CmOpenKey + 0x2a6
K 3 ntoskml.exe ObOpenObjectByNameEx + 0x1ec
K 2 ntoskml.exe ObpLookupObjectName + 0x776
K 1 ntoskml.exe CmpParseKey + 0x24e
K 0 ntoskml.exe CmpCallCallBacks + 0x3b0
(U 代表用户态帧,K 代表内核态帧)
这个调用栈完美印证了前面文字里讲的流程:cmd.exe 里的命令处理逻辑(FindFixAndRun、ExecPgm)最终调用了 CreateProcessW,一路走到 CreateProcessInternalW,再经 ntdll.dll 的 NtCreateUserProcess 切换进内核态(图中那条分界线就是用户态/内核态的边界),内核里的 PspAllocateProcess 正是我们前面讲的"阶段3"入口——它调用 RtlpOpenImageFileOptionsKey 去打开 IFEO 键,这正好对应前面讲的**"阶段2第7步:检查 IFEO 键下是否有 Debugger 值"这个环节,最终这一路查找注册表键的操作,落到了 CmOpenKey/NtOpenKey 上——这正是我们双击查看的这条 RegOpenKey 事件的来源。
这也说明了:这个栈已经走到了进程创建流程里"内核态"执行的那部分(通过 NtCreateUserProcess),而负责这次检查的辅助例程正是 PspAllocateProcess。
因为 Notepad.exe 没有关联任何 Image File Execution Options,所以进程会照原样被创建**,不会被重定向到别的镜像。
9.3 三组值得关注的事件
按顺序往下看进程和线程创建完成之后的事件,你会注意到三组事件:
- 一次简单的应用兼容性标志检查,用来让用户态进程创建代码知道,是否需要通过垫片引擎去查询应用兼容性数据库。
- 多次对 SxS(搜索 Side-By-Side)、Manifest、MUI/Language 相关键的读取——这些都是前面提到过的"程序集框架(assembly framework)"的一部分。
- 对一个或多个
.sdb文件的文件 I/O——这些正是系统里的应用兼容性数据库。这部分 I/O 正是用来检查这个应用是否需要调用垫片引擎的地方。因为记事本是个"行为良好"的微软自家程序,所以它不需要任何垫片。
Image 6 展示的正是紧随其后、在 Notepad 进程本身内部发生的下一系列事件,这些是内核态的用户态线程启动包装函数触发的动作。截图中前两条记录是notepad.exe和ntdll.dll的 Load Image(镜像加载)调试通知消息——这两条消息,只有在代码已经运行在 Notepad 自己的进程上下文里(而不是命令提示符的上下文里)时,才能被生成,这正好对应我们前面讲的阶段7第6步。
9.4 预取器与后续的 DLL 加载
紧接着,预取器(prefetcher)开始工作,去查找 Notepad 是否已经生成过预取数据库文件(关于预取器的详细内容,参见第5章)。在一台 Notepad 至少运行过一次的系统上,这个数据库文件会存在,预取器就会开始执行其中指定的操作。如果是这种情况,往下滚动,你会看到多个 DLL 被读取和查询。
这里有个值得留意的细节:跟典型的 DLL 加载方式不同(典型方式是用户态镜像加载器查看导入表、或应用手动加载 DLL),这些事件是由预取器生成的——预取器早就已经知道 Notepad 会用到哪些库了。
从截图(Image 7)里可以看到,notepad.exe 先做了 Load Image 加载自身和 ntdll.dll,紧接着读取了它的预取文件(C:\Windows\Prefetch\NOTEPAD.EXE-D8414F97.pf),然后才开始一连串的注册表查询(Segment Heap 相关设置)、文件创建,以及按预取文件指示的顺序去加载 kernel32.dll、KernelBase.dll 等一系列 DLL——这个顺序正是预取器为了加速启动而"抢跑"预读的结果。
真正典型意义上的镜像加载接下来才会发生,你会看到类似下面这样的事件。这些事件现在是由运行在用户态的代码生成的——这段代码是内核态包装函数完成工作之后才被调用的。因此,这些是来自 LdrpInitializeProcess(由 LdrInitializeThunk 为进程里的第一个线程调用)的第一批事件。
你可以通过查看这些事件的调用栈来自行确认这一点——比如 kernel32.dll 镜像加载事件的调用栈(Image 8):
U 16 ntdll.dll LdrInitializeThunk + 0xe
U 15 ntdll.dll _LdrpInitialize + 0x4a8b9
U 14 ntdll.dll LdrpInitializeProcess + 0x187b
U 13 ntdll.dll LdrLoadDll + 0x114
U 12 ntdll.dll LdrpLoadDll + 0xf2
U 11 ntdll.dll LdrpLoadDllInternal + 0x12f
U 10 ntdll.dll LdrpFindOrPrepareLoadingModule + ...
U 9 ntdll.dll LdrpLoadKnownDll + 0xf3
U 8 ntdll.dll LdrpMapDllWithSectionHandle + 0...
U 7 ntdll.dll LdrpMapImage + 0x72
U 6 ntdll.dll LdrpMapViewOfSection + 0xbe
U 5 ntdll.dll NtMapViewOfSection + 0x14
─────────────────用户态/内核态分界────────────────
K 4 ntoskml.exe KiSystemServiceCopyEnd + 0x13
K 3 ntoskml.exe NtMapViewOfSection + 0x2d5
K 2 ntoskml.exe MiMapViewOfSection + 0x336
K 1 ntoskml.exe MiMapViewOfImageSection + 0x6fc
K 0 ntoskml.exe PsCallImageNotifyRoutines + 0x123
这条调用栈清楚地展示了 DLL 加载的完整链路:从 LdrInitializeThunk 开始,一路调用 LdrpInitializeProcess(这正是我们前面反复提到的、初始化加载器的那个关键函数),再到 LdrLoadDll → LdrpLoadDllInternal → LdrpFindOrPrepareLoadingModule → LdrpLoadKnownDll → LdrpMapDllWithSectionHandle → LdrpMapImage → LdrpMapViewOfSection,最终切换进内核态的 NtMapViewOfSection,走到 MiMapViewOfSection/MiMapViewOfImageSection,并触发 PsCallImageNotifyRoutines(也就是前面讲的"镜像加载通知回调")。
更多的事件会由这个例程和它的辅助函数陆续生成,直到你最终看到由 WinMain 函数(也就是 Notepad 内部,开发者自己写的代码开始接管)生成的事件为止。要详细描述进程执行期间涉及的所有事件和用户态组件,篇幅足够写满整个章节,所以对更多事件的进一步探索,就留给读者自己去动手试验了。
十、用一张图梳理"谁在哪里跑"
最后用一张简化的 ASCII 图,把整个 CreateProcess 流程里"代码运行在谁的上下文里"这件事串起来看:
父进程(比如 cmd.exe)用户态
┌───────────────────────────────────────────┐
│ CreateProcessInternalW 阶段1 验证参数 │
│ NtCreateUserProcess(切入内核) 阶段2/3/4 │
└───────────────────┬───────────────────────┘
│ 系统调用切换
▼
内核态 ntoskrnl.exe
┌───────────────────────────────────────────┐
│ PspAllocateProcess 搭建EPROCESS/地址空间/PEB │
│ PspAllocateThread 搭建初始线程(挂起状态) │
└───────────────────┬───────────────────────┘
│ 返回用户态(仍在父进程里)
▼
父进程用户态(继续)
┌───────────────────────────────────────────┐
│ CreateProcessInternalW 阶段5 与Csrss通信 │
│ 向Csrss发消息 -> CsrCreateProcess 处理 │
└───────────────────┬───────────────────────┘
│ 阶段6 恢复初始线程
▼
子进程(比如 notepad.exe)用户态与内核态交替
┌───────────────────────────────────────────┐
│ KiStartUserThread -> PspUserThreadStartup │
│ LdrInitializeThunk 加载器初始化 加载DLL │
│ RtlUserThreadStart -> WinMain 真正的程序代码 │
└───────────────────────────────────────────┘
这张图想强调的是:父进程用户态代码 → 内核态处理 → 父进程用户态继续(与 Csrss 通信)→ 子进程自己的用户态/内核态交替,这四段完全不是同一个执行上下文,一个进程创建请求实际上"跨越"了好几个不同的执行环境,这也是为什么前面 Process Monitor 实验里,要同时盯着 cmd.exe 和 notepad.exe 两个进程才能看全整个流程。
十一、小结
CreateProcess 的七个阶段,本质上可以按"谁负责""干什么"划分成三条主线:
- 用户态的
Kernel32.dll(阶段1、2的一部分、5、6):负责参数解析、属性转换、决定到底该跑什么镜像(是真 Windows EXE 还是要转给Ntvdm.exe/Cmd.exe代跑)、和 Csrss 打交道办理 Windows 子系统特有的登记手续; - 内核态的执行体(阶段2的核心部分、3、4):真正搭建
EPROCESS/KPROCESS/PEB/TEB这些数据结构,创建地址空间、创建初始线程; - 子进程自己的执行上下文(阶段7):加载器完成 DLL 加载、堆初始化等收尾工作,最终把控制权交给开发者写的
WinMain/main。
这套设计把"通用的执行体进程对象创建"和"Windows 子系统特有的语义"清晰地分开——这正是 Windows 多环境子系统架构能够成立的原因:理论上除了 Win32 子系统,还可以有别的子系统用同一套执行体进程对象创建机制,只是阶段5那部分和自己的子系统进程打交道的逻辑不同而已。而 Process Monitor 实验则从实践角度印证了这一整套理论描述——从cmd.exe一路查找notepad.exe、到内核里检查 IFEO 键、再到notepad.exe自己内部由预取器和加载器接力完成的 DLL 加载,每一步都能在调用栈里找到对应的例程名称。
进程终止详解
一、先理解一件事:进程是一个"容器",也是一道"边界"
要理解进程是怎么被终止的,得先建立一个基础认知:进程本质上是一个容器,同时也是一道边界。
这句话具体是什么意思呢?意思是:一个进程使用的资源,不会自动对其他进程可见。所以如果两个进程之间要传递信息,必须依赖某种进程间通信(IPC)机制才行——不存在"顺手就能看到"这回事。
正因为有这道边界的存在,一个进程不可能意外地往另一个进程的内存里写入任意字节。如果真要这么做,必须显式调用类似 WriteProcessMemory 这样的函数才行。而要让这个调用真正生效,还得先显式地打开一个带有正确访问掩码(PROCESS_VM_WRITE)的句柄——而这个句柄能不能拿到,取决于目标进程是否愿意授予这个权限,不是想拿就能拿到的。
这种进程之间天然的隔离性,还带来了另一个好处:如果某个进程发生了异常,这个异常不会波及到其他进程。最坏的结果,也就是这一个进程自己崩溃了,而系统其余部分依然完好无损。这也是操作系统"进程"这个抽象概念最核心的价值之一——故障隔离。
二、进程退出的两种方式:体面退出 vs 强行终止
2.1 体面退出:ExitProcess
一个进程可以通过调用 ExitProcess 函数来体面地(gracefully)退出。
对于很多程序来说(具体取决于链接器设置),当第一个线程的 main 函数返回之后,进程的启动代码会代表这个进程去调用 ExitProcess——也就是说,即便你自己没写这行调用,编译器/链接器生成的启动代码也会替你把这一步补上。
这里"体面"这个词具体指的是什么呢?指的是:加载进这个进程里的所有 DLL,都会得到一次"知会"的机会——系统会调用它们的 DllMain 函数,并传入 DLL_PROCESS_DETACH 这个通知码,让每个 DLL 有机会做一些收尾工作(比如释放自己申请的资源、把缓冲的数据写回磁盘等等)。ExitProcess 只能由进程自己调用,要求自己退出——它不是一个"外部指挥别人退出"的函数。
2.2 不体面的终止:TerminateProcess
如果需要从进程外部强行结束一个进程,可以用 TerminateProcess 函数。举个例子,Process Explorer 和任务管理器,当用户在界面上点"结束进程"时,用的正是这个函数。
TerminateProcess 的调用前提是:调用方必须已经拿到一个带有 PROCESS_TERMINATE 访问掩码的进程句柄——而这个访问权限同样不是想给就给的,目标进程可以拒绝授予。这正是为什么有些进程(比如 Csrss.exe)很难被(或者说根本不可能被)结束——因为普通用户根本拿不到带有这个访问掩码的句柄。
这里"不体面(ungraceful)"具体是什么意思呢?意思是:DLL 完全没有机会执行任何代码(DLL_PROCESS_DETACH 根本不会被发送),进程里所有的线程都会被粗暴地、立即终止。这在某些情况下会导致数据丢失——比如某个文件缓存本来还有数据没来得及写回磁盘,这下就没机会去做这个"刷盘"操作了。
用一张对比表把两者的区别理一遍:
| 对比项 | ExitProcess(体面退出) |
TerminateProcess(强行终止) |
|---|---|---|
| 发起方 | 进程自己 | 其他进程(需要合适的访问权限) |
| 所需权限 | 无需额外权限(自己退出自己) | 需要目标进程句柄的 PROCESS_TERMINATE 访问掩码 |
| DLL 通知 | 会收到 DLL_PROCESS_DETACH 通知,有机会清理资源 |
不会收到任何通知,DLL 没有清理机会 |
| 线程处理 | 线程走完正常的退出流程 | 所有线程被立即、粗暴地终止 |
| 可能后果 | 资源被妥善释放 | 可能造成数据丢失(比如缓存没来得及刷盘) |
三、无论怎么"死",进程占用的核心资源都不会泄漏
不管一个进程是通过哪种方式退出的(ExitProcess 也好,TerminateProcess 也好,甚至是异常崩溃),有一个底线是绝对不会被打破的:永远不会有资源泄漏。具体来说:
- 进程的所有私有内存都会被内核自动释放;
- 进程的地址空间会被销毁;
- 进程持有的所有内核对象句柄都会被关闭。
这是操作系统层面提供的强保证——不管进程自己写得多烂、退出得多"粗暴",这些底层资源的回收由内核来兜底,不需要也不可能靠进程自己"自觉"来完成。
3.1 进程"死了"但没完全"消失"的情况
这里有个细节值得注意:如果外部仍然持有这个进程的打开句柄(也就是说,EPROCESS 结构体依然存在于内存里,还没被销毁),那么其他进程依然可以通过这个句柄,获取一些进程管理相关的信息——比如调用 GetExitCodeProcess 去查询这个进程的退出码。
换句话说,进程"实际执行结束"和"这个进程对象在内核里彻底消失",是两件不完全同步的事。只有等到所有指向这个进程的句柄都被关闭之后,EPROCESS 结构体才会被真正销毁——到那一刻,这个进程才算是彻彻底底、什么痕迹都不剩了。
用一张简单的状态图理清这个生命周期:
这也解释了为什么像 Process Explorer 这类工具,能在进程结束之后,依然短暂地在列表里看到它、查到它的退出码——因为工具本身可能还持有一个句柄没释放,导致 EPROCESS 还"苟延残喘"着。
四、一个容易被忽视的例外:第三方驱动的内核内存
前面说"进程退出绝不会泄漏资源",这个保证其实是有边界的——具体来说,它保证的是进程自己直接拥有的资源(私有内存、地址空间、自己创建的句柄)。但如果第三方驱动代表某个进程,在内核内存里做了分配——比如响应某次 IOCTL 调用,或者仅仅是因为收到了一次"进程通知"就顺手分配了一块内存——释放这块内存的责任在驱动自己身上,操作系统不会替它兜底。
也就是说:Windows 不会去追踪或清理"进程拥有"的内核内存(唯一的例外是:因为进程创建了句柄而关联到某个对象的内存,这部分是会被清理的,因为它挂在句柄这条线上)。但凡是驱动自己在内核里"私下"分配、且没有通过句柄机制关联起来的内存,如果驱动自己不管,这块内存就真的会一直泄漏下去。
驱动通常是怎么知道"该收拾东西了"的呢?典型的做法是:
- 通过
IRP_MJ_CLOSE或IRP_MJ_CLEANUP通知,得知某个设备对象的句柄已经被关闭; - 或者通过进程终止通知,得知某个进程已经终止了。
驱动收到这些通知之后,就该主动去释放自己之前分配、且跟这个进程/句柄相关联的那部分内核内存——如果驱动开发者疏忽了这一步,就会造成实实在在的内核内存泄漏,而且这种泄漏不会随着进程的终止自动消失,会一直累积在系统里,直到重启才能清空。
(关于IOCTL更详细的内容,会在讲 I/O 系统那一章展开。)
五、小结
进程终止这件事,可以从三个层次去理解:
- 发起方式的两条路:
ExitProcess(自己走,DLL 有机会体面收尾)和TerminateProcess(被别人强行叫停,DLL 完全没有反应时间,可能丢数据)——二者的核心差别就在于"有没有给 DLL 发DLL_PROCESS_DETACH通知"这一点上。 - 内核层面的强保证:不管走哪条路退出,进程自己的私有内存、地址空间、内核对象句柄,内核都会自动、彻底地回收,绝不会泄漏——但要注意"进程退出"和"
EPROCESS对象彻底销毁"并不是同一时刻,只要还有外部句柄开着,这个进程的"骨架"就还在,还能被查询退出码之类的信息。 - 一个不受内核保护的灰色地带:第三方驱动如果在内核里私自分配了内存,且没有通过标准的句柄/对象机制关联起来,那么这块内存的生死完全取决于驱动自己有没有正确处理清理通知——这是操作系统设计上刻意留给驱动开发者自己负责的一块责任田,不是内核的疏漏,而是"谁分配、谁负责"这条基本原则的自然延伸。
从这套设计能看出 Windows 进程模型的一个核心思路:把"进程"这个抽象打造成一个尽可能不泄漏、边界清晰的容器,让应用层开发者可以放心地依赖"进程死了资源就干净了"这个假设去写代码;但同时也诚实地划出了一条边界——内核只对它自己能追踪到的资源负责,那些绕开标准机制、由驱动自行其是分配的内存,责任还是要落回驱动开发者身上。
镜像加载器(Image Loader)详解
一、镜像加载器是什么,为什么它这么重要
前面讲完了内核怎么创建进程对象、怎么完成各种内核相关的初始化——但这些工作本身并不会让应用程序真正跑起来,它们只是把进程的上下文和环境搭建好而已。事实上,跟驱动(内核态代码)不一样,应用程序是跑在用户态的。所以真正让应用跑起来的初始化工作,绝大部分是在内核之外完成的,负责这项工作的组件叫镜像加载器(image loader),内部也常被简称为 Ldr。
镜像加载器住在用户态的系统 DLL Ntdll.dll 里,而不是内核库里。所以它的行为跟普通 DLL 里的代码没什么两样——同样受内存访问和安全权限的限制约束。它之所以特殊,是因为两个"保证":
- 它一定会存在于正在运行的进程里(
Ntdll.dll永远会被加载); - 它是新进程用户态执行的第一段代码。
正因为加载器跑在真正的应用代码之前,它对用户和开发者来说通常是"隐形"的。不过,虽然加载器的初始化任务本身是隐藏的,程序在运行期间其实经常会跟加载器的接口打交道——比如加载/卸载 DLL、查询某个 DLL 的基址,这些操作背后都在调用加载器。
1.1 加载器主要负责哪些任务
- 初始化应用的用户态状态,比如创建初始堆、搭建**线程本地存储(TLS)和纤程本地存储(FLS)**槽位;
- 解析应用的导入表(IAT),找出它依赖的所有 DLL(然后递归地解析每个 DLL 自己的导入表),接着解析这些 DLL 的导出表,确保要用的函数确实存在(有些特殊的"转发条目"还能把一个导出重定向到另一个 DLL);
- 运行期动态加载/卸载 DLL(包括按需加载),并维护一份所有已加载模块的清单(模块数据库);
- 处理清单文件(manifest),支撑 Windows Side-by-Side(SxS)、**多语言用户界面(MUI)**文件和资源;
- 读取应用兼容性数据库,看是否需要加载垫片(shim)引擎 DLL;
- 支持 API Set 和 API 重定向——这是 One Core 架构能支持通用 Windows 平台(UWP)应用的核心机制之一;
- 通过 SwitchBack 机制启用动态运行时兼容性缓解措施,同时对接垫片引擎和 Application Verifier 机制。
可以看出,这里面大多数任务都是应用能正常跑起来的先决条件——没有这些工作,从调用外部函数到使用堆,一切都会立即失败。
1.2 加载器如何"隐身"于调用栈之外
进程创建完成之后,加载器会调用一个特殊的原生 API NtContinue,基于栈上的一个异常帧来"继续执行"——这跟异常处理程序的工作方式一模一样。这个异常帧是内核(前面讲进程创建流程时提到过)构建的,里面包含了应用程序真正的入口点。
正因为加载器不是通过标准的函数调用或跳转进入正在运行的应用的,所以你在某个线程的调用栈里,永远不会看到加载器的初始化函数出现在调用树里——它是通过"异常返回"这种特殊机制切换过去的,而不是普通的函数调用链。
二、动手实验:观察镜像加载器的工作过程
这个实验会用到 **Global Flags(Gflags.exe)**里的一个调试功能,叫 loader snaps(加载器快照),让你在调试应用启动过程时,能看到加载器输出的调试信息。
2.1 操作步骤
- 从 WinDbg 安装目录启动
Gflags.exe,点击 Image File 选项卡。 - 在 Image 字段里输入
Notepad.exe,按 Tab 键,这会启用一系列选项。勾选 Show Loader Snaps 选项,点击 OK 或 Apply。 - 启动 WinDbg,打开 File 菜单,选择 Open Executable,找到
c:\windows\system32\notepad.exe并启动它。你应该会看到几屏类似下面这样的调试信息。
下面这段调试输出,逐行拆开看:
0f64:2090 @ 02405218 - LdrpInitializeProcess - INFO: Beginning execution of notepad.exe (C:\WINDOWS\notepad.exe)
Current directory: C:\Program Files (x86)\Windows Kits\10\Debuggers\
Package directories: (null)
0f64:2090 @ 02405218 - LdrLoadDll - ENTER: DLL name: KERNEL32.DLL
0f64:2090 @ 02405218 - LdrpLoadDllInternal - ENTER: DLL name: KERNEL32.DLL
0f64:2090 @ 02405218 - LdrpFindKnownDll - ENTER: DLL name: KERNEL32.DLL
0f64:2090 @ 02405218 - LdrpFindKnownDll - RETURN: Status: 0x00000000
0f64:2090 @ 02405218 - LdrpMinimalMapModule - ENTER: DLL name: C:\WINDOWS\System32\KERNEL32.DLL
ModLoad: 00007fff'5b4b0000 00007fff'5b55d000 C:\WINDOWS\System32\KERNEL32.DLL
0f64:2090 @ 02405218 - LdrpMinimalMapModule - RETURN: Status: 0x00000000
0f64:2090 @ 02405218 - LdrpPreprocessDllName - INFO: DLL api-ms-win-core-rtlsupport-l1-2-0.dll was redirected to C:\WINDOWS\SYSTEM32\ntdll.dll by API set
0f64:2090 @ 02405218 - LdrpFindKnownDll - ENTER: DLL name: KERNELBASE.dll
0f64:2090 @ 02405218 - LdrpFindKnownDll - RETURN: Status: 0x00000000
0f64:2090 @ 02405218 - LdrpMinimalMapModule - ENTER: DLL name: C:\WINDOWS\System32\KERNELBASE.dll
ModLoad: 00007fff'58b90000 00007fff'58dc6000 C:\WINDOWS\System32\KERNELBASE.dll
0f64:2090 @ 02405218 - LdrpMinimalMapModule - RETURN: Status: 0x00000000
0f64:2090 @ 02405218 - LdrpPreprocessDllName - INFO: DLL api-ms-win-eventing-provider-l1-1-0.dll was redirected to C:\WINDOWS\SYSTEM32\kernelbase.dll by API set
0f64:2090 @ 02405218 - LdrpPreprocessDllName - INFO: DLL api-ms-win-core-apiquery-l1-1-0.dll was redirected to C:\WINDOWS\SYSTEM32\ntdll.dll by API set
逐行解读这段日志(0f64:2090 是进程ID:线程ID,@ 02405218 是时间戳,可以忽略):
LdrpInitializeProcess - INFO: Beginning execution of notepad.exe:加载器最先打印的一条日志,说明"进程初始化"这个总入口函数LdrpInitializeProcess已经开始执行,并顺带打印出当前目录和包目录信息。LdrLoadDll - ENTER: DLL name: KERNEL32.DLL:加载器开始尝试加载KERNEL32.DLL——这是几乎所有 Windows 程序都会用到的核心库。LdrpLoadDllInternal - ENTER:内部实现函数,真正干活的地方。LdrpFindKnownDll - ENTER/RETURN: Status 0x00000000:先检查这个 DLL 是不是"已知 DLL(Known DLL)"——如果是,直接用系统预先映射好的那份,不用再走完整的搜索路径。0x00000000表示查找成功。LdrpMinimalMapModule - ENTER/RETURN:把这个 DLL 映射进内存,中间那行ModLoad: 00007fff'5b4b0000 00007fff'5b55d000显示了这个 DLL 被映射到的内存地址范围。LdrpPreprocessDllName - INFO: DLL api-ms-win-core-rtlsupport-l1-2-0.dll was redirected to ... ntdll.dll by API set:这一行非常关键,展示了 API Set 重定向的实际效果——当代码试图导入一个名为api-ms-win-core-rtlsupport-l1-2-0.dll的"虚拟 DLL"时,系统实际上把它重定向到了真正实现这些函数的ntdll.dll。这正是后面要详细讲的 API Sets 机制的现场演示。
- 调试器最终会在加载器代码内部某处停下——那是加载器检查是否有调试器附加、并主动触发一个断点的位置。如果按
g键继续执行,你会看到更多加载器的日志消息,最终 Notepad 会真正显示出来。 - 尝试和 Notepad 交互,观察某些操作是如何触发加载器工作的。一个不错的实验是打开"保存/打开"对话框——这能证明加载器不只是在启动时运行一次,而是会持续响应线程发出的请求,这些请求可能导致其他模块被延迟加载(用完之后还可能被卸载)。
三、进程早期初始化:37 个步骤
因为加载器存在于 Ntdll.dll 里——这是一个不关联任何特定子系统的原生 DLL——所以所有进程都遵循相同的加载器行为(只有一些细微差异)。前面详细讲过内核态创建进程的步骤,以及 CreateProcess 函数做的一部分工作;这里要讲的是:一旦第一条用户态指令开始执行,跟任何子系统都无关的、在用户态发生的所有其他工作。
进程启动时,加载器执行以下步骤:
- 检查
LdrpProcessInitialized是否已经被设为 1,或者 TEB 里的SkipLoaderInit标志是否被设置。如果是,跳过所有初始化,并等待三秒,等着某个人来调用LdrpProcessInitializationComplete。这种情况用在 Windows 错误报告(WER)使用**进程反射(process reflection)**时,或者其他不需要加载器初始化的"进程 fork"尝试场景里。 - 把
LdrInitState设为 0,表示"加载器尚未初始化"。同时把 PEB 的ProcessInitializing标志设为 1,把 TEB 的RanProcessInit设为 1。 - 初始化 PEB 里的加载器锁。
- 初始化动态函数表,用于支持 JIT 代码里的栈展开(unwind)/异常处理。
- 初始化可变只读堆段(MRDATA,Mutable Read Only Heap Section),用来存放那些安全相关、不应该被漏洞利用篡改的全局变量。
- 初始化 PEB 里的加载器数据库。
- 初始化进程的国家语言支持(NLS)表(用于国际化)。
- 构建应用程序的镜像路径名。
- 从
.pdata段捕获 SEH 异常处理程序,构建内部异常表。 - 捕获五个关键加载器函数的系统调用跳转桩(thunk):
NtCreateSection、NtOpenFile、NtQueryAttributesFile、NtOpenSection、NtMapViewOfSection。 - 读取应用的缓解选项(这些选项是内核通过导出的变量
LdrSystemDllInitBlock传进来的,第7章会详细讲)。 - 查询应用的 IFEO(Image File Execution Options)注册表键。这会包括全局标志(存在
GlobalFlags里)、堆调试选项(DisableHeapLookaside、ShutdownFlags、FrontEndHeapDebugOptions)、加载器设置(UnloadEventTraceDepth、MaxLoaderThreads、UseImpersonatedDeviceMap)、ETW 设置(TracingFlags)。其他选项还包括MinimumStackCommitInBytes和MaxDeadActivationContexts。作为这一步的一部分,Application Verifier 包及相关 Verifier DLL 会被初始化,**控制流防护(CFG)**选项也会从CFGOptions读取。 - 查看可执行文件头,判断这是不是一个 .NET 应用(通过是否存在 .NET 专属镜像目录来判断),以及是不是一个 32 位镜像。同时向内核查询,确认这是否是一个 Wow64 进程。如果需要,处理一个"仅 32 位 IL(中间语言)"的镜像——这种镜像不需要 Wow64。
- 加载可执行文件的**镜像加载配置目录(Image Load Configuration Directory)**中指定的配置选项。这些选项是开发者编译应用时可以定义的,编译器和链接器也会用它们来实现某些安全和缓解特性(比如 CFG),从而控制可执行文件的行为。
- 最小化地初始化 FLS 和 TLS。
- 为临界区(critical section)设置调试选项;如果启用了相应的全局标志,创建用户态栈追踪数据库;从 IFEO 查询
StackTraceDatabaseSizeInMb。 - 为进程初始化堆管理器,创建第一个进程堆。这会用到前面提到的各种加载配置、镜像文件执行选项、全局标志,以及可执行文件头选项来设置所需参数。
- 如果开启了,启用"堆损坏时终止进程"缓解措施。
- 如果相应的全局标志启用了这个功能,初始化异常派发日志。
- 初始化线程池包,支撑线程池 API——这一步会查询并考虑 NUMA 信息。
- 初始化并转换环境块和参数块,特别是为了支持 Wow64 进程所需的处理。
- 打开
\KnownDlls对象目录,构建"已知 DLL 路径"。对于 Wow64 进程,改用\KnownDlls32。 - 对于商店应用,读取应用模型策略选项——这些选项编码在令牌的
WIN://PKG和WP://SKUID声明里(第7章"AppContainers"一节会详细讲)。 - 确定进程的当前目录、系统路径、默认加载路径(用于加载镜像和打开文件时),以及默认 DLL 搜索顺序的相关规则。这包括读取当前的策略设置,判断这个应用/服务属于通用应用(UWP)、Desktop Bridge(Centennial)、还是 Silverlight(Windows Phone 8)打包应用中的哪一种。
- 为
Ntdll.dll构建第一个加载器数据表条目,插入模块数据库。 - 构建栈展开历史表。
- 初始化并行加载器——用于借助线程池和并发线程,加载所有没有交叉依赖关系的依赖项。
- 为主可执行文件构建下一个加载器数据表条目,插入模块数据库。
- 如果需要,对主可执行镜像进行重定位。
- 如果启用了,初始化 Application Verifier。
- 如果这是一个 Wow64 进程,初始化 Wow64 引擎——这种情况下,64 位加载器会完成自己的初始化,然后 32 位加载器接管控制权,把我们前面列的这一长串步骤,从头重新走一遍(大部分步骤)。
- 如果这是一个 .NET 镜像,验证它,加载
Mscoree.dll(.NET 运行时垫片),取回主可执行文件的入口点(_CorExeMain),覆盖异常记录,把这个地址设为入口点,而不是常规的main函数。 - 初始化进程的 TLS 槽位。
- 对于 Windows 子系统应用,无论进程实际的导入表里有没有,都会手动加载
Kernel32.dll和Kernelbase.dll。按需,用这两个库来初始化 SRP/Safer(软件限制策略)机制,以及捕获 Windows 子系统的线程初始化跳转桩函数。最后,解析这两个库之间特定存在的任何 API Set 依赖。 - 初始化垫片引擎,解析垫片数据库。
- 只要前面扫描过的核心加载器函数没有被系统调用挂钩或"detour"劫持,就启用并行镜像加载器——具体启用的线程数量,取决于通过策略和镜像文件执行选项配置的加载器线程数。
- 把
LdrInitState变量设为 1,意思是"导入正在加载中"。
到这一步,镜像加载器已经准备好开始解析应用可执行文件的导入表,加载编译时动态链接的所有 DLL 了。
四、导入解析:怎么把用到的每个 DLL 找出来、加载起来
无论是 .NET 镜像(导入是通过调用进 .NET 运行时来处理的)还是普通镜像,都要经历下面这个过程。因为每个被导入的 DLL 自己也有导入表,这个操作过去是递归进行的,直到所有 DLL 都被满足、所有要导入的函数都被找到为止。每加载一个 DLL,加载器就为它保存状态信息,构建模块数据库。
在较新版本的 Windows 里,加载器改为提前构建一张依赖关系图,图里的每个节点描述一个单独的 DLL 及其依赖关系,构建出可以并行加载的独立节点。在需要串行化的关键节点上,线程池工作队列会被"排空(drained)",起到同步点的作用——其中一个这样的同步点,就在调用所有静态导入的 DLL 初始化例程之前(这是加载器倒数第二个阶段之一)。完成这一步之后,所有静态 TLS 初始化器才会被调用。最后,对于 Windows 应用来说,在这两步之间,会先调用 Kernel32 的线程初始化跳转桩函数(BaseThreadInitThunk),最后再调用 Kernel32 的进程后初始化例程。
4.1 具体步骤
- 加载进程可执行镜像导入表里引用的每一个 DLL。
- 检查这个 DLL 是否已经被加载过——查模块数据库。如果列表里没找到,加载器打开这个 DLL 文件并把它映射进内存。
- 在映射操作期间,加载器首先查看应该到哪些路径下去找这个 DLL,同时判断这个 DLL 是不是"已知 DLL"——也就是说,系统在启动时是不是已经加载过它,并提供了一个全局的内存映射文件可供直接访问。标准查找算法还可能出现一些偏离,比如通过
.local文件(强制加载器使用本地路径下的 DLL),或者通过清单文件(可以指定一个重定向的 DLL,来保证使用特定版本)。 - DLL 在磁盘上被找到并映射之后,加载器检查内核是否把它加载到了别的地址——这叫重定位(relocation)。如果加载器检测到需要重定位,它会解析 DLL 里的重定位信息,执行所需操作。如果没有重定位信息,DLL 加载会失败。
- 加载器为这个 DLL 创建一个加载器数据表条目,插入数据库。
- DLL 被映射完成之后,对这个 DLL 重复这个过程,解析它自己的导入表及所有依赖。
- 每个 DLL 加载完之后,加载器解析 IAT(导入地址表),查找被导入的具体函数。通常是按名称查找,但也可以按序号(一个索引数字)查找。对每个名称,加载器解析被导入 DLL 的导出表,尝试找到匹配项。如果找不到匹配,操作会被中止。
- 一个镜像的导入表也可以是"绑定的(bound)"——意思是在链接时,开发者已经为外部 DLL 里被导入的函数,静态地分配好了地址。这样就不需要对每个名称都做一次查找,但前提是假设应用要用到的这些 DLL,总是位于相同的地址。由于 Windows 使用地址空间随机化(ASLR,第5章详细讲),对系统应用和库来说,这个前提通常不成立。
- 一个被导入 DLL 的导出表,可以使用一个转发条目(forwarder entry)——意思是这个函数实际上是在另一个 DLL 里实现的。这本质上必须被当作一次导入/依赖来处理——所以解析完导出表之后,转发条目引用的每个 DLL 也会被加载,加载器会回到第1步重新走一遍这套流程。
所有被导入的 DLL(以及它们自己的依赖/导入)都加载完成、所有需要导入的函数都被查找到、所有转发条目也都被加载和处理完之后,这一阶段就算完成了:应用及其各个 DLL 在编译时定义的所有依赖,现在都已经被满足。在运行期间,延迟依赖(叫做 delay load)以及运行时操作(比如调用LoadLibrary)可以调用进加载器,本质上是重复同样的任务。
不过要注意:如果这些步骤是在进程启动期间发生的,一旦失败,就会导致应用启动报错。举个例子,如果试图运行一个应用,它需要的某个函数在当前版本的操作系统里根本不存在,就会弹出类似下面这样的对话框:
┌─────────────────────────────────────────────┐
│ notepad.exe - Entry Point Not Found [X] │
├─────────────────────────────────────────────┤
│ ⊗ The procedure entry point │
│ CreateDialogParamm could not be located │
│ in the dynamic link library USER32.dll. │
│ │
│ [ OK ] │
└─────────────────────────────────────────────┘
这正是你截图里 Image 2 展示的对话框:记事本试图从 USER32.dll 里导入一个叫 CreateDialogParamm 的函数(注意,这里故意打了个错,正常应该是 CreateDialogParam),但这个函数在系统里根本找不到——这正是"导入解析失败"的一个典型例子:加载器解析导入表时,在目标 DLL 的导出表里没能找到匹配的函数名,于是弹出这个错误对话框,应用无法启动。
五、导入解析完成之后的进程初始化
依赖都加载完之后,还有几项初始化任务要完成,才能真正把应用启动完毕。这个阶段,加载器会:
- 这些步骤开始时,把
LdrInitState设为 2,意味着"导入已加载完成"。 - 如果使用像 WinDbg 这样的调试器,这里会触发初始调试器断点——这正是你在前面实验里被要求敲
g继续执行的那个位置。 - 检查这是否是一个 Windows 子系统应用——如果是,
BaseThreadInitThunk函数应该早在"进程早期初始化"步骤里就已经被捕获了。这时它会被调用,并检查是否成功。类似地,TermsrvGetWindowsDirectoryW函数(如果系统支持终端服务,应该也早就被捕获了)现在会被调用,重新设定 System 和 Windows 目录的路径。 - 利用分布式依赖图,递归遍历所有依赖,为镜像所有的静态导入执行初始化例程。这一步会为每个 DLL 调用
DllMain例程(让每个 DLL 有机会执行自己的初始化工作,甚至可能在运行期间加载新的 DLL),同时处理每个 DLL 的 TLS 初始化器。这是应用启动能失败的最后几个阶段之一——如果所有已加载的 DLL 在完成各自的DllMain例程后,没有全部返回成功的返回码,加载器会中止启动应用。 - 如果镜像用到任何 TLS 槽位,调用它的 TLS 初始化器。
- 如果这个模块正在因应用兼容性原因被"打垫片(shimming)",运行垫片引擎的初始化后回调。
- 运行 PEB 里注册的、关联子系统 DLL 的后处理初始化例程。对于 Windows 应用来说,这一步会做一些终端服务专属的检查。
- 到这一步,写入一条 ETW 事件,表明进程已经成功加载。
- 如果存在最小栈提交要求,触碰一下线程栈,强制把已提交的页面换入内存(in-page)。
- 把
LdrInitState设为 3,意味着"初始化完成"。把 PEB 的ProcessInitializing字段重新设回 0。然后,更新LdrpProcessInitialized变量。
六、DLL 名称解析与重定向
6.1 什么是名称解析
名称解析指的是:当调用方没有指定或无法指定唯一文件标识时,系统把一个 PE 格式二进制文件的名称,转换成一个具体物理文件的过程。因为各种目录(应用目录、系统目录等等)的位置不可能在链接时就写死,所以这既包括所有二进制依赖的解析,也包括调用方没有指定完整路径的 LoadLibrary 操作。
在解析二进制依赖时,Windows 基本的应用模型会在一个搜索路径(一份按顺序依次搜索、直到找到匹配基础文件名的位置列表)里查找文件——不过很多系统组件会覆盖这套默认搜索路径机制,以扩展默认的应用模型。"搜索路径"这个概念,其实是命令行时代遗留下来的——那个年代,应用的"当前目录"是一个有实际意义的概念;对现代 GUI 应用来说,这多少有点不合时宜了。
6.2 安全 DLL 搜索模式
不过,把"当前目录"放进这个搜索顺序里,会带来一个安全隐患:攻击者可以在应用的当前目录里,放一个跟系统 DLL 同名的恶意二进制文件,从而覆盖掉本该加载的系统库——这种手法通常叫做"二进制种植(binary planting)"。
为了防范这类安全风险,系统引入了一个叫安全 DLL 搜索模式(safe DLL search mode)的特性,加入到路径搜索计算逻辑中,默认对所有进程启用。在安全搜索模式下,当前目录会被移到三个系统目录之后,最终的路径顺序如下:
| 顺序 | 搜索位置 |
|---|---|
| 1 | 应用程序启动所在的目录 |
| 2 | 原生 Windows 系统目录(例如 C:\Windows\System32) |
| 3 | 16 位 Windows 系统目录(例如 C:\Windows\System) |
| 4 | Windows 目录(例如 C:\Windows) |
| 5 | 应用程序启动时的当前目录 |
| 6 | %PATH% 环境变量指定的任意目录 |
DLL 搜索路径会为每一次后续的 DLL 加载操作重新计算。计算搜索路径所用的算法,跟计算默认搜索路径的算法是同一套,但应用可以通过以下几种方式修改具体的路径元素:
- 用
SetEnvironmentVariableAPI 修改%PATH%变量; - 用
SetCurrentDirectoryAPI 改变当前目录; - 用
SetDllDirectoryAPI 为进程指定一个专门的 DLL 目录——一旦指定了 DLL 目录,它会替换掉搜索路径里的当前目录,而且加载器会忽略这个进程的安全 DLL 搜索模式设置。
调用方还可以通过给LoadLibraryExAPI 传LOAD_WITH_ALTERED_SEARCH_PATH标志,来针对特定的加载操作修改 DLL 搜索路径。当传了这个标志、且传给 API 的 DLL 名称本身是一个完整路径字符串时,DLL 文件所在的那个路径,会取代应用目录参与这次操作的搜索路径计算。注意:如果传的是相对路径,这种行为是未定义的、有潜在危险的。当 Desktop Bridge(Centennial)应用加载时,这个标志会被忽略。
应用还可以给LoadLibraryEx指定其他标志,来代替LOAD_WITH_ALTERED_SEARCH_PATH: LOAD_LIBRARY_SEARCH_DLL_LOAD_DIRLOAD_LIBRARY_SEARCH_APPLICATION_DIRLOAD_LIBRARY_SEARCH_SYSTEM32LOAD_LIBRARY_SEARCH_USER_DIRS
这几个标志分别把搜索顺序限定为只搜索该标志所指的特定目录(或多个目录),也可以按需组合使用来搜索多个位置——比如把应用目录、system32、用户目录组合起来,就等价于LOAD_LIBRARY_SEARCH_DEFAULT_DIRS。此外,这些标志还可以通过SetDefaultDllDirectoriesAPI 全局设置,会影响从那一刻起所有的库加载操作。
6.3 打包应用的特殊规则
搜索路径顺序还有另一种被影响的方式:如果应用是一个打包应用(或者它不是打包服务、也不是老式的 Silverlight 8.0 Windows Phone 应用)。在这些条件下,DLL 搜索顺序不会使用传统的机制和 API,而是被限定为基于包依赖关系图的搜索——当使用 LoadPackagedLibrary API(而不是常规的 LoadLibraryEx)时,同样也是这种情况。这个基于包的依赖图,是根据 UWP 应用清单文件里 <Dependencies> 段落下的 <PackageDependency> 条目计算出来的,能保证不会有任意的 DLL 意外地加载进这个包里。
另外,当一个打包应用被加载时,只要它不是 Desktop Bridge 应用,所有前面提到的可由应用配置的 DLL 搜索路径顺序 API 都会被禁用,只使用默认的系统行为(结合上面说的、大多数 UWP 应用只在包依赖关系里查找)。
6.4 一个残留的安全隐患与应对方案
即便有了安全搜索模式,以及传统应用默认总是先搜索应用目录的路径搜索算法,仍然存在这样一种场景:一个二进制文件可能被从它常规所在的位置,复制到用户可访问的位置(比如从 c:\windows\system32\notepad.exe 复制到 c:\temp\notepad.exe——这个操作不需要管理员权限)。在这种情况下,攻击者可以在跟应用相同的目录下放一个精心构造的恶意 DLL,由于前面那套搜索顺序,这个恶意 DLL 会优先于系统 DLL 被加载——这可以被用来实现持久化,或者以其他方式影响这个应用(如果这个应用具有一定权限,尤其是当用户没意识到文件已被替换、还通过 UAC 把它提权运行时,风险更大)。
为了防御这种情况,进程和/或管理员可以使用一个叫 Prefer System32 Images 的进程缓解策略(第7章详细讲)——顾名思义,它会颠倒前面表格里第 1 条和第 2 条的顺序,让系统目录优先于应用目录被搜索。
七、DLL 名称重定向
在尝试把 DLL 名称字符串解析成一个具体文件之前,加载器会先应用一套 DLL 名称重定向规则。这些重定向规则用来扩展或覆盖 DLL 命名空间的部分内容(这个命名空间通常对应 Win32 文件系统命名空间),从而扩展 Windows 应用模型。按应用顺序排列如下:
7.1 MinWin API Set 重定向
API Set 机制的设计目的,是通过引入"契约(contract)"这个概念,让不同版本或版次的 Windows,能够以对应用透明的方式,更换实际导出某个系统 API 的二进制文件。第2章简单提到过这个机制,本节后面会更详细展开。
7.2 .LOCAL 重定向
.LOCAL 重定向机制允许应用把某个 DLL 基础文件名的所有加载请求(不管有没有指定完整路径)都重定向到应用目录下的一份本地副本。实现方式有两种:
- 创建一份 DLL 的副本,文件名是原基础名后面加上
.local(比如MyLibrary.dll.local); - 在应用目录下创建一个名为
.local的文件夹,把本地 DLL 的副本放进去(比如C:\MyApp\.LOCAL\MyLibrary.dll)。
通过.LOCAL机制重定向的 DLL,处理方式跟通过 SxS 重定向的完全一样(见下一条)。加载器只有在这个可执行文件没有关联清单文件(无论是内嵌的还是外部的)时,才会遵循.LOCAL重定向。它默认不启用——要全局启用它,需要在 IFEO 基础键(HKLM\Software\Microsoft\WindowsNT\CurrentVersion\Image File Execution Options)下添加一个 DWORD 值DevOverrideEnable,设为 1。
7.3 Fusion(SxS)重定向
Fusion(也叫 side-by-side,SxS)是 Windows 应用模型的一个扩展,允许组件通过嵌入名为清单(manifest)的二进制资源,来表达更详细的二进制依赖信息(通常是版本信息)。Fusion 机制最早是为了让应用能加载正确版本的 Windows 通用控件包(comctl32.dll)——那个库后来被拆分成了多个可以并存安装的版本;此后其他二进制文件也用同样的方式做了版本管理。从 Visual Studio 2005 开始,用微软链接器构建的应用会用 Fusion 来定位合适版本的 C 运行时库;而 Visual Studio 2015 及以后的版本,则改用 API Set 重定向来实现"通用 CRT"这个理念。
Fusion 运行时工具通过 Windows 资源加载器,从二进制文件的资源段里读取内嵌的依赖信息,并把这些依赖信息打包进一种叫激活上下文(activation context)的查找结构。系统会分别在开机时和进程启动时,创建系统级和进程级的默认激活上下文;此外,每个线程都关联着一个激活上下文栈,栈顶的激活上下文结构被认为是"当前生效"的那个。每线程的激活上下文栈,既可以通过 ActivateActCtx 和 DeactivateActCtx API 显式管理,也会在某些特定时刻被系统隐式管理——比如当一个带有内嵌依赖信息的二进制文件的 DLL 主例程被调用时。当发生一次 Fusion DLL 名称重定向查找时,系统会先在线程激活上下文栈栈顶的激活上下文里查找重定向信息,然后是进程和系统激活上下文;如果存在重定向信息,就使用激活上下文里指定的文件标识来执行这次加载操作。
7.4 Known DLL 重定向
Known DLLs(已知 DLL)是一种机制,把特定的 DLL 基础名映射到系统目录下的文件,防止这个 DLL 被别的位置的替代版本替换掉。
DLL 路径搜索算法有个边界情况值得一提:针对 64 位和 Wow64 应用的 DLL 版本检查。如果找到了一个基础名匹配的 DLL,但随后发现它是为错误的机器架构编译的——比如一个 32 位应用里出现了一个 64 位镜像——加载器会忽略这个错误,继续搜索,从"发现这个不匹配文件的那个路径元素"之后的下一个位置开始。这个行为的设计目的,是允许应用在全局 %PATH% 环境变量里同时指定 64 位和 32 位的条目,互不干扰。
八、动手实验:观察 DLL 加载搜索顺序
可以用 Sysinternals 的 Process Monitor 工具来观察加载器是怎么搜索 DLL 的。当加载器尝试解析一个 DLL 依赖时,你会看到它对搜索序列里的每个位置依次发起 CreateFile 调用去探测,直到找到指定的 DLL,或者加载失败为止。
8.1 实验步骤
- 如果 OneDrive 正在运行,从托盘图标关闭它。确保关闭所有正在查看 OneDrive 内容的资源管理器窗口。
- 打开 Process Monitor,添加过滤器,只显示
OneDrive.exe这个进程。可以选择只显示CreateFile操作。 - 转到
%LocalAppData%\Microsoft\OneDrive目录,启动OneDrive.exe或OneDrive Personal.cmd(后者会以"个人版"而不是"企业版"方式启动 OneDrive)。
8.2 从截图里能看出什么
对照你上传的 Image 1(Process Monitor 抓到的 OneDrive.exe 搜索日志),可以印证前面讲的几种搜索/重定向规则的实际效果:
- 来自 KnownDlls 的 DLL 直接从系统位置加载——截图里
ole32.dll就是这种情况,因为它是一个已知 DLL,不需要走完整搜索。 LoggingPlatform.Dll是从一个版本号子目录加载的(截图里是17.3.6743.1212)——这很可能是因为 OneDrive 调用了SetDllDirectory,把搜索请求重定向到了它最新版本对应的目录。MSVCR120.dll(MSVC 运行时 12 版本):先在可执行文件所在目录被搜索,但没找到(NAME NOT FOUND);然后在版本号子目录里被搜索,这次找到了(SUCCESS)。Wsock32.Dll(WinSock):先在可执行文件路径下搜索(没找到),再到版本号子目录下搜索(还是没找到),最终在系统目录(SysWOW64,因为这是个 32 位进程跑在 64 位系统上)里被找到。注意,这个 DLL不是一个已知 DLL,所以它老老实实走完了整个搜索顺序,没有走 KnownDlls 的捷径。
从这几个例子能直观感受到:同一个进程里,不同 DLL 会因为"是否已知 DLL"“是否设置了自定义 DLL 目录”"在哪个位置能找到"这几个因素,走出完全不同的搜索路径长度——有的一步到位,有的要试探好几个目录才能找到,甚至有的压根没找到(比如MSVCP120.dll在应用目录和用户数据目录里都没找到,说明这些位置本身就不该有这个文件,真正的文件在别处,日志里没有完整展示后续搜索)。
九、已加载模块数据库
加载器维护着一份所有已加载模块(包括 DLL 和主可执行文件本身)的清单。这份信息存放在 PEB 里,具体是一个叫 Ldr 的子结构,类型是 PEB_LDR_DATA。在这个结构体里,加载器维护着三条双向链表,都包含相同的信息,但排序方式不同(按加载顺序、按内存位置、或按初始化顺序)。这些链表里的元素,是一种叫加载器数据表条目(LDR_DATA_TABLE_ENTRY)的结构体,存储着每个模块的信息。
9.1 为什么还需要红黑树
因为在链表里做查找,算法复杂度是线性时间——代价比较高,所以加载器还维护了两棵红黑树,这是一种高效的二叉查找树。第一棵按基址排序,第二棵按模块名的哈希值排序。有了这两棵树,查找算法就能跑在对数时间内,效率明显更高,大幅加快了 Windows 8 及以后版本的进程创建性能。
另外出于安全考虑,跟链表不同,这两棵树的根节点在 PEB 里是访问不到的——这让运行在 **ASLR(地址空间布局随机化)**环境下的 shellcode 更难定位到它们(第5章会详细讲 ASLR)。
9.2 加载器数据表条目里的各个字段
| 字段 | 含义 |
|---|---|
BaseAddressIndexNode |
把这个条目链接为"按基址排序的红黑树"里的一个节点 |
BaseDllName / BaseNameHashValue |
模块自身的名字,不带完整路径;第二个字段用 RtlHashUnicodeString 存储它的哈希值 |
DdagNode / NodeModuleLink |
指向"分布式依赖图(DDAG)"数据结构的指针,通过工作线程池并行化依赖加载;第二个字段把这个结构体和它关联的 LDR_DATA_TABLE_ENTRY(属于同一张图的成员)链接起来 |
DllBase |
保存这个模块被加载到的基址 |
EntryPoint |
包含模块的初始例程(比如 DllMain) |
EntryPointActivationContext |
调用初始化器时使用的 SxS/Fusion 激活上下文 |
Flags |
这个模块的加载器状态标志(详见后面的标志含义表) |
ForwarderLinks |
一份链表,记录因为这个模块导出表里的转发条目而被加载的模块 |
FullDllName |
模块的完整限定路径名 |
HashLinks |
进程启动和关闭期间用于快速查找的链表 |
ImplicitPathOptions |
用于存储可以通过 LdrSetImplicitPathOptions API 设置的、或者基于 DLL 路径继承而来的路径查找标志 |
List Entry Links |
把这个条目链接进加载器数据库三条有序链表的各一份 |
LoadContext |
指向 DLL 当前加载信息的指针;通常是 NULL,除非正在被主动加载 |
ObsoleteLoadCount |
这个模块的引用计数(即它被加载过多少次)——这个字段已经不再准确,改由 DDAG 节点结构体来维护 |
LoadReason |
一个枚举值,说明这个 DLL 被加载的原因(动态加载、静态加载、作为转发目标、作为延迟加载依赖等等) |
LoadTime |
这个模块被加载时的系统时间值 |
MappingInfoIndexNode |
把这个条目链接为"按名称哈希排序的红黑树"里的一个节点 |
OriginalBase |
这个模块原本的基址(链接器设定的,还没经过 ASLR 或重定位),能让重定位后的导入条目处理得更快 |
ParentDllBase |
在静态(或转发、或延迟加载)依赖的情况下,保存依赖这个模块的那个 DLL 的地址 |
SigningLevel |
保存这个镜像的签名等级(第二部分第8章有关于代码完整性基础设施的更多信息) |
SizeOfImage |
这个模块在内存中的大小 |
SwitchBackContext |
SwitchBack(后面讲)用来存储和这个模块关联的当前 Windows 上下文 GUID 及其他数据 |
TimeDateStamp |
链接器在链接这个模块时写入的时间戳,加载器从模块的镜像 PE 头里读取它 |
TlsIndex |
和这个模块关联的线程本地存储槽位 |
十、动手实验:转储已加载模块数据库
在开始实验之前,重复前两个实验里用 WinDbg 把 Notepad.exe 当作被调试对象启动的那套步骤。到达初始断点(也就是前面被要求敲 g 的位置)之后,按下面的步骤操作。
10.1 用 !peb 命令查看 PEB
0:000> !peb
PEB at 000000dd4c901000
InheritedAddressSpace: No
ReadImageFileExecOptions: No
BeingDebugged: Yes
ImageBaseAddress: 00007ff720b60000
Ldr 00007ffe855d23a0
Ldr.Initialized: Yes
Ldr.InInitializationOrderModuleList: 0000022815d23d30 . 0000022815d24430
Ldr.InLoadOrderModuleList: 0000022815d23ee0 . 0000022815d31240
Ldr.InMemoryOrderModuleList: 0000022815d23ef0 . 0000022815d31250
Base TimeStamp Module
7ff720b60000 5789986a Jul 16 05:14:02 2016 C:\Windows\System32\notepad.exe
7ffe85480000 5825887f Nov 11 10:59:43 2016 C:\WINDOWS\SYSTEM32\ntdll.dll
7ffe84bd0000 57899a29 Jul 16 05:21:29 2016 C:\WINDOWS\System32\KERNEL32.DLL
7ffe823c0000 582588e6 Nov 11 11:01:26 2016 C:\WINDOWS\System32\KERNELBASE.dll
...
Ldr 那一行的地址,是指向前面讲的 PEB_LDR_DATA 结构体的指针。可以看到 WinDbg 给你展示了三条链表各自的地址范围,并且顺带帮你转储了按初始化顺序排列的这条链表,展示每个模块的完整路径、时间戳和基址。
10.2 用 !list 扩展命令批量转储每个模块条目
与其一个一个地手动去查每个模块条目的地址、再按 LDR_DATA_TABLE_ENTRY 格式转储,WinDbg 提供了 !list 扩展命令帮你批量完成这项工作:
!list –x "dt ntdll!_LDR_DATA_TABLE_ENTRY" @@C++(&@$peb->Ldr->InLoadOrderModuleList)
这条命令拆开看:
!list:WinDbg 的一个扩展命令,用来遍历一条链表,对链表里的每个节点都执行指定的子命令;-x "dt ntdll!_LDR_DATA_TABLE_ENTRY":-x参数指定要对每个节点执行的具体操作,这里是用dt(Display Type)命令,把每个节点按_LDR_DATA_TABLE_ENTRY结构体的格式展开显示;@@C++(&@$peb->Ldr->InLoadOrderModuleList):这是链表的起始地址表达式,@@C++( ... )允许你用类似 C++ 的语法访问结构体成员——这里表示"当前 PEB 的Ldr成员下的InLoadOrderModuleList链表头的地址"。
执行后,你会看到每个模块的条目,类似这样:
+0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x00000228'15d23d10 - 0x00007ffe'855d23b0 ]
+0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x00000228'15d23d20 - 0x00007ffe'855d23c0 ]
+0x020 InInitializationOrderLinks : _LIST_ENTRY [ 0x00000000'00000000 - 0x00000000'00000000 ]
+0x030 DllBase : 0x00007ff7'20b60000 Void
+0x038 EntryPoint : 0x00007ff7'20b787d0 Void
+0x040 SizeOfImage : 0x41000
+0x048 FullDllName : _UNICODE_STRING "C:\Windows\System32\notepad.exe"
+0x058 BaseDllName : _UNICODE_STRING "notepad.exe"
+0x068 FlagGroup : [4] "???"
+0x068 Flags : 0xa2cc
可以看到,这正是我们前面表格里逐一讲过的字段——DllBase、EntryPoint、SizeOfImage、FullDllName、BaseDllName、Flags,都能在这里对号入座地找到实际数值。
10.3 内核也有自己的一套加载器
这一节讲的是 Ntdll.dll 里的用户态加载器,但要注意,内核对驱动和依赖 DLL 也用了自己的一套加载器,对应的加载器条目结构叫 KLDR_DATA_TABLE_ENTRY,跟用户态的结构类似但不完全相同。同样地,内核态加载器也有自己的一份条目数据库,可以通过全局数据变量 PsActiveModuleList 直接访问。
要转储内核的已加载模块数据库,可以用类似的 !list 命令,把末尾的指针换成 nt!PsActiveModuleList,把结构体/模块名换成新的名称:
!list nt!_KLDR_DATA_TABLE_ENTRY nt!PsActiveModuleList
10.4 Flags 字段的具体含义
用这种"原始格式"去看这份列表,能获得一些额外的洞察——比如 Flags 字段,它包含的状态信息是单独调用 !peb 看不到的。下表是各个标志位的含义(注意内核和用户态加载器都用这个结构体,所以标志的含义不总是一样,下表只讲用户态的标志含义,有些标志可能在内核结构体里也存在):
| 标志 | 含义 |
|---|---|
| Packaged Binary(0x1) | 这个模块是打包应用的一部分(只能在 AppX 包的主模块上设置) |
| Marked for Removal(0x2) | 一旦所有引用(比如来自正在执行的工作线程的引用)都被释放,这个模块就会被卸载 |
| Image DLL(0x4) | 这个模块是一个镜像 DLL(而不是数据 DLL 或可执行文件) |
| Load Notifications Sent(0x8) | 已经向注册的 DLL 通知回调,通知过这个镜像的加载事件 |
| Telemetry Entry Processed(0x10) | 已经为这个镜像处理过遥测数据 |
| Process Static Import(0x20) | 这个模块是主应用程序二进制文件的静态导入 |
| In Legacy Lists(0x40) | 这个镜像条目已经存在于加载器的双向链表里 |
| In Indexes(0x80) | 这个镜像条目已经存在于加载器的红黑树里 |
| Shim DLL(0x100) | 这个镜像条目代表的是垫片引擎/应用兼容性数据库的一部分 DLL |
| In Exception Table(0x200) | 这个模块的 .pdata 异常处理程序,已经被捕获进加载器的倒排函数表 |
| Load In Progress(0x800) | 这个模块当前正在被加载 |
| Load Config Processed(0x1000) | 这个模块的镜像加载配置目录已经被找到并处理 |
| Entry Processed(0x2000) | 加载器已经完全处理完这个模块 |
| Protect Delay Load(0x4000) | 这个二进制文件的控制流防护(CFG)特性,要求保护延迟加载的 IAT(第7章有更多信息) |
| Process Attach Called(0x20000) | 已经向这个 DLL 发送过 DLL_PROCESS_ATTACH 通知 |
| Process Attach Failed(0x40000) | 这个 DLL 的 DllMain 例程在处理 DLL_PROCESS_ATTACH 通知时失败了 |
| Don’t Call for Threads(0x80000) | 不要向这个 DLL 发送 DLL_THREAD_ATTACH/DETACH 通知——可以用 DisableThreadLibraryCalls 来设置 |
| COR Deferred Validate(0x100000) | 公共对象运行时(COR)会在稍后验证这个 .NET 镜像 |
| COR Image(0x200000) | 这个模块是一个 .NET 应用 |
| Don’t Relocate(0x400000) | 这个镜像不应该被重定位或随机化 |
| COR IL Only(0x800000) | 这是一个只包含 .NET 中间语言(IL)的库,不含原生汇编代码 |
| Compat Database Processed(0x40000000) | 垫片引擎已经处理过这个 DLL |
十一、SwitchBack:让新旧行为在同一个进程里并存
每个新版本的 Windows 在修复既有 API 函数里的 bug(比如竞态条件、参数校验不正确)时,无论改动多小,都会给应用兼容性带来风险。为此,Windows 引入了一项叫 SwitchBack 的技术(在加载器里实现),让软件开发者可以在应用可执行文件的清单文件里,嵌入一个和目标 Windows 版本对应的 GUID。
举个例子:如果开发者想利用 Windows 10 对某个 API 新增的改进,就会在清单里包含 Windows 10 的 GUID;而如果开发者有一个依赖 Windows 7 特定行为的老应用,就会改为在清单里放入 Windows 7 的 GUID。
SwitchBack 解析这份信息,并把它跟嵌入在兼容 SwitchBack 的 DLL里(存放在 .sb_data 镜像段)的信息进行关联匹配,来决定这个模块应该调用受影响 API 的哪个版本。因为 SwitchBack 是在**"已加载模块"这一级工作的,所以它使得一个进程里可以同时存在旧版和新版的 DLL,各自调用同一个 API,却观察到不同的结果**——这是这个机制最巧妙的地方。
11.1 SwitchBack GUID 列表
Windows 目前定义了对应从 Windows Vista 开始的每个版本的兼容性 GUID:
| Windows 版本 | GUID |
|---|---|
| Windows Vista | {e2011457-1546-43c5-a5fe-008deee3d3f0} |
| Windows 7 | {35138b9a-5d96-4fbd-8e2d-a2440225f93a} |
| Windows 8 | {4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38} |
| Windows 8.1 | {1f676c76-80e1-4239-95bb-83d0f6d0da78} |
| Windows 10 | {8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a} |
这些 GUID 必须出现在应用清单文件的 <SupportedOS> 元素下的 ID 属性里,作为一个兼容性属性条目存在。(如果应用清单文件不含任何 GUID,系统默认选择 Windows Vista 兼容模式。)
用任务管理器可以在"详细信息"选项卡里开启一个 Operating System Context 列,展示哪些应用正在以特定的 OS 上下文运行(空值通常表示它们运行在 Windows 10 模式)。你截图里的 Image 3 就展示了这样一个例子——即便是在 Windows 10 系统上,WINWORD.EXE(Microsoft Word)和 UcMapi.exe(Skype for Business)显示的操作系统是 Windows 7,而 idaq64.exe(The Interactive Disassembler)以及 IpOverUsbSvc.exe、vcpkgsrv.exe(Microsoft Visual C++ Package Server)、Zoom.exe 显示的是 Windows Vista——这说明这些应用的清单里,声明了对应版本的 SwitchBack GUID,让它们即便运行在更新的 Windows 10 上,也依然享受(或者说被限定为)当初开发时对应版本的 API 行为。
下面是一个把兼容性设为 Windows 10 的清单条目示例:
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
11.2 SwitchBack 具体会影响哪些行为——举几个例子
运行在 Windows 7 上下文下会影响的行为,举几个例子:
- RPC 组件使用 Windows 线程池,而不是私有实现;
DirectDraw的锁(Lock)无法在主缓冲区上被获取;- 桌面上不允许在没有裁剪窗口的情况下进行 Blit(位块传输)操作;
GetOverlappedResult里的一个竞态条件被修复了;- 调用
CreateFile时被允许传递一个"降级(downgrade)"标志,即便调用方没有写权限,也能拿到对文件的独占打开权——这会导致NtCreateFile不会收到FILE_DISALLOW_EXCLUSIVE标志。
反过来,运行在 Windows 10 模式下,会微妙地影响低碎片堆(LFH)的行为——除非存在 Windows 10 的 GUID,否则会强制让 LFH 子段被完全提交,并给所有分配都填充上一个头部块。此外,在 Windows 10 上,使用"句柄无效关闭时抛出异常"这项缓解措施(第7章详细讲)时,CloseHandle和RegCloseKey会遵守这个行为;而在旧版操作系统上,如果没有附加调试器,这个行为会在调用NtClose之前被禁用,调用完成后再重新启用。
再举个例子,拼写检查功能在没有对应语言的拼写检查器时,会返回NULL;而在 Windows 8.1 上,它返回的是一个"空"的拼写检查器对象。类似地,IShellLink::Resolve函数在 Windows 8 兼容模式下,遇到相对路径时会返回E_INVALIDARG;而在 Windows 7 模式下则不含这项检查。
此外,调用GetVersionEx或者NtDll里等价的函数(比如RtlVerifyVersionInfo),会返回跟指定的 SwitchBack 上下文 GUID 对应的最高版本号。
补充说明:这些 API 已经被废弃了——如果没有提供更高版本的 SwitchBack GUID,调用
GetVersionEx在 Windows 8 及之后的所有版本上,都会返回6.2。
11.3 SwitchBack 的内部工作机制
每当一个 Windows API 因为某个可能破坏兼容性的改动而受到影响时,这个函数的入口代码会调用 SbSwitchProcedure 来触发 SwitchBack 逻辑。它会传入一个指向 SwitchBack 模块表的指针——这个表包含该模块所使用的 SwitchBack 机制的信息,同时还包含一个指向数组的指针,数组里每个元素对应一个 SwitchBack 判断点(branch-point)。这个表里的每一条,都描述了一个判断点,用一个符号化名称和一段详尽的描述来标识它,还附带一个关联的缓解标签。通常一个模块里会有好几个判断点——一个对应 Windows Vista 的行为,一个对应 Windows 7 的行为,以此类推。
对每个判断点,都给定了所需的 SwitchBack 上下文——正是这个上下文,决定了运行时会走哪一条分支(两条或更多条中的哪一条)。最后,每个判断点描述符里都包含一个函数指针,指向每条分支实际应该执行的代码。如果应用是以 Windows 10 的 GUID 运行的,这会是它 SwitchBack 上下文的一部分,SbSelectProcedure API 在解析模块表时,会执行匹配操作——它找到对应这个上下文的模块条目描述符,然后调用描述符里包含的那个函数指针。
SwitchBack 用 ETW 来追踪某个 SwitchBack 上下文和判断点的选中情况,并把数据送进 **Windows AIT(Application Impact Telemetry)**日志系统。微软可以定期收集这些数据,来判断每个兼容性条目的使用程度,识别正在使用它的应用(日志里会附带完整的调用栈),并据此通知第三方厂商。
正如前面提到的,应用的兼容性等级存储在它的清单文件里。在加载时,加载器解析清单文件,创建一个上下文数据结构,缓存在 PEB 的 pShimData 成员里。这份上下文数据包含了这个进程正在以哪些兼容性 GUID 执行,并决定了被调用的、使用 SwitchBack 的 API 里的判断点,会执行哪个版本的分支。
十二、API Sets:更普遍的重定向机制
SwitchBack 用 API 重定向来处理特定的应用兼容性场景;而 Windows 里还有一套覆盖面更广、面向所有应用的重定向机制,叫 API Sets。
12.1 API Sets 想解决什么问题
它的目的,是把 Windows API 细粒度地划分成一个个子 DLL,而不是搞几个包含几乎上千个 API 的大型多用途 DLL——毕竟这些 API 未必是当前和未来所有类型的 Windows 系统都需要的。这项技术主要是为了支持"把 Windows 架构最底层重构、和上层解耦"而开发的,跟把 Kernel32.dll、Advapi32.dll(还有其他一些库)拆分成多个虚拟 DLL 文件这件事是同步进行的。
比如你截图里的 Image 4(Dependency Walker 展示的 Kernel32.dll 依赖关系),可以看到这个核心 Windows 库,导入自一大堆以 API-MS-WIN 开头的 DLL。这些 DLL 每一个只包含 Kernel32 通常提供的 API 里很小的一个子集,但合起来就构成了 Kernel32.dll 对外暴露的整个 API 表面。比如截图里选中的 API-MS-WIN-CORE-STRING-L1-1-0.DLL,就只提供 Windows 的基础字符串函数(比如右侧展开列出的 CompareStringEx、CompareStringOrdinal、CompareStringW、FoldStringW、GetStringTypeExW、GetStringTypeW、MultiByteToWideChar、WideCharToMultiByte 这些)。
12.2 把功能拆到独立文件里,达成了两个目标
第一,这让未来的应用只需要跟提供自己实际需要功能的那些 API 库链接即可。
第二,如果微软要打造一个不支持本地化的 Windows 版本(比如一个不面向终端用户、纯英文的嵌入式系统),只需要移除这个子 DLL、修改 API Set 的模式定义就行了。这样就能得到一个更小的 Kernel32 二进制文件,而任何不需要本地化功能的应用,依然能正常运行。
12.3 MinWin:这套技术的终极产物
借助这项技术,一个叫 MinWin 的"基础" Windows 系统被定义出来(并且在源代码层面被构建出来),它只包含一套最小化的服务集合——内核、核心驱动(包括文件系统)、基础系统进程(比如 CSRSS 和服务控制管理器),以及少数几个 Windows 服务。
Windows Embedded 及其 Platform Builder 提供的技术,表面上看起来类似——系统构建者可以移除特定的"Windows 组件",比如外壳(shell)或者网络协议栈。但这两者有本质区别:从 Windows 里移除组件,会留下悬空的依赖关系——那些代码路径,一旦真的被触发执行,就会因为依赖了已被移除的组件而失败。而 MinWin 的依赖关系是完全自洽的——它压根不依赖任何可能被拆出去的东西,所以不存在"移除了却留下地雷"这个问题。
12.4 API Set 重定向表是怎么工作的
进程管理器初始化时,会调用 PspInitializeApiSetMap 函数,负责为 API Set 重定向表创建一个 Section 对象——这份表存储在 %SystemRoot%\System32\ApiSetSchema.dll 里。这个 DLL 本身不包含任何可执行代码,但它有一个叫 .apiset 的段,里面存放着 API Set 映射数据——把虚拟的 API Set DLL,映射到真正实现这些 API 的逻辑 DLL。每当有新进程启动时,进程管理器会把这个 Section 对象映射进这个进程的地址空间,并把进程 PEB 里的 ApiSetMap 字段,设置为指向这个 Section 对象被映射到的基址。
反过来,加载器里的 LdrpApplyFileNameRedirection 函数——它通常负责前面提到的 .local 和 SxS/Fusion 清单重定向——同样也会在有新的、名字以 API- 开头的导入库被加载时(无论是动态加载还是静态加载),去检查是否存在 API Set 重定向数据。API Set 表按库来组织,每个条目描述这个函数能在哪个逻辑 DLL 里找到,而那个 DLL 才是真正被加载的对象。
12.5 亲手看看这份映射表里都有什么
虽然模式数据本身是二进制格式的,但你可以用 Sysinternals 的 Strings 工具把里面的字符串转储出来,看看当前定义了哪些 DLL:
C:\Windows\System32>strings apisetschema.dll
...
api-ms-onecoreuap-print-render-l1-1-0
printrenderapihost.dllapi-ms-onecoreuap-settingsync-status-l1-1-0
settingsynccore.dll
api-ms-win-appmodel-identity-l1-2-0
kernel.appcore.dllapi-ms-win-appmodel-runtime-internal-l1-1-3
api-ms-win-appmodel-runtime-l1-1-2
api-ms-win-appmodel-state-l1-1-2
api-ms-win-appmodel-state-l1-2-0
api-ms-win-appmodel-unlock-l1-1-0
api-ms-win-base-bootconfig-l1-1-0
advapi32.dllapi-ms-win-base-util-l1-1-0
api-ms-win-composition-redirection-l1-1-0
...
这里能看出一个规律:像 api-ms-win-base-bootconfig-l1-1-0 紧跟着 advapi32.dll 出现——这正是**"虚拟 API Set DLL 名字"和"真正实现它的逻辑 DLL 名字"紧挨着排列**的体现(因为工具只是简单地把二进制文件里能识别出的字符串按顺序转储出来,两个字符串之间没有明显分隔,看起来像是"粘"在一起,但其实是两条独立的映射记录首尾相连)。类似地,还能看到大量以 api-ms-win-core- 开头的条目,比如 api-ms-win-core-heap-l1-1-0(对应堆相关 API)、api-ms-win-core-io-l1-1-1(对应 I/O 相关 API)、api-ms-win-core-job-l1-1-0(对应 Job 对象相关 API)——这些命名方式本身也透露出这套体系的分类思路:按功能域(heap、io、job、debug、handle……)切分成一个个小的虚拟 DLL,每个虚拟 DLL 背后可能指向同一个真实的逻辑 DLL(比如很多都指向 ntdll.dll 或 kernelbase.dll),也可能指向不同的逻辑 DLL。
十三、用一张图串联整个加载流程
最后用一张 mermaid 图,把镜像加载器这一整套流程的大致顺序理一遍:
这张图想强调的重点是:"名称重定向"这一步不是单选一次就完事,而是每加载一个新 DLL 都要重新走一遍(API Set、.LOCAL、SxS/Fusion、Known DLL 这几种规则按固定优先级依次尝试),而"解析导入表 → 加载依赖 DLL → 处理该 DLL 自己的导入表"这个循环,本质上是一个递归/图遍历的过程,只有当整棵依赖树都被满足之后,才能进入"调用各模块初始化例程"这一步,最终才轮到应用自己的入口点函数真正开始跑。
十四、小结
镜像加载器这一节,讲的其实是**“进程创建完之后,怎么让它真正跑起来”**这件事,可以按四条主线来记忆:
- 加载器本身的定位:它住在
Ntdll.dll里、是用户态代码、通过异常帧返回(而不是普通调用)切入应用入口,所以调用栈上看不到它自己的痕迹。 - 早期初始化的 37 个步骤:本质上是把堆、TLS/FLS、NLS、异常表、缓解选项、IFEO 配置这些"应用能跑起来的地基"依次铺好,其中第 31 步(Wow64 引擎接管重启大部分流程)和第 32 步(.NET 镜像的特殊处理)是两个比较特别的分支。
- DLL 搜索与重定向体系:安全 DLL 搜索模式(把当前目录挪到系统目录之后,防范二进制种植攻击)+ 四种名称重定向规则(API Set、
.LOCAL、SxS/Fusion、Known DLL),共同决定了"一个 DLL 名字最终会变成磁盘上哪个具体文件"。 - 两套面向兼容性的重定向机制:SwitchBack 面向的是特定 API 的行为差异(同一个进程里新旧 DLL 可以调用同一个 API 却得到不同结果),而 API Sets 面向的是架构层面的解耦(把庞大的系统 DLL 拆成许多细粒度虚拟子 DLL,服务于 MinWin 这样的最小化系统构建目标)——两者看似都是"重定向",但解决的问题完全不在一个层面上。
从 Process Monitor 和 WinDbg 的几个实验能看出,这一整套理论描述并不是纸上谈兵——从 OneDrive 加载 DLL 时依次探测多个候选路径,到 Gflags 加载器快照里实时显示的 API Set 重定向提示,再到任务管理器里那些"卡"在 Windows 7/Vista 兼容模式下的老程序,每一步都能在真实系统里找到对应的现象。
Windows 作业对象(Job)与容器(Server Silo)详解
一、什么是 Job(作业对象)
一句话理解:Job 就是给一堆进程套上的"笼子",让系统能把这些进程当作一个整体来管理。
普通情况下,Windows 对"进程"的管理是一个一个来的——你想限制内存、限制 CPU,只能一个进程一个进程地设置。但很多时候我们需要管理的是"一组"进程,比如:
- 一个杀毒软件想限制它启动的所有子进程加起来一共用多少 CPU
- 一个容器(类似 Docker)需要把里面运行的一堆进程当成一个"沙箱"整体管理
- 一个 UWP(应用商店)程序,本质上可能会创建多个进程,系统希望统一管理它们的省电策略
Job 对象就是为了解决这个问题而生的。
1.1 Job 的核心特性
- Job 是一个可命名、可加安全权限、可以被共享的内核对象。
- 一个进程可以属于多个 Job(Windows 8 / Server 2012 之后支持嵌套 Job),但一般情况下只属于 1 个。
- 进程和 Job 的关联一旦建立就不能解除——这意味着一个进程一旦加入某个 Job,它这辈子都跑不出去了(除非用特殊标志位)。
- 一个进程创建的所有子进程、子子进程,默认也会自动被拉入同一个 Job,除非:
- 创建子进程时用了
CREATE_BREAKAWAY_FROM_JOB标志,并且 - 这个 Job 本身没有禁止"越狱"(即没有设置阻止 breakaway 的限制)。
也就是说,Job 就像一个"家族登记簿",一旦某个人(进程)登记进来,他生的孩子(子进程)默认也自动跟着登记,除非提前声明"我这个孩子要单飞"。
- 创建子进程时用了
1.2 Job 会记账
Job 对象还会做累计统计,比如:
- 这个 Job 下所有进程一共用了多少 CPU 时间
- 一共创建过多少进程、现在还活着多少个
- 即使某个进程已经退出了,它曾经消耗的资源也会被记入 Job 的历史总账
1.3 Job 可以配合 I/O 完成端口做"监控通知"
Job 对象可以关联一个 I/O 完成端口(I/O Completion Port)。这样,创建这个 Job 的人(通常是监控程序),可以通过下面两种方式"蹲守"消息:
- 调用 Windows API
GetQueuedCompletionStatus - 使用线程池 API(底层对应内核函数
TpAllocJobNotification)
当 Job 里发生了"超限"或"安全相关事件"(比如又有新进程被创建、或者某个进程突然挂了)时,系统就会往这个完成端口塞一条消息,通知的人就能第一时间知道。
1.4 Job 在系统中的重要地位
Job 这套机制远不止"限制资源"这么简单,它在 Windows 内部被大量复用:
- 【UWP 应用】每一个 UWP(应用商店)程序,其实底层都跑在一个 Job 里面。可以用 Process Explorer 亲眼验证这一点。
- 【Windows 容器】实现容器支持的核心机制叫 server silo(服务器孤岛),而 silo 本质上就是"加强版的 Job"。
- 【桌面活动管理器 DAM】负责给 Win32 程序和服务做节流、计时器虚拟化、计时器冻结等"降低功耗"的骚操作,靠的主力工具就是 Job。
- 【动态公平共享调度 DFSS】允许定义和管理"调度组",靠 Job 实现。
- 【内存分区 API】允许指定自定义的内存分区,也是靠 Job 支持的。
- 【Run As(副登录)、应用程序装箱(Application Boxing)、程序兼容性助手】这些功能都靠 Job 打底。
- 【安全沙箱】给 Chrome 浏览器、微软 Office 文档转换器这类应用提供了一部分安全沙箱能力,还能通过 Job 缓解针对 WMI 请求的拒绝服务(DoS)攻击。
二、Job 能限制哪些东西(Job Limits)
Job 上可以设置一堆"限速"选项,大致分为 CPU、内存、I/O 三类。下面逐条解释:
| 限制项 | 作用说明 |
|---|---|
| 最大活动进程数 | 限制 Job 里同时存在的进程数量,超过就不允许再新建进程加入 |
| Job 级用户态 CPU 时间限制 | 整个 Job 里所有进程(包括已退出的)累计用掉的用户态 CPU 时间上限 |
| 单进程用户态 CPU 时间限制 | Job 里每个进程各自能用的用户态 CPU 时间上限,一旦超限直接被"处决"(没机会做清理工作) |
| Job 处理器亲和性 | 给 Job 里的每个进程都设定处理器亲和性掩码(线程可以在这个范围内自己调整,但进程整体没法突破) |
| Job 组亲和性 | 设定 Job 里进程可以被分配到哪些处理器组,是"处理器亲和性"的组感知(Group-Aware)升级版 |
| Job 进程优先级类 | 给 Job 里所有进程统一设定优先级类别,线程无法相对这个类别再往上加优先级 |
| 默认工作集最小/最大值 | 给 Job 里每个进程规定工作集(物理内存占用)的上下限(是"每个进程各自"生效,不是整个 Job 共享一份) |
| 进程/Job 已提交虚拟内存限制 | 限制单个进程或整个 Job 能提交(占用)的虚拟地址空间总量 |
| CPU 速率控制 | 限制 Job 在被强制节流之前最多能用多少 CPU 时间,配合调度组使用 |
| 网络带宽速率控制 | 限制整个 Job 的最大出站带宽,还能给每个网络包打上 DSCP 服务质量标记,一个层级里只能有一个 Job 设置这个 |
| 磁盘 I/O 带宽速率控制 | 跟网络带宽类似但针对磁盘 I/O,可以控制带宽本身,也可以控制每秒 I/O 操作数(IOPS),可以针对某个卷或全系统的卷生效 |
对很多限制项,Job 的所有者可以设定一个"预警阈值"——一旦达到这个阈值,系统会发一个通知(如果没注册通知,那就直接把 Job 干掉)。
此外,速率控制类的限制还支持"容忍区间"和"容忍时长",比如:
允许某个进程的网络带宽在 5 分钟内、每次最多超出限额 20%,且每次最多持续 10 秒。
这些通知都是通过往 Job 关联的 I/O 完成端口塞消息来实现的。
2.1 用户界面(UI)限制
Job 还可以对里面的进程加图形界面相关的限制,比如:
- 禁止访问 Job 外部线程拥有的窗口句柄
- 禁止读写剪贴板
- 禁止通过
SystemParametersInfo这个 Windows 函数修改各种系统界面参数
这些 UI 限制是由 Windows 子系统的 GDI/USER 驱动——也就是 Win32k.sys 负责管理的,实现方式是它向进程管理器注册了一个特殊的回调,叫 job callout(作业回调)。
如果想让 Job 里所有进程都能访问某个特定窗口句柄,可以调用UserHandleGrantAccess函数来"开个后门"授权——但这个函数只能由 Job 之外的进程调用(这很合理,毕竟不能自己给自己开权限)。
三、如何操作 Job(编程接口)
3.1 创建 Job
HANDLE CreateJobObjectW(
LPSECURITY_ATTRIBUTES lpJobAttributes, // 安全属性,一般传 NULL
LPCWSTR lpName // Job 的名字,可以为 NULL(不命名)
);
Job 一创建出来是空的,里面一个进程都没有。
3.2 把进程加进 Job
BOOL AssignProcessToJobObject(
HANDLE hJob, // 目标 Job 的句柄
HANDLE hProcess // 要加入的进程句柄
);
这个函数可以反复调用:
- 可以把多个不同的进程依次加入同一个 Job;
- 也可以把同一个进程加入多个不同的 Job——这样做就会产生"嵌套 Job"(下文详解)。
另外一种加入方式是:在创建进程的时候,通过PS_CP_JOB_LIST这个进程创建属性,直接指定一个或多个 Job 对象的句柄,新进程创建出来时就直接自动加入这些 Job 了。
3.3 设置和查询 Job 的限制
BOOL SetInformationJobObject(
HANDLE hJob,
JOBOBJECTINFOCLASS JobObjectInformationClass, // 信息类,决定设置哪一类限制
LPVOID lpJobObjectInformation, // 具体的限制结构体
DWORD cbJobObjectInformationLength
);
BOOL QueryInformationJobObject(
HANDLE hJob,
JOBOBJECTINFOCLASS JobObjectInformationClass,
LPVOID lpJobObjectInformation,
DWORD cbJobObjectInformationLength,
LPDWORD lpReturnLength
);
SetInformationJobObject 是 Job 相关 API 里最重要的一个,前面讲的所有限制(CPU、内存、I/O、UI 限制)都是靠这个函数设置的。此外,一些"内部"信息类还被用来实现容器(Silo)、桌面活动管理器(DAM)、UWP 应用等系统级机制。QueryInformationJobObject 用来把这些限制值读回来,如果之前设置过"超限通知",也需要靠这个函数去查清楚到底是哪个限制被打破了。
3.4 强制终止 Job
BOOL TerminateJobObject(
HANDLE hJob,
UINT uExitCode
);
效果等价于对 Job 里的每一个进程都调用一次 TerminateProcess——一次性团灭。
四、嵌套 Job(Nested Jobs)
4.1 历史背景
在 Windows 7 / Server 2008 R2 之前,一个进程只能属于一个 Job,这就很受限——比如某个管理程序想接管一个进程,但完全不知道这个进程是不是已经被别的 Job 占用了。
从 Windows 8 / Server 2012 开始,一个进程可以同时属于多个 Job,从而形成一个 Job 的层级结构(hierarchy)。
4.2 层级规则
- 子 Job 持有父 Job 的进程集合的一个子集。
- 一旦某个进程被加入了不止一个 Job,系统就会尝试把这些 Job 组织成层级关系。
- 限制:如果任何一个 Job 设置了 UI 限制(也就是调用过
SetInformationJobObject并传了JobObjectBasicUIRestrictions参数),那这些 Job 之间就不能组成层级关系。 - 子 Job 的限制不能比父 Job 更宽松,只能更严格。举个例子:如果父 Job 设了 100 MB 内存上限,子 Job 想设更高的内存限制(比如 200 MB),这个请求会直接失败;但子 Job 可以设一个更严格的限制,比如 80 MB,这是允许的。
- 任何针对某个 Job 的 I/O 完成端口的通知,都会同时发给这个 Job 以及它所有的祖先 Job(注意:即使某个 Job 自己没有绑定完成端口,只要它的祖先绑定了,通知照样会往上传)。
- 资源统计是层层累加的:父 Job 的资源使用统计 = 它直接管理的进程用量 + 所有子 Job 里进程用量的总和。
- 当调用
TerminateJobObject终止一个 Job 时,这个 Job 以及它下面所有子 Job 里的进程都会被终止,终止顺序是从层级最底层的子 Job 开始,自底向上。
4.3 层级结构图解
原书图 3-15 描述的是这样一个 4 进程、3 层 Job 的结构:
Job 1(根)
├── P4 (直接属于 Job1,没有嵌套)
├── Job 2(Job1 的子 Job)
│ ├── P1
│ └── P3
└── Job 3(Job1 的子 Job)
└── P2
用 mermaid 画出来是这样:
4.4 构建这个层级的具体步骤
要搭出上面这个结构,操作顺序(必须从根 Job 开始逐步添加)如下:
- 把进程 P1 加入 Job 1
- 把进程 P1 加入 Job 2 —— 这一步产生了第一层嵌套(P1 同时属于 Job1、Job2,系统识别出 Job2 是 Job1 的子 Job)
- 把进程 P2 加入 Job 1
- 把进程 P2 加入 Job 3 —— 这一步产生了第二层嵌套(P2 同时属于 Job1、Job3,Job3 也成为 Job1 的子 Job)
- 把进程 P3 加入 Job 2 —— P3 直接进入 Job2(此时 P3 也自动被视为 Job1 的一部分,因为 Job2 已经是 Job1 的子 Job 了)
- 把进程 P4 加入 Job 1 —— P4 只属于 Job1,没有更深的嵌套
五、实验:查看 Job 对象
书里安排了一个动手实验,简单复述一下思路(不做逐字翻译,讲清楚原理):
- 用
runas /user:<域>\<用户名> cmd命令,以另一个身份启动一个新的cmd.exe。系统执行 runas 的服务,会自动创建一个无名 Job,把这个新进程装进去——目的是等你注销登录的时候,能一次性把这些进程全部清理掉。 - 打开 Process Explorer,勾选"Jobs"这个高亮选项,就能看到
cmd.exe和它的子进程conhost.exe被特殊颜色标记,表示它们同属一个 Job。 - 双击进程,在弹出窗口的 “Job” 标签页里能看到 Job 的详细信息(Job 名称、包含哪些进程、Job 的限制设置等)。
- 如果你在这个 cmd 窗口里再运行
notepad.exe,会发现记事本也跑进了同一个 Job——原因是cmd.exe创建子进程时没有使用CREATE_BREAKAWAY_FROM_JOB标志,所以子进程默认继承了父进程的 Job 归属。 - 用内核调试器可以看得更细。比如:
lkd> !process 0 1 notepad.exe
可以看到 notepad.exe 进程结构体里有一个非零的 Job 指针字段,指向它所属的 Job 对象。
拿到这个 Job 指针后,可以用 !job 命令看摘要信息:
lkd> !job <job指针>
输出里的 ActiveProcesses 字段显示这个 Job 里目前活跃的进程数(例子里是 3 个:cmd.exe、conhost.exe、notepad.exe)。
加上参数 2 还能把具体的进程列表打印出来:
lkd> !job <job指针> 2
用 dt 命令还能直接把 _EJOB 这个内核结构体的字段全部摊开看,比如:
ParentJob:指向父 Job(如果是根 Job,这里是 NULL)RootJob:指向整个层级的根 JobTotalProcesses/ActiveProcesses:进程数统计
六、Windows 容器(Server Silo)
6.1 为什么需要容器
云计算越来越普及,大家越来越需要"可移植的后端服务"——今天部署在这家云厂商,明天想搬到另一家,或者从云上搬回自建机房,都希望这个过程尽量简单,而且不想为此付出运行一整个虚拟机的资源代价。
Docker 这类技术就是为了解决这个痛点而生的:把一个应用连同它的运行环境打包成一个"盒子",可以从一个 Linux 发行版轻松搬到另一个,不用操心复杂的本地安装、也不用承担虚拟机那么重的资源开销。
微软从 Windows 10 周年更新开始,把 Docker 引入 Windows,支持两种运行模式:
- Hyper-V 容器:更"重",但隔离更彻底(真正的虚拟化),客户端和服务器场景都支持。
- Server Silo 容器:更"轻",只做操作系统层面的隔离(不是真虚拟化),目前受许可限制,只支持服务器场景(客户端其实技术上已经具备这个能力,但被禁用了)。
Server Silo 和 Hyper-V 容器最大的区别是:Silo 容器共享同一个内核和驱动,只是给用户态组件搞了"第二份实例"。这样牺牲了一部分安全隔离性,换来的是轻量级的容器体验。
6.2 Job 对象和 Silo 的关系
一句话总结:Silo 本质上是"超级 Job"。
创建 Silo 的能力是通过 SetJobObjectInformation 这个 API 的若干未公开子类实现的。也就是说,一个普通 Job 对象既可以做常规的资源管理(前面讲的那些限制),又可以摇身一变成为 Silo——这种"身兼两职"的 Job,系统内部称之为 混合 Job(hybrid job)。
实际上,Job 对象能承载两种 Silo:
- 应用 Silo(application silo):用于实现 Desktop Bridge(应用商店的桥接技术,本书留到讨论 UWP 的章节详细说)
- 服务器 Silo(server silo):也就是本节讲的、用于支持 Docker 容器的那种
6.3 Silo 的隔离机制
Server Silo 隔离的第一个关键要素,是它拥有独立的对象管理器根目录(\)。
这里先简单科普一下:Windows 里所有"应用能看到的、带名字的对象"(文件、注册表键、事件、互斥体、RPC 端口等等)都放在一个统一的根命名空间下,应用靠这个命名空间来创建、查找、共享这些对象。
Server Silo 拥有自己独立的"根",意味着它对任何带名对象的访问都可以被完全控制。具体有三种做法:
- 建一份现有对象的副本:给 Silo 内部提供一个"平替版本"来访问该对象
- 建一个符号链接:直接链接到宿主上真实存在的对象
- 建一个全新的对象:这个对象只存在于 Silo 内部,比如容器化应用专门要用的对象
6.4 完整容器所需的组件
光有独立命名空间还不够,完整的隔离环境要靠 Vmcompute 服务(Docker 用它)配合下面这几个组件才能搭起来:
| 组件 | 作用 |
|---|---|
| 基础 Windows 镜像(WIM)文件,即"base OS" | 提供操作系统的一份独立副本,目前微软提供 Server Core 镜像和 Nano Server 镜像 |
| 宿主 OS 的 Ntdll.dll | 会覆盖掉 base OS 镜像里自带的 Ntdll.dll。原因是 Server Silo 复用的是同一个宿主内核和驱动,而 Ntdll.dll 负责发起系统调用,所以这一个用户态组件必须用宿主的那份,不能用镜像自带的 |
| Wcifs.sys 过滤驱动提供的沙箱虚拟文件系统 | 允许容器对文件系统做"临时修改",不影响底层真实的 NTFS 卷,容器关闭时这些改动可以直接清空 |
| VReg 内核组件提供的沙箱虚拟注册表 | 提供一套临时的注册表配置单元(hive),同时也是命名空间隔离的另一层(因为对象管理器的根命名空间隔离只覆盖注册表的"根",不覆盖 hive 本身) |
| Session Manager(Smss.exe) | 现在被用来创建额外的服务会话或控制台会话,这是容器支持所需的新能力——为每个启动的容器都创建一份专属会话 |
容器架构总览用 mermaid 表示:
6.5 Silo 的独立隔离边界
尽管容器复用的是同一个内核和 Ntdll.dll,但内核必须提供额外的边界来区分不同的 Silo。因此每个 Server Silo 都会拥有自己独立的:
- 微型共享用户数据(
SILO_USER_SHARED_DATA):包含自定义的系统路径、会话 ID、前台进程 PID、产品类型/套件版本等信息。这些数据本来是KUSER_SHARED_DATA(内核共享给用户态的一块只读数据区)的一部分,但因为它们描述的是"宿主 OS"而不是容器所使用的"base OS 镜像"的信息,所以必须单独隔离出一份。系统里很多组件和 API 都改成读取 Silo 专属的共享数据,而不是全局的KUSER_SHARED_DATA。注意:原始的KUSER_SHARED_DATA仍然保留在它原来的地址上、保持宿主视角的数据不变——这也是宿主状态会"泄漏"进容器内部的一个途径。 - 对象目录根命名空间:包括自己的
\SystemRoot符号链接、\Device目录(用户态组件访问驱动的必经之路)、设备映射与 DOS 设备映射(应用访问网络映射驱动器要用)、\Sessions目录等等。 - API Set 映射:基于 base OS 镜像里自带的 API Set 架构,而不是宿主文件系统上的那份。加载器要靠 API Set 映射来决定某个函数具体是由哪个 DLL 实现的,不同 SKU(版本)之间这个映射可能不一样,应用必须看到 base OS 的映射,而不是宿主的映射。
- 登录会话:关联着 SYSTEM 和匿名账户的本地唯一标识(LUID),外加一个描述 Silo 内用户身份的虚拟服务账户 LUID。本质上代表了 Silo 内、由 Smss 创建的服务会话里跑的服务和应用的令牌信息。
- ETW 跟踪与日志上下文:用来隔离 ETW(事件跟踪)操作,防止容器和宿主之间互相泄漏状态。
6.6 Silo 上下文(Silo Contexts)
除了内核自带的这些隔离边界之外,内核里的其他组件、以及驱动(包括第三方驱动)都可以通过 PsCreateSiloContext 这个 API,给 Silo 挂载自定义数据,或者把一个已有对象和某个 Silo 关联起来。
存储机制类比线程本地存储:每个 Silo 上下文都会占用一个**“槽索引”(slot index),这个索引会被插入到所有现存以及未来的 Server Silo 里。系统内建提供 32 个系统级存储槽,外加 256 个扩展槽,扩展性很充足。
就像每个线程有自己的线程本地存储(TLS)一样,每个 Server Silo 一创建出来,也会拿到自己专属的一份"Silo 本地存储"(SLS,Silo-Local Storage)数组**。数组里的每个条目对应一个槽索引,用来存放对应的 Silo 上下文。
关键点是:每个 Silo 在同一个槽位存的指针不一样,但存的"是哪种上下文"是固定的。比如驱动 “Foo” 拥有第 5 号槽,在所有 Silo 里都用这个槽,但每个 Silo 里存的具体指针/内容各不相同。
举个具体例子:每个容器都会跑一份自己的 Lsass.exe(本地安全机构子系统进程),而内核的安全引用监视器(SRM)需要持有指向 Lsass.exe 进程的句柄。这个句柄以前是全局变量存的单例,但现在每个容器都有自己的 Lsass.exe,就不能再用全局单例了——所以 SRM 现在改为通过查询"当前活动的 Server Silo 的 Silo 上下文"来获取这个句柄。
那宿主自己的 Lsass.exe 呢? 宿主本身(也就是 Session 0)并不是一个真正的 Silo,那 SRM 该怎么找它的句柄?
答案是:内核实现了一个叫 root host silo(根宿主 Silo) 的机制——把宿主本身也当作一个 Silo 来处理(虽然它并不是真正意义上的 Silo)。这靠一个全局内核变量 PspHostSiloGlobals 实现,它自己也有一份 Slot Local Storage 数组和其他内建组件用的 Silo 上下文。当各种 Silo 相关的 API 被传入 NULL 指针调用时,这个 NULL 就会被解读为"没有 Silo——使用宿主 Silo"。
七、实验:Dumping SRM Silo Context(宿主 Silo)的原理
即使你的 Windows 10 是普通客户端、根本没跑任何容器,"宿主 Silo"依然存在,里面保存着内核内建组件用的隔离上下文。
用 Windows 调试器的 !silo 扩展命令,加上 -g Host 参数,就能看到宿主 Silo 的全局信息,比如:
lkd> !silo -g Host
Server silo globals fffff801b73bc580:
Default Error Port: ffffb30f25b48080
ServiceSessionId : 0
Root Directory : 00007fff00000000 ''
State : Running
输出里的这个指针(fffff801b73bc580)本质上指向一个 _ESERVERSILO_GLOBALS 结构体,用 dx 命令展开:
lkd> dx -r1 (*((nt!_ESERVERSILO_GLOBALS *)0xfffff801b73bc580))
可以看到这个结构体里包含了多个子结构,比如 ObSiloState(对象管理器状态)、SeSiloState(安全子系统状态)、SeRmSiloState(SRM/LSA 连接状态)、CmSiloState(配置管理器状态,负责注册表)、EtwSiloState(ETW 状态)等等——每一块都是前面提到的"独立隔离边界"在内存里的具体体现。
继续展开 SeRmSiloState,就能看到指向 Lsass.exe 进程的句柄字段:
lkd> dx -r1 ((ntkrnlmp!_SEP_RM_LSA_CONNECTION_STATE *)0xfffff801b73bc890)
[+0x000] LsaProcessHandle : ...
[+0x008] LsaCommandPortHandle : ...
[+0x010] SepRmThreadHandle : ...
[+0x018] RmCommandPortHandle : ...
这正好印证了前面说的:SRM 现在是通过查询 Silo 上下文来找到 Lsass.exe 句柄的,而不是靠一个写死的全局变量。
八、Silo 监视器(Silo Monitors)
问题来了:如果内核驱动想在自己的 Silo 上下文里存点东西,它怎么知道现在有哪些 Silo 正在运行?又怎么知道未来什么时候会有新的容器被创建出来?
答案是 Silo 监视器机制,提供了一套 API:
PsRegisterSiloMonitor/PsStartSiloMonitor/PsUnregisterSiloMonitor:注册/启动/反注册一个监视器,用来接收"某个 Server Silo 被创建"和"被终止"的通知,注册的时候还会顺便收到所有已经存在的 Silo 的通知(补课)。PsGetSiloMonitorContextSlot:每个监视器先拿到自己专属的槽索引。PsInsertSiloContext/PsReplaceSiloContext/PsRemoveSiloContext:拿到槽索引之后,用这几个函数往对应槽位插入、替换、移除自己的上下文数据。PsAllocSiloContextSlot:如果一个组件想同时维护两份不同的上下文,可以额外申请一个槽。PsInsertPermanentSiloContext/PsMakeSiloContextPermanent:用来创建"永久性"的 Silo 上下文——这类上下文不做引用计数,生命周期不跟着 Server Silo 或者"多少人正在获取这个上下文"走。PsGetSiloContext/PsGetPermanentSiloContext:把插入的上下文取回来用。
实验:Silo 监视器与上下文——以 AFD 驱动为例
书里以 AFD(Ancillary Function Driver,Winsock 辅助功能驱动) 为例,演示了监视器结构和 Silo 上下文的存取过程。
先转储代表监视器的数据结构(因为不在符号文件里,只能按原始数据来看):
lkd> dps poi(afd!AfdPodMonitor)
ffffe387'a79fc120 ffffe387'a7d760c0
ffffe387'a79fc128 ffffe387'a7b54b60
ffffe387'a79fc130 00000009'00000101 <- 这里的 9 就是槽索引
ffffe387'a79fc138 fffff807'be4b5b10 afd!AfdPodSiloCreateCallback
ffffe387'a79fc140 fffff807'be4bee40 afd!AfdPodSiloTerminateCallback
拿到槽索引 9 之后,去宿主 Silo 的 Storage 字段里查这个槽对应的内容。Storage 是一个数组,每个"槽条目"存了一个指针和一些标志位,槽索引要乘以 2 来算出偏移,再取偏移量的第二个字段(+1)拿到上下文指针:
lkd> r? @$t0 = (nt!_ESERVERSILO_GLOBALS*)@@masm(nt!PspHostSiloGlobals)
lkd> ?? ((void***)@$t0->Storage)[9 * 2 + 1]
void ** 0xffff988f'ab815941
注意这个指针最低位被 OR 上了 0x2(表示"永久"标志),要把这一位掩掉(& -2),再用 !object 扩展命令确认这确实是一个 Silo 上下文对象:
lkd> !object (0xffff988f'ab815941 & -2)
Object: ffff988fab815940 Type: (ffff988faaac9f20) PsSiloContextNonPaged
九、创建一个 Server Silo 的完整流程
创建 Server Silo 是分好几步走的,每一步都会调用 SetInformationJobObject 传入不同的信息类:
第一步:先建一个普通 Job
HANDLE hJob = CreateJobObjectW(NULL, NULL);
这一步在 Windows 周年更新之后做了改造:现在每个 Job 都会关联一个 JID(Job ID)。JID 和 PID(进程 ID)、TID(线程 ID)来自同一个号码池——也就是客户端 ID(CID)表。所以 JID 不仅在所有 Job 之间唯一,跟所有进程、线程的 ID 也不会重复。同时,系统还会自动生成一个容器 GUID。
第二步:把这个 Job 升级为"应用 Silo"
SetInformationJobObject(hJob, /* Create Silo 信息类 */, ...);
这一步会做两件事:
- 把
EJOB(代表 Job 的执行体对象)内部的 Silo 标志位置上; - 分配前面提到的 SLS 槽数组(存放在
EJOB的Storage成员里)。
到这一步,我们得到的还只是一个"应用 Silo"。
第三步:创建独立的根对象目录命名空间
SetInformationJobObject(hJob, /* 创建命名空间信息类 */, ...);
这个信息类要求调用者具备 TCB(可信计算基)特权。因为 Silo 通常只应该由 Vmcompute 服务来创建,这个特权要求是为了防止"虚拟对象命名空间"被恶意滥用,从而搞乱、破坏应用程序。
具体做法:对象管理器会在真实宿主根目录(\)下创建(或打开)一个 Silos 目录,再拼上 JID,形成一个新的虚拟根目录(比如 \Silos\148\)。然后在这个虚拟根下创建 KernelObjects、ObjectTypes、GLOBALROOT、DosDevices 这几个子对象。
这个新根目录会被存放在一个 Silo 上下文里,具体存到哪个槽索引,是由 PsObjectDirectorySiloContextSlot(在系统启动时由对象管理器申请)决定的。
第四步:把 Silo 升级为"服务器 Silo"
SetInformationJobObject(hJob, /* 转换为 Server Silo 信息类 */, ...);
这一步会调用内核函数 PspConvertSiloToServerSilo,用来初始化前面反复提到的 ESERVERSILO_GLOBALS 结构体,包括:
- Silo 专属的共享用户数据
- API Set 映射
SystemRoot- 各种驱动/组件自己的 Silo 上下文(比如 SRM 用来识别 Lsass.exe 的那个)
关键点:在转换进行的过程中,所有已经注册并启动了回调的 Silo 监视器都会收到通知,让它们有机会往这个新 Silo 里插入自己的上下文数据。
第五步:"启动"这个服务器 Silo
最后一步是给这个 Server Silo 初始化一个新的服务会话——可以把它想象成"专属于这个容器的 Session 0"。
实现方式:给 Smss 的 SmApiPort 发送一条 ALPC 消息,消息里带着 Vmcompute 创建的(现在已经变成 Server Silo 的)Job 对象句柄。
就像创建真实的用户会话一样,Smss 会克隆一份自己,唯一区别是:这个克隆体在创建的时候就直接和这个 Job 对象绑定了。这样一来,这个新的 Smss 副本就会附着在这个 Server Silo 的所有容器化元素上。Smss 会以为自己就是 Session 0,正常执行它一贯的职责:启动 Csrss.exe、Wininit.exe、Lsass.exe 等等。
"启动"流程照常继续:Wininit.exe 接着启动服务控制管理器(Services.exe),再由它启动各种设置了"自动启动"的服务,如此层层展开。
到此为止,新应用就可以在这个 Server Silo 里正常运行了,运行时使用的登录会话,正是前面提到的虚拟服务账户 LUID 关联的那个会话。
十、配套功能:让容器真正能跑起来
10.1 设备驱动的"每 Silo 化"
光走完上面的流程,"启动"实际上还是不会成功的。举个具体例子:Services.exe 初始化的时候,需要创建一个叫 ntsvcs 的命名管道,这需要跟 \Device\NamedPipe 通信——或者从 Services.exe 的视角看,其实是 \Silos\JID\Device\NamedPipe。但问题是:这个设备对象根本不存在!
所以,想让驱动访问正常工作,驱动必须"开明"起来,主动注册自己的 Silo 监视器,利用通知机制去为每个 Silo 单独创建自己的设备对象。
内核为此提供了一对 API:
PsAttachSiloToCurrentThread(hJob); // 临时把当前线程"贴"到指定 Silo 上
PsDetachSiloFromCurrentThread(); // 解除贴附
PsAttachSiloToCurrentThread 会临时把当前 ETHREAD(线程对象)的 Silo 字段设置成传入的 Job 对象,这样这个线程发出的所有访问(比如对对象管理器的访问)都会被当成是从这个 Silo 内部发起的。
举例来说:命名管道驱动可以借助这个能力,在 \Device 命名空间下创建一个 NamedPipe 对象,这个对象自然就会归属到 \Silos\JID\ 这个虚拟命名空间下了。
10.2 容器里的"交互式"体验是怎么来的
第二个问题:应用是在类似"服务会话"(Session 0 的克隆)里跑起来的,那它们怎么能有交互式的输入输出呢?
先说明一点:Windows 容器里没有 GUI,也不支持远程桌面(RDP)连接进容器——所以只有命令行程序能在里面跑。但即使是命令行程序,正常情况下也需要一个"交互式"会话才能收发输入输出,这又是怎么解决的?
秘密藏在一个专门的宿主进程 CExecSvc.exe(容器执行服务,Container Execution Service)里:
- 它用一根命名管道,跟宿主上的 Docker / Vmcompute 服务通信;
- 它负责在(容器内的)会话里真正拉起容器化的应用程序;
- 它还负责模拟通常由
Conhost.exe提供的控制台功能——把输入输出通过命名管道,转发给宿主上实际运行docker命令的那个命令提示符(或 PowerShell)窗口; - 使用
docker cp这类命令,在容器和宿主之间来回传文件时,用的也是这个服务。
10.3 容器模板文件(wsc.def)
即便驱动已经能通过 Silo 监视器"看人下菜碟"地创建自己的设备对象了,宿主机上还有无数其他对象(内核创建的、其他组件创建的)是 Session 0 里的服务需要跟外部通信才能用的,反过来也一样。
用户态并没有一个统一的"Silo 监视器系统"来支持这种"按需通信"的需求,而且逼着每个驱动都为每个 Silo 单独造一个设备对象,也完全没道理。
举例说明这个道理:
假如一个 Silo 想在声卡上放音乐,它没必要非得用一个单独的设备对象来代表这块声卡——这块声卡跟其他 Silo、乃至宿主自己访问的是完全同一块硬件。除非确实需要"每个 Silo 独立隔离声音",否则完全没必要重复造设备对象。
再举 AFD 的例子:AFD 虽然用了 Silo 监视器,但那只是为了搞清楚"该跟哪个用户态服务里的 DNS 客户端通信"——因为 DNS 客户端是按 Silo 隔离的,跟创建单独的\Silos\JID\Device\Afd对象没关系(系统里只有一套网络/Winsock 协议栈)。
除了驱动和对象之外,注册表里也有很多"全局"信息,必须让所有 Silo 都能看到、都存在,这部分交给 VReg 组件去做沙箱化处理。
为了支持这些复杂的需求,Silo 的命名空间、注册表、文件系统规则统一由一个容器模板文件定义,默认路径是:
%SystemRoot%\System32\Containers\wsc.def
(前提是在"启用或关闭 Windows 功能"对话框里打开了 Windows 容器功能)
这个文件描述了对象管理器和注册表命名空间的规则,允许按需定义指向宿主真实对象的符号链接,还描述了该用哪个 Job 对象、哪些卷挂载点、以及网络隔离策略。理论上,未来 Windows 里用到 Silo 对象的其他场景,也可以用不同的模板文件来提供别的容器化环境。
下面是 wsc.def 文件的一个节选片段(针对 cmdserver.exe 的 Silo 定义),带逐行解释:
<!-- 这是给 cmdserver.exe 用的 Silo 定义文件 -->
<container>
<namespace>
<ob shadow="false">
<!-- 下面几行是"符号链接",让 Silo 内部访问这些名字时,
实际指向宿主根目录下的真实对象 -->
<symlink name="FileSystem" path="\FileSystem" scope="Global" />
<symlink name="PdcPort" path="\PdcPort" scope="Global" />
<symlink name="SeRmCommandPort" path="\SeRmCommandPort" scope="Global" />
<symlink name="Registry" path="\Registry" scope="Global" />
<symlink name="Driver" path="\Driver" scope="Global" />
<!-- objdir 表示创建一个对象目录,clonesd 表示"克隆宿主对应目录的安全描述符" -->
<objdir name="BaseNamedObjects" clonesd="\BaseNamedObjects" shadow="false"/>
<objdir name="GLOBAL??" clonesd="\GLOBAL??" shadow="false">
<!-- 用来把宿主的目录映射进容器 -->
<symlink name="ContainerMappedDirectories" path="\ContainerMappedDirectories" scope="Local" />
<!-- 指向 \Device 下真实设备对象的合法链接 -->
<symlink name="WMIDataDevice" path="\Device\WMIDataDevice" scope="Local" />
...
<symlink name="UNC" path="\Device\Mup" scope="Local" />
</objdir>
<objdir name="Device" clonesd="\Device" shadow="false">
<!-- 这些都是"全局共享"的设备,比如网络栈 AFD,
所有 Silo 用的是宿主上同一份,不需要每个 Silo 单独造 -->
<symlink name="Afd" path="\Device\Afd" scope="Global" />
<symlink name="ahcache" path="\Device\ahcache" scope="Global" />
<symlink name="CNG" path="\Device\CNG" scope="Global" />
<symlink name="ConDrv" path="\Device\ConDrv" scope="Global" />
...
</objdir>
</ob>
</namespace>
<registry>
<!-- 加载一份只读的注册表 hive,作为容器的基础软件层配置 -->
<load
key="$SiloHivesRoot$\Silo$TopLayerName$Software_Base"
path="$TopLayerPath$\Hives\Software_Base"
ReadOnly="true"
/>
<!-- mkkey 表示新建一个注册表键,clonesd 同样表示克隆宿主对应键的安全描述符 -->
<mkkey
name="ControlSet001"
clonesd="\REGISTRY\Machine\SYSTEM\ControlSet001"
/>
<mkkey
name="ControlSet001\Control"
clonesd="\REGISTRY\Machine\SYSTEM\ControlSet001\Control"
/>
</registry>
</container>
几个关键属性的含义:
scope="Global":这个符号链接/对象在所有 Silo 之间共享同一份(比如网络设备 Afd,全系统只有一套)。scope="Local":这个对象/链接是每个 Silo 各自独立的一份。clonesd="...":表示新建这个对象目录/注册表键时,安全描述符(权限设置)从宿主对应路径克隆过来,保证权限语义一致。shadow="false":表示不做"影子"处理(跟对象是否需要额外的写时复制隔离相关)。ReadOnly="true"(注册表<load>标签里):这份 hive 以只读方式挂载,容器不能直接改动基础软件层配置。
十一、整体知识地图(用 mermaid 串一遍)
十二、小结
这一章讲的是 Windows 里"进程分组管理"的两个层次:
- **Job(作业对象)**是基础设施:把一堆进程绑在一起,统一限制 CPU/内存/I/O,统一记账,统一通知,还支持父子层级嵌套。
- **Server Silo(服务器 Silo)**是 Job 的"豪华升级版":在普通 Job 的基础上,再叠加独立的对象命名空间、独立注册表、独立文件系统视图、独立的 Smss 启动流程,从而实现一个"看起来像独立操作系统实例,实际共享同一个内核"的轻量级容器。
理解了 Job 的基本骨架,再叠加"命名空间隔离 + 注册表沙箱 + 文件系统沙箱 + 独立会话启动"这四块拼图,就能理解 Windows 容器(对应 Docker 在 Windows 下的实现)是怎么一步步搭建起来的。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)