etw-rs:为灵活与性能而生的 ETW 解析库
原创
John Uhlmann
John Uhlmann
赛博生存指南
2026年11月11日 18:48
浙江
去阅读
原文:https://www.originhq.com/research/introducing-etw-rs
作者:John Uhlmann
发布时间:2026-11-09
翻译模型:Kimi-K3
编者按:作者 John Uhlmann 是 ETW 圈子里的老面孔——微软 krabsetw 最大的外部贡献者、Elastic EDR「调用栈威胁情报」功能的设计者。这是他第三次造 ETW 解析库,也是第一次单飞:Origin 是一家 Rust-first 的公司,对 ETW 数据有硬性需求,而现有的 Rust 移植版 ferrisetw 满足不了。文章表面上是发布新库 etw-rs,实际讲清了两件事——ETW 消费端为什么不适合「抽象」(krabsetw 的 UserTrace / KernelTrace 二分是 Windows 7 时代的化石),以及 ETW 单线程分发的架构约束如何把逐事件解析成本变成吞吐天花板。他给出的答案是 schema 缓存加编译期预生成 parser:多数事件的解析速度是现有 Rust 方案的 10 倍,纯定长字段的简单事件能到 100 倍。做 EDR 遥测和 Windows 检测工程的读者可以直接拿走结论;红队读者可以留意文中的 escape hatch 示例(SecurityTrace 的 bit 14),那是作者另一篇 PPL 保护 ETW 会话研究的实现细节。
这篇博客不是我平常那种 Event Tracing for Windows(ETW)https://learn.microsoft.com/en-us/windows/win32/etw/about-event-tracing 安全研究https://www.originhq.com/research/process-preluding 内容。它讲的是一段工程故事:我为什么觉得有必要再造一个 Rust ETW 解析库。
这是我第三次踏上 ETW 库的支线任务,不过是第一次「单飞」。
我的初恋库是微软的 krabsetwhttps://github.com/microsoft/krabsetw。在我用 ETW 做安全研究的早期,我意外成了它最大的微软外部贡献者https://github.com/microsoft/krabsetw/pulls?q=is%3Apr+author%3Ajdu2600——当时我正在研究的各种 ETW 特性和 edge case,都被我逐个贡献回了上游。这段经历让我体会到:抽象什么时候是利器,什么时候又会给非标准用例添堵。在 krabsetw 里,Win32 API 被刻意藏得严严实实。
我的第二个库是 Elastic 的 EDR agent 内部使用的。我构思并交付了他们的「带溯源信息的 ETW 威胁情报调用栈」https://www.elastic.co/security-labs/doubling-down-etw-callstacks 能力,其中包含对 ETW 开发易用性和解析性能的一次重构。那一刻其实有点悲喜交加——被我替换掉的代码,是我读过的注释写得最美的代码。而且那次刻意只做原地重构,所以我的一些性能想法当时只能搁置。
Origin 是一家 Rust-first 的软件公司,对 ETW 数据有硬性需求https://www.originhq.com/blog/what-is-endpoint-observability。最显而易见的库选择是 ferrisetwhttps://github.com/n4r1b/ferrisetw——krabsetw 的 Rust 移植版。
然而,我想要的是围绕 system logger 会话的现代灵活性,以及为安全研究留的逃生通道。这些改动会改变 API 的形状,不是能合理塞进一个 krabsetw 克隆里的特性。我也知道性能还有余量可挖,而对 ETW 来说这很要命。ETW 的每个会话都在单线程上交付事件,所以逐事件的解析成本就是吞吐量的天花板。你可以把 provider 或 keyword 分摊到多个会话,但这样一来不同会话的事件到达顺序就乱了,做关联并不好玩。ETW 刻意设计成异步、高延迟,为的是把对操作系统的影响压低——这个取舍是对的,但它让乱序问题更严重。所以,如果我想扛住内核 ETWhttps://www.elastic.co/security-labs/kernel-etw-best-etw 事件的完整洪流,每一个事件上的每一纳秒都得算着用。
为了讲清楚为什么移植版不够用,我们先回顾一下 ETW 解析的演进史。
ETW 解析简史
1. Windows 2000
在 ETW 的最初版本里,事件由 Managed Object Format(MOF)https://learn.microsoft.com/en-us/windows/win32/wmisdk/managed-object-format--mof- schema 定义,存放在 Windows Management Instrumentation(WMI)https://learn.microsoft.com/en-us/windows/win32/wmisdk/wmi-start-page 仓库里。访问 WMI 数据要走 Component Object Model(COM)https://learn.microsoft.com/en-us/windows/win32/com/the-component-object-model 接口。这意味着,想稳健地解析 MOF,你得先写一坨 COM 样板代码访问 WMI,再写一坨 WMI 样板代码遍历类结构、提取属性定义,最后还要写一坨 ETW 样板代码处理所有类型的解析。
微软给出的这套三层样板噩梦的参考示例在这里——Retrieving Event Data Using MOF(Win32 apps | Microsoft Learn)https://learn.microsoft.com/en-us/windows/win32/etw/retrieving-event-data-using-mof
易用性和性能都很差,所以实际上当时大多数 ETW 消费者干脆把已知 schema 的细节硬编码进代码。这算是一点微小的改进——直到新版本的事件到来,把代码弄崩。
2. Windows Vista
Vista 带来了现代的 manifest provider 和 Trace Data Helper(TDH)库,其中有 TdhGetProperty 这个 helper。解析一下子顺手多了……前提是你先给这个 helper 库写好自己的 helper。
大致长这样——
template <typename T>
static bool GetIntegerProperty(EVENT_RECORD* event, PCWSTR name, T& value) {
PROPERTY_DATA_DESCRIPTOR pdd{};
pdd.ArrayIndex = ULONG_MAX;
pdd.PropertyName = (ULONGLONG)name;
ULONG status = TdhGetProperty(event, 0, NULL, 1, &pdd, sizeof(T), (PBYTE)(&value));
return (ERROR_SUCCESS == status);
}
static bool GetUnicodeStringProperty(EVENT_RECORD* event, PCWSTR name, std::wstring& value) {
PROPERTY_DATA_DESCRIPTOR pdd{};
pdd.ArrayIndex = ULONG_MAX;
pdd.PropertyName = (ULONGLONG)name;
ULONG size = 0;
if (ERROR_SUCCESS != TdhGetPropertySize(event, 0, NULL, 1, &pdd, &size))
return false;
if ((size % sizeof(wchar_t)) != 0)
return false;
value.resize(size / sizeof(wchar_t));
if (ERROR_SUCCESS != TdhGetProperty(event, 0, NULL, 1, &pdd, size, (PBYTE)value.data()))
return false;
if (!value.empty() && value.back() == L'\\0')
value.pop_back();
return true;
}
VOID WINAPI EventRecordCallback(PEVENT_RECORD event) {
if (event->EventHeader.ProviderId != MicrosoftWindowsKernelProcess)
return;
if (event->EventHeader.EventDescriptor.Id != ProcessStart)
return;
ULONG processId = 0;
if (!GetIntegerProperty(event, L\"ProcessID\", processId))
return;
std::wstring imageName{};
if(!GetUnicodeStringProperty(event, L\"ImageName\", imageName))
return;
printf(\"Process Start: PID=%u Image=%ls\
\", processId, imageName.c_str());
}
微软提供了一个更完整的示例——Using TdhGetProperty to Consume Event Data(Win32 apps | Microsoft Learn)https://learn.microsoft.com/en-us/windows/win32/etw/using-tdhgetproperty-to-consume-event-data
不幸的是,这个 API 每次调用都要扫描全部属性。性能差到微软现在都警告新代码不要用它。
关于 Vista 对 ETW 的改进,这篇老文有不错的概览——Event Tracing: Improve Debugging And Performance Tuning With ETW(Microsoft Learn)https://learn.microsoft.com/en-us/archive/msdn-magazine/2007/april/event-tracing-improve-debugging-and-performance-tuning-with-etw
3. Windows 7
Windows 7 带来了一个新 API:TdhFormatProperty。现在可以对事件属性做一次 schema 感知的单遍扫描了——既稳健,性能也有指望。不过 TDH 样板代码依然少不了,而且还很容易写出性能很差的代码。
一个(性能不佳的)例子大致长这样——
bool GetEventInfo(EVENT_RECORD* event, std::unique_ptr<BYTE[]>& buffer, PTRACE_EVENT_INFO& info) {
ULONG size = 0;
ULONG status = TdhGetEventInformation(event, 0, nullptr, nullptr, &size);
if (ERROR_INSUFFICIENT_BUFFER != status)
return false;
buffer = std::make_unique<BYTE[]>(size);
info = reinterpret_cast<PTRACE_EVENT_INFO>(buffer.get());
status = TdhGetEventInformation(event, 0, nullptr, info, &size);
return (ERROR_SUCCESS == status);
}
PEVENT_PROPERTY_INFO FindPropertyInfo(PTRACE_EVENT_INFO info, PCWSTR name, USHORT& index) {
for (USHORT i = 0; i < info->TopLevelPropertyCount; ++i) {
auto prop = &info->EventPropertyInfoArray[i];
auto propName = (PCWSTR)((PBYTE)info + prop->NameOffset);
if (_wcsicmp(propName, name) == 0) {
index = i;
return prop;
}
}
return nullptr;
}
bool GetProperty(EVENT_RECORD* event, PCWSTR name, std::wstring& value) {
std::unique_ptr<BYTE[]> infoBuffer;
PTRACE_EVENT_INFO info = nullptr;
if (!GetEventInfo(event, infoBuffer, info))
return false;
USHORT targetIndex = 0;
PEVENT_PROPERTY_INFO targetProp = FindPropertyInfo(info, name, targetIndex);
if (!targetProp)
return false;
USHORT pointerSize = (event->EventHeader.Flags & EVENT_HEADER_FLAG_32_BIT_HEADER) ? 4 : 8;
PBYTE userData = reinterpret_cast<PBYTE>(event->UserData);
ULONG userDataLength = event->UserDataLength;
for (USHORT i = 0; i <= targetIndex; ++i) {
auto prop = &info->EventPropertyInfoArray[i];
if ((prop->Flags & PropertyStruct) != 0)
return false;
if ((prop->Flags & PropertyParamCount) != 0 || prop->count > 1)
return false;
ULONG consumed = 0;
USHORT outType = prop->nonStructType.OutType;
USHORT inType = prop->nonStructType.InType;
ULONG formattedSize = 0;
ULONG status = TdhFormatProperty(info, nullptr, pointerSize, inType, outType, (USHORT)userDataLength, userData, &formattedSize, nullptr, &consumed);
if (ERROR_INSUFFICIENT_BUFFER != status)
return false;
std::wstring formatted(formattedSize / sizeof(wchar_t), L'\\0');
status = TdhFormatProperty(info, nullptr, pointerSize, inType, outType, (USHORT)userDataLength, userData, &formattedSize, formatted.data(), &consumed);
if (ERROR_SUCCESS != status)
return false;
if (!formatted.empty() && formatted.back() == L'\\0')
formatted.pop_back();
if (i == targetIndex) {
value = std::move(formatted);
return true;
}
if (consumed > userDataLength)
return false;
userData += consumed;
userDataLength -= consumed;
}
return false;
}
VOID WINAPI EventRecordCallback(PEVENT_RECORD event) {
if (event->EventHeader.ProviderId != MicrosoftWindowsKernelProcess)
return;
if (event->EventHeader.EventDescriptor.Id != PROCESS_START)
return;
std::wstring processIdStr;
if (!GetProperty(event, L\"ProcessID\", processIdStr))
return;
std::wstring imageName{};
if (!GetProperty(event, L\"ImageName\", imageName))
return;
printf(\"Process Start: PID=%ls ImageName=%ls\
\", processIdStr.c_str(), imageName.c_str());
}
更完整的示例在这里——Using TdhFormatProperty to Consume Event Data(Win32 apps | Microsoft Learn)https://learn.microsoft.com/en-us/windows/win32/etw/using-tdhformatproperty-to-consume-event-data
这个 API 在性能上的主要陷阱是:TdhFormatProperty 会隐式地把所有数据转成宽字符串,这并不适合所有用例。此外,示例只展示了如何顺序消费单个事件的全部属性。而实践中,我们处理的是事件流,往往还只要属性的一个子集。
想真正把 TDH 用出性能,仍然有一大堆样板代码要写。
4. krabsetw
微软自己的开发团队也撞上了同样的问题,他们很贴心地把自家的库开源给了我们。
于是我们的解析示例变得赏心悦目地简单。
void ProcessEventCallback(const EVENT_RECORD &event, const krabs::trace_context &trace_context) {
if (event.EventHeader.EventDescriptor.Id != PROCESS_START)
return;
krabs::schema schema(event, trace_context.schema_locator);
krabs::parser parser(schema);
ULONG processId = parser.parse<ULONG>(L\"ProcessID\");
std::wstring imageName = parser.parse<std::wstring>(L\"ImageName\");
printf(\"Process Start: PID=%u ImageName=%ls\
\", processId, imageName.c_str());
}
更完整的示例在这里——krabsetw NativeExamples/user\_trace\_001.cpp(GitHub)https://github.com/microsoft/krabsetw/blob/master/examples/NativeExamples/user\_trace\_001.cpp
krabsetw 给了我们很多——
- 简化 ETW 跟踪会话管理的抽象。
- 广泛的解析前事件过滤支持。
- 让 TDH schema 查找快 10 倍的缓存。
- 强类型的属性解析 API。
- Windows 7 及以上支持。
那我们为什么不用它的 Rust 移植版呢?
缺了什么?
1. 要 helper,不要抽象
事后回看,抽象并不是 ETW 的完美搭档。
我们用抽象来创建稳定的接口,把可能随时间变化的实现细节藏起来。当你自己拥有实现时,这种做法尤其合适。
但 krabsetw 既不拥有 ETW,也不拥有各 ETW provider 的实现。
我给 krabsetw 的大部分贡献,都是在扩展(或者说掰弯)这套抽象,好让它盖住 ETW 的 edge case——要么是现代的新能力,要么是历史上的古怪行为。
不过我本职是安全研究员,场景有时比较刁钻。在大多数情况下,krabsetw 提供的接口还是非常合理、直观的。
Origin 的用例既需要 ferrisetw 尚未支持的 ETW 特性,也需要一些会进一步考验 krabsetw 抽象完整性的 ETW 机制。
我想要的是一个灵活的 helper 库,而不是一套并不总合身的抽象。
在 etw-rs 里,我刻意紧紧贴着 Win32 API 的形状来做,并提供了(unsafe 的)逃生通道https://www.originhq.com/research/securitytrace-etw-ppl,可以直接控制 Rust API 尚未支持的 Win32 API 参数。这主要是为了支持研究与原型验证——要在生产环境用,请自担风险。
// SecurityTrace: undocumented bit 14 of WMI_LOGGER_CONTEXT.Flags.
//
// EVENT_TRACE_PROPERTIES.LogBuffersLost is a union with
// WMI_LOGGER_INFORMATION.Flags in the kernel. Setting bit 14 here causes
// StartTraceW to propagate it into WMI_LOGGER_CONTEXT.Flags.SecurityTrace.
//
// When set, only Antimalware-PPL processes can query or modify the session
// via ControlTrace(). Non-PPL admin processes get ERROR_ACCESS_DENIED on
// query — though stop is NOT gated (known gap).
//
// Reference: https://www.originhq.com/research/securitytrace-etw-ppl
const SECURITY_TRACE_FLAG: u32 = 0x4000; // bit 14
// SAFETY: We are setting LogBuffersLost, which is an output field that
// StartTraceW overwrites. The kernel reads it as Flags before that happens.
// No other invariants of the properties struct are violated.
unsafe {
trace.properties_mut().LogBuffersLost = SECURITY_TRACE_FLAG;
}
另一个更常见的例子是,krabsetw 假设「已启用 provider 的 GUID」和「实际收到事件的 provider GUID」之间是 1:1 映射。例外虽然罕见,但确实存在。所以有时我「启用」一个 provider,只是为了让那些本来就会到达的事件能被处理。
我还发现自己会按预期事件量从大到小来启用 provider,只为优化线性扫描派发的性能——这段代码在热路径上。
// krabsetw/krabs/krabs/ut.hpp
for (auto& provider : trace.providers_) {
if (schema->ProviderGuid == provider.get().guid_) {
provider.get().on_event(record, trace.context_);
return;
}
}
预期 GUID 的集合在编译期就是已知的,所以只要少量样板代码,就能换来热路径上由编译器优化的派发。
fn event_callback(event: &RawEvent, cache: &mut ProcessingContext) -> Result<()> {
// Route to appropriate handler based on provider
let guid = event.provider_guid();
match guid {
process::GUID => handle_process_event(event, cache),
file::GUID => handle_file_event(event, cache),
tcpip::GUID => handle_tcpip_event(event, cache),
// This provider was not explicitly enabled, but events still arrive for it.
// This is one example of where an approach that abstracted to a callback
// function per enabled provider isn't ideal.
etw_subsystem::GUID => handle_stack_walk_event(event),
_ => {
debug_assert!(false, \"unexpected provider: {guid:?}\");
Ok(())
}
}
}
2. 少一点向后兼容
Windows 7 已经停止支持,但 krabsetw 的一些特性仍然被钉在 Windows 7 兼容上。
比如,krabsetw 里我最不喜欢的抽象,就是 UserTrace 与 KernelTrace 的区分。
Windows 11 的 system logger 是 Windows 2000 kernel logger 的演进。对一个要支持 Windows 7 的库来说,KernelTrace 这个名字大体上说得通。大体上。
在 Windows 2000 里有一个特殊的 ETW 会话叫「NT Kernel Logger」,它是消费(经典)Windows Kernel Trace provider 的唯一机制——而后者是当时唯一的内核事件来源。这个会话为了向后兼容一直保留至今,Windows 11 SDK 里仍然带着 KERNEL_LOGGER_NAME 宏。所以「Kernel」这个前缀,在历史上只和这些经典内核事件强绑定。
事实上,微软的文档直到今天还在不准确地宣称——
「NT Kernel Logger 会话是唯一可以接收内核事件 provider 事件的会话。」
——NT Kernel Logger Constants(Win32 apps | Microsoft Learn)https://learn.microsoft.com/en-us/windows/win32/etw/nt-kernel-logger-constants
微软的 PerfView 项目里还带着一个来自 Vista 源码树的 kerneltraceeventparser.mofhttps://github.com/microsoft/perfview/blob/5ef79abafdc3d950943a5003acff345aedece486/src/TraceEvent/Parsers/kerneltraceeventparser.mof 文件,从中可以窥见当时的内部命名约定。
但从 Vista 起,情况开始变化:许多经典 Windows Kernel Trace 事件有了对应的现代 provider。经典 provider 不再是唯一的内核 provider。
下一个重大变化落在 Windows 8:API 文档把术语更新为「System Trace」或「System Logger」,那个独一无二的特殊会话不再特殊。它不再是经典内核 provider 事件的唯一消费者,经典内核 provider 也不再是内核 ETWhttps://www.elastic.co/security-labs/kernel-etw-best-etw 事件的唯一来源。
「在 Windows 8、Windows Server 2012 及更高版本上,SystemTraceProvider 最多可以多路复用到 8 个 logger 会话。」
——Configuring and Starting a SystemTraceProvider Session(Win32 apps | Microsoft Learn)https://learn.microsoft.com/en-us/windows/win32/etw/configuring-and-starting-a-systemtraceprovider-session
要使用这八个 system logger 会话之一,需要一个新的 logger 模式——
EVENT_TRACE_SYSTEM_LOGGER_MODE(0x02000000):此选项将接收来自 SystemTraceProvider 的事件。如果 StartTrace 的 Properties 参数 LogFileMode 包含此标志,该 logger 将成为一个 system logger。此选项在 Windows 8 与 Windows Server 2012 上受支持。——Logging Mode Constants(Win32 apps | Microsoft Learn)https://learn.microsoft.com/en-us/windows/win32/etw/logging-mode-constants
最近,Windows 10 SDK build 20348 又引入了 System Providershttps://learn.microsoft.com/en-us/windows/win32/etw/system-providers——可以用惯常的现代 API 来配置 system logger 跟踪。
总结一下,在 krabsetw 里:
UserTrace 是只能通过 GUID 和 keyword 订阅 provider 的跟踪会话,包括:
- 用户态 provider,如
Microsoft-Windows-DNS-Client。 - 内核态 provider,如
Microsoft-Windows-Kernel-Process。 - 用户态 global logger(UMGL)provider,如
Heap Trace Provider。 - 内核态 system trace provider——通过其 System Providerhttps://learn.microsoft.com/en-us/windows/win32/etw/system-providers 别名(前提是你同时设置了
EVENT_TRACE_SYSTEM_LOGGER_MODE)。
KernelTrace 是只能通过 EnableFlags 或扩展的 PERFINFO_MASK 订阅 provider 的跟踪会话,具体说就是:
- 内核态 system trace provider。
微软文档从没说过 system logger 会话不能同时消费其他 provider 的事件。那只是 Windows 7 上的事实默认行为,krabsetw 为了保持完整兼容也就只能这么做。
所有这些想说的是:KernelTrace 这个抽象是一块 Windows 7 化石。Windows 8 及更高版本(以及 etw-rs)都乐意让你在单个跟踪会话里消费任意组合的 provider,还能为事件关联提供一定的顺序保证。
// Full example: https://github.com/originsec/etw-rs/blob/main/examples/15_combined_trace.rs
let mut trace = Trace::new(\"combined_trace\")?;
trace
.set_events(&[system_process::PERF_PROCESS, PERF_NO_SYSCONFIG])
.enable_provider(kernel_process::GUID, kernel_process::keyword::PROCESS)?;
trace.set_event_callback(|event, context| {
if event.provider_guid() == system_process::CLASS_GUID
&& event.opcode() == system_process::opcode::START
{
let mut parser = context.parser(event)?;
let pid: u32 = parser.get(\"ProcessId\")?;
// Only the classic event includes the command line and user SID
let sid: Sid = parser.get(\"UserSID\")?;
let cmdline: String = parser.as_ref::<WideStrRef>(\"CommandLine\")?.to_string_lossy();
println!(\"[+] SystemLogger pid:{pid} sid:{sid} {cmdline}\");
}
if event.provider_guid() == kernel_process::GUID
&& event.id() == kernel_process::event::PROCESS_START
{
let mut parser = context.parser(event)?;
let pid: u32 = parser.get(\"ProcessID\")?;
let image_name: String = parser.as_ref::<WideStrRef>(\"ImageName\")?.to_string_lossy();
// Only the modern event includes the sequence number and integrity
// Note: EVENT_HEADER_EXT_TYPE_SID logs the creator thread's SID
let sequence: u64 = parser.get(\"ProcessSequenceNumber\")?;
let integrity: String = parser.as_ref::<SidRef>(\"MandatoryLabel\")?.to_string();
println!(\"[+] Kernel-Process pid:{pid} sequence:{sequence} integrity:{integrity} {image_name}\");
}
Ok(())
});
旁注
有些时候,措辞的精确性很重要——我之前就主张过,解释安全绕过https://www.elastic.co/blog/edr-bypass-taxonomy 就属于这种时候。
ETW 最初那个经典的安全用例,是改进 .NET 与 PowerShell 的监控。这引出了一连串 .NET ETW 绕过https://blog.xpnsec.com/hiding-your-dotnet-etw/,其中一些是通用的用户态 ETW 绕过。
然而,这些手法常被笼统地简称为「ETW 绕过」,一些号称一体化的「免杀」loader 甚至(乐观地)塞进了动静很大的「ETW 绕过」,尽管它们根本没有托管任何 .NET tradecraft。讽刺的是,这些用户态 ETW 绕过里的绝大多数,都可以用内核 ETW providerhttps://www.originhq.com/research/unexpectedly-out-of-context-detecting-a-lockbit-sample 检测到。
3. 性能
我从 krabsetw 借来了 schema 缓存的概念,因为它的性能收益非常明确。
| 实现 | 热查找(ns/事件) | 缓存机制 |
|---|---|---|
| etw-rs | 16 ns | FxHashMap ,无锁 |
| krabsetw | 17 ns | std::unordered_map ,无锁 |
| ferrisetw | 46 ns | DashMap ,每次调用共享读锁 + Arc 克隆 |
TdhGetEventInformation |
173 ns | 无缓存 |
下一步是研究解析本身的性能。
我们的 etw-rs 基础示例与 krabsetw 有两个微妙的差别。
fn handle_process_event(event: &RawEvent, cache: &mut ProcessingContext) -> Result<()> {
if event.id() != PROCESS_START {
return Ok(());
}
let mut parser = cache.parser(event)?;
let process_id: u32 = parser.get(\"ProcessID\")?;
let image_name: String = parser.get(\"ImageName\")?;
println!(\"Process Start: PID={process_id} ImageName={image_name}\");
Ok(())
}
第一,因为我们身在 Rust,字符串是 UTF-8 的,而 TDH schema 用的是 Windows 原生宽字符串(UTF-16LE)。为了不让字符串转换成本混进解析过程,我们预先把 schema 里的属性名转成 Rust 字符串,并预填充一张「名字 → 索引」映射表。
第二,我们给 parser 预填充所有位置固定的属性的偏移。关键洞察是:只有包含两个及以上变长属性的事件,才会出现某些属性偏移不固定的情况。这意味着可以按 schema 一次性预算出大多数属性的偏移,而不必对每个事件都重算一遍。实际上大多数 Windows 事件都遵循这个模式——定宽属性在前,变长属性在末尾。
我对五个典型事件做了整事件解析性能的 benchmarkhttps://github.com/originsec/etw-rs/tree/main/benches,覆盖不同的属性数量与类型。
-
Microsoft-Windows-Kernel-Process ProcessStarthttps://github.com/jdu2600/Windows10EtwEvents/blob/84e7261126f26b538ee00bcf00938b7e3ff2647f/manifest/Microsoft-Windows-Kernel-Process.tsv#L6
-
UInt32 ProcessID, UInt64 ProcessSequenceNumber, FILETIME CreateTime, UInt32 ParentProcessID, UInt64 ParentProcessSequenceNumber, UInt32 SessionID, UInt32 Flags, UInt32 ProcessTokenElevationType, UInt32 ProcessTokenIsElevated, SID MandatoryLabel, UnicodeString ImageName, UInt32 ImageChecksum, UInt32 TimeDateStamp, UnicodeString PackageFullName, UnicodeString PackageRelativeAppId, UInt32 SecurityMitigations -
Microsoft-Windows-RPC ClientCallStarthttps://github.com/jdu2600/Windows10EtwEvents/blob/84e7261126f26b538ee00bcf00938b7e3ff2647f/manifest/Microsoft-Windows-RPC.tsv#L6
-
GUID InterfaceUuid, UInt32 ProcNum, UInt32 Protocol, UnicodeString NetworkAddress, UnicodeString Endpoint, UnicodeString Options, UInt32 AuthenticationLevel, UInt32 AuthenticationService, UInt32 ImpersonationLevel -
Microsoft-Windows-Kernel-Registry CreateKeyhttps://github.com/jdu2600/Windows10EtwEvents/blob/84e7261126f26b538ee00bcf00938b7e3ff2647f/manifest/Microsoft-Windows-Kernel-Registry.tsv#L2
-
Pointer BaseObject, Pointer KeyObject, UInt32 Status, UInt32 Disposition, UnicodeString BaseName, UnicodeString RelativeName -
Microsoft-Windows-Kernel-File Createhttps://github.com/jdu2600/Windows10EtwEvents/blob/84e7261126f26b538ee00bcf00938b7e3ff2647f/manifest/Microsoft-Windows-Kernel-File.tsv#L5
-
Pointer Irp, Pointer FileObject, UInt32 IssuingThreadId, UInt32 CreateOptions, UInt32 CreateAttributes, UInt32 ShareAccess, UnicodeString FileName -
Microsoft-Windows-Kernel-Process ThreadStarthttps://github.com/jdu2600/Windows10EtwEvents/blob/84e7261126f26b538ee00bcf00938b7e3ff2647f/manifest/Microsoft-Windows-Kernel-Process.tsv#L12
-
UInt32 ProcessID, UInt32 ThreadID, Pointer StackBase, Pointer StackLimit, Pointer UserStackBase, Pointer UserStackLimit, Pointer StartAddr, Pointer Win32StartAddr, Pointer TebBase, UInt32 SubProcessTag
benchmark 在 Intel Core Ultra 7 268V 上运行,关闭功耗节流,处理线程钉在一个 Performance Core 上。先抓取一次实时事件快照,之后所有运行都复用它。报告值是 1000 次运行的逐事件解析时长中位数。Rust 实现还要多做一步工作——把 Windows 原生的 UTF-16LE 字符串转成 Rust 原生的 UTF-8 字符串。
| 事件类型 | etw-rs(get v0) | TdhFormatProperty |
ferrisetw 1.2.0 | krabsetw 4.4.6 |
|---|---|---|---|---|
| Process Start | 483 ns | 541 ns | 2503 ns | 2522 ns |
| RPC Client Call | 328 ns | 338 ns | 1213 ns | 771 ns |
| Registry CreateKey | 176 ns | 548 ns | 749 ns | 467 ns |
| File Create | 215 ns | 294 ns | 867 ns | 872 ns |
| Thread Start | 94 ns | 201 ns | 905 ns | 1055 ns |
我们既保住了 krabsetw 优雅的易用性,又提升了性能。
4. 性能更高……易用性更低
我们的 get() 属性访问器背后是一张优化过的 O(1)「名字 → 索引」映射表,但我们其实事先就知道预期的属性索引。把它作为显式 hint 传进去,就能进一步优化快路径——hint 命中时连查表都省了。
我也受够了手动从 manifest XML 里抄定义、再重新排版成注释,所以写了个辅助工具来干这件事。
// BEGIN Generated by etw-rs generate-provider (comment-only).
// generate-provider Microsoft-Windows-Kernel-Process --event-comment 1 --query-release --drop-old-versions
//
// Event: ProcessStart (id 1)
// Keywords: WINEVENT_KEYWORD_PROCESS
//
// Version 3: // Since Windows 10 1903
// [ 0] ProcessID UInt32
// [ 1] ProcessSequenceNumber UInt64
// [ 2] CreateTime FileTime
// [ 3] ParentProcessID UInt32
// [ 4] ParentProcessSequenceNumber UInt64
// [ 5] SessionID UInt32
// [ 6] Flags UInt32
// [ 7] ProcessTokenElevationType UInt32
// [ 8] ProcessTokenIsElevated UInt32
// [ 9] MandatoryLabel SID
// [10] ImageName UnicodeString
// [11] ImageChecksum UInt32
// [12] TimeDateStamp UInt32
// [13] PackageFullName UnicodeString
// [14] PackageRelativeAppId UnicodeString
//
// Version 4 (extends v3): // Since Windows 11 24H2
// [15] SecurityMitigations UInt32
//
// END Generated by etw-rs generate-provider (comment-only).
fn handle_process_event(event: &RawEvent, cache: &mut ProcessingContext) -> Result<()> {
if event.id() != PROCESS_START {
return Ok(());
}
let mut parser = cache.parser(event)?;
let process_id: u32 = parser.get_hinted(\"ProcessID\", 0)?;
let image_name: String = parser.get_hinted(\"ImageName\", 10)?;
println!(\"Process Start: PID={process_id} ImageName={image_name}\");
Ok(())
}
性能明显更好,但 API 变得没那么好用了。一个改进是让 get() 在运行时学习 hint 顺序——因为每个事件通常只有一条解析路径。
| 事件类型 | etw-rs(get\_hinted) | etw-rs(get) | etw-rs(get v0) |
|---|---|---|---|
| Process Start | 304 ns | 327 ns | 483 ns |
| RPC Client Call | 216 ns | 227 ns | 328 ns |
| Registry CreateKey | 131 ns | 133 ns | 176 ns |
| File Create | 141 ns | 145 ns | 215 ns |
| Thread Start | 73 ns | 75 ns | 94 ns |
5. 性能太多……易用性太少
另一个观察是:既然我们已经从 schema 里拿到了整个事件布局,为什么只把索引抄下来当 hint?这意味着我们可以在编译期预生成一个 parser,把大多数已知版本的偏移都预先算好。
生成的代码块变成了这样——
// BEGIN Auto-generated by etw-rs generate-provider.
// generate-provider Microsoft-Windows-Kernel-Process --event-only 1 --query-release --drop-old-versions
etw::provider_schema! {
guid: Guid::from_u128(0x22fb2cd6_0e7b_422b_a0c7_2fad1fd0e716),
events: {
event { // ProcessStart [WINEVENT_KEYWORD_PROCESS]
id: 1,
name: \"ProcessStart\",
version { 3, fields: { // Since Windows 10 1903
ProcessID: u32, // [0] UInt32
ProcessSequenceNumber: u64, // [1] UInt64
CreateTime: u64, // [2] FileTime
ParentProcessID: u32, // [3] UInt32
ParentProcessSequenceNumber: u64, // [4] UInt64
SessionID: u32, // [5] UInt32
Flags: u32, // [6] UInt32
ProcessTokenElevationType: u32, // [7] UInt32
ProcessTokenIsElevated: u32, // [8] UInt32
MandatoryLabel: SidRef, // [9] SID
ImageName: WideStrRef, // [10] UnicodeString
ImageChecksum: u32, // [11] UInt32
TimeDateStamp: u32, // [12] UInt32
PackageFullName: WideStrRef, // [13] UnicodeString
PackageRelativeAppId: WideStrRef, // [14] UnicodeString
}}
version { 4, extends_version: 3, fields: { // Since Windows 11 24H2
SecurityMitigations: u32, // [15] UInt32
}}
}
}
}
// END Auto-generated by etw-rs generate-provider.
属性变成了带生成名称的类型化访问器,回调则变成——
fn handle_process_event(event: &RawEvent, cache: &mut ProcessingContext) -> Result<()> {
if event.id() != PROCESS_START {
return Ok(());
}
let (process_id, image_name) = match event.version() {
// Static versioned parsers give maximum speed for known version(s)
3 => {
let mut parser = ProcessStartV3Parser::new(event);
(parser.ProcessID()?, parser.ImageName()?)
}
4 => {
let mut parser = ProcessStartV4Parser::new(event);
(parser.ProcessID()?, parser.ImageName()?)
}
// Fallback safely to dynamic schema lookup for new version best effort
_ => {
debug_assert!(false, \"dynamic schema fallback used\"); // version canary
let mut parser = cache.parser(event)?;
let process_id: u32 = parser.get_hinted(\"ProcessID\", 0)?;
let image_name: String = parser.get_hinted(\"ImageName\", 10)?;
(process_id, image_name)
}
};
println!(\"Process Start: PID={process_id} ImageName={image_name}\");
Ok(())
}
为了处理所有已知版本、其余情况回退到带 hint 的动态 parser,这套写法引入了大量样板维护工作。我想要的可是性能和便利兼得。
6. 鱼与熊掌兼得
于是这些样板又被进一步包进一个生成的 parser 类里。带 hinted 动态回退的静态 parser,现在提供了与纯动态方式几乎一样的接口,却能在编译期保证属性名正确,性能还接近翻倍。
fn handle_process_event(event: &RawEvent, cache: &mut ProcessingContext) -> Result<()> {
if event.id() != PROCESS_START {
return Ok(());
}
let mut parser = ProcessStartParser::new(event, cache)?;
let process_id = parser.ProcessID()?;
let image_name = parser.ImageName()?;
println!(\"Process Start: PID={process_id} ImageName={image_name}\");
Ok(())
}
还有一个小问题:每个属性访问器内部都要承担一次相同的版本分发成本。我研究过用分发宏做逐事件的单态化(monomorphized)handler 函数,但它既伤易用性,又未必总能带来明确的性能收益。我认为这已经把性能追得太过了,就没有放进去。
至此,易用性是我们想要的,解析性能也足够出色。
此时 get_hinted() 这个 API 变体被藏了起来——它比静态 parser 既不好用也不更快。get() 则保留下来,因为它对手写代码更方便,也支持反射类用例。
我还给 krabsetwhttps://github.com/microsoft/krabsetw/pull/273 和 ferrisetwhttps://github.com/n4r1b/ferrisetw/pull/161 都提了 PR,把实际可行的性能改进回传了回去。在 krabsetw 里,首批 2-3 倍的解析性能提升已经随 v4.4.9 落地。
| 事件类型 | etw-rs(static) | etw-rs(get) | TdhFormatProperty |
krabsetw 4.4.9 | krabsetw 4.4.6 | ferrisetw 1.2.0 |
|---|---|---|---|---|---|---|
| Process Start | 221 ns | 327 ns | 541 ns | 815 ns | 2522 ns | 2503 ns |
| RPC Client Call | 127 ns | 227 ns | 338 ns | 302 ns | 771 ns | 1213 ns |
| Registry CreateKey | 70 ns | 133 ns | 548 ns | 233 ns | 467 ns | 749 ns |
| File Create | 85 ns | 145 ns | 294 ns | 258 ns | 872 ns | 867 ns |
| Thread Start | 9 ns | 75 ns | 201 ns | 279 ns | 1055 ns | 905 ns |
对大多数事件,解析性能比现有的 Rust 方案好 10 倍;对 Thread Start 这种只有定宽属性的简单事件,则好了 100 倍。
结语
三个库做下来,我的结论是:ETW 不想要抽象——它只想要顺手的 helper。为此,etw-rs 紧紧贴着 Win32 API 的形状,留着逃生通道,并在 schema 允许的地方把解析成本挪到编译期。结果既保住了 krabsetw 那种优雅的易用性,又大幅提升了解析性能。
首个 0.1.0 版本已发布到 crates.io,源码、示例和 benchmark 套件都在 https://github.com/originsec/etw-rshttps://github.com/originsec/etw-rs(Apache-2.0,Windows 10 1703+)。
欢迎提 issue、提 PR,也欢迎分享各种冷门的 provider 怪癖——edge case 本来就是这件事的意义所在。
译者解读
「ETW 不想要抽象」是交了三次学费换来的结论。 作者给 krabsetw 的大部分贡献都是在把抽象「掰弯」以覆盖 ETW 的边角,UserTrace / KernelTrace 二分是为 Windows 7 兼容留下的化石——微软文档从未禁止 system logger 会话混订其他 provider,那只是当年的事实默认。对检测工程的直接启示是:写 ETW 消费者时,紧贴 Win32 API 的 helper 比抽象层更长寿,因为 ETW 的 schema 与会话模型不归你管,抽象迟早会被新版本事件顶破。
单线程分发决定了为什么每一纳秒都要算。 ETW 为了对系统低开销而刻意异步高延迟;拆会话保吞吐就会丢掉事件顺序,关联分析受罪,于是单会话单线程成了常态,逐事件解析成本就是吞吐天花板。etw-rs 的三级优化(schema 缓存 → 固定偏移预计算 + hint → 编译期静态 parser)全部是围绕这一个约束展开的;Thread Start 这类纯定长事件解析到 9 ns,意味着内核事件洪流从「吞不下」变成「有余量」。
性能收益没有留在自己库里。 作者把可行的改进回提了 krabsetw(PR #273,v4.4.9 已落地 2-3 倍解析提升)和 ferrisetw(PR #161)——不是另起炉灶再拉踩前人,这对所有用旧库的团队是免费的午餐,也让 benchmark 数字的口径更可信。
安全味藏在旁注里。 市面上大量所谓「ETW 绕过」只断 .NET / PowerShell 的用户态遥测,内核 ETW provider 照样看得见,这与本仓库《深入解析 Windows 遥测》的主线一致。另外文中 escape hatch 的例子直接用了未文档化的 SecurityTrace 位(WMI_LOGGER_CONTEXT.Flags bit 14),设置后只有 Antimalware-PPL 进程能查询或修改该会话;作者另有配套研究专门讲这个机制(参考资源里有链接),值得连起来读。
参考资源
- 原文:Introducing etw-rs: an ETW parsing library built for flexibility and performance:https://www.originhq.com/research/introducing-etw-rs
- etw-rs 源码、示例与 benchmark 套件(Apache-2.0,Windows 10 1703+):https://github.com/originsec/etw-rs
- krabsetw(微软 ETW C++ 库):https://github.com/microsoft/krabsetw
- ferrisetw(krabsetw 的 Rust 移植):https://github.com/n4r1b/ferrisetw
- 作者前作:Doubling Down on ETW Callstacks(Elastic Security Labs):https://www.elastic.co/security-labs/doubling-down-etw-callstacks
- 作者前作:Kernel ETW is the best ETW(Elastic Security Labs):https://www.elastic.co/security-labs/kernel-etw-best-etw
- 配套研究:SecurityTrace——用 PPL 保护 ETW 会话(Origin):https://www.originhq.com/research/securitytrace-etw-ppl
- Microsoft Learn:Using TdhFormatProperty to Consume Event Data:https://learn.microsoft.com/en-us/windows/win32/etw/using-tdhformatproperty-to-consume-event-data
- Microsoft Learn:System Providers:https://learn.microsoft.com/en-us/windows/win32/etw/system-providers
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论