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
| Component | Purpose |
|---|---|
| COFF Header | Machine type, number of sections, timestamp |
| Section Table | Entries for .text (code), .data, .rdata, .reloc |
| .text Section | Compiled machine code of your BOF functions |
| .data / .rdata | Initialized data, string literals, constants |
| Symbol Table | Function names, external references (imports) |
| Relocation Table | Tells 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 Type | BOF | Reflective DLL | execute-assembly |
|---|---|---|---|
| Child process | None | Optional (fork&run) | Yes (sacrificial) |
| Cross-process injection | None | Yes | Yes |
| CLR/.NET load | None | None | Yes |
| Network footprint | ~3 KB | ~100 KB | ~200+ KB |
| Disk artifacts | None | None | Possible (.NET files) |
| Memory lifetime | Freed after run | Until process exit | Until process exit |
3. BOF Lifecycle
The journey from C source code to in-memory execution follows a specific path:
BOF Execution Lifecycle
BOF in C/C++
gcc -c bof.c -o bof.o
C2 channel
COFF sections
for .text + .data
(symbol table)
(patch addresses)
(BOF executes)
Step by step:
- Parse COFF header — Beacon reads the section table to determine how much memory to allocate for code (.text) and data (.data, .rdata).
- Allocate memory — Beacon allocates RWX memory inside its own process for the BOF's sections. The code section needs to be executable.
- Copy sections — Raw bytes from each COFF section are copied into the allocated memory.
- Resolve symbols — External symbols (like
KERNEL32$VirtualAlloc) are resolved usingGetProcAddress. The BOF naming conventionMODULE$Functiontells Beacon which DLL to search. - Apply relocations — Beacon processes the relocation table to patch absolute addresses, function pointers, and data references.
- Execute — Beacon calls the
go()entry point. The BOF runs synchronously on Beacon's thread. - 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 Format | Resolution Method | Example |
|---|---|---|
MODULE$Function | GetProcAddress(LoadLibrary("MODULE"), "Function") | KERNEL32$VirtualAlloc |
__imp_BeaconPrintf | Beacon internal function table | BOF API functions |
__imp_MODULE$Function | Same 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
- No async operations — BOFs should complete quickly (seconds, not minutes)
- No sleeping — Calling
Sleep()inside a BOF freezes Beacon entirely - No threads — Creating threads from a BOF is dangerous (memory is freed when go() returns)
- Crash = Beacon death — An unhandled exception in a BOF kills the entire Beacon process
- No SEH — Structured Exception Handling is unreliable in BOF context
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:
- No process creation — No
PsSetCreateProcessNotifyRoutinecallbacks fired - No cross-process injection — No suspicious handle operations
- Runs in Beacon's context — Syscalls originate from a process that's already running
- Minimal memory footprint — The BOF is loaded, executes, and its memory is freed
- Composable — Operators can call DRAUGR_SYSCALL from their own BOFs
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)
®ionSize, // Arg 4 (R9)
MEM_COMMIT | MEM_RESERVE, // Arg 5 (stack)
PAGE_READWRITE // Arg 6 (stack)
);
What Happens When You Call DRAUGR_SYSCALL
function SSN
stack frames
correct positions
syscall;ret
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?
Q2: Why is Draugr's BOF-based approach better suited than timer-based stack spoofing for single-threaded Beacon execution?