Module 1: What Are Beacon Object Files?
In-process execution of compiled C, without a PE, without a new process, without fork-and-run.
Why This Module?
Beacon Object Files (BOFs) are the foundation of modern post-exploitation tooling. Before you can understand how COFFLoader (by TrustedSec) parses and executes them, you need to understand what they are, why they exist, and what problem they solve compared to older execute-assembly and fork-and-run techniques. This module covers the concept, the motivation, and the architecture of BOF-based execution.
Lab Files: step01-bof-basics
A minimal BOF and its beacon.h header. Compile with -c to produce a COFF object file -- this is all a BOF is.
The Problem: Post-Exploitation Tooling
After an implant (Beacon, Sliver, Havoc, etc.) gains execution on a target, the operator needs to run additional tools: enumerate users, dump credentials, query Active Directory, manipulate tokens. Historically, there were two approaches to running these tools, and both had serious OPSEC problems.
Approach 1: Fork-and-Run
The implant spawns a new sacrificial process (e.g., rundll32.exe), injects shellcode or a reflective DLL into it, executes the tool, captures output, and then kills the process. This was the default model in Cobalt Strike for years.
TEXTFork-and-Run Execution Flow:
Beacon Process (PID 1234)
|
+-- CreateProcess("rundll32.exe", SUSPENDED) --> New Process (PID 5678)
+-- VirtualAllocEx(PID 5678, RWX)
+-- WriteProcessMemory(PID 5678, payload)
+-- ResumeThread(PID 5678)
+-- ReadPipe(output) <-- Tool runs in PID 5678
+-- TerminateProcess(PID 5678) <-- Sacrificial process dies
Fork-and-Run OPSEC Failures
Every fork-and-run execution creates a new process, triggers kernel callbacks (PsSetCreateProcessNotifyRoutine)[2], generates cross-process memory allocation and write events, and leaves a terminated process in ETW logs[3]. EDRs correlate these events trivially: a process that spawns rundll32, writes RWX memory into it, and pipes output back is textbook injection behavior. Each command execution is a fresh detection opportunity.
Approach 2: Execute-Assembly
Cobalt Strike's execute-assembly loads the .NET CLR into a sacrificial process and runs a .NET assembly. While more flexible, it still spawns a new process, loads the CLR (observable via ETW's CLR loading events and clr.dll module loads), and the .NET assembly lands in memory where AMSI can scan it[4].
The BOF Solution: In-Process Execution
Beacon Object Files, introduced in Cobalt Strike 4.1 (June 2020)[1], take a fundamentally different approach. Instead of spawning a new process, a BOF runs inside the Beacon process itself, in the same thread context. No new process. No cross-process injection. No CLR. No DLL on disk.
BOF vs Fork-and-Run
New process, injection, pipe
New process, CLR, AMSI
Same process, same thread
A BOF is a compiled C object file in COFF format -- the intermediate output of the compiler before linking. It is not a PE (no PE headers, no import table, no entry point in the traditional sense). The Beacon (or COFFLoader) acts as a miniature linker: it parses the COFF headers, loads sections into memory, resolves symbols, applies relocations, and calls the entry function.
C// A minimal BOF -- this is the ENTIRE source file
#include <windows.h>
#include "beacon.h"
void go(char* args, int len) {
BeaconPrintf(CALLBACK_OUTPUT, "Hello from BOF! PID: %d\n", GetCurrentProcessId());
}
The function go is the conventional entry point for a BOF[7] (though COFFLoader allows specifying any function name). The BOF includes beacon.h which declares the Beacon API functions. When compiled, the BOF produces a .o (object) file -- raw COFF, no linking step.
What is a COFF Object File?
COFF (Common Object File Format) is the object file format used by Microsoft's toolchain (MSVC) and MinGW. When you compile a C source file with cl.exe /c or x86_64-w64-mingw32-gcc -c, the compiler produces a .obj or .o file in COFF format. This file contains:
| Component | Purpose |
|---|---|
| COFF File Header | Machine type (x64/x86), number of sections, pointer to symbol table |
| Section Table | Array of section headers (.text, .data, .rdata, .bss) with sizes, offsets, characteristics |
| Section Data | Raw bytes for each section (compiled code, initialized data, read-only data) |
| Relocation Table | Per-section list of addresses that need fixups (because absolute addresses are unknown until load time) |
| Symbol Table | Names and metadata for all defined and external symbols (functions, variables, imports) |
| String Table | Storage for symbol names longer than 8 characters |
Critically, a COFF object file is not directly executable. It contains unresolved external references (e.g., calls to BeaconPrintf, GetCurrentProcessId) and relocations that assume a base address of zero. A linker (or a COFF loader) must resolve these references and apply relocations before the code can run.
COFF vs. PE vs. ELF
Understanding COFF requires distinguishing it from the finished executable formats it feeds into. On Windows, the linker consumes one or more COFF object files and produces a PE (Portable Executable)[6]. The PE format wraps COFF sections with substantial additional structure: a DOS stub and PE signature, an optional header specifying image base address and entry point, an import directory listing DLLs and functions to resolve at load time, export and resource directories, and base relocation tables that enable ASLR. A COFF object file has none of this infrastructure -- it is a fragment of a program, not a runnable image. This distinction is precisely what makes BOFs attractive: because a COFF object lacks PE headers and an import table, it does not trigger the same detection heuristics that EDRs apply to reflectively loaded PEs and DLLs.
On Linux, the equivalent object format is ELF (Executable and Linkable Format)[9]. ELF relocatable objects (.o files) serve the same conceptual purpose as COFF objects: they hold compiled machine code alongside unresolved symbols and relocation entries. However, the internal structure differs significantly -- ELF uses a different header layout, different relocation type encodings (e.g., R_X86_64_PC32 vs. COFF's IMAGE_REL_AMD64_REL32), and program headers that have no COFF counterpart. BOFs target the Windows COFF format specifically because the Windows API functions they invoke (from kernel32.dll, ntdll.dll, advapi32.dll) exist only on Windows, and the COFF format is what the MSVC and MinGW toolchains produce. Projects like bof-launcher have explored running BOFs on Linux by implementing a COFF loader on that platform, but the BOFs themselves remain Windows COFF objects cross-compiled with MinGW.
Why COFFLoader Exists
Cobalt Strike's Beacon has a built-in COFF loader that can execute BOFs. But what if you are not using Cobalt Strike? What if you are developing a custom C2, or you want to test BOFs from the command line, or you want to integrate BOF execution into another framework?
COFFLoader by TrustedSec is a standalone, open-source COFF loader written in C[5]. It implements the same parsing, loading, linking, and execution pipeline that Cobalt Strike's Beacon performs internally, but as a standalone program. It provides a Beacon API compatibility layer so that BOFs written for Cobalt Strike work without modification.
TEXTCOFFLoader Usage:
COFFLoader.exe go path/to/bof.o [optional hex-encoded arguments]
- "go" = name of the entry function to call
- "bof.o" = the compiled COFF object file
- hex arguments = optional BeaconDataParse-compatible argument buffer
In-Process Execution: Why It Matters
The key advantage of BOFs (and by extension, COFFLoader) is that execution happens entirely within the calling process. This has profound implications for both capability and stealth.
Advantages of In-Process BOF Execution
| Property | Fork-and-Run | BOF / COFFLoader |
|---|---|---|
| Process creation | New process per command | None -- runs in current process |
| Cross-process APIs | VirtualAllocEx, WriteProcessMemory | None -- local memory only |
| Token/handle inheritance | Must duplicate or impersonate | Inherits caller's token and handles |
| Memory footprint | Full PE or DLL loaded | Small .o file, typically 2-20 KB |
| ETW visibility | Process creation, module loads, thread creation | Only VirtualAlloc for section memory |
| Cleanup | Must terminate sacrificial process | VirtualFree the loaded sections |
Because a BOF runs in the same process and thread, it automatically inherits the current access token, any impersonated tokens, open handles, and the process environment. A BOF that queries Active Directory can use the Beacon's existing Kerberos ticket. A BOF that accesses a file share uses the Beacon's current impersonation context. No token duplication or pass-through is needed.
The Tradeoff: Stability Risk
In-process execution is a double-edged sword. If a BOF crashes (null pointer dereference, buffer overflow, unhandled exception), it crashes the entire Beacon process. There is no sacrificial process to absorb the fault. This is why BOFs must be carefully written and tested -- a bug does not just lose output, it loses the implant.
BOF Stability Rules
BOFs must not call ExitProcess or exit(). They must not use C runtime functions that rely on CRT initialization (the CRT is not initialized for the BOF). They must not leak memory (no garbage collector, no cleanup after go() returns unless explicitly coded). They must handle errors gracefully because an unhandled exception means the Beacon dies.
Limitations and Challenges of BOFs
While BOFs offer significant OPSEC advantages, they come with substantial constraints that developers must understand before writing production tooling. These limitations stem directly from the nature of in-process, no-CRT execution[7].
No C Runtime Library
BOFs execute without CRT initialization. Standard C library functions like printf, malloc, free, strlen, and memcpy are not available unless the BOF manually resolves them from msvcrt.dll at runtime, or the loader provides equivalents. Cobalt Strike and COFFLoader offer limited replacements through the Beacon API (e.g., BeaconPrintf for output), but general-purpose CRT usage is off limits. This means no stdio.h file I/O, no stdlib.h memory management, and no string.h helpers unless the developer explicitly resolves those function pointers from a loaded system DLL.
No Standard Output Streams
BOFs have no stdout or stderr. Since the CRT is not initialized, the standard I/O streams do not exist. All output must go through the Beacon API functions (BeaconPrintf, BeaconOutput) which buffer data and send it back to the operator through the C2 channel. Attempting to call printf or fprintf directly will result in either an unresolved symbol error during loading or a crash if the symbol happens to resolve to an uninitialized CRT function.
Size Constraints
BOFs are designed to be small, self-contained tools. Typical BOFs range from 2 to 20 KB in compiled form. Cobalt Strike imposes practical size limits on BOFs transmitted over its C2 channel, and very large object files with many sections and relocations consume more memory and processing time during loading. The in-process execution model means all BOF memory comes from the host process's address space, so an oversized BOF can increase the memory footprint noticeably.
Single-Threaded Execution
BOFs execute synchronously in the calling thread. While a BOF is running, the Beacon (or COFFLoader) is blocked and cannot process other tasks or check in with the C2 server. Long-running BOFs cause the implant to appear unresponsive to the operator. This makes BOFs unsuitable for tasks that require extended execution times, such as keylogging, continuous network monitoring, or port scanning large subnets.
Memory Leaks Crash the Process
In a normal application, memory leaks are problematic but survivable -- the OS reclaims all memory when the process exits. For a BOF running inside a long-lived implant, memory leaks accumulate in the host process over the entire session. There is no garbage collection and no automatic cleanup after go() returns beyond what the loader explicitly frees (the loaded COFF sections themselves). If a BOF allocates heap memory via HeapAlloc or VirtualAlloc and does not free it, that memory is permanently leaked for the lifetime of the host process.
Edge Cases: Dangerous API Calls
Certain Windows API calls are fatal when invoked from a BOF. Calling ExitProcess terminates the entire Beacon process, not just the BOF -- the host implant is irrecoverably lost[10]. Similarly, calling exit() or abort() (if resolved from the CRT) kills the host. C++ exceptions (throw, try/catch) are not supported because the exception handling infrastructure -- SEH registration, C++ unwind tables, and the __CxxFrameHandler runtime -- is not set up for dynamically loaded COFF code. An unhandled structured exception (such as an access violation or divide-by-zero) triggers the process-wide unhandled exception filter, which typically results in process termination. BOF developers must use defensive coding practices: validate all pointers, check all API return values, and never assume a call will succeed.
COFFLoader Architecture Overview
At a high level, COFFLoader performs these steps to execute a BOF. Each step will be covered in detail in subsequent modules:
TEXTCOFFLoader Execution Pipeline:
1. Read COFF file into memory buffer
2. Parse COFF file header (validate machine type, get section/symbol counts)
3. Locate section table, symbol table, string table
4. Allocate RWX memory for each section (VirtualAlloc)
5. Copy section raw data into allocated memory
6. Build function pointer table for Beacon API (InternalFunctions[30])
7. For each section, process relocations:
a. Look up the target symbol
b. Resolve symbol to address (internal section, Beacon API, or DLL import)
c. Apply the relocation fixup based on type (ADDR64, REL32, ADDR32NB, etc.)
8. Find the entry function symbol (e.g., "go" or "_go")
9. Cast the entry address to a function pointer and call it
10. Capture output from BeaconPrintf/BeaconOutput
11. Free allocated memory (VirtualFree)
BOF Compilation
A BOF is compiled but not linked. The -c flag tells the compiler to produce an object file and stop before the linking stage:
BASH# MinGW (cross-compile from Linux for Windows x64)
x86_64-w64-mingw32-gcc -c bof.c -o bof.o
# MSVC (on Windows)
cl.exe /c /GS- bof.c /Fo bof.obj
# Key flags:
# -c = compile only, do not link
# /GS- = disable stack cookies (no CRT to handle them)
# -o / /Fo = output object file name
The /GS- flag is important for MSVC: it disables stack buffer security checks (__security_check_cookie) which require the CRT to be initialized[8]. Since a BOF runs without CRT initialization, stack cookies would cause a crash.
Pop Quiz: BOF Fundamentals
Q1: What is the primary OPSEC advantage of BOFs over fork-and-run execution?
Q2: What file format is a compiled BOF?
Q3: Why is the /GS- flag important when compiling BOFs with MSVC?
References
- Raphael Mudge, "Cobalt Strike 4.1 -- The Mark of Injection," Cobalt Strike Blog, June 25, 2020. cobaltstrike.com
- Microsoft, "PsSetCreateProcessNotifyRoutine function," Windows Driver Documentation. learn.microsoft.com
- Microsoft, "About Event Tracing," Windows Desktop Development Documentation. learn.microsoft.com
- Microsoft, "Antimalware Scan Interface (AMSI)," Windows Desktop Development Documentation. learn.microsoft.com
- TrustedSec, "COFFLoader -- Beacon Object File Loader," GitHub Repository. github.com/trustedsec/COFFLoader
- Microsoft, "PE Format," Windows Desktop Development Documentation. learn.microsoft.com
- Fortra, "Beacon Object Files," Cobalt Strike User Guide. hstechdocs.helpsystems.com
- Microsoft, "/GS (Buffer Security Check)," MSVC Compiler Options Documentation. learn.microsoft.com
- Tool Interface Standards Committee, "Executable and Linkable Format (ELF) Specification," Version 1.2, May 1995. refspecs.linuxfoundation.org
- Raphael Mudge, "Beacon Object Files," Cobalt Strike Blog, 2020. cobaltstrike.com
Further Reading
- TrustedSec COFFLoader — The open-source COFF loader this course is built around; read the source to see the full parsing and execution pipeline.
- Microsoft PE/COFF Specification — The authoritative reference for COFF file headers, section tables, symbol tables, relocations, and string tables.
- CS-Situational-Awareness-BOF — A collection of production-quality BOFs by TrustedSec for host and domain enumeration; excellent examples of real-world BOF development patterns.
- bof-launcher — A cross-platform BOF loader written in Zig that can execute COFF BOFs on both Windows and Linux, demonstrating that COFF loading is not limited to Cobalt Strike.
- Cobalt Strike BOF Documentation — Official documentation covering the BOF API contract, argument parsing, output functions, and development guidelines.
- Airbus CERT Invoke-Bof — A PowerShell-based tool for loading and executing Beacon Object Files, useful for testing BOF payloads and validating detection capabilities outside of Cobalt Strike.
- A Developer's Introduction to Beacon Object Files — TrustedSec blog post walking through BOF development from scratch, including compilation, debugging, and common pitfalls.