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 + 48h 处,我们调用另一个函数对此 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 的调试输出信息