Difficulty: Beginner

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

Fork & Run
New process, injection, pipe
execute-assembly
New process, CLR, AMSI
BOF
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:

ComponentPurpose
COFF File HeaderMachine type (x64/x86), number of sections, pointer to symbol table
Section TableArray of section headers (.text, .data, .rdata, .bss) with sizes, offsets, characteristics
Section DataRaw bytes for each section (compiled code, initialized data, read-only data)
Relocation TablePer-section list of addresses that need fixups (because absolute addresses are unknown until load time)
Symbol TableNames and metadata for all defined and external symbols (functions, variables, imports)
String TableStorage 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

PropertyFork-and-RunBOF / COFFLoader
Process creationNew process per commandNone -- runs in current process
Cross-process APIsVirtualAllocEx, WriteProcessMemoryNone -- local memory only
Token/handle inheritanceMust duplicate or impersonateInherits caller's token and handles
Memory footprintFull PE or DLL loadedSmall .o file, typically 2-20 KB
ETW visibilityProcess creation, module loads, thread creationOnly VirtualAlloc for section memory
CleanupMust terminate sacrificial processVirtualFree 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?

BOFs run inside the existing Beacon/loader process in the same thread, eliminating process creation events, cross-process memory operations, and the associated ETW telemetry that EDRs use to detect fork-and-run execution.

Q2: What file format is a compiled BOF?

A BOF is compiled with the -c flag (compile only, no linking), producing a COFF .obj/.o file. It contains section data, symbols, and relocations but is not a complete executable -- the loader must perform linking at runtime.

Q3: Why is the /GS- flag important when compiling BOFs with MSVC?

The /GS- flag disables the __security_check_cookie stack protection. This mechanism requires __security_cookie to be initialized by the CRT. Since BOFs run without CRT initialization, leaving /GS enabled would cause a crash when the function epilogue tries to validate the cookie.

References

  1. Raphael Mudge, "Cobalt Strike 4.1 -- The Mark of Injection," Cobalt Strike Blog, June 25, 2020. cobaltstrike.com
  2. Microsoft, "PsSetCreateProcessNotifyRoutine function," Windows Driver Documentation. learn.microsoft.com
  3. Microsoft, "About Event Tracing," Windows Desktop Development Documentation. learn.microsoft.com
  4. Microsoft, "Antimalware Scan Interface (AMSI)," Windows Desktop Development Documentation. learn.microsoft.com
  5. TrustedSec, "COFFLoader -- Beacon Object File Loader," GitHub Repository. github.com/trustedsec/COFFLoader
  6. Microsoft, "PE Format," Windows Desktop Development Documentation. learn.microsoft.com
  7. Fortra, "Beacon Object Files," Cobalt Strike User Guide. hstechdocs.helpsystems.com
  8. Microsoft, "/GS (Buffer Security Check)," MSVC Compiler Options Documentation. learn.microsoft.com
  9. Tool Interface Standards Committee, "Executable and Linkable Format (ELF) Specification," Version 1.2, May 1995. refspecs.linuxfoundation.org
  10. Raphael Mudge, "Beacon Object Files," Cobalt Strike Blog, 2020. cobaltstrike.com

Further Reading