1. 缘起:ARK 为什么必须查隐藏驱动

之前几篇都在做进程隐藏检测(PID 暴力扫描、PspCidTable 遍历),核心哲学是多视角交叉验证——Windows 里没有绝对可信的单一视角,攻击者篡改一个数据源容易,同时篡改多个独立数据源成本急剧上升。

同样的道理完全适用于驱动。恶意驱动(Rootkit 的加载器、内核后门)想隐藏自己,最常见的手段是摘链 PsLoadedModuleList——这是 Windows 内核维护的已加载模块双向链表,摘链后普通工具(任务管理器、driverquery、甚至 NtQuerySystemInformation(SystemModuleInformation))就看不到它了。

但问题来了:驱动真的”消失”了吗?没有。只要它注册了 DRIVER_OBJECT,它就在对象管理器的命名空间里留下了痕迹。这就是本篇文章要用的第二个视角。

核心思路:检测进程时可以同时看句柄表、PspCidTable、模块链表;检测驱动,同样可以同时看「模块加载器视角」和「对象管理器视角」。

2. 驱动在内核里的两种”存在形式”

一个正常加载的驱动,会在内核里同时留下两种记录:

视角 1:模块加载器(PsLoadedModuleList)

  • NtQuerySystemInformation(SystemModuleInformation) 返回的模块列表就来自这张链表;
  • SnowSword 里 GetKernelModuleList 已经用它枚举驱动(路径 + 基址 + 大小);
  • 攻击者摘链 → 这个视角就看不到了。

视角 2:对象管理器命名空间(\Driver、\FileSystem)

  • 驱动加载时会在对象管理器根目录下注册一个 DRIVER_OBJECT,名字通常是 \Driver\驱动名;
  • 文件系统驱动注册在 \FileSystem,文件系统过滤驱动在 \FileSystem\Filters;
  • 这套命名空间由对象管理器独立维护,与 PsLoadedModuleList 是两套互不相干的机制。

攻击者如果只摘链模块链表,对象管理器目录里的 DRIVER_OBJECT 依然在——这就是检出点。

3. 视角 1 回顾:模块列表枚举(已有实现)

SnowSword 里已有实现,核心是两段式调用:

1
2
3
4
5
6
' 第一遍:查询需要的缓冲区大小
st = ZwQuerySystemInformation(SystemModuleInformation, buffer, bufferSize, @bufferSize)
If (st = STATUS_INFO_LENGTH_MISMATCH) Then buffer = Reallocate(buffer, bufferSize)

' 第二遍:取实际数据
st = ZwQuerySystemInformation(SystemModuleInformation, buffer, bufferSize, NULL)

拿到 SYSTEM_MODULE_INFORMATION 后遍历 Modules(i),用 ImageName 取驱动路径、路径最后一段作为驱动名。

4. 视角 2:枚举 \Driver 与 \FileSystem

这是本篇文章的新内容。对象管理器命名空间像文件系统一样组织目录,核心思路分三步:

4.1 打开目录对象

用 ZwOpenDirectoryObject 打开 \Driver 或 \FileSystem 目录,拿到句柄后再用 ObReferenceObjectByHandle 转成内核态的 OBJECT_DIRECTORY* 结构指针,之后关闭句柄保留对象引用——后续操作都基于结构指针,不走句柄。

4.2 遍历哈希桶(核心逻辑)

OBJECT_DIRECTORY 内部用 37 个 HashBuckets 哈希桶组织目录项,每个桶是一条 ChainLink 链表。遍历所有桶 = 枚举目录下所有对象。这是比 ZwQueryDirectoryObject 更底层的做法(公开接口只能拿名字,直接读结构能拿到对象指针)。

关键遍历逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ⚠️ 以下为核心逻辑示意,非完整可编译代码
// 实际实现需自行补全错误处理、引用计数、结构体验证

for (int i = 0; i < BUCKET_COUNT; i++) { // BUCKET_COUNT 需验证,通常为 37
for (entry = DirObj->HashBuckets[i];
entry;
entry = entry->ChainLink) {

// 1. 增加引用计数,防止遍历期间对象被释放
ObReferenceObject(entry->Object);

// 2. 判断是否为 DRIVER_OBJECT
if (ObGetObjectType(entry->Object) == *IoDriverObjectType) {
PDRIVER_OBJECT drv = (PDRIVER_OBJECT)entry->Object;

// 3. 通过 DriverSection->DllBase 获取模块基址
// 这是与视角1(模块列表)交叉验证的关键纽带
PLDR_DATA_TABLE_ENTRY ldr = drv->DriverSection;
if (ldr) {
PVOID base = ldr->DllBase; // ← 与 SystemModuleInformation 的基址比对
// 收集 base,后续与模块列表做差集分析
}
}

// 4. 释放引用计数(匹配对象除外,由上层管理生命周期)
ObDereferenceObject(entry->Object);
}
}

为什么是 37 个桶? 37 是质数,哈希取模时分布更均匀。但这个数字未文档化,不同 Windows 版本可能变化(Win7 确认是 37,Win10/11 需自行验证)。硬编码此值在跨版本工具中存在兼容性风险,建议通过符号解析或特征扫描动态获取。

4.3 引用计数纪律(最容易蓝屏的地方)

遍历过程中每个对象都必须 ObReferenceObject / ObDereferenceObject 配对,这是这段代码最容易出错的地方:

操作 后果
漏释放(非匹配对象未 Dereference) 对象永远无法回收,内存泄漏
多释放 直接 BSOD
匹配对象未转交引用给上层 对象可能被提前释放,后续访问蓝屏

状态流转:

1
2
3
4
5
ObReferenceObject (引用+1)
↓
判断对象类型
├─ 是 DRIVER_OBJECT 且匹配目标 → 保留引用,返回给调用者
└─ 其他情况 → ObDereferenceObject(引用恢复)

匹配对象把引用转交给调用者后,调用者用完必须 ObDereferenceObject,否则同样泄漏。

4.4 类型判断与基址关联

  • 用 ObGetObjectType() == *IoDriverObjectType 判断驱动对象,走导出 API 而非手动访问对象头,避免踩到无效对象头。
  • drvObj->DriverSection(LDR_DATA_TABLE_ENTRY)的 DllBase 与模块列表的基址比对——这一步把「对象管理器视角」和「模块加载器视角」用基址串了起来,正是交叉验证的基础。
  • 注意:不是每个 DRIVER_OBJECT 都有有效的 LDR_DATA_TABLE_ENTRY(DriverSection 可能为 NULL),访问前必须判空。

5. 交叉验证:两个视角的差集

把两个视角的结果并排对比:

数据源 枚举方式 攻击者篡改方式
模块列表 A SystemModuleInformation(源自 PsLoadedModuleList) 摘链模块链表
对象目录 B 遍历 OBJECT_DIRECTORY 哈希桶,枚举 \Driver + \FileSystem 删除/不注册 DRIVER_OBJECT

两个视角的数据源不同,但都能拿到驱动基址:视角 A 直接给 dwBase,视角 B 通过 DriverSection->DllBase 取。所以交叉验证可以按基址对齐:

  1. 遍历 \Driver、\FileSystem 目录,收集所有 DRIVER_OBJECT 的基址;
  2. 对每个基址与模块列表 A 的基址集合比对;
  3. 基址不在 A 中 = 模块列表里没有这个驱动 = 疑似摘链隐藏驱动——模块链表上看不到,但对象管理器里还留着 DRIVER_OBJECT,这就是检出点。

反过来,A 有而 B 无的条目要小心:不是所有模块都会注册 DRIVER_OBJECT(早期 boot 加载的驱动、未创建设备对象的驱动),直接报”隐藏”会误报,需要先收集正常基线再判断。

5.1 攻击者视角:如何绕过?

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

  1. 双杀:摘链 PsLoadedModuleList + 同时从 \Driver 目录删除对象(需调用 ObMakeTemporaryObject 或类似操作,但会留下新的痕迹);
  2. 不注册 DRIVER_OBJECT:纯内存执行,两个视角都看不到 → 引出第九篇;
  3. 伪造 DriverSection->DllBase:指向一个合法模块的基址,交叉比对时被”吸附”到合法基址,产生假阴性。

检测的本质不是”找到所有坏人”,而是提高攻击者的隐藏成本。

6. 盲区与第九篇伏笔:内存暴力搜索 DRIVER_OBJECT

如果攻击者连 \Driver 对象都不注册,两个视角都会落空。但 DRIVER_OBJECT 是真实存在于内核内存里的结构,它有一个特征——结构体首部的 Type 字段(IO_TYPE_DRIVER)。所以理论上可以扫描内核内存区域,按特征寻找所有 DRIVER_OBJECT:

1
2
3
4
暴力扫描内核内存
↓ 校验结构特征(Type 魔数、DriverName 有效性、MajorFunction 指针合法性)
↓ 找到未被任何链表/目录登记的 DRIVER_OBJECT
纯内存隐藏驱动

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

  1. OBJECT_DIRECTORY 是未导出结构:HashBuckets 的桶数(37 个)和 OBJECT_DIRECTORY_ENTRY 的布局依赖系统版本,需要在自己目标系统上验证结构布局。验证方法:

    • Windbg: dt nt!_OBJECT_DIRECTORY
    • 对比不同 Windows 版本的 _OBJECT_DIRECTORY 定义
    • 特征扫描:Directory 对象包含固定数量的指针数组模式
  2. 引用计数准则:遍历中每个对象都要 ObReferenceObject / ObDereferenceObject 配对——漏释放会引用泄漏(对象永远无法回收),多释放会直接崩溃。匹配对象把引用转交给调用者后,调用者用完必须 ObDereferenceObject。

  3. DriverSection 判空:不是每个 DRIVER_OBJECT 都有有效的 LDR_DATA_TABLE_ENTRY(DriverSection 可能为 NULL),访问前必须判空,否则蓝屏。

  4. 误报处理:A 有 B 无 的条目需要基线(boot 驱动、无设备对象驱动都正常),不能直接当隐藏报。

  5. \FileSystem\Filters 子目录:MiniFilter 的驱动对象在子目录里,漏枚举会导致视野不全。

  6. BUCKET_COUNT 硬编码风险:如前所述,37 这个数字未文档化,跨版本使用需验证。

参考

  • Windows Internals, 7th Edition(对象管理器 / 驱动对象 / PsLoadedModuleList 章节)
  • Microsoft Learn:ZwOpenDirectoryObject / ObReferenceObjectByHandle / ObGetObjectType / IoDriverObjectType
  • 本系列前篇:SnowSword–进程隐藏检测实现、PspCidTable 进程枚举学习记录

相关项目与联系

项目SnowSword —— Windows 10/11 x64 内核分析与 Anti-Rootkit 工具,源码与发布包在 Gitee。

技术合作Windows 内核 / 驱动开发 / 逆向分析 / 兼容性开发,范围与边界见 技术合作。

联系2160752730@qq.com · QQ 2160752730 · 微信 lst_3258110