内存暴力搜索隐藏驱动 —— PE 特征扫描 + 模块列表差集
1. 伏笔:上一篇留下的那个盲区
上一篇(隐藏驱动检测)我讲了两个视角的交叉验证:模块列表(PsLoadedModuleList)和对象目录(\Driver、\FileSystem)。写完我顺手模拟了一下攻击者视角,发现一个问题:
如果攻击者 摘链
PsLoadedModuleList+ 不注册\Driver对象,两个视角会同时落空——驱动”理论上不存在”,但它明明在内核里跑着。
这就是所谓的纯内存驱动(memory-only driver):不落地、不注册对象,代码直接拷进内核内存执行。传统的驱动检测手段对它全部失效。
我当时在上一篇结尾留了个伏笔:DRIVER_OBJECT 是真实存在于内核内存里的结构,能不能直接扫内存把它捞出来?这篇就来填这个坑。
2. 攻击者模型:驱动到底怎么”藏”
先明确一下我们要对抗的威胁模型。一个驱动想隐藏,需要同时做两件事:
| 视角 | 隐藏手段 |
|---|---|
模块列表(PsLoadedModuleList) |
摘链:从双向链表里摘掉自己的 LDR_DATA_TABLE_ENTRY |
对象目录(\Driver 等) |
不注册 DRIVER_OBJECT 到对象管理器命名空间 |
注意一个关键点:这两个手段都是在”登记处”动手脚,而不是销毁对象本身。驱动要跑,DRIVER_OBJECT 这个结构体就必须活在内存里——它是 I/O 子系统操作驱动的唯一凭据(派发例程、卸载例程都挂在上面)。
所以问题变成了:怎么找到这块”没登记但真实存在”的可执行内存?
如果我是攻击者,怎么让检测失效?
- 双杀:摘链
PsLoadedModuleList+ 同时从\Driver目录删除对象(需调用ObMakeTemporaryObject等,但也会留下新的痕迹);- 不注册 DRIVER_OBJECT:纯内存执行,两个视角都看不到 → 引出本篇方案;
- 伪造 DriverSection->DllBase:指向一个合法模块的基址,交叉比对时被”吸附”到合法基址,产生假阴性;
- 彻底 shellcode 化:无 PE 头、无驱动对象,纯手工 hook 内核代码——这是 inline hook 检测的战场(见本系列后续篇)。
检测的本质不是”找到所有坏人”,而是提高攻击者的隐藏成本。
3. 方案对比:DRIVER_OBJECT 特征 vs PE 头特征
理论上可以直接扫描 DRIVER_OBJECT 结构体特征,但我最终选择了 PE 头特征。以下是两条路线的对比和取舍过程。
3.1 DRIVER_OBJECT 特征(精确但脆)
DRIVER_OBJECT 是驱动在内核里的身份证。它在 x64 上是 0x150 字节,关键字段如下(以 Win10 1809 x64 实测布局为准,我用 IDA 反编译 afd.sys 的 DriverEntry 验证过字段用法):
1 | typedef struct _DRIVER_OBJECT { |
💡 类比理解:
MajorFunction就是一张 28 格子的”接线表”,每个格子对应一类 IRP,填的是处理函数地址。驱动要处理什么请求,就往对应格子塞函数指针。这张表就是驱动功能的索引——攻击者想改驱动行为,或者我们想识别驱动,都得看它。
三个最值钱的”指纹”(组合起来几乎不可能被自然数据巧合满足):
Type == IO_TYPE_DRIVER (0x0B):I/O 子系统初始化驱动对象时写入的魔数;DriverExtension->DriverObject == 自身:_DRIVER_EXTENSION第一个字段回指驱动对象指针。这是最强的判据——相互引用、自我指涉的数据结构,随机数据几乎不可能形成这种闭环;MajorFunction[0..27]全部指向内核代码区:28 个指针都必须落在内核可执行地址范围内。
但这条路我最终没选,原因很现实:
- 布局跨版本敏感:字段偏移、
IO_TYPE_DRIVER魔数在不同 Windows 版本可能不同; - 扫描范围难定:对象在非分页池,而 Win10 1809+ 非分页池定位本身就很麻烦;
- 实现成本高:需精确结构布局 + 池定位,维护负担重。
结论:DRIVER_OBJECT 四关卡校验留着当升级方案,先不实现。
3.2 PE 头特征(粗糙但稳)
驱动是一个 PE 镜像,必然以 MZ 头(IMAGE_DOS_SIGNATURE)+ PE 签名(IMAGE_NT_SIGNATURE)开头。这个特征:
- 跨版本稳定(PE 格式几十年没变过);
- 只需读内存开头几十字节(实现成本低);
- 配合模块列表差集就能区分正常驱动和隐藏驱动。
设计取舍:与其”扫结构体特征”(精确但脆),不如”扫 PE 镜像特征 + 和模块列表做差集”(粗糙但稳)——凡是带合法 PE 头、又不在模块列表里的可执行页 = 疑似隐藏驱动。
| 维度 | PE 头特征(本篇实现) | DRIVER_OBJECT 特征(升级方案) |
|---|---|---|
| 特征位置 | 镜像开头(MZ/PE 签名) | 结构体内部(Type/回指/MajorFunction) |
| 跨版本稳定性 | 高(PE 格式恒定) | 低(结构布局随版本变) |
| 能抓到 | PE 形态的隐藏驱动 | 有完整驱动对象结构的隐藏驱动 |
| 实现成本 | 低(读几十字节) | 高(需精确布局+池定位) |
4. 扫描范围与性能考量
这一步我纠结了最久,也是我觉得这篇最有价值的部分——怎么在合理时间内扫完内核内存,以下都是我的试错过程。
4.1 无脑方案:全地址空间逐字节扫
x64 内核地址空间约 128 TB。按 16 字节步进 = 8 万亿次检查,而且大部分地址是无效映射,直接读会触发页面错误蓝屏。这个方案在第一分钟就该被毙掉。
4.2 改进:按页遍历 + 先验页有效性
128 TB / 4 KB = 340 亿页,每页一次 MmIsAddressValid,哪怕单次 100ns 也要近 1 小时。作为 ARK 的”开机扫描”仍然不可接受。
4.3 最终选择:先扫”内核驱动加载区”(有已知盲区)
穷举地址空间不现实,但纯内存驱动的代码总不能凭空出现——它要么拷进驱动加载区(ntoskrnl 附近的系统模块区域),要么落在非分页池。
我选择先扫 [ntoskrnl基址 - 0x70000000, ntoskrnl基址] 的驱动加载区:
1 | ' 扫描范围 = [ntoskrnl基址 - 1.75GB, ntoskrnl基址] |
共约 44.8 万页,比 128 TB 全空间小了四个数量级,从”扫不完”变成”可接受”。
已知盲区:
- 攻击者若将驱动代码拷到非分页池高地址段,本版扫不到——这是当前实现最现实的绕过路径;
- v2 计划补全非分页池定位(见第 8 节)。
4.4 多线程分片
44.8 万页不能单线程傻扫。解决方式是多线程分片:把 [StartAddr, EndAddr] 按线程数均分,每个线程扫一段,互斥锁保护共享结果数组。
关键:区间边界必须按 PAGE_SIZE 对齐,否则 StartAddr + i * addrRange 落在页中间,漏掉页首的 MZ 头(PE 头在镜像基址=页首)。我一开始没对齐,扫出来的结果少了一大截,debug 半天才发现。
4.5 性能优化(架构设计阶段即考虑)
第一版实现是每页一次 IOCTL——44.8 万页就是 44.8 万次用户态↔内核态往返。作为验证思路的第一版能接受,但正式功能必须优化:
- 批量化:把一批页地址塞进一个缓冲区,一次 IOCTL 让驱动循环检查,回传结果数组——IOCTL 次数从 44.8 万降到几十;
- 驱动内差集:干脆驱动直接拿
SystemModuleInformation缓存模块基址集合,在驱动内完成差集,用户态只收最终结果。
本版先跑通每页 IOCTL 的验证逻辑,优化在 v2 实现。
5. 特征校验:PE 头 + 模块列表差集
范围扫到了,接下来是”怎么算命中”。命中判定拆成两半:驱动端认 PE 头,用户态认模块归属。
5.1 驱动端:MZ → PE 签名两步走
内核里逐页检查,读页首数据验证 PE 头。安全读是核心——内核地址不是都能直接解引用的,读无效页会直接蓝屏,所以必须先预检再拷贝:
1 | // ⚠️ 以下为核心逻辑示意,非完整可编译代码 |
💡 类比理解:这就像保安查门禁——每个进楼的人都要带工牌(PE 头),保安只拦”没登记的人”(模块列表里没有的 PE)。工牌本身人人都有,关键在登记簿上有没有你的名字。
5.2 用户态:和模块列表做差集
驱动端只负责”这页长得像 PE 镜像”,它不区分正常驱动和隐藏驱动(正常驱动也是 PE)。区分工作交给用户态:命中页地址用 GetDriverPathByAddr 反查它属于哪个已加载模块——
- 能查到路径 → 是已知模块的镜像,正常;
- 查不到 → 游离 PE 镜像 = 疑似隐藏驱动,记录地址,之后和模块列表交叉复核。
6. 实现:驱动端扫 + 用户态多线程调度
6.1 总体架构
1 | 用户态(FreeBasic) 驱动端(SnowSword.sys) |
6.2 用户态线程工作函数(核心逻辑)
1 | ' ⚠️ 以下为核心逻辑示意,非完整可编译代码 |
命中后用户态再调 GetDriverPathByAddr(Addr) 判断模块归属:有路径 → 正常模块;无路径 → 疑似隐藏驱动。
7. 实验计划(待执行)
⚠️ 本文是设计稿,实验结果将在后续补充。
| 步骤 | 内容 | 状态 |
|---|---|---|
| Step 1 | 干净系统自检:扫描驱动区,所有 PE 命中都应能归属到已知模块 | ⏳ 待执行 |
| Step 2 | 对抗测试:构造纯内存驱动样本(摘链 + 不注册对象),验证能否检出 | ⏳ 待执行 |
Step 1:特征自检(先证明扫描没瞎)
在干净的 Win10 1809 虚拟机里,加载 SnowSword 驱动跑扫描:
- 扫描
[ntoskrnl基址 - 0x70000000, ntoskrnl基址]驱动区,收集所有 PE 特征命中页; - 对每个命中地址调
GetDriverPathByAddr反查模块归属,与SystemModuleInformation模块列表交叉; - 预期:正常系统里,所有命中都应能归属到已知模块(
ntoskrnl、hal、afd等);归属不到模块的命中数为 0——这是自检通过的第一道标准; - 用 windbg
lm/!drvobj抽查几个命中地址,确认 PE 头校验没有把数据区误认成镜像。
自检通过的标准:命中全部可解释——每个 PE 命中都能对应一个已知模块,没有莫名其妙的多余项,也没有把正常驱动漏掉。
Step 2:对抗测试(证明能揪出隐藏驱动)
构造一个”纯内存驱动”样本:
- 写一个普通驱动,
DriverEntry里自摘链:把自己的DriverSection->InLoadOrderLinks从PsLoadedModuleList摘掉; - 不调用
IoCreateDevice(不产生设备对象),也不依赖\Driver目录注册的常规路径——模拟”双视角皆盲”; - 再跑一遍扫描,预期:模块列表和对象目录都看不到它,但 PE 扫描按镜像特征把它捞出来——
GetDriverPathByAddr查不到归属 = 命中列表里出现一个”游离 PE”;
8. 已知局限与后续计划
8.1 当前局限
- 扫描范围盲区:只扫驱动加载区,非分页池高地址段未覆盖——攻击者把代码拷到非分页池即可绕过;
- IOCTL 频率:每页一次 IOCTL,44.8 万次上下文切换,全扫分钟级;
- PE 头可抹除:攻击者加载后擦掉 MZ/PE 魔数即可让本方案失效(但会破坏 I/O 子系统的加载假设,纯 shellcode 才能这么干)。
8.2 后续计划
- v2:补全非分页池定位(Win10 1809+ 无
MmNonPagedPoolStart/End导出,需特征扫_POOL_DESCRIPTOR或借 windbg/PDB); - 升级方案:DRIVER_OBJECT 四关卡校验(Type + 回指 + MajorFunction),覆盖”抹掉 PE 头但保留驱动对象”的场景。
9. 遇到的问题与注意事项
- 页对齐:扫描区间边界必须按
PAGE_SIZE对齐,否则漏掉页首的 MZ 头(4.4 节有详细说明); - 地址类型宽度:内核地址是 64 位的,用户态保存扫描范围/结果时必须用
ULongInt,不能用ULong。截断后起始地址直接错位,扫出来全是噪音; - 安全读内核内存:不能直接解引用,必须先
ProbeForRead预检 +MmCopyMemory安全拷贝 +__try/__except兜底——缺一步都可能蓝屏; - IOCTL 频率:每页一次 IOCTL 有几十万次上下文切换,全扫分钟级;正式功能必须批量化(见 4.5);
- 调试输出:
DbgPrint别放在逐页循环里,命中才会打还好,每页都打能把调试器刷到卡死; - 误报:PE 头校验命中的”游离镜像”只是疑似,需和模块列表差集 + 人工复核,不自动判定恶意。
参考
- Windows Internals, 7th Edition(加载器 / 驱动对象 / 内存管理章节)
- Microsoft Learn: IMAGE_NT_HEADERS
- 本系列前篇:日报_隐藏驱动检测_改进版(本篇的伏笔出处)、SnowSword–进程隐藏检测实现
- 实验佐证:IDA 反编译 win10 1809 afd.sys DriverEntry(DRIVER_OBJECT 字段用法,为升级方案做的预研)
相关项目与联系
项目SnowSword —— Windows 10/11 x64 内核分析与 Anti-Rootkit 工具,源码与发布包在 Gitee。
技术合作Windows 内核 / 驱动开发 / 逆向分析 / 兼容性开发,范围与边界见 技术合作。
联系2160752730@qq.com · QQ 2160752730 · 微信 lst_3258110
