0x01 Beacon Object File
BOF(Beacon 对象文件)是C/C++编译,但未链接产生的Obj文件,BOF运行在Beacon进程中,并执行内部的Beacon API和Win32 API函数。BOF本质是COFF Obj文件,其符合COFF文件格式规范,结构类似于windows PE文件格式。在被Cobalt Strike加载和使用过程中,BOF是一段地址无关的Shellcode,BOF本身体积比较小,在传输过程中,适用于那些传输带宽小的模式,然后其本身运行在beacon进程内部,不会重新创建进程,也可以有效规避EDR。
0x02 如何开发BOF
下面是官方提供的一个demo
#include <windows.h>
#include "beacon.h"
void go(char * args, int alen) {
BeaconPrintf(CALLBACK_OUTPUT, "Hello World: %s", args);
}
可以使用Visual Studio或者MinGW进行编译,最后生成.obj文件。
cl.exe /c /GS- hello.c /Fo hello.obj
i686-w64-mingw32-gcc -c hello.c -o hello.o
x86_64-w64-mingw32-gcc -c hello.c -o hello.o
在cl.exe生成obj文件的时候,可能遇到fatal error C1034: stdio.h: 不包括路径集
问题,产生这个的原因是没有设置对应的INCLUDE和LIB环境变量。而且不能仅仅设置Vs的Include的路径,还要设置SDK的路径。具体如下:https://blog.csdn.net/weixin_41115751/article/details/89817123
在生成.obj之后,使用inline-execute + obj_path 执行obj文件
BOF内部自带4种API,数据解析API
,主要解析Aggressor Script 使用bof_pack函数打包的参数。打印输出API
,主要起到打印输出的作用。格式化API,以及内部API。内部API主要包含一些令牌句柄的使用,以及进程注入相关的API。具体细节可以参考官方的Bof文档
数据解析API主要包含: * char * BeaconDataExtract (datap * parser, int * size) * int BeaconDataInt (datap * parser) * int BeaconDataLength (datap * parser) * void BeaconDataParse (datap * parser, char * buffer, int size) * short BeaconDataShort (datap * parser)
在beacon.h中可以看到这些API原型。
/* Token Functions */
DECLSPEC_IMPORT BOOL BeaconUseToken(HANDLE token);
DECLSPEC_IMPORT void BeaconRevertToken();
DECLSPEC_IMPORT BOOL BeaconIsAdmin();
/* Spawn+Inject Functions */
DECLSPEC_IMPORT void BeaconGetSpawnTo(BOOL x86, char * buffer, int length);
DECLSPEC_IMPORT void BeaconInjectProcess(HANDLE hProc, int pid, char * payload, int p_len, int p_offset, char * arg, int a_len);
DECLSPEC_IMPORT void BeaconInjectTemporaryProcess(PROCESS_INFORMATION * pInfo, char * payload, int p_len, int p_offset, char * arg, int a_len);
DECLSPEC_IMPORT BOOL BeaconSpawnTemporaryProcess(BOOL x86, BOOL ignoreToken, STARTUPINFO * si, PROCESS_INFORMATION * pInfo);
DECLSPEC_IMPORT void BeaconCleanupProcess(PROCESS_INFORMATION * pInfo);
/* Utility Functions */
DECLSPEC_IMPORT BOOL toWideChar(char * src, wchar_t * dst, int max);
0x03 动态函数解析(DFR)
动态函数解析,即Dynamic Function Resolution (DFR)
以下demo的功能是查找当前域,需要使用两个API函数DsGetDcNameA,NetApiBufferFree都是由NETAPI32模块进行导出。
- DECLSPEC_IMPORT:导入函数的关键字
- WINAPI:函数调用约定,一般API函数都是这个
- NETAPI32:函数所在的模块名
- DsGetDcNameA/NetApiBufferFree:函数名称
#include <windows.h>
#include <stdio.h>
#include <dsgetdc.h>
#include "beacon.h"
DECLSPEC_IMPORT DWORD WINAPI NETAPI32$DsGetDcNameA(LPVOID, LPVOID, LPVOID, LPVOID, ULONG, LPVOID);
DECLSPEC_IMPORT DWORD WINAPI NETAPI32$NetApiBufferFree(LPVOID);
void go(char * args, int alen) {
DWORD dwRet;
PDOMAIN_CONTROLLER_INFO pdcInfo;
dwRet = NETAPI32$DsGetDcNameA(NULL, NULL, NULL, NULL, 0, &pdcInfo);
if (ERROR_SUCCESS == dwRet) {
BeaconPrintf(CALLBACK_OUTPUT, "%s", pdcInfo->DomainName);
}
NETAPI32$NetApiBufferFree(pdcInfo);
}
可以使用bof_help这个工具自动修改符合BOF格式的函数原型。但目前来说可能不是很好用了。参考自https://idiotc4t.com/weaponization/bof-weaponization
0x04 Obj文件解析
OBj文件的文件类型是COFF Object,使用dumpbin /all obj_path
解析Obj文件格式。Obj文件首先是_IMAGE_FILE_HEADER,保存着整个文件基本信息,然后依次保存着每个节区的SECTION HEADER
和节区内容。然后接着就是重定位表,符号表。
文件头格式如下:
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
- Machine为0x14c,表示这是一个x86的Obj
- NumberOfSections为4,说明有4个Section
- TimeDateStamp是时间戳
- PointerToSymbolTable;指向符号表
- NumberOfSymbols:符号个数
FILE HEADER VALUES
14C machine (x86)
4 number of sections
63*E60*D time date stamp Thu *** 6 13:*:29 20**
1E6 file pointer to symbol table
D number of symbols
0 size of optional header
0 characteristics
节区头的结构体如下:
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
- Name:表示节区的名字
- SizeOfRawData:表示节区数据的大小
- PointerToRawData:表示节区数据的偏移或者指针。
text段
重定位表
0x05 服务端BOF实现原理
首先是如何定位入口点,如果熟悉Cobalt Strike伪源码的,应该知道Cobalt Strike的命令分发执行位于BeaconConsole.java的public void actionPerformed(ActionEvent var1)
函数,如果不熟悉Cobalt Strike伪源码呢,使用notepad++的文件夹搜索功能,搜索inline-execute
也可以定位到public void actionPerformed(ActionEvent var1)
函数。
显然,可以看见,当执行inline-execute
命令时,首先会将命令中的objectfile的路径解析出来,然后作为参数传入InlineExecuteObject
函数。
在InlineExecuteObject
函数中,首先,调用DataUtils.getBeacon获取Beacon的各种信息,这里使用到的是CPU架构。然后传入this.InlineExecuteObject
函数中。最终调用go()这个函数。
在go这个函数中,依次获取架构,是x64还是x86,并判断Obj的架构和beacon的架构是否一致,一致才可以继续
然后读取Object文件,分别解析Code段,RData段,Data段,和Relocations段,复制这些段的数据,复制原理如下,首先,通过解析Header中的数据,可以获取各个段的起始地址和大小,这样就可以获取各个段的范围,然后就可以获取指定段的内容。
在getRelocations()
函数中,会根据不同的段,插入不同的Magic Number。例如,如果是.rdata,则会插入1024,如果是.data,则会插入1025,如果是.text,则会插入1026,如果是DynamicFunction,则会插入1027,最后以插入1028结尾。
同时,可以看到插入数据的结构,首先是插入的Type(类型),然后插入一个Magic Number,第三是插入偏移,最后插入在段中的偏移。有个例外,针对DynamicFunction这块的处理可能需要插入其他的数据。
接着是构造命令,依次添加命令号,添加obj的入口点,添加code,添加Rdata,添加data,添加Relocations,和Arguments,这个Arguments没理解是什么东西。
0x06 beacon端调用原理分析
beacon分为loader和payload,loader可以自行开发,payload采用反射注入的方式进行加载,默认情况下,导出表有两个函数,一个是ReflectiveLoader
另外一个是DllEntryPoint
。在执行payload的时候,优先执行ReflectiveLoader,在处理完PE数据后,跳转到DllEntryPoint
函数,然后根据dll加载的原因选择进行数据的初始化,还是进行工作。
在4.1的Cobalt Strike生成的beacon中,大概在这个地方(Sub_336560_CommandDisPatch)进行命令操作。
这是服务端传来的原始数据,显然,前四个字节正好是100,为命令号,和cobaltstrike发送命令数据的结构一致。
在函数Sub_336560_CommandDisPatch
中,显然可以看到,首先解析出命令号,然后将除了命令号以外的数据作为第二个参数传入,将结果作为第三个参数传入,用以获取执行的结果。
根据命令号,选择不同需要执行的函数,此处将该函数命名为Sub_32D020_inline_execute
,在Sub_32D020_inline_execute
中,首先依次解析Code段,RData段,Data段,Relocations段,和Arguments。
随后,便开始解析Relocations段,cobaltstrike通过不同的硬编码数据将不同的数据类型进行分割,0x400表示.rdata段,0x401表示.data段,0x402表示.text段,0x403表示DynamicFunction,0x404则表示结束。
Sub_32D4F4_WriteOffset
函数的目的是修改代码段中的一些常量或者DynamicFunction的地址,因为在汇编层级,这些地址都是偏移量,所以需要计算偏移量并写入代码中,才能实现调用。
最后执行shellcode
以下是传入的数据。显然,第一行是命令号和EntryPoint,然后下面是code段和rdata段,再下面是Relocations段。
然后在对比一下原始的code段,和需要执行的shellcode的区别,很显然,关于常量的偏移地址是不同的。也就是说此处做了重定位。
0x07 检测思路
常规的工具(BeaconEye)可能没有什么好的检测思路,我之前设想过,通过beaconEye有没有可能检测BOF,但是后来仔细想了一下发现不行,因为BeaconEye通过检测内存中的特征码实现的,但是BOF在调用执行完shellcode的时候就被释放了,可能无法检测。
从流量角度看,传入的流量数据,起始的命令号(100)以及getRelocations中的Magic Number是否可以作为检测依据?
0x08 execuate-assembly
在CobaltStrike3中,新增了名为execuate-assembly
命令,该命令本质是实现了在内存中加载.Net程序集。
执行execute-assembly
命令之后,判断是否存在参数,如果存在参数,得到CSharp程序路径和参数,分别传入ExecuteAssembly,如果不存在参数,只需要传入CSharp程序路径。
在ExecuteAssembly
函数中,首先读取Charp程序,并判断其是否是一个.NET程序,然后根据Beacon判断是否是64位系统,如果是X64的话,则会加载X64的装载程序初始化CRL环境并加载.NET程序
读取resources/invokeassembly.dll
文件,该程序的作用是初始化CLR以及加载.Net程序集,然后将invokeassembly.dll
,CSharp程序
,以及一些配置信息一起发送给beacon。
beacon.exe,第70号命令即为execte-assembly
,其逻辑也很简单,在解析玩配置信息之后,拉起一个Rundll32进程,然后将invokeassembly.dll注入进去,invokeassembly.dll就会在rundll32中初始化环境并加载程序集了。
转储了invokeassembly.dll,拖到IDA中,发现其和beacon一样采用了反射注入的方式。直接定位到关键函数。
__int64 __fastcall sub_180001470(__int64 a1, const void *a2, unsigned int a3)
{
[.....]
v6 = GetStdHandle(0xFFFFFFF5);
SetStdHandle(0xFFFFFFF4, v6);
if ( !sub_180001294(&v16) )
{
result = Sub_1800022B8_Output("[-] Failed to create the runtime host\n", v7);
goto LABEL_27;
}
v9 = (*(*v16 + 80i64))(v16);
if ( v9 < 0 )
{
v10 = "[-] CLR failed to start w/hr 0x%08lx\n";
LABEL_5:
result = Sub_1800022B8_Output(v10, v9);
goto LABEL_27;
}
if ( v18 )
((*v18)[2])(v18);
v18 = 0i64;
v9 = (*(*v16 + 104i64))(v16, &v18);
if ( v9 < 0 )
{
v10 = "[-] ICorRuntimeHost::GetDefaultDomain failed w/hr 0x%08lx\n";
goto LABEL_5;
}
v11 = v18;
if ( !v18 )
{
sub_180001DA0(0x80004003i64);
__debugbreak();
}
if ( v22 )
(*(*v22 + 16i64))(v22);
v22 = 0i64;
v12 = *v11;
v13 = sub_180001000(&v22);
v9 = (*v12)(v11, &unk_180010510, v13);
if ( v9 < 0 )
{
v10 = "[-] Failed to get default AppDomain w/hr 0x%08lx\n";
goto LABEL_5;
}
rgsabound.cElements = v3;
rgsabound.lLbound = 0;
v14 = SafeArrayCreate(0x11u, 1u, &rgsabound);
SafeArrayLock(v14);
memmove(v14->pvData, a2, v3);
SafeArrayUnlock(v14);
[.....]
if ( v17 )
(*(*v17 + 16i64))(v17);
v17 = 0i64;
v9 = (*(*v15 + 360i64))(v15, v14, &v17);
if ( v9 < 0 )
{
v10 = "[-] Failed to load the assembly w/hr 0x%08lx\n";
goto LABEL_5;
}
v20 = v17;
if ( v17 )
(*(*v17 + 8i64))(v17);
[.....]
if ( v17 )
result = (*(*v17 + 16i64))(v17);
if ( v22 )
result = (*(*v22 + 16i64))(v22);
if ( v18 )
result = ((*v18)[2])(v18);
return result;
}
首先初始化CLR环境,根据不同的版本采用不同的函数初始化CLR环境,
启动CLR环境
获取默认的程序域
加载assembly
获取入口点,并执行。
idiotc4t在Execute-Assembly实现中仔细描述了如何编写一段内存加载.Net程序集。首先初始化CLR环境,CLR全称为公共语言运行库,即Common Language Runtime。CLR托管在进程中,是加载和运行.Net程序集的地方,关于CLR的概述可以参考微软关于CLR的描述。常见的windows进程并不会加载CLR环境,可以使用ProcessExplorer或者ProcessHacker等工具查看是否加载CLR环境。加载CLR环境主要分四步:
- 1) 调用CLRCreateInstance函数以实例化
ICLRMetaHost
或ICLRMetaHostPolicy
接口,CLRCreateInstance函数原型如下,第一个参数为clsid,第二个参数是 riid,第三个参数是返回的接口。
HRESULT CLRCreateInstance(
[in] REFCLSID clsid,
[in] REFIID riid,
[out] LPVOID * ppInterface
);
- 2) 调用
ICLRMetaHost::EnumerateInstalledRuntimes
,ICLRMetaHost::GetRuntime
或者ICLRMetaHostPolicy::GetRequestedRuntime
方法以获取有效的ICLRRuntimeInfo指针。以ICLRMetaHost::GetRuntime
为例,第一个参数为pwzVersion,表示 .NET Framework 的版本,riid为标识符,此参数的唯一有效值是 IID_ICLRRuntimeInfo。第三个参数是返回的ICLRRuntimeInfo接口的指针。
HRESULT GetRuntime (
[in] LPCWSTR pwzVersion,
[in] REFIID riid,
[out,iid_is(riid), retval] LPVOID *ppRuntime
);
- 3) 调用
GetInterface
获取ICorRuntimeHost
或者ICLRRuntimeHost
。rclsid为接口的CLSID,riid是接口的iid,ppUnk返回的接口的指针。加载.Net程序集可以使用两种接口ICorRuntimeHost
或者ICLRRuntimeHost
。使用ICorRuntimeHost
的有点是可以兼容V1.0的程序集,但是ICLRRuntimeHost
在代码实现上会比较容易。
HRESULT GetInterface(
[in] REFCLSID rclsid,
[in] REFIID riid,
[out, iid_is(riid), retval] LPVOID *ppUnk);
- 4)启动CLR环境集
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();
程序域为安全性、可靠性和版本控制以及卸载程序集提供了隔离边界,需要将程序集加载到对应的程序域中,才能执行其中包含的代码,这也就是为啥要获取程序集的原因了。程序集的加载方式决定了它的即时 (JIT) 编译代码是否可以由进程中的多个应用程序域共享,以及程序集是否可以从进程中卸载。具体关于程序域和程序集可以参考微软关于程序域和程序集的概述,通过调用GetDefaultDomain
获取默认的程序集,并通过调用QueryInterface
检索该程序集的接口。
iRuntimeHost->GetDefaultDomain(&pAppDomain);
pAppDomain->QueryInterface(__uuidof(_AppDomain), (VOID**)&pDefaultAppDomain);
在拥有运行时环境CLR,已经可以被托管的容器程序域之后,可以加载程序集了。调用Load_3
函数加载程序集安全数组,并获取入口点。
HRESULT Load_3 (
SAFEARRAY* rawAssembly,
Assembly **pRetVal )
pDefaultAppDomain->Load_3(pSafeArray, &pAssembly);
pAssembly->get_EntryPoint(&pMethodInfo);
最后,调用Invoke_3执行程序集的入口点。
HRESULT hr = pMethodInfo->Invoke_3(vObj, args, &vRet);