Windows实时文件监控利器:osquery的ntfs_journal_events表

本文介绍了Trail of Bits开发的基于NTFS变更日志的osquery表ntfs_journal_events,实现Windows端实时文件监控,涵盖技术原理、实现细节及优势对比。

通过osquery在Windows上实现实时文件监控 - Trail of Bits博客

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(即资源管理器)接收。这种方法也有许多缺点:它要求接收应用程序维护一个窗口(即使只是消息专用窗口),使用一些奇怪的“项目列表”视图文件系统路径,并受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 hVolume = 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 journalData;
DWORD bytesReturned;
DeviceIoControl(
    hVolume,
    FSCTL_QUERY_USN_JOURNAL,
    NULL,
    0,
    &journalData,
    sizeof(journalData),
    &bytesReturned,
    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
22
READ_USN_JOURNAL_DATA_V1 readData = {0};
readData.StartUsn = journalData.NextUsn;
readData.ReasonMask = 0xFFFFFFFF;  // 所有原因
readData.ReturnOnlyOnClose = FALSE;
readData.Timeout = 0;  // 无超时
readData.BytesToWaitFor = 0;
readData.UsnJournalID = journalData.UsnJournalID;
readData.MinMajorVersion = 2;
readData.MaxMajorVersion = 3;

char buffer[4096];
DWORD bytesReturned;
DeviceIoControl(
    hVolume,
    FSCTL_READ_USN_JOURNAL,
    &readData,
    sizeof(readData),
    buffer,
    sizeof(buffer),
    &bytesReturned,
    NULL
);

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

解释变更记录缓冲区

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

1
2
3
4
5
PUSN_RECORD usnRecord = (PUSN_RECORD)(buffer + sizeof(USN));
while ((char*)usnRecord < buffer + bytesReturned) {
    process_usn_record(usnRecord);
    usnRecord = (PUSN_RECORD)((char*)usnRecord + usnRecord->RecordLength);
}

然后,在我们的process_usn_record中:

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

    // 根据主要版本转换为适当的结构
    if (record->MajorVersion == 2) {
        PUSN_RECORD_V2 v2 = (PUSN_RECORD_V2)record;
        // 处理v2记录...
    } else if (record->MajorVersion == 3) {
        PUSN_RECORD_V3 v3 = (PUSN_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位)序数,引用底层文件系统对象。这与通过使用FileIdInfo作为信息类调用GetFileInformationByHandleEx获得的FRN相同。FRN大致对应UNIX领域的“inode”概念,并具有类似的语义(每个文件系统唯一,非系统范围)
  • ParentFileReferenceNumber:另一个FRN,用于此记录对应的文件或目录的父目录(或卷)
  • FileNameLengthFileNameOffsetFileName:文件或目录文件名的字节长度、偏移和指针。注意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:\\Important\\"
    ]
  }
}
  • 配置给我们基本要监控变更日志的卷列表;我们为每个创建一个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 设计