说明
本项目由 AI 完成。仓库中的实现、构建脚本和技术文档都是在 AI 辅助下写成的,本文是对这些记录的整理,不是事后补写的人工实验报告。文中的 BugCheck、偏移和失败现象来自项目笔记。换一台机器、换一个 Windows 版本,同样的数字不一定还能对上,请以自己的调试器为准。
实验只应在自己的虚拟机里做,并且关闭 Secure Boot。本文讨论的是固件交接 Windows 之后,内核对象管理器和内存池何时可用。请勿用于未授权的系统。
1. 要解决的问题
目标很窄:在 Windows 启动早期,把一个只依赖 ntoskrnl.exe 和 hal.dll 的最小驱动放进非分页内存,再调用它的 DriverEntry。
加载分成三段。
- 一个 EFI Application 作为 Loader,读入 Runtime Driver,再把控制权交还给机器上原来的 Windows Boot Manager。
- 这个 Runtime Driver 活过
ExitBootServices。SetVirtualAddressMap 之后,它的代码出现在 Windows 的虚拟地址空间里。
- Windows 早期若调用被挂钩的运行时服务,驱动在内核侧做就绪判断,通过之后才映射载荷。
SetVirtualAddressMap 完成之前的调用来自 bootmgfw / winload 这一段,此时还没有内核虚拟地址空间,必须原样放行。映射若放在这个时刻,后面的池分配和模块链表都还不存在。
2. 就绪门:为什么不能一进钩子就调用 DriverEntry
钩子第一次被内核调用时,往往仍处于 Phase 0 / Phase 1。对象管理器的高层接口、安全引用监视器和若干执行体组件还没有初始化完。
项目里曾经用 ObReferenceObjectByName 去探测 \Driver\Null,结果是 0xC0000005,栈落在 nt!SepCreateAccessStateFromSubjectContext。安全主体上下文还是空的,这次查询本身就会崩。早期路径改为只读查看已加载模块:null.sys 出现,才认为 I/O 管理器侧已经走过一段。这里不能把「名字能解析」当成「对象管理器可以安全使用」。
模块链表也踩过匹配错误。FullDllName 带系统路径,形如 \SystemRoot\System32\hal.dll,拿它和 "hal.dll" 比较,第一个字符就是反斜杠,永远匹配不上。短名在 BaseDllName。这个字段的偏移随系统版本变化,笔记里的 +0x58 / +0x60 只对当时调试的版本有效,发版前要用符号再对一次。
除了模块名,门禁还要求当时是 PASSIVE_LEVEL,并且一次最小的非分页分配能够成功并释放。单次通过不够,连续多次通过再进入映射,避免刚好转到可用又立刻变回去。
延迟不能靠忙等。KeDelayExecutionThread 的间隔单位是 100 纳秒。250 毫秒是 -2500000,少写一个零就只睡了 25 毫秒,重试会在内核还没起来时耗尽。
3. 谁来触发
最初只挂钩 GetTime,指望 HAL 读 RTC 时进来。一部分主板上,SetVirtualAddressMap 之后几乎看不到这次调用。计时走 HPET 或 TSC 时,固件的 GetTime 不再是早期内核的必经之路。
变量服务是更稳的补充通道。启动早期的代码完整性、Secure Boot 相关逻辑会读变量,GetVariable、SetVariable、GetNextVariableName 至少有一个会被碰到。多通道只解决「有没有人叫醒映射器」,不代替上一节的就绪判断。某个服务被调用,只说明固件运行时还在,不说明池和模块链表已经可用。
4. 可执行池
Windows 10 2004 之后出现 ExAllocatePool2,POOL_FLAG 的布局在不同 SDK 之间变过。用错枚举时,分到的非分页内存带 NX。映射完成后一执行就是 0x50,参数指向不可执行。
项目改回用了很久的 ExAllocatePoolWithTag,池类型取经典的 NonPagedPool(数值 0)。这不是「新接口不能用」,而是旗标必须和手头这套 WDK 头文件核对之后才能用。若坚持用 ExAllocatePool2,把「可执行」对应的旗标在当前 SDK 里打印出来,再和分配结果的页属性对照。
5. 三处 PE 解析错误
这三处和隐藏无关,是手动映射时把结构体读错。
数据目录偏移。 64 位可选头里,DataDirectory 相对可选头起点是 +0x70。从 NT 头算起还要加上 FileHeader 的 0x18,整体是 +0x88。少加这一截时,读到的是 SizeOfHeapReserve 一类字段,导出表 RVA 是错的,向前扫会碰到未映射页,表现为 0x50。导出、重定位、导入、加载配置目录要用同一个基址。
导入描述符宽度。 IMAGE_IMPORT_DESCRIPTOR 的 OriginalFirstThunk、TimeDateStamp、ForwarderChain、Name、FirstThunk 都是 32 位,整项 20 字节。在 x64 上把它们当成 64 位来读,相邻的 Name 会拼进 FirstThunk 的高位,遍历步长也不再是 20,随后就是野指针。
转发导出。 从 hal.dll 取出 KeQueryPerformanceCounter 后立即 0xFC(ATTEMPTED_EXECUTE_OF_NOEXECUTE_MEMORY)。这个导出是转发项,AddressOfFunctions 里的 RVA 落在导出目录自身范围内,指向 .rdata 中的字符串 ntoskrnl.KeQueryPerformanceCounter,不是代码。RVA 落在导出目录 [VirtualAddress, VirtualAddress + Size) 内时,要解析转发串,再去目标模块找真正的函数。
6. 载荷侧当时能成立的前提
映射器不会提供一个正常的 DRIVER_OBJECT,入口要能接受 DriverEntry(NULL, NULL)。驱动应是 x64 PE32+,保留重定位,按名字导入,静态依赖停在 ntoskrnl.exe 和 hal.dll。/guard:cf 不能开:这条路径没有经过内核的 CFG 注册,间接调用会崩。
DriverEntry 里不要等待即插即用设备。虚拟机上的 PS/2 设备很早就能看到,物理机上的 USB 键盘鼠标要等总线枚举。同一份驱动在虚拟机里正常、在真机上返回 STATUS_DEVICE_NOT_READY (0xC00000A3),多半是把「设备已经存在」写进了入口。外设绑定放到以后真正要用的时候,并限制重试频率。
镜像大小、导入模块和入口约定可以用 dumpbin /headers 与 dumpbin /imports 在编译后先看一遍,再交给映射器。
7. 引导失败时的退路
映射放在启动链上,一次失败就会变成反复重启。Loader 用一个 NVRAM 计数记住尚未确认的启动,连续失败达到阈值后跳过映射,直接进入原来的 Windows。Loader 写入和驱动清除必须使用同一组变量名和 Vendor GUID。清除侧若误用 gEfiGlobalVariableGuid,成功之后计数清不掉,下一次仍会被当成失败。
NVRAM 变量会留在主板上。调试状态不要写成长期变量;计数器只作为防止启动死循环的手段,确认成功后应删掉。
另一件和固件有关的事:部分主板开快速启动时,不会主动连接全部存储控制器,Loader 因此找不到 Windows 所在的 ESP。启动后对控制器做一次递归 ConnectController,存储栈才会齐。
8. 记录里的验证范围
构建依赖 EDK2、NASM、Visual Studio 2026 和 Python 3。脚本里的安装路径是开发机上的绝对路径,别人编译前要改成自己的。
笔记中的现象来自虚拟机和部分实机,不是全平台矩阵。下面几条在文档里被当成边界,而不是已证明的普遍结论。
- 关闭 Secure Boot 才走这条链。打开时,外来的 Loader 和 Runtime Driver 不会按本文的方式被执行。
- 开启 HVCI、VBS 或固件的镜像页保护后,
EfiRuntimeServicesCode 可以是只读可执行。任何在运行时改这块镜像的假设都会变成 #PF,而且只在一部分机器上出现。
BaseDllName 的偏移、池旗标和「null.sys 已加载即表示足够晚」都绑定在当时的系统版本上。
9. 附件
附件只放源码:DxeMapperPkg、生成载荷数组的脚本,以及把 EDK2 / NASM / vcvars64 改成可配置路径之后的构建脚本。不要附带 build_ws、预编译的 .efi、.sys、部署 ISO 和内核调试日志。
读代码时按这个顺序:Loader 如何把 Runtime Driver 装进来并交还 bootmgfw,运行时服务挂钩如何在 SetVirtualAddressMap 之前放行,映射前的就绪判断,然后才是重定位、导入和 cookie。蓝屏记录以第 2 节到第 5 节为准。