文章总结: 本文介绍了如何从原生C/C++代码中执行.NET程序的技术,重点讲解了使用CLR托管接口和COM技术在内存中加载和执行.NET程序集的方法。文章详细解释了非托管代码与托管代码的区别,展示了如何使用ICorRuntimeHost接口获取AppDomain引用,并通过COM互操作调用mscorlib库中的方法。文章还提供了具体的代码实现,包括创建SAFEARRAY、加载程序集、获取类型引用和调用方法的完整流程。最后作者提醒,若要注入恶意程序集,需要修补AMSI以避免检测。 综合评分: 87 文章分类: 恶意软件,二进制安全,免杀,逆向分析,安全开发
恶意软件开发系列(九):使用C/C++内存执行.NET程序
aeverj
红队工坊
2025年12月17日 06:00 北京
翻译自 Malware development part 9 – hosting CLR and managed code injection
这是恶意软件开发系列的第9篇文章。在这个系列中,我们将探索并尝试实现恶意应用程序用来执行代码、躲避防御和维持驻留的多种技术。
今天我们要探索的是如何从原生代码中执行托管代码的技术。
注意:和往常一样,我们使用的是64位代码。
非托管代码与托管代码
非托管代码(原生代码)会被直接编译成处理器能够解释的汇编语言。而托管代码则被编译成某种中间表示形式(字节码),由运行时环境来解释执行。运行时环境还能管理内存、进行垃圾回收等操作。
一般来说,使用Java或.NET这类托管语言开发应用程序会更容易,因为开发者不需要操心内存分配、释放以及其他底层细节。运行时环境对这些底层操作和系统API进行了抽象,有时甚至能实现跨平台开发(比如.NET Core)。
但有时候,开发者也能从原生语言(如C/C++)提供的与操作系统API和内存的直接集成中受益。更重要的是(从恶意软件开发者的角度来看)——原生代码更难被逆向工程,并且提供了更多混淆的可能性。
.NET运行时托管
当托管程序集(可执行文件或DLL)通过CreateProcess或LoadLibrary加载时,Windows加载器会对其进行解释,加载例程会初始化CLR(公共语言运行时,Common Language Runtime)。在PE文件中有一个COM描述符目录(COM Descriptor Directory),其中包含.NET元数据,它的存在表明这个PE文件包含托管代码。
不过,我们也可以使用[CLR托管接口]https://docs.microsoft.com/en-us/dotnet/framework/unmanaged-api/hosting/clr-hosting-interfaces从原生应用程序中手动托管CLR。
mscoree.dll实现了我们可以用于CLR托管的函数。其中有一个ICLRRuntimeHost接口,可以用来启动CLR运行时并从磁盘执行程序集。代码非常简单:
ICLRMetaHost* metaHost = NULL;
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&metaHost);
ICLRRuntimeInfo* runtimeInfo = NULL;
metaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (LPVOID*)&runtimeInfo);
ICLRRuntimeHost* runtimeHost = NULL;
runtimeInfo->GetInterface(CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost, (LPVOID*)&runtimeHost);
runtimeHost->Start();
DWORD retVal;
CLRRuntimeHost->ExecuteInDefaultAppDomain(L"path_to_assembly", L"Namespace.Class", L"MethodName", L"argument", &retVal);
根据[文档]https://docs.microsoft.com/en-us/dotnet/framework/unmanaged-api/hosting/iclrruntimehost-executeindefaultappdomain-method,要执行的托管方法必须具有以下签名:
static int pwzMethodName (String pwzArgument)
在内存中执行.NET代码
然而,要执行存储为字节数组(比如在内存中)的托管程序集就没那么简单了。为此,我们必须使用已弃用的ICorRuntimeHost接口。它允许从原生代码对托管运行时进行更多控制。
所以我们需要的是CorRuntimeHost而不是CLRRuntimeHost,这就是我们使用[ICLRRuntimeInfo::GetInterface]https://docs.microsoft.com/en-us/dotnet/framework/unmanaged-api/hosting/iclrruntimeinfo-getinterface-method请求的内容:
ICorRuntimeHost* corRuntimeHost = NULL;
runtimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, (LPVOID*)&corRuntimeHost);
要在内存中执行托管程序集,我们将使用托管方法和一些反射:
Assembly managedAssembly = AppDomain.CurrentDomain.Load(assemblyByteArray);
Type managedType = managedAssembly.GetType("Namespace.Class");
object[] parameters = new object[1] {("Argument_1")};
managedType.InvokeMember("MethodName", BindingFlags.Public | BindingFlags.InvokeMethod | BindingFlags.Static, null, null, parameters);
但如何从非托管的CLR主机执行这段托管代码呢?我们将利用组件对象模型(Component Object Model)。
可以[将.NET组件暴露给COM]https://docs.microsoft.com/en-us/dotnet/framework/interop/exposing-dotnet-components-to-com。
.NET框架系统程序集在编译时启用了COM互操作性,因此可以从CLR”外部”调用公共方法。
首先,我们需要一个指向AppDomain的非托管引用(指针)。应用程序域(Application domains)是在单个运行时主机内提供隔离边界的容器。这就是CorRuntimeHost接口派上用场的地方——它允许检索当前进程的默认AppDomain:
IUnknown* appDomainThunk;
pCorRuntimeHost->GetDefaultDomain(&appDomainThunk);
_AppDomain* defaultAppDomain = NULL;
appDomainThunk->QueryInterface(&defaultAppDomain);
此时,我们可以使用C++和COM重写.NET代码。
使用COM从C++访问mscorlib
我们需要的所有代码都位于mscorlib.dll中,这是一个包含常见.NET类型和方法的托管库。
首先,我们需要以某种方式将mscorlib库中定义的COM接口”翻译”成C++编译器能理解的格式,比如定义外部函数名称、签名和类型的头文件(即导入、内存偏移、函数调用的堆栈对齐等)。
有一个[类型库导出器]https://docs.microsoft.com/en-us/dotnet/framework/tools/tlbexp-exe-type-library-exporter工具(tlbexp.exe),我们可以用它从托管程序集创建类型库。Visual Studio和.NET工作负载安装后应该会有类型库文件,但让我们手动创建并分析它们:
tlbexp.exe mscorlib.dll
可以使用OLE-COM对象查看器(oleview.exe,Visual C++ SDK的一部分)查看.tlb文件。
让我们看看用于在内存中加载程序集的.NET代码:
Assembly managedAssembly = AppDomain.CurrentDomain.Load(assemblyByteArray);
在我们的例子中,CurrentDomain就是_AppDomain* defaultAppDomain指针。所以我们需要找到一个作为COM接口导出的Load(byte[])函数。
我们可以看到,AppDomain.Load方法的每个重载都声明了单独的COM接口函数。我们特别针对使用存储原始程序集的字节数组作为唯一参数的重载。因此Assembly AppDomain.Load(byte[] rawAssembly)变成了HRESULT _stdcall Load_3([in] SAFEARRAY(unsigned char) rawAssembly, [out, retval] _Assembly** pRetVal);。值通过最后一个参数以引用方式返回。
另一件事是,我们不能直接向这个函数提供非托管字节数组,我们需要创建一个[SAFEARRAY]https://docs.microsoft.com/en-us/archive/msdn-magazine/2017/march/introducing-the-safearray-data-structure。这是COM/OLE自动化的另一个复杂部分。注意:SafeArrayCreate中使用的VT_UI1是对应于unsigned char/byte数组的VARTYPE。
SAFEARRAYBOUND bounds[1];
bounds[0].cElements = sizeof (rawAssemblyByteArray);
bounds[0].lLbound = 0;
SAFEARRAY* safeArray = SafeArrayCreate(VT_UI1, 1, bounds);
SafeArrayLock(safeArray);
memcpy(safeArray->pvData, rawAssemblyByteArray, sizeof (rawAssemblyByteArray));
SafeArrayUnlock(safeArray);
_AssemblyPtr managedAssembly = NULL;
最后,将程序集加载到AppDomain:
defaultAppDomain->Load_3(safeArray, &managedAssembly)
下一步是获取程序集中定义的类型的引用。这一行:
Type managedType = managedAssembly.GetType("Namespace.Class");
转换为:
_TypePtr managedType = NULL;
_bstr_t managedClassName("ManagedApp.Program");
managedAssembly->GetType_2(managedClassName, &managedType);
现在让我们创建一个参数数组。要实现这个:
object[] parameters = new object[1] {("Argument_1")};
我们需要再次使用SAFEARRAY,其中VARTYPE对应于[VARIANT]https://docs.microsoft.com/en-us/windows/win32/winauto/variant-structure,这是另一个COM/OLE特定的数据结构,例如用于保存字符串:
SAFEARRAY* managedArguments = SafeArrayCreateVector(VT_VARIANT, 0, 1);
_variant_t argument(L"Argument_1");
LONG index = 0;
SafeArrayPutElement(managedArguments, &index, &argument);
最后一件事是按名称调用函数:
managedType.InvokeMember("EntryPoint", BindingFlags.Public | BindingFlags.InvokeMethod | BindingFlags.Static, null, null, parameters);
_bstr_t managedMethodName(L"EntryPoint");
_variant_t managedReturnValue;
_variant_t empty;
managedType->InvokeMember_3(
managedMethodName,
static_cast<BindingFlags>(BindingFlags_InvokeMethod | BindingFlags_Static | BindingFlags_Public),
NULL, empty, managedArguments, &managedReturnValue);
总结
我所描述的这段纯粹在内存中加载.NET程序集的代码已经为人所知很多年了,但我找不到任何关于它如何工作的好的解释。希望有人会觉得这很有趣。
我还花了一些时间尝试使用ICLRRuntimeHost而不是已弃用的ICorRuntimeHost,但我找不到一个简单而优雅的方法来在内存中执行程序集,特别是检索指向AppDomain的指针。在我看来,唯一可能的解决方案是加载一些小的程序集,它会使用GetFunctionPointerForDelegate返回一个指向托管函数的非托管指针,然后使用[ICLRRuntimeHost::ExecuteInAppDomain方法]https://docs.microsoft.com/en-us/dotnet/framework/unmanaged-api/hosting/iclrruntimehost-executeinappdomain-method调用它,但这个方法根本没有很好的文档记录。无论如何,[这里有一个stackoverflow.com上的例子]https://stackoverflow.com/questions/40643018/clr-injected-net-code-crashes-on-file-access,我还没有验证过。
还有一件事:如果你要注入一些”恶意”程序集,记得修补AMSI(反恶意软件扫描接口,Anti-Malware Scan Interface),因为AppDomain.Load(byte[])(或_AppDomain::Load_3())会使用AMSI扫描二进制文件以查找恶意意图的指标。详细信息请参见[这段.NET运行时源代码]https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/peimagelayout.cpp#L347。
如果你对网络安全、红队攻防技术充满热情,渴望学习更多实战技巧,例如渗透测试、自动化脚本编写、免杀技术等, 欢迎关注我的公众号
在这里,我会持续分享更多高质量的技术文章,与你一同探索网络安全的奥秘,提升实战技能! 让我们一起在队攻防的道路上,不断精进,突破边界!
免责声明: 本文仅供安全技术研究与学习交流之用。 严禁将本文所提及的技术用于任何非法用途,包括但不限于未经授权的渗透测试、网络攻击、恶意代码传播等。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:红队工坊 aeverj《恶意软件开发系列(九):使用C/C++内存执行.NET程序》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论