-
-
[原创]获取SYS加载方源头进程的方案-基于ETW技术
-
发表于: 1天前 459
-
前一个贴子给大家介绍了在内核中借助ALPC消息扫描技术,实现的DNS发起方的探查,很多朋友留言,表达了自己的见解,也提到了ETW技术,这里也算是做个结尾,再来介绍基于ETW技术的“驱动加载方exe回溯”的方法,如果上一个帖子的方案与这个帖子的方案结合,基本上DNS发起方源头探查与SYS加载发起方源头探查就都能覆盖了,当然,也有一些特殊手段实现的SYS加载,像是借壳加载这种,那需要其他的特殊手段(Section对象访问+DriverObject对象访问等)来实现监控了。废话不多说,我们来介绍技术流程。
在dll/sys的加载中,常规手段上有两种方法(当然还有其他的方法)实现这个过程的感知,一个是通过IMG镜像回调,另一个是通过MF文件过滤器的IRP_MJ_ACQUIRE_FOR_SECTION_SYNCHRONIZATION 这个IRP,这两个方法都能发现镜像加载的过程。对于dll来说,加载宿主的PID就在参数中,但是对于SYS驱动来说,获取到的PID基本就是System(4)这个进程了。但在EDR或者其他安全产品中,我们始终需要想办法找到“发起方”,这个时候通过参数里的信息就不能满足了,需要借助其他辅助信息旁路来获取对应的宿主信息(当然不能说绝对,借助InfinityHook也是可以一步到位的),这里介绍的就是ETW。
通过ETW是如何拿到这个SYS加载的发起方呢?这里就需要简单介绍一下SYS在应用层常规的加载技术:
常见的驱动加载工具(例如 sc.exe、InstDrv、自研 loader)并不是直接把 SYS 映射进内核。 它们通常调用 OpenSCManagerW、CreateServiceW/OpenServiceW、StartServiceW, 通过本地 RPC/ALPC 把请求发送给 services.exe。随后由 SCM 在 services.exe 中完成驱动服务启动逻辑, 并调用 NtLoadDriver 进入内核。因此,ImageLoad 事件里看到 System 并不代表真实发起者就是 System。
下面是应用层加载流程的简述:
1. 应用层加载 SYS 的典型 API 链:应用层加载驱动最常见的方式是把驱动注册成一个“驱动服务”。核心 API 链如下:
OpenSCManagerW() -> CreateServiceW(..., SERVICE_KERNEL_DRIVER, ..., ImagePath, ...) 或 OpenServiceW() -> StartServiceW() -> CloseServiceHandle() 典型代码形态如下: SC_HANDLE scm = OpenSCManagerW( NULL, NULL, SC_MANAGER_CONNECT | SC_MANAGER_CREATE_SERVICE); SC_HANDLE svc = CreateServiceW( scm, L"EDRSim", L"EDRSim", SERVICE_START | DELETE | SERVICE_STOP, SERVICE_KERNEL_DRIVER, SERVICE_DEMAND_START, SERVICE_ERROR_NORMAL, L"C:\\Users\\Administrator\\Desktop\\bin\\EDRSim.sys", NULL, NULL, NULL, NULL, NULL); StartServiceW(svc, 0, NULL);
API说明:

2.CreateServiceW 写入的服务注册表结构:CreateServiceW 会在 SCM 数据库中创建服务对象。对本机系统而言,服务配置最终落在注册表:
HKLM\SYSTEM\CurrentControlSet\Services\<ServiceName>
对于驱动服务,常见字段如下:

当后续调用 StartServiceW 时,用户态传递的是服务名,而不是直接把 SYS 文件交给内核。 SCM 会把服务名解析到上述注册表路径,再构造 Native API 所需的驱动服务路径:
\Registry\Machine\System\CurrentControlSet\Services\<ServiceName>
3. 从 Win32 API 到 services.exe:RPC 与 ALPC
OpenSCManagerW、CreateServiceW、OpenServiceW、StartServiceW 这些 Win32 API 暴露在 advapi32.dll 中。应用程序调用它们时,实际并不会在当前进程内直接执行服务数据库修改和驱动加载。 这些请求会被转换成发往 SCM 服务进程 services.exe 的 RPC 调用。本机场景通常使用本地 RPC 协议序列 ncalrpc。从 Win32 API 视角看,这是 RPC runtime 的内部细节; 从 ETW 视角看,本地 RPC 在现代 Windows 中会落到 LPC/ALPC 通信上,因此可以通过 Kernel ALPC 相关事件观察到.
4.链路拓扑

5.services.exe 内部如何触发 NtLoadDriver
services.exe 收到 StartServiceW 对应的 SCM RPC 请求后,会检查服务配置。 如果服务类型是 SERVICE_KERNEL_DRIVER 或 SERVICE_FILE_SYSTEM_DRIVER,它不会像普通 Win32 服务那样创建用户态服务进程, 而是进入驱动服务启动路径。
核心动作可以概括为:
1. 根据服务名定位 HKLM\SYSTEM\CurrentControlSet\Services\<ServiceName> 2. 检查 Type/Start/ErrorControl/ImagePath 等配置; 3. 构造 Native 注册表路径: \Registry\Machine\System\CurrentControlSet\Services\<ServiceName> 4. 调用 NtLoadDriver(RegistryPath),到达内核; 5. 内核读取服务项,解析 ImagePath; 6. 内核映射 .sys 镜像,创建 DRIVER_OBJECT; 7. 调用 DriverEntry(DriverObject, RegistryPath);
从内核角度看,ZwLoadDriver 的参数不是普通 DOS 文件路径,而是驱动服务注册表路径。 这也是为什么很多驱动加载流程必须先写入 Services 注册表项,随后才能启动。
接下来就是ETW 的事件关联了,由于应用程序加载sys与ETW事件的获取严格意义上是“异步”的,所以需要借助事件与时间的关联来进行识别:
当捕获到 .sys Kernel ImageLoad事件时: 1. 取 image path 和时间戳 T2 2. 在 T2 前的短时间窗口内查找 services.exe 相关 SCM RPC/ALPC 请求 3. 优先匹配 StartService/OpenService/CreateService 语义 4. 若能拿到服务名,则解析 Services\Name\ImagePath 与 image path 归一化比较 5. 输出 RPC/ALPC 客户端进程作为 loader source 6. 若找不到 SCM 链路,再降级输出 System/Unknown,并标记为“未完成源头关联”
根据前面一大堆啰嗦的流程描述,下面开始看代码:
【为了提高开发效率,这里的ETW解析库直接使用了开源项目“krabsetw”编译出来的静态库“etwtracelib.lib”】
我们在“etwtracelib”项目中增加几个关心的ETW回调,并创建一个lib的导出函数“EtwCopyStartTrace”供exe使用,核心代码如下:
// 导出函数入口。函数内部同时启动 Kernel Trace 和 User
// Trace,前者采集 ImageLoad/Process/ALPC,后者采集 RPC。该函数不会主动
// 返回,调用方应放到工作线程里运行。
extern "C" void EtwCopyStartTrace()
{
try
{
krabs::kernel_trace kernelTrace(L"ETWMonitor-Kernel");
krabs::user_trace rpcTrace(L"ETWMonitor-RPC");
krabs::kernel::image_load_provider imageLoadProvider;
krabs::kernel::process_provider processProvider;
krabs::kernel::alpc_provider alpcProvider;
krabs::provider<> rpcProvider(L"Microsoft-Windows-RPC");
//******** 下面是几个关键的ETW追踪函数,实现几个事件的联合跟踪,这几个函数的细节代码篇幅过大,就不贴了,大家可以下载代码自己看 *******
EtwCopySetupImageLoadProvider(imageLoadProvider);
EtwCopySetupProcessProvider(processProvider);
EtwCopySetupAlpcProvider(alpcProvider);
EtwCopySetupRpcProvider(rpcProvider);
// Kernel providers can share one kernel trace. The RPC provider is a
// normal user-mode provider and must be enabled on a user trace.
kernelTrace.enable(imageLoadProvider);
kernelTrace.enable(processProvider);
kernelTrace.enable(alpcProvider);
rpcTrace.enable(rpcProvider);
// krabs::trace::start is blocking, so each trace runs on its own
// thread. The outer loop intentionally keeps the library alive.
std::thread kernelThread([&kernelTrace]()
{
try
{
kernelTrace.start();
}
catch (const std::runtime_error& error)
{
std::cerr << error.what() << std::endl;
kernelTrace.stop();
}
});
std::thread rpcThread([&rpcTrace]()
{
try
{
rpcTrace.start();
}
catch (const std::runtime_error& error)
{
std::cerr << error.what() << std::endl;
rpcTrace.stop();
}
});
while (true)
{
Sleep(1000);
}
kernelTrace.stop();
rpcTrace.stop();
kernelThread.join();
rpcThread.join();
}
catch (const std::runtime_error& error)
{
std::cerr << error.what() << std::endl;
}
}下面是调用lib的exe测试程序的代码:
/*
* ETWMonitor 测试程序。
*
* 设计说明:
* 该程序只负责注册回调、启动 etwtracelib 中的 ETW 采集逻辑,并打印
* SYS 加载结果。RPC/ALPC 溯源、路径归一化、ImageLoad 解析都放在
* etwtracelib 副本库中实现。
*
*/
#include <Windows.h>
#include <cstdio>
#include <thread>
#if defined(_M_X64) && defined(NDEBUG)
#pragma comment(lib, "etwtracelib.lib")
#elif defined(_M_X64)
#pragma comment(lib, "etwtracelib.lib")
#else
#pragma comment(lib, "etwtracelib.lib")
#endif
// 必须和 etwtracelib 副本里的 IMAGE_LOAD_OPERATE 保持二进制兼容。
// iTypeId == 4 时,cBuf 指向该结构;如果库侧字段布局变化,这里也要同步更新。
typedef struct _IMAGE_LOAD_OPERATE
{
ULONG ulLoaderPid; // 修正后的加载发起方 PID;SYS 场景下通常来自 RPC/ALPC 溯源。
ULONG ulTargetPid; // 镜像加载目标进程 PID;SYS 通常没有普通用户态目标进程。
ULONG ulImageType; // 1 表示 DLL,2 表示 SYS。
char cLoaderPath[1024]; // 修正后的加载发起方进程路径。
char cTargetProcessPath[1024]; // 镜像加载目标进程路径。
char cImagePath[1024]; // 被加载的 DLL/SYS 镜像路径。
}
IMAGE_LOAD_OPERATE, * PIMAGE_LOAD_OPERATE;
typedef void(*FUNC_DoCmd)(int iTypeId, char* cBuf, int ilen);
// 静态库对外暴露的新入口名。副本故意与原始工程不同,便于区分符号。
extern "C" void EtwCopyStartTrace();
extern "C" void EtwCopySetCallback(FUNC_DoCmd pFunc);
// 库回调函数。库侧会同时上报 DLL/SYS,但当前 exe 只验证 SYS 加载溯源,
// 因此这里再过滤一次,只打印 ulImageType == 2 的记录。
static void EtwCopyOnEtwImageEvent(int iTypeId, char* cBuf, int ilen)
{
// iTypeId 4 对应 IMAGE_LOAD_OPERATE。长度检查用于防止库/测试程序版本
// 不一致时 memcpy 越界或解析错位。
if (iTypeId != 4 || ilen != sizeof(IMAGE_LOAD_OPERATE))
{
return;
}
IMAGE_LOAD_OPERATE imageInfo = { 0 };
memcpy(&imageInfo, cBuf, sizeof(imageInfo));
if (imageInfo.ulImageType != 2)
{
// 控制台暂时只输出 SYS;DLL 事件仍然由库侧采集,只是在测试程序中过滤。
return;
}
printf("IMAGE[SYS] loaderPid:%lu loader:%s targetPid:%lu target:%s image:%s\n",
imageInfo.ulLoaderPid,
imageInfo.cLoaderPath,
imageInfo.ulTargetPid,
imageInfo.cTargetProcessPath,
imageInfo.cImagePath);
}
int main()
{
// 先注册回调,再启动 ETW trace;否则早期 ImageLoad 事件可能已经被采集,
// 但由于没有回调而无法交给测试程序打印。
EtwCopySetCallback(EtwCopyOnEtwImageEvent);
// EtwCopyStartTrace 是阻塞式监控循环,所以放到工作线程中运行。
// 主线程 join 等待,使该程序表现为一个持续运行的控制台监控器。
std::thread traceThread([]()
{
EtwCopyStartTrace();
});
traceThread.join();
return 0;
}验证结果如图:
