Hook是一种可以改变程序执行流程的技术,巧妙利用Hook技术可以实现很多实用的操作,如:监控、过滤、拦截、修改等。
但在现代操作系统中,出现了诸多限制,导致无法轻松的进行底层Hook。其中典型例子就是Win10的PatchGuard。
现在,我将为大家讲解一个神奇的技术:InfinityHook。
什么是 InfinityHook
InfinityHook,是一个可以Hook各种系统调用、上下文切换、页面错误、DPC等内核事件的技术。
它目前可以与PatchGuard同时运行,且比常规Hook技术具有更好隐蔽性。
InfinityHook并不是其名意所谓的“无限Hook”,实际意义是可以安全的对内核底层进行Hook。
因为安全界中攻与防的对抗,产生了一种,谁先Hook,谁Hook的最底层,谁最有优势的概念。
而攻与防之间互相抢夺Hook优势的现象,像极了一个无尽深渊,没有终点。
微软为了保护底层系统的安全,防止被第三方程序滥用,在一系列新版本操作系统中推出和升级了PatchGuard。
而InfinityHook的出现打破了微软的这一保护,拥有更安全更底层的Hook优势。
随着时间的推移这项技术会广为人知,这意味着一场更激烈的攻防无限Hook战争即将开战。故名“InfinityHook”。
想要更深入剖析InfinityHook的原理,就得先了解Windows的Event Tracing机制。
什么是 Event Tracing for Windows
Windows事件跟踪(ETW)是一种高效的内核级跟踪工具,可让您将内核或应用程序定义的事件记录到日志文件中。您可以实时或从日志文件中使用事件,并使用它们来调试应用程序或确定应用程序中发生性能问题的位置。
ETW允许您动态启用或禁用事件跟踪,允许您在生产环境中执行详细跟踪,而无需重新启动计算机或应用程序。
事件跟踪API分为三个不同的组件:
控制器(Controllers):用于启动和停止事件跟踪会话并启用提供程序。
提供者(Providers):用于提供事件。
消费者(Consumers):用于消费事件。
控制器(Controllers)
控制器是定义日志文件的大小和位置、启动和停止事件跟踪会话、启用提供程序以便将事件记录到会话、管理缓冲池的大小以及获取会话的执行统计信息的应用程序。
提供者(Providers)
提供程序是包含事件跟踪工具的应用程序。提供程序注册后,控制器可以在提供程序中启用或禁用事件跟踪。提供者定义其启用或禁用的实现。
通常,启用的提供程序会生成事件,而禁用的提供程序则不会。这使您可以向应用程序添加事件跟踪,而无需始终生成事件。
虽然ETW模型将控制器和提供程序分离为单独的应用程序,但应用程序可以包含这两个组件。
消费者(Consumers)
消费者是选择一个或多个事件跟踪会话作为事件源的应用程序。消费者可以同时从多个事件跟踪会话中请求事件;系统按时间顺序提供事件。
消费者可以接收存储在日志文件中的事件,也可以接收实时传递事件的会话。处理事件时,消费者可以指定开始和结束时间,并且仅传递在指定时间范围内发生的事件。
我们可以在 “此电脑->管理->性能->数据收集器集->事件跟踪会话” 中找到系统中所有正在运行的事件跟踪会话。
(系统中所有正在运行的事件跟踪会话)
即将讲解的InfinityHook就将使用第一个事件跟踪会话“Circular Kernel Context Logger”(之后简称CKCL)进行操作。
InfinityHook 原理剖析
首先我们要知道一点,InfinityHook因为一些特殊的操作必须要在Ring0层才能实现。
而ETW模型的3个组件是可以在Ring3完成。所以我们可以参考微软开放的文档例子帮助分析。
找到微软开放的“配置和启动NT内核记录器会话”的代码:
https://docs.microsoft.com/en-us/windows/win32/etw/configuring-and-starting-the-nt-kernel-logger-session
我从网页截取了关键代码贴了上来:
EVENT_TRACE_PROPERTIES* pSessionProperties = NULL;
BufferSize = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(LOGFILE_PATH) + sizeof(KERNEL_LOGGER_NAME);
pSessionProperties = (EVENT_TRACE_PROPERTIES*) malloc(BufferSize);
ZeroMemory(pSessionProperties, BufferSize);
pSessionProperties->
Wnode.BufferSize = BufferSize;
pSessionProperties->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
pSessionProperties->Wnode.ClientContext = 1; //QPC clock resolution
pSessionProperties->Wnode.Guid = SystemTraceControlGuid;
pSessionProperties->EnableFlags = EVENT_TRACE_FLAG_NETWORK_TCPIP;
pSessionProperties->LogFileMode = EVENT_TRACE_FILE_MODE_CIRCULAR;
pSessionProperties->MaximumFileSize = 5; // 5 MB
pSessionProperties->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);
pSessionProperties->LogFileNameOffset = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(KERNEL_LOGGER_NAME);
StringCbCopy((LPWSTR)((char*)pSessionProperties + pSessionProperties->LogFileNameOffset), sizeof(LOGFILE_PATH), LOGFILE_PATH);
status = StartTrace((PTRACEHANDLE)&SessionHandle, KERNEL_LOGGER_NAME, pSessionProperties);
可以不难发现,配置会话的关键结构体是EVENT_TRACE_PROPERTIES,启动会话的关键API是StartTrace。
EVENT_TRACE_PROPERTIES结构体的定义:
typedef struct _EVENT_TRACE_PROPERTIES {
WNODE_HEADER Wnode;
//
// data provided by caller
ULONG BufferSize; // buffer size for logging (kbytes)
ULONG MinimumBuffers; // minimum to preallocate
ULONG MaximumBuffers; // maximum buffers allowed
ULONG MaximumFileSize; // maximum logfile size (in MBytes)
ULONG LogFileMode; // sequential, circular
ULONG FlushTimer; // buffer flush timer, in seconds
ULONG EnableFlags; // trace enable flags
union {
LONG AgeLimit; // unused
LONG FlushThreshold; // Number of buffers to fill before flushing
} DUMMYUNIONNAME;
// data returned to caller
ULONG NumberOfBuffers; // no of buffers in use
ULONG FreeBuffers; // no of buffers free
ULONG EventsLost; // event records lost
ULONG BuffersWritten; // no of buffers written to file
ULONG LogBuffersLost; // no of logfile write failures
ULONG RealTimeBuffersLost; // no of rt delivery failures
HANDLE LoggerThreadId; // thread id of Logger
ULONG LogFileNameOffset; // Offset to LogFileName
ULONG LoggerNameOffset; // Offset to LoggerName
} EVENT_TRACE_PROPERTIES, *PEVENT_TRACE_PROPERTIES;
typedef struct _WNODE_HEADER
{
ULONG BufferSize; // Size of entire buffer inclusive of this ULONG
ULONG ProviderId; // Provider Id of driver returning this buffer
union
{
ULONG64 HistoricalContext; // Logger use
struct
{
ULONG Version; // Reserved
ULONG Linkage; // Linkage field reserved for WMI
} DUMMYSTRUCTNAME;
} DUMMYUNIONNAME;
union
{
ULONG CountLost; // Reserved
HANDLE KernelHandle; // Kernel handle for data block
LARGE_INTEGER TimeStamp; // Timestamp as returned in units of 100ns
// since 1/1/1601
} DUMMYUNIONNAME2;
GUID Guid; // Guid for data block returned with results
ULONG ClientContext;
ULONG Flags; // Flags, see below
} WNODE_HEADER, *PWNODE_HEADER;
StartTrace函数的声明:
EXTERN_C
ULONG
WMIAPI
StartTraceA (
_Out_ PTRACEHANDLE TraceHandle,
_In_ LPCSTR InstanceName,
_Inout_ PEVENT_TRACE_PROPERTIES Properties
)
;
在这段代码中有一个需要关注的点:
BufferSize = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(LOGFILE_PATH) + sizeof(KERNEL_LOGGER_NAME);
pSessionProperties = (EVENT_TRACE_PROPERTIES*) malloc(BufferSize);
分配的内存长度不仅仅只是结构体的大小,还包含了两段其他的长度。
看一下msdn对这个API的描述:
https://docs.microsoft.com/en-us/windows/win32/etw/starttrace
其中同样有一点值得关注:
This function copies the session name that you provide to the offset that the LoggerNameOffset member of Properties points to.
此函数将您提供的会话名称复制到属性的LoggerNameOffset成员指向的偏移量。
这段话说明我们拷贝到结构体之后的字符串还会在StartTrace内部被处理。
对在Ring3启动跟踪会话的信息了解的差不多了。但是问题来了,如何在Ring0层实现这些东西呢?
所以我们得上IDA分析一下这个函数的内部实现。
StartTrace 的内部实现
从msdn的描述里我们可以看到:
DLL
Sechost.dll on Windows 8.1 and Windows Server 2012 R2;
Advapi32.dll on Windows 8, Windows Server 2012, Windows 7, Windows Server 2008 R2, Windows Server 2008, Windows Vista and Windows XP
所以我们将Advapi32.dll文件用IDA打开,下载dll的符号文件让IDA自动分析。
分析完成后,转到StartTraceA函数,从函数头部开始看。
首先是对参数的合法性检查。
接着是对参数2的InstanceName字符串内容进行比较。
如果为“NT Kernel Logger”,则将SystemTraceControlGuid的Guid赋值给Properties->Wnode.Guid。
如果为“Circular Kernel Context Logger”, 则将CKCLGuid的Guid赋值给Properties->Wnode.Guid。
我们看下这个CKCLGuid的内容,之后会用到。
首先判断“Properties->LogFileNameOffset”如果小于等于0,则跳到LABEL_67标签。
在图片底部可以看到LABEL_67标签置零了2个变量,但并没有返回退出。
说明这个函数允许LogFileNameOffset的值为0。
同样,我们在msdn查阅一下这个结构体:
https://docs.microsoft.com/en-us/windows/win32/etw/event-trace-properties
找到对这个成员的描述,截取关键内容下来:
LogFileNameOffset
Offset from the start of the structure's allocated memory to beginning of the null-terminated string that contains the log file name.
从结构的已分配内存开始到包含日志文件名的以空值终止的字符串的开头的偏移量。
If you do not want to log events to a log file (for example, you specify EVENT_TRACE_REAL_TIME_MODE only), set LogFileNameOffset to 0. If you specify only real-time logging and also provide an offset with a valid log file name, ETW will use the log file name to create a sequential log file and log events to the log file. ETW also creates the sequential log file if LogFileMode is 0 and you provide an offset with a valid log file name.
如果您不想将事件记录到日志文件中(例如,仅指定EVENT_TRACE_REAL_TIME_MODE),请将LogFileNameOffset设置为0.如果仅指定实时日志记录并提供具有有效日志文件名的偏移量,则ETW将使用日志文件名,用于创建顺序日志文件并将事件记录到日志文件中。
如果LogFileMode为0,ETW还会创建顺序日志文件,并提供带有效日志文件名的偏移量。
文章头部对ETW的介绍中写到:
您可以实时或从日志文件中使用事件,并使用它们来调试应用程序或确定应用程序中发生性能问题的位置。
我们可以得知, 日志文件是用于调试应用程序或定位性能问题位置的。而InfinityHook并不需要这些东西。
所以LogFileNameOffset成员可以直接置0,跳过这段继续往下分析。
接着是一些参数标志合法性检查,这些合法性检查由Etwp开头的API完成。
假定我们传递的所有参数都是正确的,所以无需跟进这些API进行分析。
从堆中分配一块内存空间,后面要用到。
将我们传递进来的Properties参数填进v27变量内。
接着我们可以看到几个字符串操作API。还记得刚刚在msdn提到的关注点吗?应该就是这里了。
第一个字符串的处理是将我们传递进来的InstanceName参数初始化为一个ANSI_STRING字符串。
再将其转换为一个UNICODE_STRING字符串。
转换完的UNICODE_STRING字符串存放在 (PUNICODE_STRING)v27 + 9 的位置。
UNICODE_STRING结构体的大小为16,故步长为16。实际存放地址是在 v27 + 144 。
再看下EVENT_TRACE_PROPERTIES结构体的大小为120。
说明这个UNICODE_STRING字符串存放在 v27 + sizeof(EVENT_TRACE_PROPERTIES) + 24 的位置。
处理完第一个字符串后有一个判断,如果v46等于0,则跳到LABEL_69标签。
而这个v46就是刚刚判断LogFileNameOffset如果等于0所置零的变量。
所以这里可以跳过第二个字符串的处理,直接往下看。
看到了两个Etwp开头的API,我们先跟进第一个API看看。
不难看出,这个API对Property的内容影响并不大,只有两处,74和72偏移。
对比结构体定义发现,是在对EnableFlags成员填值。
这个成员放到后面再讲,所以这里先暂时跳过此API。
接着来看EtwpStartLogger这个API。
可以很直观的看到,检查了一些成员后调用了NtTraceControl这个API。
这个就是我们在Ring0层启动事件跟踪会话的API了。
在这API之下是一些与结构体内容无关的操作。所以直接跳过,返回上层继续看。
将堆内存的Property内容,置回到参数Property中。然后跳到LABEL_58标签。
释放堆内存,返回成功与否。
至此,StartTraceA这个API逆向完成。
我们知道了部分参数的填写规则和关键API。
下一步我们开始写InfinityHook的实现代码。
InfinityHook 的实现
首先,从刚刚分析出来的信息:
UNICODE_STRING类型的InstanceName存放在 Property + sizeof(EVENT_TRACE_PROPERTIES) + 24 的位置。
所以我们定义一个名为CKCL_TRACE_PROPERTIES的结构体,这个结构体继承于EVENT_TRACE_PROPERTIES。
struct CKCL_TRACE_PROPERTIES : EVENT_TRACE_PROPERTIES
{
CHAR Padding[24];
UNICODE_STRING InstanceName;
};
然后定义一个变量为其申请一块内存空间,并对其置零。
为了保险起见,以防底层API还会对结构体更后面的内存进行操作,所以申请的大小填PAGE_SIZE。
CKCL_TRACE_PROPERTIES *pProperty = (CKCL_TRACE_PROPERTIES *)ExAllocatePoolWithTag(NonPagedPool, PAGE_SIZE, ALLOC_TAG);
if (pProperty == NULL) {
KeBugCheckEx(HAL_MEMORY_ALLOCATION, PAGE_SIZE, 0, NULL, 0);
return STATUS_MEMORY_NOT_ALLOCATED;
}
RtlZeroMemory(pProperty, PAGE_SIZE);
然后初始化一个变量名为InstanceName的UNICODE_STRING字符串。
UNICODE_STRING InstanceName;
RtlInitUnicodeString(&InstanceName, L"Circular Kernel Context Logger");
接着我们开始填写pProperty内容。
pProperty->Wnode.BufferSize:填写我们为其申请内存的大小PAGE_SIZE。
pProperty->Wnode.ClientContext:
根据msdn的描述,此成员为时间戳的分辨率。可以为3个值。
Query performance counter (QPC). QPC计数器提供高分辨率时间戳,不受系统时钟调整的影响。存储在事件中的时间戳等同于QueryPerformanceCounter API返回的值。
System time(系统时间)。
CPU cycle counter(CPU循环计数器). CPU计数器提供最高分辨率的时间戳,并且是检索资源最少的。但是,CPU计数器不可靠,不应在生产中使用。例如,在某些计算机上,除了在某些状态下停止之外,定时器还会因热量和功率变化而改变频率。
我们这里填写为3值(CPU循环计数器),因为它的实现是通过一条汇编指令实现的,后面会讲。
pProperty->Wnode.Flags:根据msdn的描述,必须包含WNODE_FLAG_TRACED_GUID以指示该结构包含事件跟踪信息。
pProperty->Wnode.Guid:
从上面的IDA中取出来改为代码则是:
// 54dea73a-ed1f-42a4-af713e63d056f174
const GUID CkclSessionGuid = { 0x54dea73a, 0xed1f, 0x42a4, { 0xaf, 0x71, 0x3e, 0x63, 0xd0, 0x56, 0xf1, 0x74 } };
pProperty->BufferSize:每个事件的缓冲区,直接填4字节即可,因为我们不需要实际接收事件内容。
pProperty->LogFileMode:上面说过,我们不需要ETW写入日志文件,所以设置为EVENT_TRACE_BUFFERING_MODE。
pProperty->MinimumBuffers:为事件跟踪会话的缓冲池分配的最小缓冲区数,最小为2,直接填2。
pProperty->MaximumBuffers:
这个成员msdn描述的有点矛盾。
既说必须大于等于MinimumBuffers,又说LogFileMode设置了EVENT_TRACE_BUFFERING_MODE可以不用设置此值。
虽然应该可以忽略这个成员,但这里为了保险起见,我们设置和MinimumBuffers一样的值。
pProperty->InstanceName:这个为我们刚刚扩充的成员,填写初始化的UNICODE_STRING字符串InstanceName即可。
所以Property的成员填写代码是这样的。
pProperty->Wnode.BufferSize = PAGE_SIZE;
pProperty->Wnode.ClientContext = 3;
pProperty->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
pProperty->Wnode.Guid = CkclSessionGuid;
pProperty->BufferSize = sizeof(ULONG);
pProperty->LogFileMode = EVENT_TRACE_BUFFERING_MODE;
pProperty->MinimumBuffers = pProperty->MaximumBuffers = 2;
pProperty->InstanceName = InstanceName;
然后就是控制CKCL事件跟踪会话。
NtTraceControl,msdn并没有给出他的声明。结合谷歌搜索、GitHub项目和IDA分析,可以得到的声明为:
EXTERN_C
NTSYSCALLAPI
NTSTATUS
NTAPI
NtTraceControl(
_In_ ULONG FunctionCode,
_In_reads_bytes_opt_(InBufferLen
) PVOID InBuffer,
_In_ ULONG InBufferLen,
_Out_writes_bytes_opt_(OutBufferLen) PVOID OutBuffer,
_In_ ULONG OutBufferLen,
_Out_ PULONG ReturnLength
)
;
其中参数一可以是5个常量中的任意一个:
#define EtwpStartTrace 1
#define EtwpStopTrace 2
#define EtwpQueryTrace 3
#define EtwpUpdateTrace 4
#define EtwpFlushTrace 5
这5个常量在此API内部对应5个Etwp开头的API,分别代表:启动跟踪、停止跟踪、查询跟踪、更新跟踪、刷新跟踪。
InfinityHook只需要:启动、停止和更新。
// 启动
NtTraceControl(EtwpStartTrace, pProperty, PAGE_SIZE, pProperty, PAGE_SIZE, &ReturnLength);
// 停止
NtTraceControl(EtwpStopTrace, pProperty, PAGE_SIZE, pProperty, PAGE_SIZE, &ReturnLength);
// 更新
NtTraceControl(EtwpUpdateTrace, pProperty, PAGE_SIZE, pProperty, PAGE_SIZE, &ReturnLength);
值得注意的是,我们需要在更新跟踪的时候,设置pProperty->EnableFlags标志,以此来过滤我们想要的事件。
本文将以Hook Syscall中的NtOpenProcess作为例子进行讲解。
所以这里设置EVENT_TRACE_FLAG_SYSTEMCALL标志,以拦截到所有的Syscall事件。
我们将以上内容封装为一个函数,所以整个函数的代码是这样的:
NTSTATUS EventTraceControl(CKCL_TRACE_OPERATION Operation)
{
NTSTATUS Status = STATUS_UNSUCCESSFUL;
ULONG ReturnLength = 0;
CKCL_TRACE_PROPERTIES *pProperty = (CKCL_TRACE_PROPERTIES *)ExAllocatePoolWithTag(NonPagedPool, PAGE_SIZE, ALLOC_TAG);
if (pProperty == NULL) {
KeBugCheckEx(HAL_MEMORY_ALLOCATION, PAGE_SIZE, 0, NULL, 0);
return STATUS_MEMORY_NOT_ALLOCATED;
}
RtlZeroMemory(pProperty, PAGE_SIZE);
UNICODE_STRING InstanceName;
RtlInitUnicodeString(&InstanceName, L"Circular Kernel Context Logger");
pProperty->Wnode.BufferSize = PAGE_SIZE;
pProperty->Wnode.ClientContext = 3;
pProperty->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
pProperty->Wnode.Guid = CkclSessionGuid;
pProperty->BufferSize = sizeof(ULONG);
pProperty->LogFileMode = EVENT_TRACE_BUFFERING_MODE;
pProperty->MinimumBuffers = pProperty->MaximumBuffers = 2;
pProperty->InstanceName = InstanceName;
switch (Operation)
{
case CKCL_TRACE_START:
Status = NtTraceControl(EtwpStartTrace, pProperty, PAGE_SIZE, pProperty, PAGE_SIZE, &ReturnLength);
break;
case CKCL_TRACE_END:
Status = NtTraceControl(EtwpStopTrace, pProperty, PAGE_SIZE, pProperty, PAGE_SIZE, &ReturnLength);
break;
case CKCL_TRACE_SYSCALL:
// 这里添加更多标志可以捕获更多事件
pProperty->EnableFlags = EVENT_TRACE_FLAG_SYSTEMCALL;
Status = NtTraceControl(EtwpUpdateTrace, pProperty, PAGE_SIZE, pProperty, PAGE_SIZE, &ReturnLength);
break;
default:
Status = STATUS_UNSUCCESSFUL;
break;
}
ExFreePool(pProperty);
return Status;
}
接着调用这个函数,启动并更新CKCL事件跟踪对象:
NTSTATUS StartCkclEventTrace()
{
NTSTATUS Status = STATUS_UNSUCCESSFUL;
// 测试 CKCL 会话是否已经启动
Status = EventTraceControl(CKCL_TRACE_SYSCALL);
if (!NT_SUCCESS(Status)) {
// 没有启动 尝试打开
Status = EventTraceControl(CKCL_TRACE_START);
if (!NT_SUCCESS(Status)) {
LOG_ERROR("Start CKCL failed.", Status);
return Status;
}
Status = EventTraceControl(CKCL_TRACE_SYSCALL);
if (!NT_SUCCESS(Status)) {
LOG_ERROR("Start CKCL failed.", Status);
return Status;
}
}
LOG_INFO("CKCL is running", 0);
return Status;
}
至此,CKCL已成功启动和更新。
下面,我们需要找到一些内核全局地址,来方便我们进行之后的操作。
EtwpDebuggerData,是一张存放所有ETW信息的表。
通过IDA搜索可以发现有这个变量符号,但是查看交叉引用却发现IDA并没有分析出哪里使用了这个变量。
所以只能通过暴搜的方法去找到这个变量的地址。
通过多个系统版本的内核文件进行IDA分析,可以发现两条信息。
根据相同的数值,得出EtwpDebuggerData的特征码是“?? ?? 2c 08 04 38 0c”。
不同系统上的EtwpDebuggerData可能存在于不同的区段。
那我们首先取到内核基地址,解析PE头找到“.data”和".rdata"区段的起始地址和区段大小,然后根据特征码进行暴搜。
ULONG KernelSize = 0;
PVOID KernelBase = System::GetKernelBase(&KernelSize);
if (KernelBase == NULL) {
LOG_ERROR("Get kernel base failed.", 0);
return FALSE;
}
const auto fnSearchInSection = [&](CHAR *SectionName, CHAR *Pattern, CHAR *Masks)->PVOID
{
ULONG SizeOfSection = 0;
PVOID SectionBase = Image::GetSection(KernelBase, SectionName, &SizeOfSection);
if (SectionBase == NULL) {
return NULL;
}
return Utils::FindPattern(SectionBase, SizeOfSection, Pattern, Masks);
};
PVOID EtwpDebuggerData = fnSearchInSection(".data", "\x00\x00\x2c\x08\x04\x38\x0c", "??xxxxx");
if (EtwpDebuggerData == NULL) {
EtwpDebuggerData = fnSearchInSection(".rdata", "\x00\x00\x2c\x08\x04\x38\x0c", "??xxxxx");
if (EtwpDebuggerData == NULL) {
return FALSE;
}
}
接着在 EtwpDebuggerData + 0x10 处取到EtwpDebuggerDataSilo指针。
再从 EtwpDebuggerDataSilo + 0x10 的位置取到CkclWmiLoggerContext指针。
这两个数据的硬编码偏移在所有系统上都一样,并没有发生改变。
EtwpDebuggerDataSilo和CkclWmiLoggerContext,是直接参考的GitHub上的项目。
因为在IDA中找了很久都没有找到关于EtwpDebuggerDataSilo的信息,
仅仅找到了一个可能是CkclWmiLoggerContext的指针,是动态分配的。
鄙人逆向能力有限,实在没能跟到EtwpDebuggerDataSilo在哪。如果有大佬研究出来了,欢迎在评论区分享,谢谢!
PVOID *EtwpDebuggerDataSilo = *(PVOID**)((ULONG_PTR)EtwpDebuggerData + 0x10);
if (EtwpDebuggerDataSilo == NULL) {
LOG_ERROR("EtwpDebuggerDataSilo is bad.", EtwpDebuggerDataSilo);
return FALSE;
}
g.CkclWmiLoggerContext = EtwpDebuggerDataSilo[2];
if (g.CkclWmiLoggerContext == NULL) {
LOG_ERROR("CkclWmiLoggerContext is bad.", EtwpDebuggerDataSilo);
return FALSE;
}
接着我们需要取到SyscallEntry的页面基址。
直接从msr中可以取出SyscallEntry。
__readmsr(IA32_LSTAR_MSR)
但是有一个问题,还记得2018年的内核页表隔离补丁吗?
不记得没关系,大表哥之前分析过的帖子:https://bbs.pediy.com/thread-223805.htm
简单来说,在打了内核页表隔离补丁的系统上,取到的只是一个影子入口,通过影子入口内的过渡代码,最后走到真正的入口。
而CKCL事件是在真实入口中触发的,只拿到影子入口并不能完成之后的操作。
影子入口和过渡代码都存在于内核模块的KVASCODE区段内,该区段是否存在取决于有没有打内核页表隔离补丁。
如果区段存在,我们则取到这个区段的入口地址和区段大小。
再对比我们从msr中得到的SyscallEntry是否在此区段内,则可以判定内核页表隔离有没有被开启。
/*
通过 KVASCODE 节表是否存在,判断有没有打补丁
也可以通过 NtQuerySystemInformation 来查询判断
*/
ULONG SectionSize = 0;
PVOID SectionBase = Image::GetSection(KernelBase, "KVASCODE", &SectionSize);
if (SectionBase == NULL) {
return SyscallEntry;
}
// 判断 SyscallEntry 是否在 KVASCODE 节表内,如果不在则是真实入口直接返回
if (!(SyscallEntry >= SectionBase && SyscallEntry < (PVOID)((ULONG_PTR)SectionBase + SectionSize))) {
return SyscallEntry;
}
这是没打补丁通过msr取到的SyscallEntry,函数名是KiSystemCall64。
这是打了KB4056892补丁后通过msr取到的SyscallEntry,可以看到函数名是KiSystemCall64Shadow。
在函数尾部有一个jmp,跳到了真正的入口KiSystemServiceUser,而这个真正的入口一定是在KVASCODE区段外。
所以我们利用HDE反汇编引擎,对影子入口的每条指令进行解析,找到一条跳出KVASCODE区段的jmp指令。
hde64s Hde;
for (PVOID ShadowPagePtr = SyscallEntry; ; ShadowPagePtr = (PVOID)((ULONG_PTR)ShadowPagePtr + Hde.len))
{
// 解析每条汇编指令,找到第一个 jmp(e9) 出 KVASCODE 区段的指令
if (!hde64_disasm(ShadowPagePtr, &Hde)) {
break;
}
if (Hde.opcode != 0xE9) {
continue;
}
// 忽略 jmp 目标为 KVASCODE 区域内的指令
PVOID KiSystemServiceUser = (PVOID)((ULONG_PTR)ShadowPagePtr + (INT)Hde.len + (INT)Hde.imm.imm32);
if (KiSystemServiceUser >= SectionBase && KiSystemServiceUser < (PVOID)((ULONG_PTR)SectionBase + SectionSize)) {
continue;
}
// 找到 KiSystemServiceUser
SyscallEntry = KiSystemServiceUser;
break;
}
至此,定位到真实SyscallEntry函数地址。
再回过头来看看我们刚刚找的CkclWmiLoggerContext。
这个变量的结构体定义是WMI_LOGGER_CONTEXT。
通过Windbg输出这个结构体在Win10 1709上的定义:
0: kd> dt nt!_WMI_LOGGER_CONTEXT
+0x000 LoggerId : Uint4B
+0x004 BufferSize : Uint4B
+0x008 MaximumEventSize : Uint4B
+0x00c LoggerMode : Uint4B
+0x010 AcceptNewEvents : Int4B
+0x014 EventMarker : [2] Uint4B
+0x01c ErrorMarker : Uint4B
+0x020 SizeMask : Uint4B
+0x028 GetCpuClock : Ptr64 int64
+0x030 LoggerThread : Ptr64 _ETHREAD
+0x038 LoggerStatus : Int4B
+0x03c FailureReason : Uint4B
+0x040 BufferQueue : _ETW_BUFFER_QUEUE
+0x050 OverflowQueue : _ETW_BUFFER_QUEUE
+0x060 GlobalList : _LIST_ENTRY
+0x070 DebugIdTrackingList : _LIST_ENTRY
+0x080 DecodeControlList : Ptr64 _ETW_DECODE_CONTROL_ENTRY
+0x088 DecodeControlCount : Uint4B
+0x090 BatchedBufferList : Ptr64 _WMI_BUFFER_HEADER
+0x090 CurrentBuffer : _EX_FAST_REF
+0x098 LoggerName : _UNICODE_STRING
+0x0a8 LogFileName : _UNICODE_STRING
+0x0b8 LogFilePattern : _UNICODE_STRING
+0x0c8 NewLogFileName : _UNICODE_STRING
+0x0d8 ClockType : Uint4B
+0x0dc LastFlushedBuffer : Uint4B
+0x0e0 FlushTimer : Uint4B
+0x0e4 FlushThreshold : Uint4B
+0x0e8 ByteOffset : _LARGE_INTEGER
+0x0f0 MinimumBuffers : Uint4B
+0x0f4 BuffersAvailable : Int4B
+0x0f8 NumberOfBuffers : Int4B
+0x0fc MaximumBuffers : Uint4B
+0x100 EventsLost : Uint4B
......
+0x980 BufferCompressDuration : _LARGE_INTEGER
在 WMI_LOGGER_CONTEXT + 0x28 的位置GetCpuClock是一个函数指针。
这个函数将在每次触发CKCL事件跟踪时会被调用。
换言之就是每次发生Syscall时会触发CKCL事件,从而调用到GetCpuClock。
GetCpuClock的函数指针指向就是根据我们开启事件跟踪填写的pProperty->Wnode.ClientContext来赋值的。
ClientContext的:1、2、3,分别对应给GetCpuClock赋值:
PpmQueryTime、EtwpGetSystemTime、EtwpGetCycleCount。
我们之前填的3,所以这个指针应该指向EtwpGetCycleCount。
通过IDA看一下EtwpGetCycleCount的实现:
unsigned __int64 EtwpGetCycleCount()
{
return __rdtsc();
}
我们在这里将这个指针替换为我们自己的函数。
PVOID ReplaceGetCpuClock(PVOID TargetAddr)
{
PVOID *pEtwpGetCycleCount = (PVOID*)((ULONG_PTR)g.CkclWmiLoggerContext + 0x28);
PVOID Result = *pEtwpGetCycleCount;
*pEtwpGetCycleCount = TargetAddr;
return Result;
}
ReplaceGetCpuClock(FakeGetCpuClock);
在FakeGetCpuClock函数内,先判断当前PreviousMode是否为KernelMode。
当为KernelMode时,我们直接返回 rdtsc() ,防止意外重入。
ULONG64 FakeGetCpuClock()
{
if (ExGetPreviousMode() == KernelMode) {
return __rdtsc();
}
// ......
return __rdtsc();
}
接着我们需要取到栈顶和栈帧,往上遍历堆栈。
在gs段寄存器中同样存放着栈顶,在其偏移量0x1a8的位置。通过内联函数获取__readgsqword进行取值。
PVOID *StackBase = (PVOID*)__readgsqword(0x1A8);
PVOID *StackFrame = (PVOID*)_AddressOfReturnAddress();
虽然这里可以通过KeQueryCurrentStackInformation,进行获取。
但还记得我们文章开头说的话吗,如果被别人Hook了这个API岂不是只能甘拜下风了?
所以我们需要尽可能的少用及不用API。
判断两个只有Syscall调用才会产生的标志,和之间的指针是否有在SyscallEntry函数范围内,以此来确定这是否是一个Syscall调用事件。
for (PVOID *StackCurrent = StackBase; StackCurrent > StackFrame; StackCurrent--)
{
// 检查Syscall特有标志
if (*(ULONG*)StackCurrent != (ULONG)0x501802 || *(USHORT*)(StackCurrent - 1) != (USHORT)0xF33) {
continue;
}
// 往回遍历
for (StackCurrent--; StackCurrent < StackBase; ++StackCurrent)
{
PVOID CurrentPage = PAGE_ALIGN(*StackCurrent);
// 粗略用2个页的大小判断一下是否是Syscall调用
if (CurrentPage < g.SystemCallEntryPage ||
CurrentPage >= (PVOID)((ULONG_PTR)g.SystemCallEntryPage + PAGE_SIZE * 2)) {
continue;
}
// 到这里基本可以确定为Syscall事件了
}
}
此次事件的Syscall目标指针则相对存放在当前栈的 rsp + 72h 处,我们调用另一个函数对此Syscall目标指针进行派发修改。
PVOID *SyscallTarget = &StackCurrent[9];
SyscallDispatch(SyscallTarget);
我们先在驱动入口函数中中取到NtOpenProcess的地址保存起来。
g.OriginalNtOpenProcess = (fn_NtOpenProcess)Utils::GetRoutineAddress(L"NtOpenProcess");
if (g.OriginalNtOpenProcess == NULL) {
return STATUS_UNSUCCESSFUL;
}
再与这里的SyscallTarget做对比,如果地址相同则修改到我们自己的FakeNtOpenProcess。
void SyscallDispatch(PVOID *SyscallTarget)
{
if (*SyscallTarget == g.OriginalNtOpenProcess) {
*SyscallTarget = FakeNtOpenProcess;
}
}
然后在FakeNtOpenProcess中做相应的处理。
NTSTATUS FakeNtOpenProcess(PHANDLE ProcessHandle, ACCESS_MASK DesiredAccess, POBJECT_ATTRIBUTES ObjectAttributes, PCLIENT_ID ClientId)
{
if (ClientId->UniqueProcess == (HANDLE)123) {
LOG_INFO("Target process is being opened.", 0);
return STATUS_ACCESS_DENIED;
}
return g.OriginalNtOpenProcess(ProcessHandle, DesiredAccess, ObjectAttributes, ClientId);
}
接着在驱动卸载例程中调用我们封装的EventTraceControl函数关闭或重启CKCL。
因为基本在所有系统上,CKCL事件跟踪会话是默认开启的,所以我们尽量重启它,而不是关闭。
重启之后其所有信息都会恢复默认,就不需要还原对GetCpuClock指针的修改了。
if (NT_SUCCESS(EventTraceControl(CKCL_TRACE_END))) {
EventTraceControl(CKCL_TRACE_START);
}
至此,驱动层的代码已写完。编译驱动,在虚拟机中测试看下效果。
再看下Windbg的调试输出信息:
InfinityHook 的执行流程图
总结
相关参考资料链接:
https://github.com/everdox/InfinityHook
https://docs.microsoft.com/en-us/windows/win32/etw/about-event-tracing
https://docs.microsoft.com/en-us/windows/win32/etw/configuring-and-starting-the-nt-kernel-logger-session
https://bbs.pediy.com/thread-223805.htm
InfinityHook说到底也算是PatchGuard的一个疏忽,不出意外的话在不久的将来应该会被微软修复。
但至少这短暂的时间也够看一场戏了。
毕竟攻防无绝对,技术无黑白。没有攻与防之间的战斗,哪里来绝对的安全呢?
如果发现文章内容有任何不正之处,欢迎评论区指出。 感谢阅读!
看雪ID:Sprite雪碧
https://bbs.pediy.com/user-592310.htm
本文由看雪论坛Sprite雪碧 原创
转载请注明来自看雪社区
往期热门回顾
1、Windows Kernel Exploit 内核漏洞学习(3)-任意内存覆盖漏洞
2、Windows Kernel Exploit 内核漏洞学习(2)-内核栈溢出
3、VirtualBox 6.0.10发布:支持Ubuntu和Debian的UEFI安全启动驱动签名
4、MuddyWater(污水)APT组织针对塔吉克斯坦的攻击活动的分析(二)
﹀
公众号ID:ikanxue
官方微博:看雪安全
商务合作:[email protected]