首页
社区
课程
招聘
[分享]DXE Runtime 之后的早期内核手动映射驱动(WIN11 25H2)
发表于: 4天前 316

[分享]DXE Runtime 之后的早期内核手动映射驱动(WIN11 25H2)

4天前
316

说明

本项目由 AI 完成。仓库中的实现、构建脚本和技术文档都是在 AI 辅助下写成的,本文是对这些记录的整理,不是事后补写的人工实验报告。文中的 BugCheck、偏移和失败现象来自项目笔记。换一台机器、换一个 Windows 版本,同样的数字不一定还能对上,请以自己的调试器为准。

实验只应在自己的虚拟机里做,并且关闭 Secure Boot。本文讨论的是固件交接 Windows 之后,内核对象管理器和内存池何时可用。请勿用于未授权的系统。

1. 要解决的问题

目标很窄:在 Windows 启动早期,把一个只依赖 ntoskrnl.exe 和 hal.dll 的最小驱动放进非分页内存,再调用它的 DriverEntry。

加载分成三段。

  1. 一个 EFI Application 作为 Loader,读入 Runtime Driver,再把控制权交还给机器上原来的 Windows Boot Manager。
  2. 这个 Runtime Driver 活过 ExitBootServices。SetVirtualAddressMap 之后,它的代码出现在 Windows 的虚拟地址空间里。
  3. 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 节为准。


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

上传的附件:
收藏
点赞 ・2
打赏
分享
最新回复 (1)
雪    币: 9280
活跃值: (13704)
能力值: (RANK:385 )
在线值:
发帖
回帖
粉丝
2 楼
感谢楼主分享 DXE Runtime 之后早期内核手动映射驱动的实现思路与踩坑记录

帖子要点:
1. Loader 用 EFI Application 读入 Runtime Driver 并交还原 Boot Manager,驱动活过 ExitBootServices 后进入内核虚拟地址空间
2. SetVirtualAddressMap 之前的调用来自 bootmgfw/winload,无内核地址空间须原样放行,映射不能放在此时
3. 就绪门:钩子首次被调用常处于 Phase 0/1,用 ObReferenceObjectByName 探测会崩,改为只读查看已加载模块
4. 模块匹配用 BaseDllName 而非带路径的 FullDllName,偏移随版本变化需用符号核对
5. 门禁还要求 PASSIVE_LEVEL 且最小非分页分配成功,连续多次通过再映射,延迟用 KeDelayExecutionThread 按 100ns 单位
6. 触发通道:GetTime 在部分主板不可靠,补充 GetVariable/SetVariable/GetNextVariableName 多通道,但不替代就绪判断
7. 可执行池:ExAllocatePool2 旗标布局变动易致 NX,改回 ExAllocatePoolWithTag 取 NonPagedPool
8. 三处 PE 解析错误:DataDirectory 偏移应为 +0x88、导入描述符按 20 字节读、转发导出需解析转发串再找目标模块
9. 载荷前提:入口接受 DriverEntry(NULL,NULL),x64 PE32+ 保留重定位按名导入,不开 /guard:cf,入口不等待即插即用设备
10. 引导失败退路:NVRAM 计数达阈值跳过映射,写入与清除须同一组变量名和 Vendor GUID,快速启动时需递归 ConnectController
11. 验证范围:依赖 EDK2/NASM/VS2026/Python3,关闭 Secure Boot,HVCI/VBS 下运行时镜像可能只读可执行

谢谢楼主的记录,读完对早期映射的时机把握有了更具体的认识,也聊几点想法:
· 优点:把就绪判断拆成模块可见、IRQL 和池分配三重条件,比单纯靠服务调用触发更稳,思路可复用
· 局限:BaseDllName 偏移、池旗标和 null.sys 判据都绑定系统版本,换版本要重新核对,移植成本不低
· 对比:相比常规驱动加载依赖对象管理器,这条路径绕开正常加载流程,换来更早的执行时机,但失去 DRIVER_OBJECT 和 CFG 注册等保障
· 对比:NVRAM 计数防死循环是实用做法,不过变量残留主板,和纯调试器手动触发相比多了一层清理负担
大家看完帖子,对就绪门或 PE 解析部分有不同做法,欢迎一起聊聊

小结由 AI 辅助整理,方便大家先看个大概,完整内容还是看楼主原帖更准确。
4天前
0
游客
登录 | 注册 方可回帖
返回