COffLoader For Windows(BOFLoader)
2026-03-13 20:47:15 # 武器化

一、前言

C2植入物的功能模块中一般包含了RunPE,execute-shellcode,execute-assembly等等最基础的渗透功能,但是光靠这些是无法支持后渗透的复杂任务,为此我们需要引入BOF执行器。由于我用Go实现implant,使用Go来完成BOF执行器还是太过复杂,为了缩减implant的体积,有必要引入”扩展”的功能。

早期CS要实现复杂的功能,往往需要用到fork&run技术,CS 默认会临时启动一个牺牲进程(如 rundll32),再把反射DLL注入进去,有着明显的进程创建事件父子进程链,并且该进程本身在EDR看来就是可疑的,因为它没有关联磁盘上的dll文件且无签名。

当然不能以现在的视角去评价过去的反射DLL注入技术,在当时这种做法是先进的,不过随着攻防对抗强度的升级,fork&run的做法不再符合OPSEC要求。

命运的转折点发生在2020年,Cobalt Strike 4.1 正式推出了Beacon Object File(BOF) 技术,用来替代早期“fork&run”模式,此项技术一经发布就被红队视为“OPSEC分水岭”,这一历史时刻注定是红队的狂欢,它把对抗强度从“进程链”拉回到“内存行为”层面,也直接催生了后续各类加载器(COFFLoader、coffee、goffloader等)和更激进的内存免杀研究,安全研究员们开发了数以百计的BOF,从简单的信息枚举到复杂的横向移动、凭证窃取,几乎覆盖了渗透测试的所有环节。COFFLoader 等项目更是证明了BOF理念的可移植性,使其不再局限于Cobalt Strike。

现如今,回顾这一技术里程碑,我们不仅能欣赏其设计之美,更能深刻理解攻防对抗螺旋式上升的内在动力:防御方在更高维度上布防,攻击方就必须在更深层次上突破。BOF正是这样一个推动整个领域向前迈进的关键节点。

要实现C2中的植入物,有一个必定要实现的功能就是BOF执行器,这可以说是现代C2的标配

二、CoffeeLoader的原理和代码实现

Windows CoffeeLoader的代码主要参考下面的两个项目:

  1. Cen4enCen/CenCoffLdr: A parser for COFF files.
  2. Havoc/payloads/Demon/src/core/CoffeeLdr.c at main · HavocFramework/Havoc

说是参考,其实90%的代码都是抄Cen4enCen师傅的实现,他的代码解决了大部分CoffeeLoader存在的问题,是一个非常优秀的CoffeeLoader实现,具体来说是:

  1. 动态分配GOT和BSS的大小。
  2. 处理.BSS段,适配MSVCMINGW
  3. 增加捕获异常的VEH,避免CoffeeLoader崩溃而导致整个Beacon崩溃。
  4. OPSEC操作,比如说模块踩踏、设置上下文劫持注入等等。

本文会详细的分析Cen4enCen师傅的项目,并写下自己学习CoffeeLoader思考过程,增加X86的适配。我觉得没有必要再造一个轮子项目,所以我不打算开源自己修改后的CoffeeLoader,有需要的就参考 Cen4enCen/CenCoffLdr: A parser for COFF files.,记得给Cen4enCen师傅点star啊。

测试用到的BOF文件:trustedsec/CS-Situational-Awareness-BOF: Situational Awareness commands implemented using Beacon Object Files

CoffeeLoader大致步骤

  1. 读取COFF文件到内存,此时还是磁盘文件的形式,并没有进行映射操作。
  2. 解析COFF文件,计算总大小、GOT表大小和BSS段大小。
  3. 使用模块践踏计算得到一个可写内存空间,把各节复制进去,并在尾部预留GOT和 .bss。
  4. 遍历 relocation,把外部符号解析成真实函数地址,把节内引用修补成正确偏移。
  5. 找到BOF 入口点(go _go),把 .text 设成可执行。
  6. 注册VEH,构造一个挂起线程的上下文,让线程从BOF入口开始跑。
  7. BOF跑完后,通过 RtlExitUserThread 退出,主线程等待结束并清理资源。

看起来好像很简单,但和反射DLL技术相比,CoffeeLoader真正困难的地方并不在“把文件读进内存”,而在于后续对COFF对象的手工处理。

对 CoffeeLoader来说,最重要也最复杂的两个步骤就是重定位和符号解析,因为这两步本质上是在模拟链接器的工作。也就是说,我们不是像正常程序那样把 .o 文件交给编译器和链接器去处理,而是要在运行时自己把对象文件中的节、符号、外部引用和地址关系重新组织起来,最终把 .o 文件链接到当前进程申请出的那块内存中,并让它能够像一个已经完成链接的模块一样正常执行。

⚠请注意,下面的流程分析不会给完整的源代码,会忽略很多细节方面的东西!

2.1 InitWin32Api

为了提高CoffeeLoader的OPSEC性,我们需要实现动态获取API的功能,并将其封装到一个 Win32Api 结构体中,请注意声明一个全局变量 win32Api 以备后续其他模块或者.c文件使用。

PixPin_2026-03-07_22-01-27.png

声明全局变量在某个头文件,然后在其他.c文件的做法是很多C/C++类型的Beacon的惯用做法。就比如Havoc在Demon.h中这样写 extern PINSTANCE Instance;

PixPin_2026-03-07_21-54-02.png

然后在某一个 .c 文件里做定义,比如说Havoc的 Demon.c 里定义了Instance变量。

PixPin_2026-03-08_14-16-39.png

再然后其他.c文件(比如 CoffeeLdr.c包含 Demon.h 文件,这样就可以使用到Instance中定义的一些变量,比如说 Win32 变量,以备使用动态获取到的WindowsAPI。

PixPin_2026-03-07_22-15-22.png

PixPin_2026-03-07_22-15-49.png

为了再动态获取API中避免使用到模块名和函数名等命名字符串,我们需要选择字符串hash算法得到唯一的hash值,至于算法的抉择,我选择使用ROTR算法,然后在计算 GetModuleHandleAGetProcAddressLoadLibraryACreateThread 的函数名hash,并在 CoffeeProcessSymbol 函数中使用到了存储着这些函数名hash的变量 LdrApi,我把CoffeeLoader放到有卡巴斯基的环境中测试,居然报毒了,通过简单控制变量法,我定位到其实是用到了 LdrApi 的这部分代码报毒,更进一步地排查出是使用到了这些函数名的ROTR13的hash值。

PixPin_2026-03-07_22-34-20.png

可能是太多C2使用到了ROTR13算法了吧,我将ROTR13改成ROTR14,即32位整数右移14位,这样卡巴斯基就不报毒了。

PixPin_2026-03-07_22-42-29.png

当然也可以参考Havoc的djb2风格的滚动字符串哈希吗,Cen4enCen师傅就使用到了这种Hash算法,目前卡巴斯基不报毒,请各位师傅放心参考!

PixPin_2026-03-07_22-42-03.png

也可以选择CRC32哈希算法,算法的选择是多样的,只要能将字符串作为输入参数,得到32位整数即可。

扯远了,回归正题,我们在最上面不是说声明了一个全局变量 Win32Api 吗,接下来就是用动态获取API获得目标Windows API,初始化 Win32Api

我定义了一个 InitWin32Api 函数,专门负责初始化 Win32Api 变量。具体来说是

  1. 使用 GetModuleByPeb 获取Kernel32和ntdll这两个模块的地址。
  2. GetApiAddressByHash 根据指定的 模块hash + 函数名hash 找到目标APi的地址。

PixPin_2026-03-07_22-46-23.png

GetModuleByPeb 是经典的

  1. 获取PEB
  2. PEB中获取 Ldr
  3. Ldr 中获取 InMemoryOrderModuleList,遍历链表中的每一个 LIST_ENTRY
  4. LIST_ENTRY 获取 LDR_DATA_TABLE_ENTRY 从而拿到 DllBaseBaseDllName,计算BaseDllName的hash值,与指定的模块hash值匹配。
  5. 返回目标模块的句柄。

PixPin_2026-03-07_22-51-49.png

GetApiAddressByHash 也是经典的操作

  1. DOS头->NT头->导出表
  2. 取出导出表里的三张关键表AddressOfNames,AddressOfFunctions,AddressOfNameOrdinals
  3. 获取导出函数名,计算导出函数名的hash,然后与目标 模块哈希 + 函数哈希 匹配
  4. 如果匹配成功,则返回目标函数的地址。

PixPin_2026-03-07_23-04-56.png

我这里增加了x86的支持,具体是增加了一个 GetCurrentPeb 函数。

PixPin_2026-03-07_23-05-26.png

2.2 BofPack

一个合格的BOF加强器,给 ColbaltStrike BOF 的参数必须按照特定方式格式化,这样能更好的融入庞大的BOF生态。

PixPin_2026-03-12_18-57-16.png

常参数打包应该遵循如下的规则:

1
[totalSize][arg1Size][arg1Data][arg2Size][arg2Data]...

我们可以通过BofPack来打包任意个数的参数。

PixPin_2026-03-12_19-25-06.png

打包说完了,那么解析呢?一般BOF文件先用 BeaconDataParse 来解析参数,解析完后,parser->buffer指向第一个参数长度

1
2
3
[arg1Size]    [arg1Data]    [arg2Size]   [arg2Data]...
|
parserparser->buffer

再根据参数长度,选择 BeaconDataPtrBeaconDataIntBeaconDataShortBeaconDataLengthBeaconDataExtract 获取参数,其中 BeaconDataExtract 的作用是“从当前游标位置取一个变长参数”,通常就是字符串或字节块。BeaconDataExtract 的流程是:

  1. 先读当前项的长度 Length
  2. buffer += 4,跳过长度字段
  3. 把当前位置当作数据起始地址返回
  4. 再把游标继续向后移动 Length

比如说下面的这个例子,先用 BeaconDataParse 来解析参数,随后用 BeaconDataExtract 获取lpwComputername和lpwPassword。

PixPin_2026-03-12_19-10-28.png

2.3 ParseTotalSize

在真正“装载并修补” COFF 之前,先预估整个BOF运行时需要多大内存,并顺便算出GOT表和 .bss要占多少空间。所以它本质上是在回答:

  • 所有节复制进内存要多大
  • 外部导入函数地址表要多大(对应got表的大小)
  • 外部未解析数据区要多大(对应.bss)

遍历所有节,每次取出一个节 COFF_SECTION 并获取到该节的第一个重定位项 COFF_RELOC 的地址。

PixPin_2026-03-08_14-41-57.png

然后把该节的原始大小加入总大小,然后再对总大小做页对齐(4KB大小)操作。

PixPin_2026-03-08_14-42-49.png

遍历当前节的所有重定位项,每个重定项 COFF_RELOC 都会关联一个符号 CoffSymbol,其中 COFF_RELOC 决定重定位的类型,而 CoffSymbol 存储着符号名。

取出符号名。短名字(CoffSymbol->First.Value[0] != 0:直接从CoffSymbol.First.Name取8字节,长名字:符号名超过8字节,则从字符串表中取。

PixPin_2026-03-08_14-45-09.png

识别外部符号,只关注:StorageClass == IMAGE_SYM_CLASS_EXTERNAL、SectionNumber == 0,这表示它是外部未定义符号。然后根据外部符号区分是函数导入还是 .bss(全局未初始化的变量) 项。判断的标准是符号名是不是 __imp_ 这类导入符号前缀。

如果是:

  • 说明它是外部函数引用
  • dwNumberOfFunc++

如果不是:

  • 说明它被当成 .bss
  • 累加pCoffSymbol->Value到 *pstBSSSize
  • dwBssEntryNum++

PixPin_2026-03-08_14-52-21.png

最后汇总总大小

  • sizeof(PVOID) * dwNumberOfFunc :给GOT预留空间
  • *pstBSSSize :给 .bss 预留空间
  • 0x4:额外预留的一个小标记/偏移保留区

为什么会有 *stTotalSize += 0x4 呢,这行的真实意思不是“COFF规范要求保留 4 字节”,而是这个项目自己定义的一套 .bss 偏移约定,我们看下面这个条件判断语句来自 CoffeeProcessSection 函数:

1
2
3
4
if (!pvFunctionPtr && dwBssEntryOffset)
{
ulBssAddr = (ULONG_PTR)pCoffee->BSS + dwBssEntryOffset;
}

dwBssEntryOffset隐含着:

  • dwBssEntryOffset == 0:说明这不是 .bss
  • dwBssEntryOffset != 0:说明这是 .bss

所以如果第一个 .bss 变量的偏移真的是0,那这里就没法区分,这是第一个 .bss 变量,还是根本没有 .bss 偏移。比如下图的一个例子,某个.bss变量在bss表中的偏移就是0,

PixPin_2026-03-08_19-29-09.png

为了解决这个问题,于是Cen4enCen用了一个很直接的办法:

  • 把 .bss 的真实可用偏移整体后移4
  • 让 0 永远只表示“无效/没有 .bss”

一句话总结:这 4 个字节不是给 .bss 数据本身用的,而是为了让 .bss 偏移从 4 开始,这样 0 就可以继续被当作“没有 .bss 符号”的标记值。大部分C2并没有实现bss段的解析,这一部分代码我感觉是Cen4enCen的精妙的解法。

不过调试分析的时候我发现GOT和BSS存在冗余分配的现象。

比如说下面的一个bof文件。

PixPin_2026-03-09_19-06-24.png

它的汇编代码如下图

PixPin_2026-03-09_19-10-32.png

按照ParseTotalSize的逻辑,dwNumberOfFuncdwBssEntryNum 实际上表示所有外部符号在汇编代码中出现的次数。

__imp_BeaconPrintf 在代码中出现了了两次,出现一次占位则GOT表分配8字节,等待后续填充。

因为 num1 这个符号出现了2次,大小为 2 × dword = 8字节,str1 这个符号出现了2次,大小为 2 × qword = 16,故bss段的大小为 8 + 16 = 24字节。

PixPin_2026-03-08_17-05-45.png

冗余分配,但是精确记录。如果这个符号之前已经登记过了,就直接复用原来的偏移,只有第一次见到该符号时,才真正给它分配空间。以下是 CoffeeProcessSymbol 的确保精确记录的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (BssEntry[dwCnt].pvSysmbolAddr == (PVOID)pCoffSymbol)
{
break;
}
else if (BssEntry[dwCnt].pvSysmbolAddr == NULL && BssEntry[dwCnt].stOffset == 0)
{
BssEntry[dwCnt].stOffset = pCoffSymbol->Value;
BssEntry[dwCnt].pvSysmbolAddr = (PVOID)pCoffSymbol;
break;
}
else
{
dwSum += BssEntry[dwCnt].stOffset;
}

下图是正确修正后并使用GOT和BSS的bof汇编代码。

PixPin_2026-03-09_19-18-51.png

2.4 CoffeeModuleStomping

模块践踏它不是自己新申请一块显眼的可执行内存去放恶意代码,而是先加载一个正常DLL,然后把这个 DLL映像里的部分内容覆盖掉,让自己的代码“住进”这个合法模块的内存区域里执行,显著的优点是:不出现新的可执行匿名内存

CoffeeModuleStomping的代码如下

PixPin_2026-03-09_21-20-00.png

它的目标很明确:优先尝试加载并践踏,“clipwinrt.dll”作为载体;如果失败就退化为 NtAllocateVirtualMemory分配RW内存。之后在这篇内存区域内复制各节、布局GOT、布局BSS、做 relocation、执行 BOF。

2.5 节区映射与init Bss Entry

把COFF文件里的各个节手动复制到一块连续的新内存里,每复制完一个section,就把下一个section的起始地址对齐到页边界,一般是 0x1000。

PixPin_2026-03-09_21-47-08.png

所有节都复制到新内存后,紧接着构造GOT表和BSS。

PixPin_2026-03-09_21-49-25.png

所以内存布局大致像这样:

1
2
3
4
5
6
7
8
9
10
新内存起始地址
-> .text
-> 对齐到下一页
-> .rdata
-> 对齐到下一页
-> .data
-> 对齐到下一页
->……
-> GOT
-> BSS

2.6 CoffeeProcessSymbol

符号解析:我引用的这个未定义的外部符号到底是谁

把一个重定项里引用到的未定义的外部符号,判断它到底是Beacon API、普通DLL导入函数,还是.bss变量,然后给出对于函数符号,给出最终函数地址;对于bss变量,则返回它在BSS段中的偏移

Cen4enCen师傅的代码只能识别x64的外部函数符号,我在此基础上增加了x86的判断逻辑。

判断的依据是

x64的格式

  1. __imp_libraryName$functionName , __imp_functionName
  2. __imp_Beacon 前缀

x86的格式

  1. __imp__libraryName$functionName__imp__functionName
  2. __imp__Beacon 前缀

.bss变量:如果不属于上面两种情况,则认为是.bss变量。

(一)Beacon API 符号

  • GetImportPrefixSize() 判断是不是 __imp___imp__
  • GetBeaconPrefixSize() 再判断是不是 __imp_Beacon__imp__Beacon

如果是 Beacon 符号,流程是:

  1. 跳过__imp___imp__ 前缀
  2. 去掉可能存在的前导下划线
  3. 去掉 x86 stdcall 后缀,比如 @8
  4. 把规范化后的名字拿去和 BeaconApi[] 表逐项哈希比较
  5. 找到后把对应本地实现地址写入 *pvFunctionAddr
  6. 返回 TRUE

PixPin_2026-03-09_22-38-33.png

例如:

x64
PixPin_2026-03-09_22-22-54.png

x86
PixPin_2026-03-09_22-34-50.png

(二)普通导入符号

如果不是 Beacon,但有 __imp___imp__ 前缀,就按DLL导入函数处理。

它支持两种情况。

  1. 标准格式__imp_library$function__imp__libraryName$functionName
    例如:

x64
PixPin_2026-03-09_22-22-40.png

x86
PixPin_2026-03-09_22-32-17.png

流程是:

  • 检查符号名里有没有 $
  • $ 前面当库名,后面当函数名
  • 规范化库名,如果没有 .dll 就补 .dll
  • 尝试先从 PEB 找模块
  • 找不到就 LoadLibraryA
  • 然后调用 LdrGetProcedureAddress 拿函数地址
  • 成功后写入 *pvFunctionAddr

PixPin_2026-03-11_20-57-22.png

  1. 非标准格式__imp_function __imp__functionName

PixPin_2026-03-09_22-41-29.png

这种情况下它尽力而为,去 LdrApi[] 这个内置表里匹配一些常见函数,比如:

  • LoadLibraryA
  • GetProcAddress
  • FreeLibrary
  • CreateThread
  • toWideChar

这类情况函数挺常见的,例如

x64
PixPin_2026-03-09_22-29-48.png

x86
PixPin_2026-03-09_22-34-01.png

(三) .bss 符号

如果不属于上面两种情况,则认为是.bss变量。

流程是:

  1. 遍历全局 BssEntry[]
  2. 看这个 pCoffSymbol 之前有没有登记过
  3. 如果登记过,就找到它前面所有 BSS 变量大小之和
  4. 如果没登记过,就把当前符号的大小 pCoffSymbol->Value 记录进去
  5. 最后把偏移写到 *pdwBssAddr

PixPin_2026-03-09_22-41-42.png

2.7 CoffeeProcessSection

重定位:我已经知道符号对应的目标是谁了,那当前这条指令/这块数据里应该写入什么值。

因为section被我们手动搬到了一块新内存里,原来对象文件里的地址/位移就不对了。
所以要根据relocation type,把引用位置修补成正确值。

主流程是

  1. 遍历每个 section
  2. 取到该 section 的重定位表
  3. 遍历每一个 relocation
  4. 找到这个 relocation 对应的符号
  5. 判断这个符号是:
    • 外部函数
    • Beacon API
    • .bss 变量
    • 本地 section 内符号
  6. 根据重定位类型,把 pvRelocAddr 处的值修好

具体流程我就不过多介绍了,随便问个AI就能说的明明白白,下文我会着重讲解不同类型的重定位项如何修正。

(一)外部函数重定位

PixPin_2026-03-10_19-01-57.png

这段代码:

①真实函数地址放进GOT的一个槽位里。

1
pCoffee->GOT[dwNumberOfFunc] = pvFunctionPtr;

②把当前重定位字段修成指向这个GOT槽位的RIP相对偏移,因为 IMAGE_REL_AMD64_REL32 修的是一个 32位相对位移字段,不是64位绝对地址字段。

公式

1
offset = GOT槽位地址 - 重定位字段地址 - 4

或者更简洁地表示:

1
offset = Target - NextIP

举一个例子,我要修正 call cs:__imp_BeaconPrintf 这个指令

1
2
3
4
……
.text$mn:000000000000002E FF 15 7C 00 00 00 call cs:__imp_BeaconPrintf
.text$mn:0000000000000034 4C 8B 0D 6D 00 00 00 mov r9, cs:str1
……
  • 000000000000002E -> FF 15
  • 0000000000000030 -> 7C 00 00 00(重定位字段)

此时根据下面的代码,我们能得出重定位字段地址pvRelocAddr = 0x0000000000000030

1
pvRelocAddr = pCoffee->SecMap[dwSectionCnt].Ptr + pCoffee->Reloc->VirtualAddress;

NextIP = 重定位字段地址 + 重定位字段的长度,即NextIP = pvRelocAddr + 4 = 0000000000000034

GOT表的地址很简单,取地址就行:&pCoffee->GOT[dwNumberOfFunc]

最终

1
2
3
Offset = GOT槽位地址 - (重定位字段地址 + 4)

即Offset = (UINT32)((ULONG_PTR)(&pCoffee->GOT[dwNumberOfFunc]) - (ULONG_PTR)(pvRelocAddr)-sizeof(UINT32))

(二)本地 section 符号或 .bss 符号

当重定位类型位类型为 IMAGE_REL_AMD64_REL32 ~ REL32_5,这是RIP相对重定位,写进去的是 32 位位移,不是绝对地址,与上文分析过程类似,只不过多了后缀,其后缀 _1~_5 表示重定位字段后面跟着多长的操作数。

公式

1
Offset = Target - NextIP - extra

这里:

①Target 是目标运行时地址,比如说本地section符号(如.data定义的字符串常量)
PixPin_2026-03-10_22-06-23.png

又或者.bss变量

PixPin_2026-03-10_22-06-51.png

对于.bss 符号,其bss变量地址为

1
ulBssAddr = (ULONG_PTR)pCoffee->BSS + dwBssEntryOffset;
  • pCoffee->BSS 表示BSS节的地址
  • dwBssEntryOffset 表示bss变量在节内的偏移

本地section符号,其字符串常量地址为

1
pvSymbolSecAddr + pCoffSymbol->Value
  • pvSymbolSecAddr 表示字符串常量所在的节的地址
  • pCoffSymbol->Value 表示字符串常量的节内偏移

②NextIP = pvRelocAddr + 4

③extra = pCoffee->Reloc->Type - 4

比如

  • 当Type = IMAGE_REL_AMD64_REL32,extra = 4 - 4 = 0
  • 当Type = IMAGE_REL_AMD64_REL32_1,extra = 5 - 4 = 1
  • 当Type = IMAGE_REL_AMD64_REL32_2,extra = 6 - 4 = 2
  • 当Type = IMAGE_REL_AMD64_REL32_3,extra = 7 - 4 = 3
  • 当Type = IMAGE_REL_AMD64_REL32_4,extra = 8 - 4 = 4
  • 当Type = IMAGE_REL_AMD64_REL32_5,extra = 9 - 4 = 5

PixPin_2026-03-10_22-08-49.png

有实际的例子吗,当然有啊,比如说下图,就是一个典型的Type = IMAGE_REL_AMD64_REL32_4

PixPin_2026-03-10_22-20-23.png

  • C7 05 表示指令mov
  • 89 00 00 00 是目标文件静态布局下的占位位移,编译器帮我们算出来的,大小为dword,即32位
  • 7B 00 00 00 表示操作数 7Bh,大小为dword,即32位

调试一下,修正 mov cs:num1, 7Bh 的地址。

PixPin_2026-03-10_22-43-51.png

计算得出offset = 0x00004ffd,并不是 89,因为我们的bof的各节是按页对齐,相比之前的磁盘文件形式,对齐后的文件体积会更大,计算出的偏移也会更大。

PixPin_2026-03-10_22-44-39.png

红框圈住的是 兜底机制,上面条件都不满足会进入到这里,这里不保证Offset的正确性。

PixPin_2026-03-10_22-51-06.png

剩余的 IMAGE_REL_AMD64_ADDR32NBIMAGE_REL_AMD64_ADDR64 的重定位类型并不场景,就不过多介绍了。

还有x86的部分,有需要的就参考一下Havoc的实现 Havoc/payloads/Demon/src/core/CoffeeLdr.c at main · HavocFramework/Havoc

PixPin_2026-03-10_22-55-13.png

2.8 RunCoff

RunCoff 只负责找到BOF入口点,注册VEH,然后把它安全地跑起来

首先是找到BOF入口点:它遍历符号表,用 _Strcmp 把符号名和传进来的 szBofEntryPoint 比较。算出入口函数的运行时地址:pvCoffeeEntryPoint = SecMap[].Ptr(section被映射到内存后的基址) + Symbol.Value(该符号在所属section内的偏移)。

PixPin_2026-03-11_18-44-45.png

.text 节改执行权限:遍历section,找到名字哈希等于.text的代码节,然后调用NtProtectVirtualMemory把对应内存页改成PAGE_EXECUTE_READ

PixPin_2026-03-11_18-51-12.png

注册一个VEH 异常兜底:如果BOF跑崩了,不让整个Beacon一起崩溃,而是把线程执行流改到ExitThread上,尽量平滑退出。

用伪造线程上下文的方式传入参数,执行入口点。

PixPin_2026-03-11_18-53-41.png

2.9 VectoredExceptionHandler

VEH,即向量化异常处理,主要用于系统监控、反调试、全局异常捕获。注册VEH,并将优先级设置为最先 (0=最先,1=最后)之后,本进程发生异常时,系统会先按VEH链依次调用已注册的处理函数;某个处理函数可以选择继续分发,也可以修改上下文后直接恢复执行。

VectoredExceptionHandler:发生异常就把线程上下文改成 ExitThread(0),返回 EXCEPTION_CONTINUE_EXECUTION,线程从ExitThread(0) 开始跑并退出,loader主线程继续存活。

PixPin_2026-03-11_19-02-10.png

Cen4enCen提到某些BOF会抛出一个特殊的异常,该异常是 0xE06D7363,是MSVC C++ 异常常见的异常码。比如这个BOF:C2-Tool-Collection/BOF/Askcreds/SOURCE/Askcreds.c at main · outflanknl/C2-Tool-Collection

不过我在win11上用 mingw编译上述的bof,并用Cen4enCen的coffeeloader复现时,并没有出现异常 0xE06D7363,于是我编写一个bof文件,主动去触发 0xE06D7363 异常,我这么做的目的是验证是否所有的 0xE06D7363 异常都不影响CS的inline-execute,结果beacon崩溃了。算了我不研究了,这种情况应该不多见。

下图是win11上用 mingw编译上述的Askcreds.c,没有出现异常。

PixPin_2026-03-11_22-22-23.png

如果我们使用CS的inline-execute命令去执行这个BOF,也不会出现任何的问题。

PixPin_2026-03-11_22-23-38.png

所以这种情况应该很少见,但为了兼容某些BOF所抛出的异常,不至于直接结束BOF文件,我们可以添加下面的一个判断逻辑:如果状态异常码为 0xE06D7363,返回 EXCEPTION_CONTINUE_SEARCH 表示:“我不处理,让后面的VEH/SEH继续来”。

PixPin_2026-03-11_19-17-48.png

2.10 HitCoffeeEntryPoint

PixPin_2026-03-12_00-08-49.png

  1. 初始化 CONTEXT 和对象属性

PixPin_2026-03-11_23-52-23.png

  1. 创建一个挂起线程

PixPin_2026-03-11_23-53-35.png

具体来说就是使用 pfnNtCreateThreadEx 在本进程内创建一个挂起的线程,线程的起始地址为 win32Api.ulTpReleaseCleanupGroupMembers + 0x450,这个起始地址随意,比如我可以修改为 win32Api.pfnRtlExitUserThread 等合法的地址,毕竟后面代码马上会用NtSetContextThread把真实起点改掉

PixPin_2026-03-11_23-54-46.png

  1. 取出挂起线程的当前上下文并伪造 BOF 的执行现场

我增加了x86的代码逻辑

对于x64

  • rip = BOF的入口点
  • rcx = BOF的第一个参数(BOF执行所需的参数)
  • rdx = BOF的第二个参数(BOF执行所需的参数的大小)
  • ctx.Dr0 = ctx.Dr1 = ctx.Dr2 = ctx.Dr3 = 0,去掉硬件断点痕迹
  • rsp = 返回地址,即RtlExitUserThread

对于x86,首先压入返回地址,之后通过栈来传递参数,从右往做依次入栈。

PixPin_2026-03-11_23-58-07.png

除了上面的执行方式外,我们也可以函数指针的方式执行,havoc就是这种执行方式。

PixPin_2026-03-12_00-05-43.png

  1. 写回上下文并恢复线程

这里就是把刚才伪造好的寄存器状态写回线程,然后恢复运行。恢复之后,线程就会从你指定的BOF入口点开始执行。

PixPin_2026-03-12_00-07-11.png

三、实验

执行whoami.o

PixPin_2026-03-12_19-37-39.png

执行Askcreds.o

PixPin_2026-03-12_19-38-41.png

总结

本文通过分析 Cen4enCen/CenCoffLdr: A parser for COFF files. 项目,学习现代红队的BOF加载器的原理和实现,并增加了x86的逻辑,对原项目进行扩展。

文章写的很随意,毕竟我没有打算发在先知社区,大家就在我的博客看个乐子就行。

我计划:从现在开始,到7月中旬,在本博客里每隔一个月发表至少一篇文章,不限长短,当然长篇文章居多,就当是我的一次小爆发吧。7月后我将实施一个更宏大的目标,这里暂且保密。

参考资料

  1. Cen4enCen/CenCoffLdr: A parser for COFF files.
  2. Havoc/payloads/Demon/src/core/CoffeeLdr.c at main · HavocFramework/Havoc
  3. trustedsec/CS-Situational-Awareness-BOF: Situational Awareness commands implemented using Beacon Object Files
  4. outflanknl/C2-Tool-Collection: A collection of tools which integrate with Cobalt Strike (and possibly other C2 frameworks) through BOF and reflective DLL loading techniques.
Prev
2026-03-13 20:47:15 # 武器化