Windows 驱动实例分析系列: injdrv驱动分析 - 设计篇
1. 项目概述
injdrv是一个开源的 Windows 内核驱动(KMD)示例项目,旨在通过 APC(Asynchronous Procedure Call,异步过程调用)机制,在进程初始化的早期阶段向用户态进程注入 DLL。该项目由 wbenny 开发,支持 Windows 7 至 Windows 10,覆盖 x86、x64、ARM32 和 ARM64 架构,并特别处理了 Wow64 进程的注入场景。
本文将从架构设计、注入方法、同步与内存管理等多个维度,剖析 injdrv 的核心设计原理。
2. 整体架构
injdrv 由三个主要模块组成:
| 模块 | 路径 | 功能 |
|---|---|---|
| 驱动核心 | injdrv.sys(由src/injdrv/和src/injlib/编译) | 内核驱动,负责注册回调、管理注入状态、执行 APC 注入 |
| 用户态加载器 | injldr.exe(src/injldr/) | 安装/卸载驱动,并启动 ETW 会话以接收日志 |
| 被注入的 DLL | injdll*.dll(src/injdll/) | 纯 ntdll 依赖的 DLL,演示函数钩子与 ETW 日志 |
驱动加载后,会注册两个关键回调:
- 进程创建/销毁通知:
PsSetCreateProcessNotifyRoutineEx - 映像加载通知:
PsSetLoadImageNotifyRoutine
当新进程创建时,驱动分配一个INJ_INJECTION_INFO结构体,记录进程 ID、已加载的系统 DLL 标志、LdrLoadDll地址等信息。该结构体挂载在全局链表InjInfoListHead中,供后续回调查询。
进程启动后,系统会按顺序加载ntdll.dll、wow64相关模块(仅 Wow64 进程)及导入表依赖的 DLL。每一次映像加载都会触发InjLoadImageNotifyRoutine,该回调会检查当前进程是否满足注入条件,若满足则通过 APC 触发注入。
3. 注入时序与状态机
injdrv 的核心设计原则是在正确的时机注入,避免因目标 DLL(如kernel32.dll)尚未完全初始化而导致LdrLoadDll调用失败。
3.1 状态追踪
每个进程的INJ_INJECTION_INFO中有一个LoadedDlls位掩码,记录已加载的关键系统 DLL。定义如下:
typedefenum_INJ_SYSTEM_DLL{INJ_SYSTEM32_NTDLL_LOADED=0x0008,INJ_SYSTEM32_WOW64_LOADED=0x0010,INJ_SYSTEM32_WOW64WIN_LOADED=0x0020,INJ_SYSTEM32_WOW64CPU_LOADED=0x0040,INJ_SYSWOW64_NTDLL_LOADED=0x0004,// ... 其他架构相关标志}INJ_SYSTEM_DLL;每当映像加载回调触发,若加载的 DLL 路径匹配上述系统 DLL(通过后缀匹配),则设置对应标志位,并捕获ntdll.dll中LdrLoadDll的地址。
3.2 注入条件判定
InjCanInject函数根据当前进程是否为 Wow64,计算所需的最小加载集合。例如:
- 原生 x64 进程:只需
INJ_SYSTEM32_NTDLL_LOADED - Wow64 x86 进程(x64 系统):需要
ntdll.dll(原生)、wow64.dll、wow64win.dll、wow64cpu.dll以及SysWOW64\ntdll.dll(32位)
只有当LoadedDlls包含所有必需标志时,才认为注入窗口已打开。
3.3 Windows 7 的特殊处理
在 Windows 7 下,Wow64 初始化过程中会加载kernel32.dll和user32.dll(原生与 Wow64 版本),但这些加载发生在wow64!ProcessInit中,此时 Wow64 子系统尚未完全就绪。因此,源码中额外推迟注入,直到这些 DLL 也被加载。
4. 三种注入方法
injdrv 实现了三种不同的注入策略,分别应对不同的架构和场景。
4.1 Thunk 方法(通用)
适用:所有架构。注入与目标进程同架构的 DLL。
原理:
- 在驱动中,通过
ZwCreateSection创建一个大小为PAGE_SIZE的内存区段。 - 首次映射为
PAGE_READWRITE,将一段 shellcode(thunk)和目标 DLL 路径写入该区段。 - 解除映射,再以
PAGE_EXECUTE_READ重新映射,使内存变为可执行但不可写,避免 RWX 权限。 - 队列一个用户态 APC,其
NormalRoutine指向这段 shellcode,NormalContext为LdrLoadDll地址,SystemArgument1/2分别为路径指针和长度。 - shellcode 执行时,构造
UNICODE_STRING并调用LdrLoadDll。
关键设计:为避免在映像加载回调中直接调用ZwMapViewOfSection导致递归死锁(因为映像加载本身可能发生在NtMapViewOfSection调用栈内),驱动先队列一个内核态 APC,该 APC 的KernelRoutine中执行实际的区段映射和用户态 APC 队列。内核态 APC 会在即将返回用户态时被调度,此时原始的MapViewOfSection已返回,AddressCreationLock已释放,从而避免死锁。
4.2 Thunkless 方法(仅 x64)
适用:Windows x64 系统。注入x64 DLL到原生 x64 进程和 Wow64 x86 进程。
背景挑战:在 Wow64 进程中,x86 CFG(Control Flow Guard)会阻止执行低于 4GB 地址的 shellcode,而 x64 的KiUserApcDispatcher位于高于 4GB 的地址,CFG 检查会失败。因此不能使用普通的 thunk 分配。
巧妙设计:
- 不分配可执行 shellcode,而是直接将
NormalRoutine设置为 x64ntdll.dll中LdrLoadDll的函数地址。 - APC 参数设置:
NormalContext = NULL→ 对应SearchPathSystemArgument1 = NULL→ 对应DllCharacteristicsSystemArgument2 = &DllName(指向一个在进程内存中分配的UNICODE_STRING)
- 依赖 x64 的 APC 分发机制:
KiUserApcDispatcher会传递第 4 个参数(即R9,指向CONTEXT结构)给NormalRoutine,正好作为LdrLoadDll的BaseAddress输出参数。LdrLoadDll会覆写CONTEXT.P1Home字段,该字段之后不再被使用,因此不会引发问题。
这种方法无需分配可执行内存,完美绕过 CFG 限制。
4.3 Wow64Log Reparse 方法(通用)
适用:所有架构。注入原生架构 DLL(与 OS 同架构)到所有进程(包括 Wow64)。
原理:
- Wow64 子系统在启动时会尝试加载
%SystemRoot%\System32\wow64log.dll,该 DLL 正常情况下不存在,加载失败被忽略。 - injdrv 实现了一个微过滤器(基于 WDK 的 SimRep 示例),在
IRP_MJ_CREATE预处理回调中,检测到对wow64log.dll的打开请求时,使用IoReplaceFileObjectName将路径替换为 injdll 原生 DLL 的路径,并返回STATUS_REPARSE。 - 这样系统会重新解析路径,实际加载 injdll,而 injdll 内部导出了
Wow64LogInitialize、Wow64LogMessageArgList等函数,满足 Wow64 的依赖检查,从而成功加载。
对于原生进程(不加载wow64.dll),则回退使用 Thunk 方法。
5. APC 强制交付机制
默认情况下,injdrv 在队列 APC 后调用KeTestAlertThread(UserMode),此函数会检查当前线程是否有用户态 APC 挂起,若有则设置Thread->ApcState.UserApcPending = TRUE,确保在下次内核→用户态切换时立即交付 APC。
如果禁用此强制(ForceUserApc = FALSE),APC 将延迟到线程进入可警告状态(例如调用NtAlertThread)时才会被交付,通常发生在进程初始化结束、即将进入main之前。但那时 TLS 回调可能已经执行完毕,因此注入点会稍晚。
6. 内存保护与安全
Thunk 方法中,为了规避ZwProtectVirtualMemory在 Windows 8.1 以下版本不可用的问题,采用区段重映射技巧:
- 创建区段时指定
PAGE_EXECUTE_READWRITE权限(仅用于区段对象本身)。 - 首次映射为
PAGE_READWRITE,写入数据。 - 解除映射,再次映射为
PAGE_EXECUTE_READ。
由于区段对象本身的权限是EXECUTE_READWRITE,只要映射时的保护不超过对象权限,系统允许。最终用户态内存只有EXECUTE_READ,杜绝了 RWX 内存段,提升安全性。
7. 跨架构与 Wow64 支持
injdrv 通过编译时宏和运行时检测,实现多架构支持:
- 架构枚举:
INJ_ARCHITECTURE定义 x86/x64/ARM32/ARM64。 - 系统 DLL 列表:包含不同架构下的 ntdll 路径(如
\SysWOW64\ntdll.dll、\SysArm32\ntdll.dll等),通过后缀匹配识别。 - Wow64 检测:使用
PsGetProcessWow64Process判断是否为 Wow64 进程,使用PsWow64GetProcessMachine在 ARM64 上区分模拟的 x86 或 ARM32 环境。 - APC 包装:对于 Wow64 进程,调用
PsWrapApcWow64Thread修改 APC 参数,使原生ntdll.dll的KiUserApcDispatcher能够正确地将 APC 路由到 Wow64 的 32 位分发器。
8. ETW 日志与钩子演示
被注入的injdll.dll仅依赖ntdll.dll,它通过 Detours 库挂钩NtQuerySystemInformation和NtCreateThreadEx。为了在不引入advapi32.dll的情况下使用 ETW,代码通过宏将EventWriteString等 API 映射到ntdll.dll中的EtwEventWriteString等原生函数,从而直接调用内核 ETW 服务。
用户态工具injldr.exe在启动时创建 ETW 会话并注册回调,实时打印被挂钩函数的调用信息。
9. 受保护进程的处理
驱动会调用PsIsProtectedProcess检查目标进程是否受保护(如 PPL)。若是,则直接跳过注入并释放INJ_INJECTION_INFO,避免触发代码完整性错误。项目文档提及,可通过操纵未公开结构临时解除保护,但此举风险极高,不在本设计范围内。
10. 总结
injdrv 的设计体现了对 Windows 内核机制(APC、进程回调、内存区段、过滤器、CFG)的深刻理解,通过分层决策和多种注入策略,在兼容性、安全性和可靠性之间取得了平衡。其核心思想可归纳为:
- 时机精确:追踪系统 DLL 加载状态,确保
ntdll.dll和 Wow64 组件准备就绪。 - 内存安全:利用区段重映射避免 RWX 内存,利用内核 APC 规避死锁。
- 架构自适:针对不同 CPU 架构和 Wow64 环境选择最优注入路径。
- 利用系统特性:如 Thunkless 方法巧妙借用 APC 调用约定,Reparse 方法利用文件重解析注入原生 DLL。
该项目不仅是学习 Windows 驱动开发的优秀范例,也为安全研究人员深入理解进程注入技术和系统内部行为提供了宝贵的参考。后续我们将继续分析其代码实现细节,敬请期待下一篇实现篇。