使用 osquery 在 Windows 上实现实时文件监控

本文介绍了 Trail of Bits 开发的 ntfs_journal_events,一种基于事件的 osquery 表,用于在 Windows 上实现实时文件变更监控。文章详细探讨了 Windows 文件监控的技术方法、NTFS 日志机制及其在 osquery 中的集成。

使用 osquery 在 Windows 上实现实时文件监控

文件监控用于车队安全和管理目的

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

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

Windows 文件监控简要调查

Windows 上的文件监控方法通常分为三种方法:

  • Win32/WinAPI 接口:FindFirstChangeNotification、ReadDirectoryChangesW
  • 文件系统过滤器驱动程序和微过滤器
  • 日志监控

下面我们将介绍每种方法的技术细节及其优缺点(包括一般性和与 osquery 相关的)。

Win32 API

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

  • FindFirstChangeNotification 可用于在特定目录的条目(以及所有子目录的条目,如果需要)上放置一组通知过滤器。
  • FindFirstChangeNotification 返回的句柄可与标准 Windows 对象等待例程(如 WaitForSingleObject 和 WaitForMultipleObjects)一起使用。
  • 等待并处理后,可以使用 FindNextChangeNotification 排队后续事件。

这些例程有几个问题:

  • FindFirstChangeNotification 不监控指定目录本身,只监控其条目。因此,监控目录及其条目的“正确”方法是调用该函数两次:一次用于目录本身,另一次用于其父目录(或驱动器根目录)。这反过来需要额外的过滤,如果父目录中唯一感兴趣的条目是目录本身。
  • 这些例程提供了检索文件系统事件的过滤和同步,但不暴露事件本身或其关联的元数据。实际事件必须通过 ReadDirectoryChangesW 检索,该函数接受被监视目录的打开句柄和许多与轮询函数相同的参数(因为它可以完全独立于它们使用)。用户还必须处理 OVERLAPPED 的复杂世界,以便在异步上下文中安全地使用 ReadDirectoryChangesW。
  • ReadDirectoryChangesW 可能难以与 Windows 上的回收站和其他伪目录概念一起使用。此 SO 帖子建议可以使用 GetFinalPathNameByHandle 解析最终移动的名称。此 GitHub 问题表明该函数的行为在 Windows 版本之间也不一致。
  • 最后但并非最不重要的一点是,ReadDirectoryChangesW 为每个目录句柄内部使用固定大小的缓冲区,如果无法跟上事件数量,将在处理之前刷新所有更改记录。换句话说,其内部缓冲区不充当环形缓冲区,并且不能信任其在存在大量高 I/O 负载时逐渐或优雅地降级。

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

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

过滤器驱动程序和微过滤器

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

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

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

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

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

日志监控

我们有第三个选项:NTFS 日志。

与大多数(相对)现代的文件系统一样,NTFS 是日志式的:对底层存储的更改之前会更新一个(通常是环形的)区域,该区域记录与更改关联的元数据。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,
    NULL,
    OPEN_EXISTING,
    FILE_FLAG_BACKUP_SEMANTICS,
    NULL
);

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

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

我们发出另一个 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
21
READ_USN_JOURNAL_DATA_V1 read_data = {0};
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;

char buffer[4096];
DeviceIoControl(
    volume_handle,
    FSCTL_READ_USN_JOURNAL,
    &read_data,
    sizeof(read_data),
    buffer,
    sizeof(buffer),
    &bytes_returned,
    NULL
);

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

解释更改记录缓冲区

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

1
2
3
4
5
6
7
8
USN* next_usn = reinterpret_cast<USN*>(buffer);
char* record_ptr = buffer + sizeof(USN);

while (record_ptr < buffer + bytes_returned) {
    USN_RECORD* record = reinterpret_cast<USN_RECORD*>(record_ptr);
    process_usn_record(record);
    record_ptr += record->RecordLength;
}

然后,在我们的 process_usn_record 中:

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

    if (record->MajorVersion == 2) {
        USN_RECORD_V2* v2 = reinterpret_cast<USN_RECORD_V2*>(record);
        // 处理 v2 记录
    } else if (record->MajorVersion == 3) {
        USN_RECORD_V3* v3 = reinterpret_cast<USN_RECORD_V3*>(record);
        // 处理 v3 记录
    }
}

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

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

然而,一旦解决了这个问题,以下字段在两者中都可用:

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

砰:通过更改日志的文件事件。观察我们的方法避开了文件监控中的许多常见性能和开销问题:我们完全异步操作,并且完全不阻塞文件系统。这本身就是对微过滤器模型的重大改进,该模型对每个 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:\\ImportantFiles\\*"
    ]
  }
}
  • 配置给我们要监控更改日志的卷的基本列表;我们为每个创建一个 USNJournalReader,并通过 Dispatcher::addService() 将它们添加为服务
  • 每个读取器进行自己的更改日志监控和事件收集,向发布者报告事件列表
  • 我们执行一些规范化,包括将“旧”和“新”重命名事件减少为单一的 NTFSEventRecords。我们还维护一个父 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 设计