利用 osquery 实现 Windows 实时文件监控的技术解析

本文详细介绍了如何通过 osquery 的 ntfs_journal_events 表在 Windows 上实现高性能实时文件监控,涵盖 NTFS 日志机制、技术实现细节及对比传统监控方法的优势,适用于终端安全与管理场景。

利用 osquery 实现 Windows 实时文件监控

TL;DR: Trail of Bits 开发了 ntfs_journal_events,这是一个基于事件的 osquery Windows 表,支持实时文件变更监控。您可以使用此表高效监控 Windows 终端上特定文件、目录及整个模式的变更。查看模式文档

文件监控在设备安全与管理中的重要性

文件事件监控和审计是终端安全与管理的关键基础:

  • 许多恶意活动可通过文件系统活动的已知模式可靠预警:重写系统库、将有效负载投放到固定位置以及(尝试)移除防御程序均表明潜在入侵。
  • 非恶意的完整性违规也可通过文件监控检测:员工越狱公司设备或规避安全策略。
  • 大型设备群的软件部署、更新和自动化配置:“每台主机是否都安装并更新了软件 X 到版本 Y?”
  • 非安全问题的自动故障排除和修复:共享文件权限错误、网络配置错误、磁盘(过度)使用。

Windows 文件监控方法概览

Windows 文件监控方法通常分为三类:

  1. Win32/WinAPI 接口:FindFirstChangeNotification、ReadDirectoryChangesW
  2. 文件系统筛选器驱动和微筛选器
  3. 日志监控

下文将详细介绍每种方法的技术细节及其优缺点(包括通用性和与 osquery 的相关性)。

Win32 API

Windows API 提供了一组(大多)文件系统无关的函数,用于轮询注册目录的事件:

  • FindFirstChangeNotification 可用于在特定目录条目(及所有子目录,如请求)上设置通知筛选器。
  • FindFirstChangeNotification 返回的句柄可与标准 Windows 对象等待例程(如 WaitForSingleObjectWaitForMultipleObjects)一起使用。
  • 等待并处理后,后续事件可通过 FindNextChangeNotification 排队。

这些例程存在一些陷阱:

  • FindFirstChangeNotification 不监控指定目录本身,仅监控其条目。因此,监控目录及其条目的“正确”方法是调用该函数两次:一次针对目录本身,另一次针对其父目录(或驱动器根目录)。这又需要额外筛选,如果父目录中唯一感兴趣的条目是目录本身。

  • 这些例程提供检索文件系统事件的筛选和同步,但不暴露事件本身或其关联元数据。实际事件必须通过 ReadDirectoryChangesW 检索,该函数接受被监视目录的打开句柄和许多与轮询函数相同的参数(因为它可以完全独立使用)。用户还必须处理 OVERLAPPED 的复杂世界,以便在异步上下文中安全使用 ReadDirectoryChangesW

  • 最后,ReadDirectoryChangesW 内部为每个目录句柄使用固定大小缓冲区,如果无法跟上事件数量,将在处理前刷新所有变更记录。换句话说,其内部缓冲区不作为环形缓冲区运行,不能信任其在大量高 I/O 负载下逐渐或优雅降级。

还存在一个较旧的解决方案:SHChangeNotifyRegister 可用于注册一个窗口作为文件通知的接收者(通过 Windows 消息从 shell 即 Explorer 发送)。此方法也有许多缺点:需要接收应用程序维护一个窗口(即使只是消息专用窗口),使用一些奇怪的“项目列表”视图文件系统路径,并受 Windows 消息传递(有限)吞吐量的限制。

总之,这些 API 的性能和准确性问题使它们不适合 osquery。

筛选器驱动和微筛选器

与 Windows 环境中的许多其他工程挑战一样,文件监控有一个核选项:内核模式 API。Windows 足够友好地为此提供了两个通用类别:旧版文件系统筛选器 API 和更新的微筛选器框架。本文将介绍后者,因为它是 Microsoft 推荐的。

微筛选器是内核模式驱动,直接拦截 Windows 文件系统执行的 I/O 操作。由于它们在通用文件系统接口层操作,微筛选器(大多)对其底层存储不可知——它们(理论上)可以拦截 NT 内核已知的任何文件系统操作,无论文件系统类型或底层实现如何。微筛选器也是可组合的,意味着多个筛选器可以在无冲突的情况下注册并与文件系统交互。

微筛选器通过筛选管理器实现,该管理器基于配置的唯一“高度”(较低高度对应较早加载,因此较早访问)和“加载顺序组”中的存在建立筛选器加载顺序,该组对应唯一的高度范围。加载顺序组本身按升序加载,其成员按随机顺序加载,意味着在同一组中拥有比另一个微筛选器更低的高度并不保证更高优先级。Microsoft 在此处提供了一些(公共)加载顺序组和高度范围的文档;公开已知高度列表可在此处获取。您甚至可以自己请求一个!

尽管强大、灵活且通常是内省 Windows 文件系统的正确选择,但微筛选器不适合 osquery 的文件监控目的:

  • 对于树内(即非扩展)表,osquery 有反对系统修改的策略。安装微筛选器需要我通过加载驱动修改系统,并要求 osquery 要么随驱动一起提供,要么在安装时获取一个。
  • 因为微筛选器是完整的内核模式驱动,它们带来不受欢迎的安全和稳定性风险。
  • osquery 的设计向其用户做出某些保证:它是一个单可执行文件、用户模式代理,在运行时自我监控性能开销——内核模式驱动将违反该设计。

日志监控

第三个选项可供我们使用:NTFS 日志。

与大多数(相对)现代文件系统一样,NTFS 是日志式的:对底层存储的变更 preceded by 更新到一个(通常是环形的)区域,该区域记录与变更关联的元数据。Dan Luu 的“Files are fraught with peril”包含一些良好的日志动机示例,以“撤销日志”的形式。

日志记录提供许多好处:

  • 增加对损坏的弹性:单个 I/O 操作(例如,取消链接文件)的完整用户空间到内核到硬件操作链在内部不是原子的,意味着崩溃可能使文件系统处于不确定或损坏状态。拥有最后预提交操作的日志记录使文件系统更可能回滚到已知良好状态。
  • 因为日志提供文件系统操作的可逆记录,与底层存储硬件的交互可以更积极:触发提交的批处理大小可以增加,提高性能。
  • 由于日志及时且小(相对于文件系统),它可用于避免昂贵的文件系统查询(例如,stat)元数据。这在 Windows 上尤其相关,其中元数据请求通常涉及获取完整 HANDLE

NTFS 的日志机制实际上分为两个独立组件:$LogFile 是一个预写日志,处理用于回滚目的的日志记录,而变更日志($Extend\$UsnJrnl)按类型记录卷上的最近变更(即,没有回滚所需的偏移和大小信息)。

Windows 将其文件历史功能用于后者,我们也将使用它。

访问变更日志

⚠ 以下示例已为简洁而简化。它们不包含错误处理和边界检查,这两者对于安全正确使用都至关重要。在复制前阅读 MSDN 和/或 osquery 中的完整源代码!⚠

幸运的是,打开句柄并从卷的 NTFS 变更日志读取是一个相对轻松的过程,只需几个步骤。

我们通过普通的 CreateFile 调用获取要监控的卷的句柄:

1
2
3
4
5
6
7
8
9
HANDLE volume_handle = CreateFile(
    L"\\\\?\\C:",
    GENERIC_READ | GENERIC_WRITE,
    FILE_SHARE_READ | FILE_SHARE_WRITE,
    nullptr,
    OPEN_EXISTING,
    FILE_FLAG_BACKUP_SEMANTICS,
    nullptr
);

我们在句柄上发出 DeviceIoControl[FSCTL_QUERY_USN_JOURNAL] 以获取最新的更新序列号(USN)。USN 唯一标识一批一起提交的记录;我们将使用我们的第一个按时间顺序“锚定”查询:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
USN_JOURNAL_DATA journal_data;
DeviceIoControl(
    volume_handle,
    FSCTL_QUERY_USN_JOURNAL,
    nullptr,
    0,
    &journal_data,
    sizeof(journal_data),
    &bytes_returned,
    nullptr
);

我们发出另一个 DeviceIoControl,这次使用 FSCTL_READ_USN_JOURNAL,从日志拉取原始记录缓冲区。我们使用 READ_USN_JOURNAL_DATA_V1 告诉日志只给我们从最后一步获取的 USN 开始的记录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
READ_USN_JOURNAL_DATA_V1 read_data = {};
read_data.StartUsn = journal_data.NextUsn;
read_data.ReasonMask = 0xFFFFFFFF;
read_data.ReturnOnlyOnClose = FALSE;
read_data.Timeout = 0;
read_data.BytesToWaitFor = 0;
read_data.UsnJournalID = journal_data.UsnJournalID;
read_data.MinMajorVersion = 2;
read_data.MaxMajorVersion = 3;

DeviceIoControl(
    volume_handle,
    FSCTL_READ_USN_JOURNAL,
    &read_data,
    sizeof(read_data),
    buffer,
    buffer_size,
    &bytes_returned,
    nullptr
);

注意最后两个字段(2U 和 3U)——它们稍后将相关。

解释变更记录缓冲区

DeviceIoControl[FSCTL_READ_USN_JOURNAL] 给我们一个可变长度 USN_RECORD 的原始缓冲区,前缀是一个单独的 USN,我们可以用它来发出未来请求:

1
2
3
4
5
PUSN_RECORD record = (PUSN_RECORD)((PCHAR)buffer + sizeof(USN));
while (record < (PUSN_RECORD)((PCHAR)buffer + bytes_returned)) {
    process_usn_record(record);
    record = (PUSN_RECORD)((PCHAR)record + record->RecordLength);
}

然后,在我们的 process_usn_record 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void process_usn_record(PUSN_RECORD record) {
    if (record->MajorVersion == 4) {
        return; // 跳过 v4 记录
    }

    if (record->MajorVersion == 2) {
        auto v2 = (USN_RECORD_V2*)record;
        // 处理 v2 字段...
    } else if (record->MajorVersion == 3) {
        auto v3 = (USN_RECORD_V3*)record;
        // 处理 v3 字段...
    }
}

回忆 READ_USN_JOURNAL_DATA_V1 的最后两个字段——它们对应返回给我们的 USN_RECORD 版本范围,包含。我们明确排除 v4 记录,因为它们仅作为范围跟踪的一部分发出,并且不包含我们需要的任何额外信息。您可以在其 MSDN 页面上阅读更多关于它们的信息。

MSDN 明确这些转换是必要的:USN_RECORDUSN_RECORD_V2 的别名,并且 USN_RECORD_V3 不保证有任何共同布局,除了 USN_RECORD_COMMON_HEADER 中定义的布局。

然而,一旦解决,以下字段在两者中都可用:

  • Reason: 指示当前记录中累积变更的标志位掩码。参见 MSDN 的 USN_RECORD_V2USN_RECORD_V3 获取原因常量列表。
  • FileReferenceNumber: 引用底层文件系统对象的唯一(通常 128 位)序号。这与通过调用 GetFileInformationByHandleExFileIdInfo 作为信息类可获得的 FRN 相同。FRN 大致对应 UNIX 领域的“inode”概念,并具有类似的语义(每个文件系统唯一,非系统范围)。
  • ParentFileReferenceNumber: 另一个 FRN,用于此记录对应的文件或目录的父目录(或卷)。
  • FileNameLength, FileNameOffset, FileName: 此记录对应的文件或目录的文件名的字节长度、偏移和指针。注意 FileName 是基本(即未限定)名称——检索完全限定名称需要我们通过打开其句柄(OpenFileById)、调用 GetFinalPathNameByHandle 并连接两者来解析父 FRN 的名称。

砰:通过变更日志的文件事件。观察我们的方法避开了文件监控中的许多常见性能和开销问题:我们完全异步操作,且完全不阻塞文件系统。这 alone 是对微筛选器模型的实质性改进,该模型对每个 I/O 操作施加开销。

注意事项

与提到的其他技术一样,变更日志方法文件监控并非没有缺点。

正如表名所示,变更日志监控仅适用于 NTFS(和 ReFS,似乎部分被放弃)。它不能用于监控 FAT 或 exFAT 卷上的变更,因为这些完全缺乏日志记录。它也不适用于 SMB 共享,尽管它将适用于适当底层格式的集群共享卷。

重命名操作的处理也稍微烦人:变更日志记录一个事件用于“旧”文件被重命名,另一个用于“新”文件被创建,意味着我们必须将两者配对成一个事件以进行连贯呈现。这不难(事件相互引用并具有不同的掩码),但这是一个额外步骤。

变更日志文档也明显缺少关于丢弃记录可能性的信息:需要起始 USN 和在原始缓冲区中返回后续 USN 暗示后续查询预期成功,但未提供关于变更日志回绕行为大小的官方细节。此博客文章表明默认大小为 1MB,这可能对大多数工作负载足够。它也可以通过 fsutil 更改。

可能更重要的是 MSDN 文档中 Reason 位掩码的这一行:

标识自文件或目录打开以来在此文件或目录日志记录中累积的变更原因的标志。

当文件或目录关闭时,生成最终 USN 记录,设置 USN_REASON_CLOSE 标志。下一次变更(例如,下一次打开操作或删除后)启动新记录,带有新的原因标志集。

这意味着在打开文件生命周期上下文中的重复事件可以合并到 Reason 掩码中的单个位:USN_REASON_DATA_EXTEND 每个记录只能设置一次,因此由打开、两次写入和关闭组成的 I/O 模式将仅指示某些写入发生,而不是哪个或多少次。因此,变更日志不能回答关于开放资源上 I/O 规模的详细问题;只能是否某些事件确实发生在它上面。然而,对于完整性监控目的,这不是主要缺陷,因为我们主要感兴趣的是知道文件何时变更以及它们变更时的最终状态是什么。

将变更日志引入 osquery

上面的代码片段为我们从单个卷检索和解释变更日志记录奠定了基础。osquery 的用例更复杂:我们希望监控用户注册感兴趣的每个卷,并对检索到的记录执行筛选以将输出限制到一组配置的模式。

每个 NTFS 卷都有自己的变更日志,因此每个都需要独立打开和监控。osquery 的发布-订阅框架非常适合此任务:

  • 我们定义一个事件发布者(NTFSEventPublisher
  • 在我们的配置阶段(NTFSEventPublisher::configure()),我们读取用户配置,类似于 Linux file_events 表:
1
2
3
4
5
6
7
8
{
  "ntfs_journal_events": {
    "include_paths": [
      "C:\\Windows\\System32\\*.dll",
      "D:\\AppData\\*.exe"
    ]
  }
}
  • 配置给我们要监控变更日志的卷的基本列表;我们为每个创建一个 USNJournalReader 并通过 Dispatcher::addService() 添加它们作为服务
  • 每个读取器进行自己的变更日志监控和事件收集,向发布者报告事件列表
  • 我们执行一些规范化,包括将“旧”和“新”重命名事件减少为单一的 NTFSEventRecord。我们还维护父 FRN 到目录名称的缓存,以避免缺少由目录重命名引起的变更,并最小化我们发出的打开句柄请求数量
  • 发布者 fire() 那些规范化事件供我们的订阅表使用:ntfs_journal_events

结合在一起,这给我们提供了上面屏幕截图中看到的基于事件的表。是查询时间了!

总结

ntfs_journal_events 表使 osquery 成为 Windows 文件监控的一流选项,并进一步减小了 osquery 在 Windows 和 Linux/macOS 之间的功能差距(后者长期以来拥有 file_events 表)。

您有 osquery 开发或部署需求吗?联系我们!Trail of Bits 多年来一直处于 osquery 开发的核心,并从事从核心到表开发到新平台支持的一切工作。

如果您喜欢这篇文章,请分享: Twitter | LinkedIn | GitHub | Mastodon | Hacker News

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计