Difficulty: Beginner

Module 2: Beacon Object Files (BOFs)

Tiny compiled objects that run inside Beacon itself — no new process, no DLL, no disk artifacts.

Module Objective

Draugr is delivered as a Beacon Object File. Understanding what BOFs are, how Cobalt Strike loads and executes them, and why they're preferred over alternatives is essential context for the rest of this course. This module covers the COFF format, the BOF lifecycle, the BOF API, and why Draugr's design as a BOF creates specific constraints and advantages.

1. What Is a BOF?

A Beacon Object File (BOF) is a compiled C/C++ object file in COFF (Common Object File Format) that runs directly inside the Cobalt Strike Beacon process. The key distinction: it is an object file (.o), not a linked executable or DLL.

When you compile C code normally, the compiler produces an object file, then the linker resolves external symbols (function addresses, global variables) and produces a final PE executable. With BOFs, you skip the linker entirely. You compile to .o and send the raw object file to Beacon over the C2 channel. Beacon itself acts as the linker, resolving symbols at runtime.

Compilation// Normal compilation: compiler + linker
cl.exe /c bof.c          // Produces bof.obj (COFF object file)
link.exe bof.obj ...     // Linker resolves symbols, produces bof.exe

// BOF compilation: compiler only, no linking
x86_64-w64-mingw32-gcc -c bof.c -o bof.o
// That's it. Send bof.o to Beacon. Beacon IS the linker.

COFF Object File Structure

ComponentPurpose
COFF HeaderMachine type, number of sections, timestamp
Section TableEntries for .text (code), .data, .rdata, .reloc
.text SectionCompiled machine code of your BOF functions
.data / .rdataInitialized data, string literals, constants
Symbol TableFunction names, external references (imports)
Relocation TableTells Beacon where to patch addresses after loading

Because it's a raw object file, a BOF is extremely small. A typical BOF that performs process injection might be 3-5 KB. Compare that to a reflective DLL (50-200 KB) or a .NET assembly (100 KB+). Less data over the C2 wire means less opportunity for network detection.

2. Why BOFs Matter

BOFs were introduced in Cobalt Strike 4.1 as an alternative to existing post-exploitation mechanisms. Here's how they compare:

Beacon Object File (BOF)
  • Runs inside Beacon's own process and thread
  • Typically 3-10 KB compiled size
  • No child process creation
  • No DLL injection or reflective loading
  • No disk artifacts whatsoever
  • Memory freed immediately after execution
  • Single-threaded: blocks Beacon during execution
Reflective DLL / execute-assembly
  • Often spawns a sacrificial process (fork&run)
  • 50-200+ KB typical size
  • Process creation events trigger EDR callbacks
  • execute-assembly loads the CLR into memory
  • May leave .NET metadata artifacts
  • Memory persists until sacrificial process exits
  • Can run asynchronously in separate process

IOC (Indicator of Compromise) Comparison

IOC TypeBOFReflective DLLexecute-assembly
Child processNoneOptional (fork&run)Yes (sacrificial)
Cross-process injectionNoneYesYes
CLR/.NET loadNoneNoneYes
Network footprint~3 KB~100 KB~200+ KB
Disk artifactsNoneNonePossible (.NET files)
Memory lifetimeFreed after runUntil process exitUntil process exit

3. BOF Lifecycle

The journey from C source code to in-memory execution follows a specific path:

BOF Execution Lifecycle

Operator writes
BOF in C/C++
Compile with MinGW
gcc -c bof.c -o bof.o
Send .o over
C2 channel
Beacon parses
COFF sections
Allocate memory
for .text + .data
Resolve imports
(symbol table)
Apply relocations
(patch addresses)
Call go() entry
(BOF executes)

Step by step:

  1. Parse COFF header — Beacon reads the section table to determine how much memory to allocate for code (.text) and data (.data, .rdata).
  2. Allocate memory — Beacon allocates RWX memory inside its own process for the BOF's sections. The code section needs to be executable.
  3. Copy sections — Raw bytes from each COFF section are copied into the allocated memory.
  4. Resolve symbols — External symbols (like KERNEL32$VirtualAlloc) are resolved using GetProcAddress. The BOF naming convention MODULE$Function tells Beacon which DLL to search.
  5. Apply relocations — Beacon processes the relocation table to patch absolute addresses, function pointers, and data references.
  6. Execute — Beacon calls the go() entry point. The BOF runs synchronously on Beacon's thread.
  7. Cleanup — After go() returns, Beacon collects any output and frees the allocated memory.

4. BOF API

Cobalt Strike provides a small API for BOFs to interact with Beacon. These functions are available without any linking — Beacon resolves them during the symbol resolution phase:

C (BOF API)// Output functions - send data back to the operator
void   BeaconPrintf(int type, char *fmt, ...);   // printf-style output
void   BeaconOutput(int type, char *data, int len); // raw data output

// Argument parsing - process args sent with the BOF command
void   BeaconDataParse(datap *parser, char *buffer, int size);
char*  BeaconDataExtract(datap *parser, int *size);
int    BeaconDataInt(datap *parser);
short  BeaconDataShort(datap *parser);

// Format buffer - build structured output
void   BeaconFormatAlloc(formatp *format, int maxsz);
void   BeaconFormatPrintf(formatp *format, char *fmt, ...);
void   BeaconFormatFree(formatp *format);
char*  BeaconFormatToString(formatp *format, int *size);

Dynamic Function Resolution

BOFs resolve Win32 API functions using a special naming convention. Instead of linking against import libraries, you declare functions with the MODULE$Function syntax:

C (BOF Imports)// Declaration syntax: DECLSPEC_IMPORT tells the compiler this is external
// The symbol name MODULE$Function tells Beacon where to find it

DECLSPEC_IMPORT HANDLE WINAPI KERNEL32$GetProcessHeap();
DECLSPEC_IMPORT LPVOID WINAPI KERNEL32$HeapAlloc(HANDLE, DWORD, SIZE_T);
DECLSPEC_IMPORT NTSTATUS NTAPI NTDLL$NtAllocateVirtualMemory(
    HANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG
);
DECLSPEC_IMPORT HMODULE WINAPI KERNEL32$GetModuleHandleA(LPCSTR);

// Usage in your BOF code:
void go(char *args, int len) {
    HANDLE heap = KERNEL32$GetProcessHeap();
    LPVOID mem = KERNEL32$HeapAlloc(heap, 0, 4096);
    BeaconPrintf(CALLBACK_OUTPUT, "Allocated at: %p", mem);
}

Symbol Resolution Rules

Symbol FormatResolution MethodExample
MODULE$FunctionGetProcAddress(LoadLibrary("MODULE"), "Function")KERNEL32$VirtualAlloc
__imp_BeaconPrintfBeacon internal function tableBOF API functions
__imp_MODULE$FunctionSame as MODULE$Function (alternate form)__imp_NTDLL$NtClose

5. Single-Threaded Execution

A critical BOF constraint: BOFs execute synchronously on Beacon's main thread. While your BOF runs, Beacon cannot process new commands, check in with the team server, or do anything else. It is completely blocked.

BOF Execution Constraints

This constraint is exactly why Draugr's approach is superior to timer-based stack spoofing. Timer-based approaches (like ThreadStackSpoofer) use a secondary thread with a timer callback to overwrite the stack during sleep. This is inherently slow and introduces timing windows. Draugr's synthetic frames are constructed and torn down in microseconds — just some stack pointer manipulation and memory writes — making it perfectly suited for the single-threaded BOF execution model.

6. Why Draugr Is a BOF

Draugr as a BOF: Design Advantages

Draugr wraps sensitive syscalls (NtAllocateVirtualMemory, NtOpenProcess, NtWriteVirtualMemory, etc.) with synthetic stack frames. By running as a BOF inside Beacon:

The DRAUGR_SYSCALL and DRAUGR_API macros provide a clean interface that any BOF can use. Your BOF code calls the macro exactly like it would call the real API, and Draugr handles all the stack frame construction, syscall execution, and cleanup transparently.

7. The DRAUGR Macro Interface

Draugr provides two primary macros that operators use to make spoofed calls:

C (Draugr API)// For NT syscalls (NtAllocateVirtualMemory, NtOpenProcess, etc.)
DRAUGR_SYSCALL(functionName, arg1, arg2, ...)

// For Win32 API calls (VirtualProtect, WriteFile, etc.)
DRAUGR_API(functionName, arg1, arg2, ...)

Variadic Macro Dispatch

Under the hood, DRAUGR_SYSCALL and DRAUGR_API use variadic macro magic to dispatch to specialized versions based on argument count. This is necessary because the x64 calling convention places the first 4 arguments in registers (RCX, RDX, R8, R9) and additional arguments on the stack — the assembly stub needs to know exactly how many stack arguments to copy:

C (Macro Internals)// The variadic dispatch selects the right macro based on arg count
#define DRAUGR_SYSCALL(fn, ...) \
    DRAUGR_SYSCALL_N(__VA_ARGS__, _9, _8, _7, _6, _5, _4, _3, _2, _1, _0)(fn, __VA_ARGS__)

// Each numbered variant knows exactly how many stack args to set up
// Args 1-4:  RCX, RDX, R8, R9 (register args, no stack copy needed)
// Args 5+:   Pushed onto the shadow stack space by the spoof routine

// Example usage in a BOF:
NTSTATUS status = DRAUGR_SYSCALL(
    NtAllocateVirtualMemory,     // Function to call
    processHandle,                // Arg 1 (RCX) -> goes to R10 for syscall
    &baseAddress,                 // Arg 2 (RDX)
    0,                            // Arg 3 (R8)
    &regionSize,                  // Arg 4 (R9)
    MEM_COMMIT | MEM_RESERVE,     // Arg 5 (stack)
    PAGE_READWRITE                // Arg 6 (stack)
);

What Happens When You Call DRAUGR_SYSCALL

Macro resolves
function SSN
Build synthetic
stack frames
Copy args to
correct positions
JMP to ntdll
syscall;ret
Return NTSTATUS
to caller

From the operator's perspective, it's a drop-in replacement. Replace NtAllocateVirtualMemory(...) with DRAUGR_SYSCALL(NtAllocateVirtualMemory, ...) and the call stack is automatically spoofed. No manual frame construction, no assembly knowledge required.

Module 2 Quiz: Beacon Object Files

Q1: Why are BOFs typically only 3-10 KB while reflective DLLs are 50-200 KB?

BOFs are unlinked COFF object files. They contain only the raw compiled code and a symbol table. The linker step (which normally pulls in import libraries, CRT code, and produces PE headers) is skipped entirely. Beacon acts as the linker at runtime, resolving symbols via GetProcAddress. This eliminates all the overhead of a fully linked PE.

Q2: Why is Draugr's BOF-based approach better suited than timer-based stack spoofing for single-threaded Beacon execution?

BOFs run synchronously on Beacon's thread and must complete quickly. Timer-based stack spoofing uses a secondary thread with timer callbacks to overwrite the stack during sleep — this is slow and has timing windows. Draugr constructs synthetic frames via direct stack manipulation (just writing return addresses and adjusting RSP), which takes microseconds and is perfectly suited for the single-threaded, fast-execution BOF model.