CobaltStrike BOF生成原理分析

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

1
2
3
4
5
/#include <windows.h>
/#include "beacon.h"
void go(char * args, int alen) {
BeaconPrintf(CALLBACK_OUTPUT, "Hello World: %s", args);
}

        可以使用Visual Studio或者MinGW进行编译,最后生成.obj文件。

1
2
3
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原型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 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:函数名称
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/#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和节区内容。然后接着就是重定位表,符号表。

        文件头格式如下:

1
2
3
4
5
6
7
8
9
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:符号个数
    1
    2
    3
    4
    5
    6
    7
    8
    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

        节区头的结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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:表示节区数据的偏移或者指针。
    mark

        text段
mark

        重定位表
mark

0x05 服务端BOF实现原理

        首先是如何定位入口点,如果熟悉Cobalt Strike伪源码的,应该知道Cobalt Strike的命令分发执行位于BeaconConsole.java的public void actionPerformed(ActionEvent var1)函数,如果不熟悉Cobalt Strike伪源码呢,使用notepad++的文件夹搜索功能,搜索inline-execute也可以定位到public void actionPerformed(ActionEvent var1)函数。
mark

        显然,可以看见,当执行inline-execute命令时,首先会将命令中的objectfile的路径解析出来,然后作为参数传入InlineExecuteObject函数。
mark

        在InlineExecuteObject函数中,首先,调用DataUtils.getBeacon获取Beacon的各种信息,这里使用到的是CPU架构。然后传入this.InlineExecuteObject函数中。最终调用go()这个函数。
mark

        在go这个函数中,依次获取架构,是x64还是x86,并判断Obj的架构和beacon的架构是否一致,一致才可以继续
mark

        然后读取Object文件,分别解析Code段,RData段,Data段,和Relocations段,复制这些段的数据,复制原理如下,首先,通过解析Header中的数据,可以获取各个段的起始地址和大小,这样就可以获取各个段的范围,然后就可以获取指定段的内容。
mark
mark

        在getRelocations()函数中,会根据不同的段,插入不同的Magic Number。例如,如果是.rdata,则会插入1024,如果是.data,则会插入1025,如果是.text,则会插入1026,如果是DynamicFunction,则会插入1027,最后以插入1028结尾。
mark

        同时,可以看到插入数据的结构,首先是插入的Type(类型),然后插入一个Magic Number,第三是插入偏移,最后插入在段中的偏移。有个例外,针对DynamicFunction这块的处理可能需要插入其他的数据。
mark

        接着是构造命令,依次添加命令号,添加obj的入口点,添加code,添加Rdata,添加data,添加Relocations,和Arguments,这个Arguments没理解是什么东西。
mark
mark

0x06 beacon端调用原理分析

        beacon分为loader和payload,loader可以自行开发,payload采用反射注入的方式进行加载,默认情况下,导出表有两个函数,一个是ReflectiveLoader另外一个是DllEntryPoint。在执行payload的时候,优先执行ReflectiveLoader,在处理完PE数据后,跳转到DllEntryPoint函数,然后根据dll加载的原因选择进行数据的初始化,还是进行工作。
mark

        在4.1的Cobalt Strike生成的beacon中,大概在这个地方(Sub_336560_CommandDisPatch)进行命令操作。
mark

        这是服务端传来的原始数据,显然,前四个字节正好是100,为命令号,和cobaltstrike发送命令数据的结构一致。
mark
mark

        在函数Sub_336560_CommandDisPatch中,显然可以看到,首先解析出命令号,然后将除了命令号以外的数据作为第二个参数传入,将结果作为第三个参数传入,用以获取执行的结果。
mark

        根据命令号,选择不同需要执行的函数,此处将该函数命名为Sub_32D020_inline_execute,在Sub_32D020_inline_execute中,首先依次解析Code段,RData段,Data段,Relocations段,和Arguments。
mark

        随后,便开始解析Relocations段,cobaltstrike通过不同的硬编码数据将不同的数据类型进行分割,0x400表示.rdata段,0x401表示.data段,0x402表示.text段,0x403表示DynamicFunction,0x404则表示结束。
mark

        Sub_32D4F4_WriteOffset函数的目的是修改代码段中的一些常量或者DynamicFunction的地址,因为在汇编层级,这些地址都是偏移量,所以需要计算偏移量并写入代码中,才能实现调用。
mark
mark

        最后执行shellcode
mark

        以下是传入的数据。显然,第一行是命令号和EntryPoint,然后下面是code段和rdata段,再下面是Relocations段。
mark
mark

        然后在对比一下原始的code段,和需要执行的shellcode的区别,很显然,关于常量的偏移地址是不同的。也就是说此处做了重定位。
mark

0x07 检测思路

        常规的工具(BeaconEye)可能没有什么好的检测思路,我之前设想过,通过beaconEye有没有可能检测BOF,但是后来仔细想了一下发现不行,因为BeaconEye通过检测内存中的特征码实现的,但是BOF在调用执行完shellcode的时候就被释放了,可能无法检测。

        从流量角度看,传入的流量数据,起始的命令号(100)以及getRelocations中的Magic Number是否可以作为检测依据?

0x08 execuate-assembly

        在CobaltStrike3中,新增了名为execuate-assembly命令,该命令本质是实现了在内存中加载.Net程序集。

        执行execute-assembly命令之后,判断是否存在参数,如果存在参数,得到CSharp程序路径和参数,分别传入ExecuteAssembly,如果不存在参数,只需要传入CSharp程序路径。
mark

        在ExecuteAssembly函数中,首先读取Charp程序,并判断其是否是一个.NET程序,然后根据Beacon判断是否是64位系统,如果是X64的话,则会加载X64的装载程序初始化CRL环境并加载.NET程序
mark

        读取resources/invokeassembly.dll文件,该程序的作用是初始化CLR以及加载.Net程序集,然后将invokeassembly.dll,CSharp程序,以及一些配置信息一起发送给beacon。
mark

        beacon.exe,第70号命令即为execte-assembly,其逻辑也很简单,在解析玩配置信息之后,拉起一个Rundll32进程,然后将invokeassembly.dll注入进去,invokeassembly.dll就会在rundll32中初始化环境并加载程序集了。
mark

        转储了invokeassembly.dll,拖到IDA中,发现其和beacon一样采用了反射注入的方式。直接定位到关键函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
__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环境,
mark

        启动CLR环境
mark

        获取默认的程序域
mark

        加载assembly
mark

        获取入口点,并执行。
mark

        idiotc4t在Execute-Assembly实现中仔细描述了如何编写一段内存加载.Net程序集。首先初始化CLR环境,CLR全称为公共语言运行库,即Common Language Runtime。CLR托管在进程中,是加载和运行.Net程序集的地方,关于CLR的概述可以参考微软关于CLR的描述。常见的windows进程并不会加载CLR环境,可以使用ProcessExplorer或者ProcessHacker等工具查看是否加载CLR环境。加载CLR环境主要分四步:
mark

  • 1) 调用CLRCreateInstance函数以实例化ICLRMetaHostICLRMetaHostPolicy接口,CLRCreateInstance函数原型如下,第一个参数为clsid,第二个参数是 riid,第三个参数是返回的接口。
1
2
3
4
5
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接口的指针。

    1
    2
    3
    4
    5
    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在代码实现上会比较容易。

    1
    2
    3
    4
    HRESULT GetInterface(
    [in] REFCLSID rclsid,
    [in] REFIID riid,
    [out, iid_is(riid), retval] LPVOID *ppUnk);
  • 4)启动CLR环境集

    1
    2
    3
    4
    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检索该程序集的接口。

1
2
iRuntimeHost->GetDefaultDomain(&pAppDomain);
pAppDomain->QueryInterface(__uuidof(_AppDomain), (VOID**)&pDefaultAppDomain);

        在拥有运行时环境CLR,已经可以被托管的容器程序域之后,可以加载程序集了。调用Load_3函数加载程序集安全数组,并获取入口点。

1
2
3
4
5
6
HRESULT Load_3 (
SAFEARRAY* rawAssembly,
Assembly **pRetVal )
pDefaultAppDomain->Load_3(pSafeArray, &pAssembly);
pAssembly->get_EntryPoint(&pMethodInfo);

        最后,调用Invoke_3执行程序集的入口点。

1
HRESULT hr = pMethodInfo->Invoke_3(vObj, args, &vRet);

0x09 参考