乐于分享
好东西不私藏

boflink 源码评估与博文全译:给 BOF 补上缺失的链接器

boflink 源码评估与博文全译:给 BOF 补上缺失的链接器

原文:https://blog.cybershenanigans.space/posts/boflink-a-linker-for-beacon-object-files/

作者:Matt Ehrnschwender 

发布时间:2025-05-30 

翻译模型:GLM-5.3 

仓库地址:https://github.com/MEhrn00/boflink

本文分两部分。第一部分是对 boflink v0.6.3(约 1.13 万行 Rust)的源码级审计:架构解剖、链接管线逐阶段拆解、工程配套,以及一份按影响排序的优缺点清单——阅读对象是红队工具链开发者、BOF/COFF loader 作者与对链接器实现感兴趣的工程师。第二部分是作者发布博文的全文翻译(含全部命令与终端输出),忠实保留原文结构与代码块。boflink 本身是开发工具而非攻击载荷:它解决的是 BOF「编译完了却没人链接」的工具链断层,不改变 BOF 的执行语义,也不提供任何检测规避能力。


目录

  • 第一部分 源码评估
    • 1. 项目概况:量化事实
    • 2. 架构解剖:一张链接图
    • 3. 链接管线:从 .obj 到 .bof 的完整走线
    • 4. 工程配套
    • 5. 优点
    • 6. 缺点与风险
  • 第二部分 博文全译
    • 引言
    • 背景
    • 当前 Beacon Object File 的局限
    • 认识 boflink
    • 安装与使用
    • 功能特性
    • MSVCRT 的坑
    • 语言支持
    • 结语(原文)
  • 译注与延伸评注
  • 参考资源

第一部分 源码评估

1. 项目概况:量化事实

  • v0.6.3(2026-06-16),BSD-3-Clause 协议,纯 Rust,edition 2024,MSRV(最低支持的 Rust 版本)1.91,publish = false(不发布 crates.io,仅源码与 release 分发)。
  • 博文发布于 2025-05-30,一年间从早期版本演进到 0.6.3:CHANGELOG 完整记录了这段轨迹——0.5.0(2025-09)补上 weak external 支持,0.6.0(2025-11)补 CS 4.12 的 BeaconDownload 符号与 man page,0.6.1/0.6.2 修了多个正确性 bug(COMDAT leader 归属、同节 VA 重定位误用、i386 导入符号下划线位置),0.6.3(2026-06)重写 CLI 前端并新增 --error-limit--color-diagnostics--print-gcc-specs
  • 代码量:主程序 src/ 约 7,900 行,三个辅助 crate(boflink_logboflink_stdextmoduledef)合计约 2,300 行,构建工具 xtask 约 530 行,总计约 1.13 万行(不含 examples 与测试)。
  • workspace 五成员boflink(链接器本体)、boflink_log(日志与彩色诊断)、boflink_stdext(路径/时间/文件系统扩展)、moduledef(Module-Definition .def 文件解析器,独立成库、带自己的 README 与测试,供自定义 Beacon API 工作流使用)、xtask(安装/卸载/打包/CI 任务)。
  • 依赖面刻意收窄:核心依赖只有 object(COFF/归档解析与写出,一切的地基)、bumpalo(arena 分配)、indexmap(保序 map)、bitflagscpp_demangle(GCC 风格符号 demangle)、crc32fast 等 16 个 crate;windows crate 仅在 Windows 目标按特性引入(undname、控制台 API)。
  • unsafe 全部集中在一个文件:全库 unsafe 只出现在 src/undname.rs(调用 dbghelp 的 UnDecorateSymbolName 做 MSVC 符号 demangle 的 FFI 包装,22 处)——整个链接图、COFF 写出、重定位应用全部是 safe Rust。
  • 测试是端到端的tests/linker.rs 的 11 个用例直接驱动编译好的 boflink 二进制,用内联汇编生成测试对象文件,再用 object crate 解析输出并断言(.bss 尺寸合计、COMMON 符号按大小排序、COMDAT 三种 selection 的取舍、节分组等);CI 在 ubuntu 与 windows 双平台跑 cargo xtask ci(含 clippy 与 rustfmt)。
  • 示例工程 4 个:hello-world(Makefile 双工具链)、custom-api(自定义 Beacon API)、cmake-hello-world(CMake 集成,含 5 份交叉编译 toolchain 文件)、libraries(导入库使用)。

一句话定位:这是一个 「面向 BOF loader 而非系统 linker」的专用部分链接器(partial linker)——吃进编译器原样吐出的 .obj,吐出对 loader 最友好的 COFF。它不是武器,是武器的工作台。

2. 架构解剖:一张链接图

boflink 的核心抽象是一张有向图(src/graph/),明显受了 LLD 的 InputSection/ Symbol 图设计影响,但用 Rust 的方式重新表达:

src/├── main.rs           管线编排:输入读取、懒归档解析、符号解析循环、收尾├── cli.rs            手写 CLI(不用 clap):响应文件、GCC/MSVC/CMake/Rust 兼容 flag├── linker.rs         LinkContext 全局状态 + ErrHandler(错误限额)├── archive.rs        归档解析:符号表缓存 + 老式导入库(legacy import)三级解析├── bofapi.rs         内置 55 个 Beacon API 符号表(i386/x64 双形态)├── directives.rs     /DEFAULTLIB 指令解析(MSVC #pragma comment(lib))├── undname.rs        MSVC 符号 demangle(仅 Windows,全库唯一 unsafe 所在)└── graph/    ├── spec.rs    SpecLinkGraph:解析期先收集 COFF 元数据(不依赖 arena)    ├── link.rs    LinkGraph::add_coff:把 COFF 装进图(971 行)    ├── built.rs   BuiltLinkGraph:分区、GC、COMDAT、thunk、COMMON(864 行)    ├── output.rs     OutputGraph:写出最终 COFF + 应用重定位(830 行)    ├── cache.rs      每个 COFF 插入时的节/符号/COMDAT 缓存    ├── edge.rs       五类边 + 侵入式链表 EdgeList    └── node/         四类节点:coff / section / symbol / library

四类节点:CoffNode(来源文件身份,用于诊断)、SectionNode(节:数据、特征位、校验和、虚地址、丢弃标记)、SymbolNode(符号:存储类、四张边表、输出符号表索引)、LibraryNode(导入库/Beacon API 归宿)。

五类边:DefinitionEdge(符号定义在节上,权重携带地址与 COMDAT selection)、RelocationEdge(节引用符号,权重携带重定位地址与类型)、ImportEdge(符号来自某个 DLL)、AssociativeEdge(节与节的 COMDAT 关联,例如 .pdata 跟随代码节)、WeakDefaultEdge(弱符号的默认定义回退)。

两个值得点名的设计决策:

其一,arena + 侵入式链表的内存布局。 所有节点与边都从 bumpalo arena 分配(Bump),输入文件缓冲进 typed-arena(保证 'data 生命周期)。邻接关系不用 Vec<&Edge> 而是 EdgeList——手写的侵入式单链表,头尾指针与长度放在 Cell 里,节点本身存 Cell<Option<&Edge>> 的 next 指针。结果是:加边零额外分配(边已在 arena 里)、遍历缓存友好、节点可变怞性靠 Cell/OnceCell 内部解决,全库没有一处 Rc<RefCell<>>。代价是 pop_front 会泄漏节点(代码注释自己承认)且安全性全靠约定维持——这是一笔自觉的性能换复杂度交易。

其二,链接图的「规格态」与「构建态」分离。SpecLinkGraph 在读输入文件阶段就开始收集 COFF 信息(为识别目标架构),真正的图构建在架构确定后才开始。这让「先读文件、再定架构、再建图」的顺序成为可能——目标架构可以不写死在命令行里,从第一个输入 COFF 推断。

3. 链接管线:从 .obj 到 .bof 的完整走线

run_linker(src/main.rs)把整条管线串起来,走线如下:

阶段一:读输入(read_input_arg)。 显式 .obj 直接解析进 input_coffs(IndexMap 以 CoffPath{文件路径, 归档成员} 为键天然去重);.lib 归档则进 lazy_archives 惰性等待。find_library 按 6 种文件名模式(lib<name>.dll.a<name>.dll.alib<name>.a<name>.liblib<name>.lib<name>.a)在 -L 搜索路径里找库;Windows 上自动并入 LIB 环境变量(VS 开发者命令行的库路径)。

阶段二:保入口(ensure_entrypoint)。 入口符号(默认 go)如果在惰性归档里,先把对应成员抽出来——GC 与后续阶段的根。

阶段三:定架构。-m i386pep/i386pe 显式指定,否则取第一个可识别的输入 COFF 架构(x64 或 x86,不支持 ARM64)。

阶段四:API 符号表(read_api_symbols)。 无 --custom-api 时使用内置表:bofapi.rs 硬编码 55 个 Beacon API 符号(BeaconPrintfBeaconDataParse 一直到 CS 4.12 的 BeaconDownload),i386 下同时注册 _name 与 __imp__name 双形态,x64 注册 name 与 __imp_name。这些符号在解析时被当作来自虚拟库 "Beacon API" 的导入。有 --custom-api 时解析一个真实导入库归档,取其导入成员。

阶段五:建图 + 迭代解析(add_coff_inputs / resolve_symbols)。 每个输入 COFF 进 LinkGraph::add_coff:节建 SectionNode(按特征位区分初始化/未初始化数据)、符号建 SymbolNode(外部符号按名字全局合并——重复定义留给收尾报错)、COMMON 符号挂到合成的 "COMMON data" 伪节、COMDAT 关联节加 AssociativeEdge.pdata 与代码节自动关联(防止 GC 把异常信息丢掉)、重定位建 RelocationEdge。然后是一个循环:把当前所有「归档可见」的未定义符号(外部、未定义、非弱或弱搜索为 library)逐个对惰性归档查符号表,抽出成员进图——抽出成员可能带来新的未定义符号,直到不动点。.drectve 里的 /DEFAULTLIB(来自 #pragma comment(lib))也会在这一步并入搜索库。

阶段六:收尾(finish / finish_unresolved)。 逐符号检查:未定义且被引用、重复定义(多个非 COMDAT 定义)、多重定义(COMDAT selection 违约:NoDuplicates 出现多次、SameSize 尺寸不一致、ExactMatch 校验和不一致)——三者都产出带引用位置的诊断,超限(默认 20)退出。--warn-unresolved-symbols 可降级为警告。

阶段七:可选后处理 + 输出。

  • --gc-sections:从入口与 --require-defined 根出发对节做 DFS 可达性标记,其余丢弃(可达性穿越「节 → 重定位 → 符号 → 定义 → 节」与关联边,弱符号的默认定义也算一条出路);
  • --merge-bss:把 .bss 类节并入 .data 类(写出时以 0 填充),并把 COMMON 伪节分配到 .bss 末尾;
  • prelink 三连:handle_comdat_leaders(按 selection 去重 COMDAT、关联节跟随根节的取舍)、apply_import_thunks(给不带 __declspec(dllimport) 前缀的导入调用合成 8 字节 jmp [rip+__imp_xxx] thunk 节——built.rs 里的 CODE_THUNK_DATA)、allocate_commons(COMMON 符号按尺寸降序分配地址以减少 padding);
  • build_output_sections:节按类别(Code/InitializedData/UninitializedData/ReadOnlyData/Exception/…共 20 余种 SectionType,由节名 + 特征位推断)分区,.text$xxx 分组节用 BTreeMap 保字典序(这是 C++ 静态初始化与 GCC 节布局的约定);--merge-groups(默认)把分组合并进单一 .text/.data/.bss,--no-merge-groups 则按组独立成节;
  • output.rs::build_output:两遍走 COFF writer——先 reserve(节名/节数据/重定位/符号表索引)后 write(文件头、节头、节数据、重定位、符号表、字符串表),最后在输出缓冲上原地 fixup 重定位。同节内的非 VA 型重定位直接算掉、不进输出重定位表(这是给 loader 减负的关键一手);重定位应用全部走 checked_add 溢出保护与节边界检查;代码节对齐填充用 0x90(NOP),数据节用 0x00;文件头 time_date_stamp 恒为 0——可复现构建友好。

导入符号最终以 BOF DFR 约定重写:库导入输出为 __imp_<DLL大写>$<函数名>,Beacon API 导出为 __imp_<函数名>——这正是各 BOF loader 约定的动态函数解析格式。

4. 工程配套

  • xtask 承担一切杂务cargo xtask install(安装并建 Linux 侧 GCC 集成)、uninstalldist(打包 + SHA256 清单)、ci(fmt + clippy + test),dist 任务在 release 流水线里产出各平台压缩包。这是无第三方插件依赖的 cargo 别名方案。
  • 文档三件:README(安装与三工具链用法)、docs/boflink.1.md(man page 源,pandoc 构建)、CHANGELOG(Keep a Changelog 格式,逐版本带 commit 哈希与 issue 链接)。
  • Dependabot + 双平台 CI;release profile 提供 release-lto(fat LTO + strip + 单 codegen unit)。
  • --dump-link-graph:把内部链接图写成 GraphViz DOT,dot 可渲染成 SVG/PNG——纯调试辅助,但把 linker 的中间状态白盒化,对学习链接器实现的人是福利。

5. 优点

架构与正确性

  1. 「为 loader 优化而非为系统 linker 优化」的 pass 设计是全项目的灵魂,且执行得系统完整:COMDAT 五种 selection(Any/SameSize/ExactMatch/Largest/Associative)全有处理路径;COMMON 符号像真链接器一样在 .bss 末尾分配且按尺寸降序排布减少填充;分组节(.text$xxx)保序合并;GCC 元数据节(.rdata$zzz)用 JamCRC 校验和去重——而 GCC 原始 .obj 里这个校验和是 0,boflink 在建图时自己算(link.rs:300-308);debug 节与 LNK_REMOVE 节直接丢弃。这些每一项都是「loader 少写一层特判、少一种崩溃姿势」。
  2. 同节重定位的完全解析:目标符号与重定向同处一个输出节时,重定位在链接期直接算掉、不进输出表——输出 BOF 的重定位表里只剩 loader 必须处理的跨节/导入项,把 loader 的工作量压到最小。
  3. import thunk 合成:对没走 __declspec(dllimport) 的调用点,合成 .text$<symbol> 的 jmp [rip+__imp_...] 8 字节 thunk 节并把导入边重新挂到新生成的 __imp_ 符号上——直接调用的兼容问题在链接期闭环,而不是留给 loader。
  4. 老式导入库(legacy import library)解析archive.rs 实现了 .idata$5/.idata$6 符号成员 → _head_* → _iname 尾成员的三级跳来找回 DLL 名——object crate 只认现代短格式导入成员,这层补齐让老 .lib 也能用(0.6.3 还修了 MinGW dlltool 生成库的兼容问题)。
  5. weak external 支持(0.5.0 起):IMAGE_WEAK_EXTERN_SEARCH 的 alias 与 library 两种搜索语义都有处理,弱默认定义参与归档可见性判断、GC 可达性与重定向选择——开源 BOF 工具链里少见的完整度。
  6. 安全的实现基底:1.1 万行里 unsafe 只有 undname.rs 的 FFI 一处;重定位数值操作全部 checked;不可信输入的边界(重定位出界、符号引用非法索引、节号非法)多有事前检查与带上下文的错误信息。

诊断与易用性

  1. 诊断质量是编译器级的:未定义符号报告带 demangled 名(MSVC ? 名走 UnDecorateSymbolName,GCC _Z 名走 cpp_demangle__imp_ 前缀还原成 __declspec(dllimport) 标注)、最多 5 个引用位置(用 BTreeMap 区间查询把重定位地址映射到最近的源符号/标签,连 MSVC $SGxxxx 数据标签都识别)、外加 "referenced N more times";重复/多重定义同样带全部定义位置;--error-limit 到量退出,像真正的编译器。
  2. 兼容性工程扎实:手写 CLI 支持 @ 响应文件递归展开(带环检测);-Bstatic/-Bdynamic--whole-archive 状态机;GCC 的 -plugin/-plugin-opt/-flto、Rust 的 --dynamicbase/--nxcompat、CMake 的 --out-implib/--major-image-version 等一律吞掉并警告或静默——能嵌进现有构建系统是它能被用起来的前提;--print-gcc-specs 一条命令生成 MinGW 集成所需的 spec 文件(0.6.3 用它取代了早期 install 脚本建 ld 符号链接的 mold 式 workaround)。

工程成熟度

  1. 测试即端到端:11 个用例全部走「汇编生成输入 → 跑真实二进制 → 解析输出断言」路径,断言的是 COFF 结构语义(.bss 合计尺寸、COMMON 地址排序、COMDAT 保留者)而非内部实现细节——重构安全网是真网。
  2. 可复现输出time_date_stamp 恒 0,同样输入产出字节相同的 BOF——对需要给 BOF 做签名/哈希登记的用户是隐性但实在的好处。
  3. 模块划分有复用意识moduledef(.def 解析)独立成库带测试;boflink_log 把彩色诊断与 error limit 从主程序剥离。

6. 缺点与风险

按影响排序:

较高

  • panic 密集的输出路径output.rs 里符号表索引未分配、输出名未保留等场景全部是 unwrap_or_else(|| panic!) / unwrap() / unreachable!()output.rs:266-298、486-495、534-539 等),link.rs:503 的 add_import_edge 直接 panic!("symbol {symbol} does not exist")。这些是内部不变量,正常输入触不到;但这是一个吃「不可信 .obj/.lib」的工具,畸形但可解析的输入(符号表自不一致、恶意构造的节号引用)能否走到这些 panic 没有穷尽验证——0.6.1 就修过一个 .bss 尺寸大于输出文件时的 panic(output.rs:607,issue #38)。无 fuzzing 覆盖是与之配套的短板。
  • std::process::exit(1) 散布在库代码check_erroredErrHandler::log 超限、finish 失败路径都直接 exit——跳过所有析构,且让这些模块无法被当作库复用(比如嵌进 C2 的构建流水线)。CLI 语义泄漏进了库层。

  • 两处架构错误信息的参数顺序写反了link.rs:92-98 与 link.rs:505-511 都是 bail!("invalid architecture '{:?}', expected '{:?}'", 本机架构, 输入架构)——按格式串,"invalid" 说的是本机架构、"expected" 说的是输入架构,语义恰好颠倒。纯外观 bug,但会误导排障者。
  • ensure_entrypoint 的静默早退main.rs:293-296):惰性归档符号迭代一旦遇到 Err 就整函数 return——不是跳过这个归档继续试下一个,而是放弃搜索。入口符号恰好排在坏归档后面时会漏抽,后续阶段才报未定义。
  • ExactMatch COMDAT 只比对校验和symbol.rs:260-266 的 TODO 自认还需要比对重定位与定义——C++ 内联函数场景可能误判等价。
  • resolve_symbols 的不动点循环是 O(符号 × 归档) 级:每轮全量重算 archive_resolvable_externals,每个未解析符号线性扫全部惰性归档;有 unresolved_symbols 备忘录兜底,但大输入(几十个库、上千符号)下仍偏慢,且全程序单线程——链接速度的上限就定在这。

较低

  • ~330 行近复制粘贴的双胞胎build_output_sections 与 build_merged_output_sectionsbuilt.rs:501-829)只差「分组是否合并」一件事,却各写了一遍分区逻辑;output.rs::build_output 单函数 700+ 行,reserve/write/fixup 三阶段内联——最该重构的两处。
  • MSVC 符号 demangle 仅 Windows 构建undname.rs 是 cfg(windows)):Linux 上交叉链接时 ? 名原样输出,诊断可读性降级。
  • 兼容性消费的选项只存不用dynamicbasenxcompathigh_entropy_vamajor/minor_image_versionout_implibplugin_opt 等字段进了 CliOptions 却没有任何读取方——为兼容吞掉无可厚非,存下来不删是轻微的误导。
  • MSRV 1.91 + edition 2024 门槛高:let-chains 等 2024 edition 特性用得很顺,但把「装个 Rust 就能编」的最低成本抬上去了(博文时期还是 1.85,一年内漂了 6 个小版本)。
  • C++ 未支持(作者明示)、ARM64 不支持、无 LTO、无增量链接——「哑链接器」的自我定位使这些不算 bug,但用户要知道边界。

对红队用户的 OPSEC 提醒

  • 输出 BOF 的符号表保留全部非标签符号名,包括 static 函数(如示例里的 my_function)与节名(.text.xdata…)。--gc-sections 能裁掉死代码,但没有 strip 选项——BOF 文件落地或传输时,符号名会直接陈述这套代码的能力意图。介意的话,落地前自行处理符号表。
  • --dump-link-graph 产物(DOT 文件)包含完整符号与节结构,调试完记得清理。

第二部分 博文全译

以下为 Matt Ehrnschwender《Boflink: A Linker For Beacon Object Files》(2025-05-30,约 5,883 词)全文翻译。终端输出与代码块保留原文,未作删节。

引言

这是为我最近发布的一个项目所写的博文。其源代码可在 GitHub 上找到。

背景

Cobalt Strike 的 Beacon Object File(BOF)的设计与其他运行时代码执行实现相比相当独特。它们是被编译成 COFF object file 的小型程序,由 COFF loader 加载并执行。BOF 带来的另一个概念是动态函数解析(dynamic function resolution,DFR),它允许 COFF 调用来自外部 DLL 的函数。

自从 BOF 首次在 Cobalt Strike 中引入以来,其受欢迎程度爆发式增长。由于这种流行,许多项目陆续发布以利用这项新技术。在最初概念提出后不久,TrustedSec 发布了一篇关于开发 BOF loader 的博文。这篇博文与 COFFLoader 实现让开发者更容易开发自己的 BOF,也更容易把这种能力集成进自己的工具。BOF loader 已被众多红队工具广泛采用。它们可以提供一套相当轻量、与底层平台无关的扩展系统,从开发者视角看这让 BOF 非常理想。

尽管 BOF 背后的概念早在 2020 年就已提出,其开发流程与工具链大体上原地未动。面向 BOF 开发的公开项目模板有所增加,例如 bof-vs 模板,它们能帮上开发中的一些环节。

但这些模板仍然受制于 BOF 形态本身带来的一些局限。

当前 Beacon Object File 的局限

BOF 的局限之一在于它处理外部导入的方式。它使用一种称为 DFR 的方法:把导入符号的名字改写成包含该符号所属库的名字。我此前写过一篇关于由此带来的种种麻烦的博文:《Writing Beacon Object Files Without DFR》。

另一个设计局限是 BOF 把 object file 当作「最终文件格式」使用。object file 的主要目的是在构建过程中充当中间编译产物,而不是一种适合其他用途的完整文件。这让编译器产出的原始 object file 更多地是为系统 linker 后续构建完整可执行文件而优化。

编译器可能在 object file 里塞入各种符号与其他编译产物——它们对 linker 有用,但若 loader 未经妥善处理就可能出问题。这就导致 loader 需要额外的逻辑来应对这些情况,并依据上下文决定如何处置特定产物。如果处理不当,编译器生成的代码又假设该产物已被正确处理,问题就会浮现。

object file 还是编译单个翻译单元(translation unit,通常即单个 C 或 C++ 源文件)的产物。源码可以拆成多个文件,但最终这些文件要么通过 #include 语句、要么靠拼接内容合到一起交给编译器。这可能让维护与调试变得困难。

认识 boflink

boflink 是一个用来填补 BOF 开发流程中缺失的链接阶段的工具。它是一个 linker:接收编译器生成的、未经修改的 object file,把它们链接成一个能被 BOF loader 加载的 Beacon Object File。

它的主要目标是充当 BOF 开发与 BOF 加载之间的桥梁,帮助简化这两端。

在 BOF 开发这一侧,boflink 会对导入符号执行符号解析,并把它们重写成 BOF DFR 格式。BOF 开发者可以像平常一样使用从 DLL 导入的符号,而不需要先给它们加上所属库的名字前缀。另一项能力是它可以把多个 COFF 链接到一起。这些方面让 BOF 的源代码更接近传统可执行文件的源代码——BOF 特有的产物都由 boflink 处理掉了。

在 BOF loader 这一侧,boflink 产出的 COFF 更适合 BOF loader 处理,而不是为系统 linker 优化。编译器常常会纳入一些产物、以对 linker 更理想(而非对 loader 更理想)的方式组织结构。这类例子包括 COMDAT section、重定位标签(relocation label)、分组节(grouped section)、COMMON 符号与 linker 元数据节。简化 BOF loader 要加载的 COFF,有助于降低 BOF 崩溃的风险——loader 不再需要为这些情况纳入额外的复杂度。

安装与使用

releases 页面提供预构建产物下载。它们在 CI 中构建并随每个版本发布。

在 Windows 上配置 boflink 只需要 boflink 可执行文件。把它放进某个 %PATH% 目录即可,方便调用。

在 Linux 上,boflink 设计为与 MinGW 工具链集成、经由编译驱动(compile driver)调用。也可以配合自定义工具链使用;不过许多 Linux 发行版自带的 MinGW 是最容易上手的。

把 boflink 与 MinGW GCC 集成需要创建一个特殊的符号链接。原因在于 GCC 不支持直接使用自定义 linker,这个符号链接就是用来绕开该限制的。mold linker 在最初没有 GCC 支持时也用过这个变通手段。做法是:在一个空目录里创建名为 ld 的符号链接,目标指向已安装的 boflink 可执行文件。

发布压缩包里带有一个小小的 install 脚本,它会把可执行文件安装到 ~/.local/bin/boflink,并在 ~/.local/libexec/boflink/ld 创建上述符号链接。

从源码安装

从源码安装需要 Rust 版本 >=1.85。可在终端运行 rustc --version 检查。

构建并从源码安装需要下载源代码,然后运行 cargo xtask install

cargo xtask ... 命令不是任何需要额外安装的外部插件,它是 cargo 的一个别名:构建并运行一个小型可执行文件来正确安装本程序。

它会运行 cargo install –path . –bin boflink 从源码构建可执行文件并复制到 $CARGO_HOME/bin 路径。在 Linux 上,它还会通过把 ld 符号链接放进 ~/.local/libexec/boflink 目录来为 MinGW GCC 完成配置。

cargo xtask uninstall 命令可以撤销 install 命令所做的一切。只有当初用 cargo xtask install 安装的才能这样卸载;若可执行文件来自发布压缩包安装,则不会被卸载。

使用

boflink 扮演的是某种「哑链接器(dumb linker)」。也就是说,它不包含任何形式的默认配置,一切配置都预期从命令行或其他途径提供。

Windows

在 Windows 上,应当在 Visual Studio 开发者命令行中使用 boflink。该环境包含一组搜索路径,搜索链接库时会自动纳入其中。

在 Visual Studio 开发者命令行里可以直接调用 boflink 可执行文件,传入任意命令行选项。

boflink [-o <output>][options] <files>...boflink -o mybof.bof file1.obj file2.obj -lkernel32 -ladvapi32

Linux

在 Linux 上,boflink 可执行文件并不打算被直接调用,而应当通过编译驱动(例如 MinGW GCC 或 Clang)使用。编译驱动会把自己的默认配置传给 boflink 使用。

对 MinGW GCC,需要以下命令行标志。

x86_64-w64-mingw32-gcc -B ~/.local/libexec/boflink -fno-lto -nostartfiles <args>...x86_64-w64-mingw32-gcc -B ~/.local/libexec/boflink -fno-lto -nostartfiles -o mybof.bofsource.cobject.o

上面的示例假设 ~/.local/libexec/boflink 目录已按安装一节所述建好 ld 符号链接。-fno-lto 与 -nostartfiles 标志也是正常工作所必需的。

Clang 提供 --ld-path= 标志来设置 linker。它需要是 boflink 可执行文件的绝对路径。

clang --ld-path=/path/to/boflink --target=x86_64-windows-gnu -nostartfiles <args>...clang --ld-path=/path/to/boflink --target=x86_64-windows-gnu -nostartfiles -o mybof.bofsource.cobject.o

--ld-path= 标志对格式非常挑剔:前缀必须是两个连字符,且必须使用等号 =。与 MinGW GCC 一样,-nostartfiles 选项也是必需的,用于把启动文件排除在 linker 输入之外。

功能特性

以下是 boflink 当前已实现的一些功能及其用法。

符号解析

boflink 会用命令行指定的链接库与库搜索路径来解析未定义符号。在输出 BOF 里,这些符号的名字会被重写为包含解析出的库名,以符合 BOF DFR 格式。

对于这个示例源文件:

#include<windows.h>#include"beacon.h"voidgo(void){    DWORD pid = GetCurrentProcessId();BeaconPrintf(CALLBACK_OUTPUT, "pid: %lu\n", pid);}

编译它会使 GetCurrentProcessId 与 BeaconPrintf 符号以未定义形式出现。

matt@laptop ~> lsbeacon.h  example.c  Makefilematt@laptop ~> make example.ox86_64-w64-mingw32-gcc    -c -o example.o example.cmatt@laptop ~> rabin2 -s example.o[Symbols]nth paddr      vaddr      bindtypesize lib name                          demangled――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――00x00000000 0x00000000 LOCALFILE4      .file00x0000012c 0x00000000 GLOBAL FUNC 4      go00x0000012c 0x00000000 LOCAL  SECT 4      .text00x00000000 0x00000040 LOCAL  SECT 4      .data00x00000000 0x00000050 LOCAL  SECT 4      .bss00x0000016c 0x00000060 LOCAL  SECT 4      .rdata00x0000017c 0x00000070 LOCAL  SECT 4      .xdata00x00000188 0x00000080 LOCAL  SECT 4      .pdata00x00000194 0x00000090 LOCAL  UNK  4      .rdata$zzz0   ---------- ---------- NONE   UNK  4      imp.__imp_GetCurrentProcessId0   ---------- ---------- NONE   UNK  4      imp.__imp_BeaconPrintfmatt@laptop ~>

把编译出的 example.o COFF 交给 boflink,boflink 会解析这些符号,并按 BOF DFR 格式、以它找到定义所在 DLL 的名字重写它们。

matt@laptop ~> lsbeacon.h  example.c  example.o  Makefilematt@laptop ~> x86_64-w64-mingw32-gcc -B ~/.local/libexec/boflink -fno-lto -nostartfiles -o example.bof example.omatt@laptop ~> rabin2 -s example.bof[Symbols]nth paddr      vaddr      bind   type size lib name                                                    demangled―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――00x000001040x00000000LOCAL  SECT 4      .text00x000001040x00000000GLOBALFUNC 4      go00x000000000x00000040LOCAL  SECT 4      .data00x000000000x00000050LOCAL  SECT 4      .bss00x000001440x00000060LOCAL  SECT 4      .rdata00x000001940x000000b0LOCAL  SECT 4      .xdata00x000001a00x000000c0LOCAL  SECT 4      .pdata0   ---------- ---------- NONE   UNK  4      imp.__imp_BeaconPrintf0   ---------- ---------- NONE   UNK  4      imp.__imp_KERNEL32$GetCurrentProcessIdmatt@laptop ~>

MinGW GCC 在其默认配置中把 kernel32 列为搜索库,因此不需要手动加上。要在 Windows 上用 MSVC 链接 kernel32,需要在 Visual Studio 开发者命令行中运行 boflink,并在命令行把 kernel32 作为额外的链接库传入。

 matth@laptop ~> ls        Directory: C:\Users\matthMode                 LastWriteTime         Length Name----------------------------a---          5/27/2025   1:50 PM          15962  beacon.h-a---          5/27/2025   1:50 PM            162  example.c matth@laptop ~> cl /c/GS- .\example.cMicrosoft (R) C/C++ Optimizing Compiler Version 19.44.35207.1 for x64Copyright (C) Microsoft Corporation.  All rights reserved.example.c matth@laptop ~> ls        Directory: C:\Users\matthMode                 LastWriteTime         Length Name----------------------------a---          5/27/2025   1:50 PM          15962 beacon.h-a---          5/27/2025   1:50 PM            162 example.c-a---          5/27/2025   1:52 PM            1225 example.obj matth@laptop ~> rabin2 -s .\example.obj[Symbols]nth paddr      vaddr      bind   type size lib name                          demangled--------------------------------------------------------------------------------------0   -------------------- LOCAL  ABS  4      @comp.id-0x010489870   -------------------- LOCAL  ABS  4      @feat.00-0x800100900   -------------------- LOCAL  ABS  4      @vol.md-0x000000030   0x0000012c 0x00000000 LOCAL  SECT 4      .drectve0   0x00000189 0x00000060 LOCAL  SECT 4      .debug$S0   0x00000219 0x000000f0 LOCAL  SECT 4      .text$mn0   -------------------- NONE   UNK  4      imp.__imp_GetCurrentProcessId0   -------------------- NONE   UNK  4      imp.__imp_BeaconPrintf0   0x00000219 0x000000f0 GLOBAL FUNC 4      go0   0x00000219 0x000000f0 LOCAL  6    4      $LN30   0x0000025f 0x00000120 LOCAL  SECT 4      .xdata0   0x0000025f 0x00000120 LOCAL  UNK  4      $unwind$go0   0x00000267 0x00000130 LOCAL  SECT 4      .pdata0   0x00000267 0x00000130 LOCAL  UNK  4      $pdata$go0   0x00000291 0x00000140 LOCAL  SECT 4      .data0   0x00000291 0x00000150 LOCAL  UNK  4      $SG741380   0x0000029b 0x00000150 LOCAL  SECT 4      .chks64 matth@laptop ~> boflink -o example.bof .\example.obj -lkernel32 matth@laptop ~> rabin2 -s .\example.bof[Symbols]nth paddr      vaddr      bind   type size lib name                                                    demangled-----------------------------------------------------------------------------------------------0   0x000000b4 0x00000000 LOCAL  SECT 4      .text0   0x000000b4 0x00000000 GLOBAL FUNC 4      go0   0x000000dc 0x00000030 LOCAL  SECT 4      .xdata0   0x000000dc 0x00000030 LOCAL  UNK  4      $unwind$go0   0x000000e4 0x00000040 LOCAL  SECT 4      .pdata0   0x000000e4 0x00000040 LOCAL  UNK  4      $pdata$go0   0x000000f0 0x00000050 LOCAL  SECT 4      .data0   -------------------- NONE   UNK  4      imp.__imp_BeaconPrintf0   -------------------- NONE   UNK  4      imp.__imp_KERNEL32$GetCurrentProcessId matth@laptop  ~>

如果有符号无法解析,boflink 会返回错误,列出哪些符号仍处于未定义状态。

#include<windows.h>#include<lmcons.h>#include"beacon.h"externvoidUnresolvedSymbol(void);staticvoidmy_function(void){UnresolvedSymbol();}voidgo(void){my_function();char username[UNLEN + 1] = {0};if (GetUserNameA(username, &(DWORD){sizeof(username)}) != 0) {BeaconPrintf(CALLBACK_OUTPUT, "Your username is %s", username);    }}
 matth@laptop ~> cl /c /GS- .\example.cMicrosoft (R) C/C++ Optimizing Compiler Version 19.44.35207.1for x64Copyright (C) Microsoft Corporation.  All rights reserved.example.c matth@laptop ~> boflink -o example.bof .\example.obj -lkernel32boflink: error: undefined symbol: __declspec(dllimport) GetUserNameA>>> referenced by .\example.obj:(go)boflink: error: undefined symbol: UnresolvedSymbol>>> referenced by .\example.obj:(my_function) matth@laptop ~ [1]>

链接多个文件

boflink 也能把多个 object file 链接成单个 BOF。这通常被称为「部分链接(partial linking)」:把多个 object file 合并产出单个 object file,而不是完整的可执行文件或共享库。

go.c

#include"beacon.h"#include"other.h"voidgo(void){BeaconPrintf(CALLBACK_OUTPUT, "Hello world from the go() function");other_function();}

other.h

#ifndef OTHER_H#define OTHER_Hvoidother_function(void);#endif// OTHER_H

other.c

#include"other.h"#include"beacon.h"voidother_function(){BeaconPrintf(CALLBACK_OUTPUT, "Hello world from other_function()");}

使用 MinGW GCC:

matt@laptop ~> lsbeacon.h  go.c  Makefile  other.c  other.hmatt@laptop ~> x86_64-w64-mingw32-gcc -B ~/.local/libexec/boflink -fno-lto -nostartfiles -o mybof.bof go.c other.cmatt@laptop ~> lsbeacon.h  go.c  Makefile  mybof.bof  other.c  other.hmatt@laptop ~> rabin2 -s mybof.bof[Symbols]nth paddr      vaddr      bind   type size lib name                                                    demangled―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――00x00000104 0x00000000 LOCAL  SECT 4.text00x00000104 0x00000000 GLOBAL FUNC 4      go00x00000134 0x00000030 GLOBAL FUNC 4      other_function00x00000000 0x00000090 LOCAL  SECT 4.data00x00000000 0x000000a0 LOCAL  SECT 4.bss00x00000194 0x000000b0 LOCAL  SECT 4.rdata00x00000244 0x00000160 LOCAL  SECT 4.xdata00x0000025c 0x00000180 LOCAL  SECT 4.pdata0   ---------- ---------- NONE   UNK  4      imp.__imp_BeaconPrintf0   ---------- ---------- NONE   UNK  4      imp.__imp_KERNEL32$GetCurrentProcessIdmatt@laptop ~>

在 Windows 上使用 MSVC:

matth@laptop ~> ls        Directory: C:\Users\matthMode                 LastWriteTime         Length Name----------------------------a---          5/27/2025   1:50 PM          15962 beacon.h-a---          5/27/2025   2:33 PM            164 go.c-a---          5/27/2025   2:33 PM            273 other.c-a---          5/27/2025   2:33 PM             85 other.h matth@laptop ~> cl /c/GS- .\go.c .\other.cMicrosoft (R) C/C++ Optimizing Compiler Version 19.44.35207.1 for x64Copyright (C) Microsoft Corporation.  All rights reserved.go.cother.cGenerating Code... matth@laptop ~> ls        Directory: C:\Users\matthMode                 LastWriteTime         Length Name----------------------------a---          5/27/2025   1:50 PM          15962 beacon.h-a---          5/27/2025   2:33 PM            164 go.c-a---          5/27/2025   2:35 PM            1225 go.obj-a---          5/27/2025   2:33 PM            273 other.c-a---          5/27/2025   2:33 PM             85 other.h-a---          5/27/2025   2:35 PM            1364 other.obj matth@laptop ~> boflink -o mybof.bof .\go.obj .\other.obj -lkernel32 matth@laptop ~> ls        Directory: C:\Users\matthMode                 LastWriteTime         Length Name----------------------------a---          5/27/2025   1:50 PM          15962 beacon.h-a---          5/27/2025   2:33 PM            164 go.c-a---          5/27/2025   2:35 PM            1225 go.obj-a---          5/27/2025   2:35 PM            947 mybof.bof-a---          5/27/2025   2:33 PM            273 other.c-a---          5/27/2025   2:33 PM             85 other.h-a---          5/27/2025   2:35 PM            1364 other.obj matth@laptop ~> rabin2 -s .\mybof.bof[Symbols]nth paddr      vaddr      bind   type size lib name                                                    demangled-----------------------------------------------------------------------------------------------0   0x000000b4 0x00000000 LOCAL  SECT 4      .text0   0x000000b4 0x00000000 GLOBAL FUNC 4      go0   0x000000d4 0x00000020 GLOBAL FUNC 4      other_function0   0x0000010c 0x00000060 LOCAL  SECT 4      .xdata0   0x0000010c 0x00000060 LOCAL  UNK  4      $unwind$go0   0x00000114 0x00000068 LOCAL  UNK  4      $unwind$other_function0   0x0000011c 0x00000070 LOCAL  SECT 4      .pdata0   0x0000011c 0x00000070 LOCAL  UNK  4      $pdata$go0   0x00000128 0x0000007c LOCAL  UNK  4      $pdata$other_function0   0x00000134 0x00000090 LOCAL  SECT 4      .data0   -------------------- NONE   UNK  4      imp.__imp_BeaconPrintf0   -------------------- NONE   UNK  4      imp.__imp_KERNEL32$GetCurrentProcessId matth@laptop ~>

部分链接也是 GNU ld 支持的特性。boflink 的部分链接与 GNU ld 的差别在于:GNU ld 的部分链接主要用于更大构建流程中的增量链接(incremental linking)。

增量链接是一项用来加速程序重建的优化特性。其原理是把多个 object file 链接到一起,产出一个新的「预链接(pre-linked)」object file 供最终链接阶段使用。

linker 在增量链接时往「预链接」object file 里嵌入额外的元数据来加速最终链接,这是相当常见的做法。

下面是用 MinGW ld 对上述文件做部分链接的示例。

matt@laptop ~> x86_64-w64-mingw32-gcc -r -o combined.o go.c other.cmatt@laptop ~> lsbeacon.h  combined.o  go.c  Makefile  other.c  other.hmatt@laptop ~> rabin2 -s combined.o[Symbols]nth paddr      vaddr      bind   type size lib name                          demangled――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――00x000000000x00000000 LOCAL  FILE 4      .file00x0000012c 0x00000000 GLOBAL FUNC 4      go00x0000012c 0x00000000 LOCAL  SECT 4      .text00x000000000x00000090 LOCAL  SECT 4      .data00x000000000x000001d0 LOCAL  SECT 4      .bss00x000001bc 0x000000a0 LOCAL  SECT 4      .rdata00x000002c40x000001b0 LOCAL  SECT 4      .xdata00x000002ac 0x00000190 LOCAL  SECT 4      .pdata00x0000026c 0x00000150 LOCAL  UNK  4      .rdata$zzz00x000000000x00000000 LOCAL  FILE 4      .file00x0000015c 0x00000030 GLOBAL FUNC 4      other_function00x0000015c 0x00000030 LOCAL  SECT 4      .text00x000000000x00000090 LOCAL  SECT 4      .data00x000000000x000001d0 LOCAL  SECT 4      .bss00x000001ec 0x000000d0 LOCAL  SECT 4      .rdata00x000002d00x000001bc LOCAL  SECT 4      .xdata00x000002b80x0000019c LOCAL  SECT 4      .pdata00x0000022c 0x00000110 LOCAL  UNK  4      .rdata$zzz0   ---------- ---------- NONE   UNK  4      imp.__imp_GetCurrentProcessId0   ---------- ---------- NONE   UNK  4      imp._pei386_runtime_relocator0   ---------- ---------- NONE   UNK  4      imp.__imp_BeaconPrintfmatt@laptop ~>

你可以在上面看到,MinGW ld 没有对节符号去重。它还会嵌入未定义的 _pei386_runtime_relocator 符号,因为它知道自己稍后在最终链接过程中会需要它。这对进一步链接出完整程序有帮助,但在 BOF loader 里可能引发问题。

boflink 的部分链接流程包含多个专门为「给 loader 定制 COFF」而设计的 linker pass,包括:节符号去重、丢弃 linker 元数据节、合并分组节、对同节定义符号应用重定位、为 COMMON 符号分配空间、处理 COMDAT 节等。其结果是最终输出的 COFF 比 MinGW ld 产出的简单得多,BOF loader 加载起来也更容易。

comment pragma 链接库

使用 MSVC 工具链时,往编译出的 COFF 里嵌入供 linker 处理的额外元数据是常见做法。这些被称为 comment 记录(comment record),通过 #pragma comment 预处理指令添加。其中一些 comment 记录包含供 linker 处理的额外选项。

boflink 目前支持处理 lib 记录——在解析符号时纳入额外的搜索库。

#include<windows.h>#include<lmcons.h>#include"beacon.h"#pragma comment(lib, "kernel32")#pragma comment(lib, "advapi32")void go(void) {    DWORD pid = GetCurrentProcessId();    BeaconPrintf(CALLBACK_OUTPUT"Current process id is %lu", pid);char username[UNLEN + 1] = {0};if (GetUserNameA(username, &(DWORD){sizeof(username)}) != 0) {        BeaconPrintf(CALLBACK_OUTPUT"Your username is %s", username);    }}

处理这个文件时,boflink 会看到 "kernel32" 与 "advapi32" 两个链接库,并把它们纳入符号解析阶段。

matth@laptop ~> ls        Directory: C:\Users\matthMode                 LastWriteTime         Length Name----------------------------a---          5/27/2025   1:50 PM          15962 beacon.h-a---          5/27/2025   3:33 PM            457 example.c matth@laptop ~> cl /c/GS- .\example.cMicrosoft (R) C/C++ Optimizing Compiler Version 19.44.35207.1 for x64Copyright (C) Microsoft Corporation.  All rights reserved.example.c matth@laptop ~> ls        Directory: C:\Users\matthMode                 LastWriteTime         Length Name----------------------------a---          5/27/2025   1:50 PM          15962 beacon.h-a---          5/27/2025   3:33 PM            457 example.c-a---          5/27/2025   3:36 PM            1476 example.obj matth@laptop ~> boflink -o example.bof .\example.obj matth@laptop ~> rabin2 -s .\example.bof[Symbols]nth paddr      vaddr      bind   type size lib name                                                    demangled-----------------------------------------------------------------------------------------------0   0x000000b4 0x00000000 LOCAL  SECT 4      .text0   0x000000b4 0x00000000 GLOBAL FUNC 4      go0   0x00000128 0x00000080 LOCAL  SECT 4      .xdata0   0x00000128 0x00000080 LOCAL  UNK  4      $unwind$go0   0x00000134 0x00000090 LOCAL  SECT 4      .pdata0   0x00000134 0x00000090 LOCAL  UNK  4      $pdata$go0   0x00000140 0x000000a0 LOCAL  SECT 4      .data0   -------------------- NONE   UNK  4      imp.__imp_BeaconPrintf0   -------------------- NONE   UNK  4      imp.__imp_KERNEL32$GetCurrentProcessId0   -------------------- NONE   UNK  4      imp.__imp_ADVAPI32$GetUserNameA matth@laptop ~>

注意,这种向 boflink 传递链接库的方式,取决于编译源文件所用的编译器是否支持嵌入 comment 记录。

BSS 处理

许多 BOF loader 的一个常见局限是不支持加载 .bss 节。boflink 提供了 --merge-bss 标志,为不支持 .bss 的 BOF loader 补上兼容性。它会把 .bss 定义的符号零初始化,并放到 .data 节的末尾。它实际上做了许多 BOF 开发者在代码里手工做的事:要么给全局变量默认初始化,要么用属性让编译器把它们定义进 .data 节。它没有「魔法般地修复」BOF loader 的 .bss 问题,而是把零初始化的活儿揽给了 boflink。

我很好奇,那么多 BOF loader 不加 .bss 加载支持的理由是什么。从 BOF loader 的视角看,未初始化数据节与文件 backed 节的加载方式非常相似。唯一区别是未初始化数据节会在节头特征位里设置 IMAGE_SCN_CNT_UNINITIALIZED_DATA 标志,表示该节不是文件 backed 的。若存在该标志,loader 不用文件数据初始化节,而是零初始化它。所需分配大小像常规文件 backed 节一样存在 SizeOfRawData 字段里。给 BOF loader 添加 .bss 支持只需要:检查节头里的该标志,然后要么零初始化分配的节,要么用节头 PointerToRawData 文件偏移处的数据初始化。

TrustedSec 的 COFFLoader 会对尺寸非零且 PointerToRawData 字段为 NULL 的节做零初始化。这意味着它其实支持加载 .bss 节——.bss 节的 PointerToRawData 字段为 NULLSizeOfRawData 字段非零。

bsstest.c

#include<windows.h>#include"beacon.h"staticint bss_variable;staticfloat other_bss_variable;void go(void) {    bss_variable = 123;    BeaconPrintf(CALLBACK_OUTPUT"bss_variable: %d\n", bss_variable);    other_bss_variable = 3.14;    BeaconPrintf(CALLBACK_OUTPUT"other_bss_variable: %0.2f\n", other_bss_variable);}
matt@laptop ~/COFFLoader (main)> x86_64-w64-mingw32-gcc --o bsstest.o bsstest.cmatt@laptop ~/COFFLoader (main)>./COFFLoader64.exe go bsstest.oGot contents of COFF fileRunning/Parsing the COFF filebss_variable:123other_bss_variable:3.14Ran/parsed the coffOutdata Below:bss_variable:123other_bss_variable:3.14matt@laptop ~/COFFLoader (main)>

容易与 .bss 节混淆的是 .bss 定义符号与 COMMON 符号之间的差异。MaskRay 的博文《All about COMMON symbols》把这两者以及它们在 C 里引发诸多问题的原因讲得非常好。

GCC 10 与 Clang 11(约 2020 年发布)引入了一项变更:编译时默认加上 -fno-common 标志。这会让公开的未初始化全局变量被当作 .bss 定义符号而非 COMMON 符号处理。

下面这个例子展示 COMMON 符号长什么样、以及它与 .bss 定义符号的差异。

commonstest.c

#include<windows.h>#include"beacon.h"int common_symbol;staticint bss_symbol;void go(void) {    common_symbol = 123;    BeaconPrintf(CALLBACK_OUTPUT"common_symbol: %d\n", common_symbol);    bss_symbol = 123;    BeaconPrintf(CALLBACK_OUTPUT"bss_symbol: %d\n", bss_symbol);}

common_symbol 是一个公开的未初始化全局变量——正是它导致编译出的 object file 里出现 COMMON 符号。

用 MinGW GCC 时需要 -fcommon 编译标志才能让 common_symbol 被当作 COMMON 符号,因为 -fno-common 已是默认。bss_symbol 变量则是普通的 .bss 定义符号,因为它以 static 存储期声明。这些符号可以用 llvm-readobj 在 COFF 符号表里查看。

matt@laptop~>lsbeacon.hcommonstest.cmatt@laptop~>x86_64-w64-mingw32-gcc-fcommon-c-ocommonstest.ocommonstest.cmatt@laptop~>llvm-readobj-scommonstest.oSymbol {Name:bss_symbolValue:0Section:.bss(3)BaseType:Null(0x0)ComplexType:Null(0x0)StorageClass:Static(0x3)AuxSymbolCount:0  }Symbol {Name:common_symbolValue:4Section:IMAGE_SYM_UNDEFINED(0)BaseType:Null(0x0)ComplexType:Null(0x0)StorageClass:External(0x2)AuxSymbolCount:0  }

bss_symbol 显示为定义在 .bss 节地址 0 处。common_symbol 则相当不同。COFF 里的 COMMON 符号表示为:节号为 IMAGE_SYM_UNDEFINED 且 Value 非零。这与常规未定义符号非常相似,唯一区别是 COMMON 的 Value 字段非零。系统 linker 会按 MaskRay 上述博文所述解析 COMMON 符号,但它们最终要到链接之后才会定义进 .bss 节。

MSVC 的默认行为与 MinGW GCC 或 Clang 不同:它会把公开的未初始化全局变量编译成 COMMON 符号,等价于上例中传 -fcommon 标志。

在 MSVC 下避开 COMMON 符号的一种办法是给未初始化全局变量加 static。缺点是这样符号只在其所在翻译单元内可见,无法被外部引用。

据我所知,在 MSVC 下创建公开的 .bss 定义符号的唯一办法是用 bss_seg pragma 包一层。

#include<windows.h>#include"beacon.h"#pragma bss_seg(push, bss, ".bss")int bss_symbol;#pragma bss_seg(pop, bss)voidgo(void){    bss_symbol = 123;BeaconPrintf(CALLBACK_OUTPUT, "bss_symbol: %d\n", bss_symbol);}

我能理解,围绕 COMMON 符号的混淆正是 BOF loader 决定不支持带未初始化全局变量的 BOF 的原因。MinGW GCC 与 Clang 在最初的 BOF loader 发布之后才更改默认值,这会导致 COMMON 符号在测试中更频繁地出现。

网上深入解释 COMMON 符号的易得信息并不多,这让它们相当冷门。许多 C 开发课程根本不提它们,因为它们是编译器特有的「特性」,并不真正属于语言本身。在 C 里使用 COMMON 符号有许多坑,它们的存在主要是为了 FORTRAN 兼容性。

BOF loader 应该能相当容易地支持 .bss 定义符号。如上所述,TrustedSec 的 COFFLoader 就支持。考虑到复杂度,我个人不指望 BOF loader 处理 COMMON 符号,但在 BOF 源码里避开它们相当容易。

boflink 确实像常规 linker 一样支持处理 COMMON 符号,若它们出现,会把它们分配在 .bss 节的末尾。

自定义 Beacon API

Beacon Object File 可以利用所谓的「Beacon API」——BOF loader 暴露的一组 API 函数,供 BOF 与 loader 本身交互。

Cobalt Strike 的 Beacon API 是默认内置的,但开发者可以使用自定义的 Beacon API 取代默认的 Cobalt Strike 版本。

自定义 Beacon API 是一个导入库,通过 --custom-api 命令行标志传入。

下面是一个在 boflink 中使用自定义 Beacon API 的例子。

本例的自定义 API 包含四个函数:MyApiVersionMyApiPrintfMyApiAlloc 与 MyApiFree。可以用一个 Module-Definition 文件创建导出这些符号的导入库。

myapi.def

LIBRARY MyApiEXPORTS  MyApiVersion  MyApiPrintf  MyApiAlloc  MyApiFree

在 Linux 上使用 llvm-dlltool

llvm-dlltool -l libmyapi.a -d myapi.def

在 Windows 上使用 lib.exe

lib /machine:x64 /def:myapi.def /out:myapi.lib

使用 MinGW dlltool:

x86_64-w64-mingw32-dlltool -l libmyapi.a -d myapi.def

推荐使用 llvm-dlltool 而非 MinGW dlltool。原因是 llvm-dlltool 与 lib.exe 创建的导入库比 MinGW dlltool 的更优化。

下面是示例源代码。

myapi.h

#ifndef MYAPI_H#define MYAPI_H#ifdef __cplusplusextern"C" {#endif// __cplusplus#include<stdint.h>__declspec(dllimport) intMyApiVersion(void);__declspec(dllimport) voidMyApiPrintf(constchar *format, ...);__declspec(dllimport) void *MyApiAlloc(size_t size);__declspec(dllimport) voidMyApiFree(void *ptr);#ifdef __cplusplus};#endif// __cplusplus#endif// MYAPI_H

custom-api.c

#include"myapi.h"voidgo(void){int version = MyApiVersion();MyApiPrintf("MyApiVersion: %d", version);int *value = MyApiAlloc(sizeof(int));    *value = 123;MyApiPrintf("value: %d", *value);MyApiFree(value);}

用包含 API 符号的导入库即可编译并链接这个 BOF。

MinGW GCC

# llvm-dlltool is preferred over x86_64-w64-mingw32-dlltoolllvm-dlltool -l libmyapi.a -d myapi.def# x86_64-w64-mingw32-dlltool is supported but should be used only if llvm-dlltool is not availablex86_64-w64-mingw32-dlltool -l libmyapi.a -d myapi.def# Usagex86_64-w64-mingw32-gcc -B ~/.local/libexec/boflink -fno-lto -nostartfiles -Wl,--custom-api=libmyapi.a -o custom-api.bof custom-api.c

Clang

llvm-dlltool -l libmyapi.a -d myapi.defclang --ld-path=$(which boflink) --target=x86_64-windows-gnu -nostartfiles -Wl,--custom-api=libmyapi.a -o custom-api.bof custom-api.c

MSVC

lib /machine:x64 /def:myapi.def /out:myapi.libcl /GS- /c /Fo:custom-api.obj custom-api.cboflink --custom-api myapi.lib -o custom-api.bof custom-api.obj

--custom-api 命令行标志在 boflink 如何查找导入库上也提供了灵活性:既可以传导入库在磁盘上的路径,也可以传一个链接库名字让 boflink 在库搜索路径列表里查找。

如果自定义 API 导入库属于某个打包工具链、存放在 /toolchains/myapi/lib/libmyapi.a,那么用下面的命令行标志即可链接。

x86_64-w64-mingw32-gcc -B ~/.local/libexec/boflink -fno-lto -nostartfiles -Wl,--custom-api=myapi -o custom-api.bof custom-api.c -L /toolchains/myapi/libboflink -o custom-api.bof custom-api.o –custom-api myapi -L /toolchains/myapi/lib

boflink 会把 /toolchains/myapi/lib 搜索路径加入链接库搜索路径列表,并查找 libmyapi.a 库。

链接图可视化

在内部,boflink 创建一张有向图用于执行各种链接操作。开发过程中,用外部工具把这张图可视化变得很有用。

--dump-link-graph 标志会把链接图状态以 GraphViz DOT 格式写入指定文件。

x86_64-w64-mingw32-gcc -B ~/.local/libexec/boflink -fno-lto -nostartfiles -Wl,--dump-link-graph=graph.dot -o example.bof example.cclang --ld-path=$(which boflink) --target=x86_64-windows-gnu -nostartfiles -Wl,--dump-link-graph=graph.dot -o example.bof example.cboflink --dump-link-graph graph.dot -o example.bof example.c

这会创建一个名为 graph.dot 的文件——纯文本的 GraphViz dot 文件,内容是链接图。dot 命令行工具可以把该文件渲染成 SVG 或 PNG,用图片查看器或浏览器即可检视。

# Installing the Graphviz dot command line toolsudo apt install graphvizwinget install –id Graphviz.Graphviz# Converting to an SVGdot -Tsvg graph.dot -o graph.svg# Converting to a PNGdot -Tpng graph.dot -o graph.png

这个功能不给链接出的 BOF 增加任何东西,但作为调试工具可能有用。

MSVCRT 的坑

BOF 开发者在 BOF 里包含并使用来自 msvcrt.dll 的 C 标准库函数是相当普遍的。使用 msvcrt.dll 的问题在于:微软大约在 2010 年就基本放弃了对它的全部支持,因为它与 C89(对,1989 年发布的那个 C 标准)存在大量不兼容,而且不破坏 ABI 兼容性就无法补上 C89 支持。微软甚至不再随系统提供它的导入库,且已有一阵子了。

这意味着在 Windows 上不创建自定义导入库就无法链接 msvcrt.dll。就算你决定用 msvcrt.dll,其中的函数与 C89 不兼容,微软头文件里声明的 C 标准库函数对它可能并不成立。

在 MinGW 一侧,MinGW 对付 msvcrt.dll 问题的方案基本上是「完全不对付」。如果你尝试用 MinGW 链接 msvcrt.dll 的某些函数,MinGW 会改为静态链接它们自己的实现。这是为了让你不会在不知情的情况下踩坑——某个 C 标准库函数在 msvcrt.dll 里的实现与头文件声明完全不同。

boflink 对此的解决方案是改用 UCRT。UCRT 是 msvcrt.dll 的替代品,自 Windows 10 起默认安装。boflink 在 Windows(配合 MSVC)与 MinGW 上都支持链接 UCRT。

至于 BOF loader 对 UCRT 的支持,我不清楚每一个 BOF loader 的实现,情况可能各异。我确实知道 TrustedSec 的 COFFLoader 在我的部分测试中配合 UCRT 是可以工作的。

在 Windows 上链接 UCRT,把 -lucrt 作为额外的链接标志传入即可找到 C 标准库函数。

MinGW 另行提供了一套支持 UCRT 的工具链,包含独立的编译器 x86_64-w64-mingw32ucrt-gcc(取代 x86_64-w64-mingw32-gcc)。使用 x86_64-w64-mingw32ucrt-gcc 即链接 UCRT。

鉴于上述坑,强烈不建议链接 msvcrt.dll。如果用不了 UCRT,建议尝试 shlwapi.h 或 strsafe.h 里的替代函数;另一个选项是使用编译器自带的 intrinsic 函数(编译器内建函数)。

对于习惯了在 BOF 里用 msvcrt.dll 的人来说,这可能相当不便。鉴于它在许多开源 BOF 里非常普遍,我确实花了很多时间研究如何给它加某种后备支持。我没找到一个能以非侵入方式工作的好方案;况且微软一直在推动迁移到 UCRT,支持 UCRT 是更好的选择。

语言支持

boflink 的开发与测试都是针对 C 语言编写的 BOF。这是因为 C 是目前为止开发 BOF 最常用的语言。C 之外的语言(例如 C++)完全没有用 boflink 测试过。

话虽如此,某些 C++ 目前 可能 可以工作,但由于未经测试,C++ 支持的状况是未知的。boflink 实现了 COFF 规范的大部分,但 C++ 编译器可能存在一些 boflink 未妥善处理的边角情况。除此之外,C++ 的一些语言特性无法在 COFF 中正确表达——这些特性需要在启动期间或由 BOF loader 进行某种形式的运行时初始化。

结语(原文)

这个项目的目标是帮助精简 BOF 开发流程。BOF 开发的许多方面出了名地繁琐,会让它变得更难一些。即时(on the fly)能力开发正成为越来越吃重的任务。进一步精简开发流程所投入的时间与研究常被忽视,却能带来很多长期价值。

顺带一提,有人可能会问:「对于典型的 BOF 开发场景,这个工具是不是有点杀鸡用牛刀了?」是的,我完全同意,对当前 BOF 开发者面临的问题来说,这是个相当夸张的方案。这个项目同时也是我的一个个人研究项目,用来更多理解 linker 的工作原理。我选择拿 Beacon Object File 来做,是因为它能作为一个开发工具提供一些价值,而且给 BOF 写 linker 比给完整可执行文件写 linker 简单得多。


译注与延伸评注

一、博文与当前版本(v0.6.3)的差异。 博文写于 2025-05-30 的早期版本,一年后的 0.6.3 已有明显演进,读文时需要注意:

  • GCC 集成方式换了:博文里的「空目录建 ld 符号链接 + -B 指向」的 mold 式 workaround 仍在文档中,但官方推荐已变为 boflink --print-gcc-specs > boflink.specs 后 gcc -specs=boflink.specs 的 spec 文件方式(README 现以此为主);--mingw64/--mingw32/--ucrt64/--ucrt32 四个查询 GCC 搜索路径的选项已标记 deprecated。
  • MSRV 从 1.85 升到 1.91(edition 2024 + let-chains),从源码构建的门槛抬高。
  • 能力补齐:0.5.0 补 weak external;0.6.0 补 CS 4.12 的 BeaconDownload 与 man page;0.6.3 重写 CLI 前端并新增 --error-limit--color-diagnostics。博文没提的 --gc-sections--require-defined/--keep-symbol--sysroot-m i386pep/i386pe、响应文件支持等在当前版本都已可用。
  • 正确性修复:0.6.1 修了 COMDAT leader 归属错误(会把组内后续定义都当 leader)与「同节 VA 型重定位被误当相对重定位应用」两个实打实的 bug——对评估者而言,这类修复恰恰说明 COMDAT/重定位路径的复杂度,源码审计部分指出的 ExactMatch 校验和 TODO 仍是遗留面。

二、定位:这是工具链,不是武器。 boflink 不改变 BOF 的执行语义、不混淆、不加壳,全部价值在「开发体验」与「loader 兼容性」两条线上。对本仓库此前译介的 BOF 生态(DLL 侧加载、DEF CON 34 工坊中的 BOF 使用)而言,它填补的是「多文件 BOF 工程化」这一环——bof-vs 之类的模板解决的是项目脚手架,boflink 解决的是链接产物本身。若你在维护自研 C2 的 BOF loader,文中「面向 loader 的 pass 列表」(COMDAT、COMMON、分组节、重定位标签、元数据节)本身就是一份 loader 兼容性检查清单。

三、防御视角。 BOF 是红队侧的内存执行形态,boflink 让其开发更工程化并不会直接改变检测面;但有一个二阶效应值得留意:链接产物的符号表保留完整符号名(见第一部分 OPSEC 提醒),DFR 格式的 __imp_KERNEL32$XXX 导入符号本身就是 BOF 的稳定结构特征——沙箱与内存扫描器可以用「COFF + __imp_<DLL大写>$<函数> 命名模式 + go 入口」做低成本识别,这与开发者是否用 boflink 无关,却是评估「BOF 落地物可观测性」的现成抓手。

四、学习价值。 把这个仓库当作「链接器原理教材」读是成立的:链接图四节点五边的抽象、arena + 侵入式链表的内存策略、两遍写 COFF(reserve → write)与原地 fixup 重定位,都是生产级 linker 的核心手艺的等比例缩小。作者自述「给 BOF 写 linker 比给完整可执行文件写简单得多」——反过来说,读完这 1.1 万行,LLD/lld 与 mold 的入门门槛也矮了一截。--dump-link-graph 出的 DOT 图配合 dot 渲染,是理解符号解析与 GC 可达性的直观教具。


参考资源

boflink 仓库:https://github.com/MEhrn00/boflink

原文(Cybershenanigans Blog):https://blog.cybershenanigans.space/posts/boflink-a-linker-for-beacon-object-files/

TrustedSec COFFLoader 博文:https://trustedsec.com/blog/coffloader-standing-on-the-shoulders-of-giants

TrustedSec COFFLoader 仓库:https://github.com/trustedsec/COFFLoader

Cobalt Strike《Beacon Object Files》(BOF 概念原始博文):https://www.cobaltstrike.com/blog/beacon-object-files/

MaskRay《All about COMMON symbols》:https://maskray.me/blog/2021-10-31-all-about-common-symbols