1. 伏笔:上一篇留下的那个盲区

上一篇(隐藏驱动检测)我讲了两个视角的交叉验证:模块列表(PsLoadedModuleList)和对象目录(\Driver、\FileSystem)。写完我顺手模拟了一下攻击者视角,发现一个问题:

如果攻击者 摘链 PsLoadedModuleList + 不注册 \Driver 对象,两个视角会同时落空——驱动”理论上不存在”,但它明明在内核里跑着。

这就是所谓的纯内存驱动(memory-only driver):不落地、不注册对象,代码直接拷进内核内存执行。传统的驱动检测手段对它全部失效。

我当时在上一篇结尾留了个伏笔:DRIVER_OBJECT 是真实存在于内核内存里的结构,能不能直接扫内存把它捞出来?这篇就来填这个坑。

2. 攻击者模型:驱动到底怎么”藏”

先明确一下我们要对抗的威胁模型。一个驱动想隐藏,需要同时做两件事:

视角 隐藏手段
模块列表(PsLoadedModuleList) 摘链:从双向链表里摘掉自己的 LDR_DATA_TABLE_ENTRY
对象目录(\Driver 等) 不注册 DRIVER_OBJECT 到对象管理器命名空间

注意一个关键点:这两个手段都是在”登记处”动手脚,而不是销毁对象本身。驱动要跑,DRIVER_OBJECT 这个结构体就必须活在内存里——它是 I/O 子系统操作驱动的唯一凭据(派发例程、卸载例程都挂在上面)。

所以问题变成了:怎么找到这块”没登记但真实存在”的可执行内存?

如果我是攻击者,怎么让检测失效?

  1. 双杀:摘链 PsLoadedModuleList + 同时从 \Driver 目录删除对象(需调用 ObMakeTemporaryObject 等,但也会留下新的痕迹);
  2. 不注册 DRIVER_OBJECT:纯内存执行,两个视角都看不到 → 引出本篇方案;
  3. 伪造 DriverSection->DllBase:指向一个合法模块的基址,交叉比对时被”吸附”到合法基址,产生假阴性;
  4. 彻底 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct _DRIVER_OBJECT {
CSHORT Type; // +0x00 ← 类型魔数 IO_TYPE_DRIVER (0x0B)
CSHORT Size; // +0x02 ← 结构大小 0x150
PDEVICE_OBJECT DeviceObject; // +0x08
ULONG Flags; // +0x10
PVOID DriverStart; // +0x18 ← 驱动代码基址
ULONG DriverSize; // +0x20
PVOID DriverSection; // +0x28 ← LDR_DATA_TABLE_ENTRY*(模块链表节点)
PDRIVER_EXTENSION DriverExtension; // +0x30 ← 扩展结构,内含回指
UNICODE_STRING DriverName; // +0x38 ← 驱动名字符串
PUNICODE_STRING HardwareDatabase; // +0x48
PFAST_IO_DISPATCH FastIoDispatch; // +0x50
PVOID DriverInit; // +0x58
PVOID DriverStartIo; // +0x60
PVOID DriverUnload; // +0x68
PVOID MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1]; // +0x70,共 28 个
} DRIVER_OBJECT;

💡 类比理解:MajorFunction 就是一张 28 格子的”接线表”,每个格子对应一类 IRP,填的是处理函数地址。驱动要处理什么请求,就往对应格子塞函数指针。这张表就是驱动功能的索引——攻击者想改驱动行为,或者我们想识别驱动,都得看它。

三个最值钱的”指纹”(组合起来几乎不可能被自然数据巧合满足):

  1. Type == IO_TYPE_DRIVER (0x0B):I/O 子系统初始化驱动对象时写入的魔数;
  2. DriverExtension->DriverObject == 自身:_DRIVER_EXTENSION 第一个字段回指驱动对象指针。这是最强的判据——相互引用、自我指涉的数据结构,随机数据几乎不可能形成这种闭环;
  3. 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
2
3
' 扫描范围 = [ntoskrnl基址 - 1.75GB, ntoskrnl基址]
ulonglong endAddr = GetKernelModuleBase("ntoskrnl.exe", &ModuleSize);
uonglong startAddr = EndAddr - 0x70000000;

共约 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
2
3
4
5
6
7
8
9
10
11
12
13
// ⚠️ 以下为核心逻辑示意,非完整可编译代码
// 实际实现需自行补全安全拷贝、异常处理、入参校验

// 1. 页对齐粗检 + ProbeForRead 快速预检(比直接拷贝快一个量级)
// 2. 安全拷贝 DOS 头(MZ 魔数)——必须用 MmCopyMemory/VxkCopyMemory,不能直接解引用
if (dosHeader.e_magic != IMAGE_DOS_SIGNATURE) // 'MZ'
return Normal;

// 3. MZ 命中,再安全拷贝 PE 头(e_lfanew 指向 PE 签名)
if (ntHeader.Signature == IMAGE_NT_SIGNATURE) { // 'PE\0\0'
// 4. 疑似 PE 镜像 → 标记为候选隐藏驱动,交用户态做模块归属差集
return HideDriver;
}

💡 类比理解:这就像保安查门禁——每个进楼的人都要带工牌(PE 头),保安只拦”没登记的人”(模块列表里没有的 PE)。工牌本身人人都有,关键在登记簿上有没有你的名字。

5.2 用户态:和模块列表做差集

驱动端只负责”这页长得像 PE 镜像”,它不区分正常驱动和隐藏驱动(正常驱动也是 PE)。区分工作交给用户态:命中页地址用 GetDriverPathByAddr 反查它属于哪个已加载模块——

  • 能查到路径 → 是已知模块的镜像,正常;
  • 查不到 → 游离 PE 镜像 = 疑似隐藏驱动,记录地址,之后和模块列表交叉复核。

6. 实现:驱动端扫 + 用户态多线程调度

6.1 总体架构

1
2
3
4
5
6
7
8
用户态(FreeBasic)                    驱动端(SnowSword.sys)
┌─────────────────────┐ IOCTL ┌─────────────────────────┐
│ 多线程分片扫描 │ ─────────▶ │ OptimizedScanKernelMemory│
│ 每页发一次 IOCTL 询问 │ ◀───────── │ 逐页 PE 特征检查 │
│ 互斥锁保护结果数组 │ └─────────────────────────┘
│ GetDriverPathByAddr │
│ 差集 → 展示 │
└─────────────────────┘

6.2 用户态线程工作函数(核心逻辑)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
' ⚠️ 以下为核心逻辑示意,非完整可编译代码
' 实际实现需自行补全错误处理、内存管理、类型安全

for (ulonglong addr = p->startAddr ; ddr <= p->endAddr ; addr += PAGE_SIZE) {
stMemory.addr = (PVOID)addr;
stMemory.dwSize = PAGE_SIZE;

' 每页发一次 IOCTL,让驱动检查这页是不是 PE 镜像
if (IoControl(hDrv, IOCTL_ScanKernelMemory, @stMemory, SizeOf(MemoryStruct),
@result, SizeOf(MEMORY_SCAN_RESULT)) <> 0) {
if (result <> Normal) {
' 命中:互斥锁保护下记录结果
MutexLock(p->hMutex);
' 记录 Addr 到共享数组
MutexUnlock(p->hMutex);
}
}
}

命中后用户态再调 GetDriverPathByAddr(Addr) 判断模块归属:有路径 → 正常模块;无路径 → 疑似隐藏驱动。

7. 实验计划(待执行)

⚠️ 本文是设计稿,实验结果将在后续补充。

步骤 内容 状态
Step 1 干净系统自检:扫描驱动区,所有 PE 命中都应能归属到已知模块 ⏳ 待执行
Step 2 对抗测试:构造纯内存驱动样本(摘链 + 不注册对象),验证能否检出 ⏳ 待执行

Step 1:特征自检(先证明扫描没瞎)

在干净的 Win10 1809 虚拟机里,加载 SnowSword 驱动跑扫描:

  1. 扫描 [ntoskrnl基址 - 0x70000000, ntoskrnl基址] 驱动区,收集所有 PE 特征命中页;
  2. 对每个命中地址调 GetDriverPathByAddr 反查模块归属,与 SystemModuleInformation 模块列表交叉;
  3. 预期:正常系统里,所有命中都应能归属到已知模块(ntoskrnl、hal、afd 等);归属不到模块的命中数为 0——这是自检通过的第一道标准;
  4. 用 windbg lm / !drvobj 抽查几个命中地址,确认 PE 头校验没有把数据区误认成镜像。

自检通过的标准:命中全部可解释——每个 PE 命中都能对应一个已知模块,没有莫名其妙的多余项,也没有把正常驱动漏掉。

Step 2:对抗测试(证明能揪出隐藏驱动)

构造一个”纯内存驱动”样本:

  1. 写一个普通驱动,DriverEntry 里自摘链:把自己的 DriverSection->InLoadOrderLinks 从 PsLoadedModuleList 摘掉;
  2. 不调用 IoCreateDevice(不产生设备对象),也不依赖 \Driver 目录注册的常规路径——模拟”双视角皆盲”;
  3. 再跑一遍扫描,预期:模块列表和对象目录都看不到它,但 PE 扫描按镜像特征把它捞出来——GetDriverPathByAddr 查不到归属 = 命中列表里出现一个”游离 PE”;

8. 已知局限与后续计划

8.1 当前局限

  1. 扫描范围盲区:只扫驱动加载区,非分页池高地址段未覆盖——攻击者把代码拷到非分页池即可绕过;
  2. IOCTL 频率:每页一次 IOCTL,44.8 万次上下文切换,全扫分钟级;
  3. PE 头可抹除:攻击者加载后擦掉 MZ/PE 魔数即可让本方案失效(但会破坏 I/O 子系统的加载假设,纯 shellcode 才能这么干)。

8.2 后续计划

  • v2:补全非分页池定位(Win10 1809+ 无 MmNonPagedPoolStart/End 导出,需特征扫 _POOL_DESCRIPTOR 或借 windbg/PDB);
  • 升级方案:DRIVER_OBJECT 四关卡校验(Type + 回指 + MajorFunction),覆盖”抹掉 PE 头但保留驱动对象”的场景。

9. 遇到的问题与注意事项

  1. 页对齐:扫描区间边界必须按 PAGE_SIZE 对齐,否则漏掉页首的 MZ 头(4.4 节有详细说明);
  2. 地址类型宽度:内核地址是 64 位的,用户态保存扫描范围/结果时必须用 ULongInt,不能用 ULong。截断后起始地址直接错位,扫出来全是噪音;
  3. 安全读内核内存:不能直接解引用,必须先 ProbeForRead 预检 + MmCopyMemory 安全拷贝 + __try/__except 兜底——缺一步都可能蓝屏;
  4. IOCTL 频率:每页一次 IOCTL 有几十万次上下文切换,全扫分钟级;正式功能必须批量化(见 4.5);
  5. 调试输出:DbgPrint 别放在逐页循环里,命中才会打还好,每页都打能把调试器刷到卡死;
  6. 误报: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