Difficulty: Beginner

Module 3: The BOF API Contract

The API that every BOF author must know: data parsing, output, and the contract between BOF and loader.

Why This Module?

BOFs cannot use the C runtime library (printf, malloc, etc.) because the CRT is never initialized.[1] Instead, BOFs communicate with the loader through the Beacon API -- a set of functions declared in beacon.h.[2] COFFLoader implements a compatibility layer for these functions so BOFs written for Cobalt Strike work unchanged.[3] This module covers the API contract from the BOF author's perspective.

Lab Files: step03-beacon-api

Full beacon.h with all API declarations, plus a BOF that demonstrates argument parsing with datap and formatted output with formatp.

The beacon.h Header

Every BOF includes beacon.h, which declares the Beacon API functions. These functions are not defined in the BOF -- they are external symbols that the loader resolves at load time. The BOF calls them through function pointers that the loader patches via relocations.

The API falls into four categories:

CategoryFunctionsPurpose
Data ParsingBeaconDataParse, BeaconDataInt, BeaconDataShort, BeaconDataLength, BeaconDataExtractParse the argument buffer passed to go()
OutputBeaconPrintf, BeaconOutputSend text/data back to the operator
FormattingBeaconFormatAlloc, BeaconFormatFree, BeaconFormatAppend, BeaconFormatPrintf, BeaconFormatToString, BeaconFormatInt, BeaconFormatResetBuild structured output buffers
Process/TokenBeaconUseToken, BeaconRevertToken, BeaconIsAdmin, BeaconGetSpawnTo, BeaconSpawnTemporaryProcess, BeaconInjectProcess, BeaconCleanupProcessToken manipulation and process spawning

The datap Structure

Arguments are passed to a BOF as a raw byte buffer. The datap structure is a cursor-based parser that walks through this buffer extracting typed values:

Ctypedef struct {
    char* original;   // pointer to the start of the buffer
    char* buffer;     // current read position (advances as you extract)
    int   length;     // remaining bytes from buffer to end
    int   size;       // total size of the parseable region
} datap;

datap Parsing Model

original
start of buffer
buffer
current cursor
remaining
length bytes left

Data Parsing Functions

BeaconDataParse

Cvoid BeaconDataParse(datap* parser, char* buffer, int size);

// Initializes the parser with a buffer.
// IMPORTANT: Skips the first 4 bytes (size prefix in CS argument format).
// After this call:
//   parser->original = buffer
//   parser->buffer   = buffer + 4
//   parser->length   = size - 4
//   parser->size     = size - 4

The 4-Byte Skip

Cobalt Strike's argument packing format prepends a 4-byte little-endian length prefix to the argument buffer.[4] BeaconDataParse skips these 4 bytes automatically. If you are building argument buffers manually for COFFLoader, you must include this 4-byte prefix or your data will be misaligned.

BeaconDataInt

Cint BeaconDataInt(datap* parser);

// Extracts a 4-byte (32-bit) integer from the current cursor position.
// Advances parser->buffer by 4 and decrements parser->length by 4.
// Returns 0 if insufficient data remains.

BeaconDataShort

Cshort BeaconDataShort(datap* parser);

// Extracts a 2-byte (16-bit) short from the current cursor position.
// Advances parser->buffer by 2 and decrements parser->length by 2.
// Returns 0 if insufficient data remains.

BeaconDataLength

Cint BeaconDataLength(datap* parser);

// Returns the number of bytes remaining in the buffer (parser->length).
// Does not modify the cursor position.

BeaconDataExtract

Cchar* BeaconDataExtract(datap* parser, int* size);

// Extracts a length-prefixed binary blob:
//   1. Reads a 4-byte length prefix from the current position
//   2. Returns a pointer to the data immediately after the prefix
//   3. Advances the cursor past the data
//   4. Sets *size to the extracted length
// This is how strings and byte arrays are packed in CS argument format.

Argument Buffer Format

When Cobalt Strike (or COFFLoader with hex arguments) passes data to a BOF, the buffer is packed in a specific format:

TEXTArgument Buffer Layout:

+---4 bytes---+---4 bytes---+---N bytes---+---4 bytes---+---M bytes---+
| total_size  | len_of_str1 |   string1   | len_of_str2 |   string2   |
+-------------+-------------+-------------+-------------+-------------+

Example: Two strings "hello" and "world"
  0x12000000   // total size (18 bytes of payload)
  0x06000000   // length 6 (including null terminator)
  68656C6C6F00 // "hello\0"
  0x06000000   // length 6
  776F726C6400 // "world\0"

The first 4 bytes (total_size) are skipped by BeaconDataParse.
Each subsequent value is extracted by BeaconDataInt, BeaconDataShort,
or BeaconDataExtract depending on the expected type.

A Complete Data Parsing Example

C#include <windows.h>
#include "beacon.h"

// BOF that takes a hostname (string) and port (int)
void go(char* args, int len) {
    datap parser;
    BeaconDataParse(&parser, args, len);

    // Extract a length-prefixed string
    int hostname_len;
    char* hostname = BeaconDataExtract(&parser, &hostname_len);

    // Extract a 4-byte integer
    int port = BeaconDataInt(&parser);

    BeaconPrintf(CALLBACK_OUTPUT, "Connecting to %s:%d\n", hostname, port);

    // ... do work with hostname and port ...
}

Output Functions

BeaconPrintf

Cvoid BeaconPrintf(int type, char* fmt, ...);

// Printf-style output. In Cobalt Strike, this sends output back to
// the operator console. In COFFLoader, it prints to stdout and
// appends to an internal buffer (beacon_compatibility_output).
//
// type values:
//   CALLBACK_OUTPUT      = 0x00   // normal output
//   CALLBACK_OUTPUT_OEM  = 0x1e   // OEM-encoded output
//   CALLBACK_ERROR       = 0x0d   // error output
//   CALLBACK_OUTPUT_UTF8 = 0x20   // UTF-8 output

BeaconOutput

Cvoid BeaconOutput(int type, char* data, int len);

// Sends raw bytes as output (not printf-formatted).
// Useful for binary data or pre-formatted strings.
// Same type constants as BeaconPrintf.

The formatp Structure & Format Functions

The formatp structure is identical to datap but used for building output buffers incrementally:

Ctypedef struct {
    char* original;   // allocated buffer base
    char* buffer;     // current write position
    int   length;     // bytes written so far
    int   size;       // total allocated capacity
} formatp;

Format API Usage Pattern

Cvoid go(char* args, int len) {
    formatp buffer;

    // Allocate a format buffer (512 bytes capacity)
    BeaconFormatAlloc(&buffer, 512);

    // Append formatted text
    BeaconFormatPrintf(&buffer, "User: %s\n", "SYSTEM");
    BeaconFormatPrintf(&buffer, "PID:  %d\n", GetCurrentProcessId());

    // Append a big-endian integer (for structured data)
    BeaconFormatInt(&buffer, 42);

    // Append raw bytes
    BeaconFormatAppend(&buffer, "raw", 3);

    // Extract the built buffer and send it
    int output_size;
    char* output = BeaconFormatToString(&buffer, &output_size);
    BeaconOutput(CALLBACK_OUTPUT, output, output_size);

    // Free the format buffer
    BeaconFormatFree(&buffer);
}

BeaconFormatInt: Endian Swap

BeaconFormatInt appends a 4-byte integer in big-endian (network byte order), not native little-endian.[5] This matches Cobalt Strike's internal data format for structured output. The COFFLoader compatibility layer implements this with a byte-swap before writing.[3]

Token and Process Functions

These functions are available but have limited or stub implementations in COFFLoader's compatibility layer.[3] They are primarily useful in the context of a real Cobalt Strike Beacon:[6]

FunctionSignaturePurpose
BeaconUseTokenvoid BeaconUseToken(HANDLE token)Impersonate using the given token
BeaconRevertTokenvoid BeaconRevertToken(void)Revert to original token
BeaconIsAdminBOOL BeaconIsAdmin(void)Check if current context is elevated
BeaconGetSpawnTovoid BeaconGetSpawnTo(BOOL x86, char* buf, int len)Get the spawnto path for process creation
BeaconSpawnTemporaryProcessBOOL BeaconSpawnTemporaryProcess(...)Create a sacrificial process for post-ex
BeaconInjectProcessvoid BeaconInjectProcess(HANDLE hProc, int pid, char* pay, int pay_len, int offset, char* arg, int arg_len)Inject payload into a process
BeaconCleanupProcessvoid BeaconCleanupProcess(PROCESS_INFORMATION* pi)Clean up a spawned process

The DLL Import Convention

BOFs cannot use standard C library imports. To call a Windows API function, a BOF must declare it using a special naming convention that tells the loader which DLL to load and which function to resolve:

C// In beacon.h or the BOF source:
// DECLSPEC_IMPORT tells the compiler this is a DLL import
// The function is declared as a regular prototype

// For x64, the symbol name becomes: __imp_KERNEL32$GetCurrentProcessId
// For x86, the symbol name becomes: __imp__KERNEL32$GetCurrentProcessId
DECLSPEC_IMPORT DWORD WINAPI KERNEL32$GetCurrentProcessId(void);
DECLSPEC_IMPORT HANDLE WINAPI KERNEL32$OpenProcess(DWORD, BOOL, DWORD);
DECLSPEC_IMPORT BOOL WINAPI ADVAPI32$OpenProcessToken(HANDLE, DWORD, PHANDLE);
DECLSPEC_IMPORT NTSTATUS NTAPI NTDLL$NtQuerySystemInformation(ULONG, PVOID, ULONG, PULONG);

// Usage in the BOF:
void go(char* args, int len) {
    DWORD pid = KERNEL32$GetCurrentProcessId();
    BeaconPrintf(CALLBACK_OUTPUT, "My PID: %d\n", pid);
}

The LIBRARY$Function Convention

The LIBRARY$Function naming convention is not a Windows convention -- it is specific to BOFs.[7] The loader parses the symbol name, splits on $, calls LoadLibraryA("LIBRARY") to load the DLL, and then GetProcAddress(hLib, "Function") to resolve the function.[3] The __imp_ prefix (or __imp__ on x86) is added automatically by the compiler because of DECLSPEC_IMPORT (__declspec(dllimport)).

Callback Type Constants

C// Defined in beacon_compatibility.h
#define CALLBACK_OUTPUT      0x00   // standard output
#define CALLBACK_OUTPUT_OEM  0x1e   // OEM codepage output
#define CALLBACK_ERROR       0x0d   // error output (shown in red in CS)
#define CALLBACK_OUTPUT_UTF8 0x20   // UTF-8 encoded output

In Cobalt Strike, these control how the output is displayed in the operator console.[8] In COFFLoader, CALLBACK_OUTPUT and CALLBACK_ERROR both go to stdout, but the type is preserved in the output buffer for frameworks that consume COFFLoader's output programmatically.[3]

Why the API Is Designed This Way

The Beacon API may seem unusual compared to conventional C programming, but every design decision traces back to the constraints of in-process execution. A BOF runs inside the Beacon process itself -- there is no separate address space, no loader-initialized runtime, and no process startup sequence.[1] Understanding these constraints explains why the API takes its particular shape.

No C Runtime Library. When a normal C program starts, the OS loader calls the CRT entry point (mainCRTStartup or similar), which initializes the heap manager, sets up stdio file descriptors, processes environment variables, and parses command-line arguments before calling main(). A BOF never goes through this initialization. The loader maps the COFF sections into memory, resolves relocations, and jumps straight to go().[9] This means printf, malloc, fopen, and every other CRT function is unavailable. The BOF author cannot even call strlen unless they declare the MSVCRT import explicitly using the LIBRARY$Function convention (e.g., MSVCRT$strlen).[7]

No stdout or stderr. Even if the CRT were available, a Beacon process typically has no console attached. It runs as a background process (often injected into another process), so there is no terminal to write to. The BeaconPrintf and BeaconOutput functions exist to buffer output internally and transmit it back to the operator over the C2 channel. This output buffering is fundamental -- without it, any text the BOF produces would simply vanish.[8]

No heap guarantees. Because the CRT heap is not initialized, the BOF cannot call malloc or free directly. The format API (BeaconFormatAlloc, BeaconFormatFree) provides a controlled allocation mechanism. In Cobalt Strike's real implementation, these allocations are managed by the Beacon runtime. In COFFLoader's compatibility layer, they fall through to the Win32 heap functions (HeapAlloc/HeapFree) or calloc/free from the host process CRT.[3]

Cursor-based parsing over serialized buffers. Arguments cannot be passed as function parameters in the usual sense because the BOF entry point has a fixed signature: void go(char* args, int len). All arguments must be serialized into a single byte buffer by the operator's client, transmitted over the C2 channel, and deserialized by the BOF using the datap cursor. This design avoids the need for complex marshalling infrastructure and keeps the BOF entry point uniform across all BOFs regardless of their argument types.[4]

Limitations and Edge Cases

The Beacon API is intentionally minimal, and this minimalism comes with sharp edges that BOF authors must be aware of. The API provides little to no safety checking, and misuse leads to silent corruption rather than clean error messages.

Buffer Overreads in datap

When you call BeaconDataInt or BeaconDataShort and fewer bytes remain than the function expects to read, the function returns 0 without extracting data.[2] However, the behavior of BeaconDataExtract is more nuanced. It first reads a 4-byte length prefix. If the length prefix itself can be read but the claimed length exceeds the remaining buffer, the pointer returned will reference memory past the end of the argument buffer. In COFFLoader's implementation, this does not trigger an access violation immediately -- it simply returns a pointer into whatever memory happens to follow the buffer.[3] The BOF will then read garbage data or, worse, leak adjacent memory contents.

NULL datap Handling

If a BOF is invoked with no arguments (a NULL or zero-length argument buffer), calling BeaconDataParse with a NULL pointer for the buffer parameter produces a datap structure where original is NULL and buffer points to address 0x4 (NULL + 4 due to the size prefix skip). Any subsequent call to BeaconDataInt or BeaconDataExtract on this parser will dereference this near-NULL pointer and crash. Robust BOFs should check args and len before parsing:[10]

Cvoid go(char* args, int len) {
    if (args == NULL || len < 4) {
        BeaconPrintf(CALLBACK_ERROR, "No arguments provided\n");
        return;
    }
    datap parser;
    BeaconDataParse(&parser, args, len);
    // ... safe to proceed ...
}

formatp Buffer Overflow

The BeaconFormatAlloc function allocates a fixed-size buffer (specified by the caller). If subsequent calls to BeaconFormatPrintf or BeaconFormatAppend write more data than the allocated capacity, the behavior depends on the implementation. In COFFLoader's compatibility layer, BeaconFormatPrintf uses vsnprintf internally and will silently truncate output that exceeds the remaining capacity.[3] In contrast, BeaconFormatAppend performs a raw memcpy with no bounds checking, which means writing past the end of the format buffer causes heap corruption. BOF authors should allocate generously and track their output size, especially when formatting output from enumeration loops where the total output size is unpredictable.

Thread Safety

The Beacon API functions are not thread-safe. The datap and formatp structures maintain internal cursor state, and the global output buffer (beacon_compatibility_output in COFFLoader) is a shared resource. If a BOF spawns threads that call Beacon API functions concurrently, output will be interleaved or corrupted. In practice, this is rarely an issue because BOFs are designed to run synchronously and return quickly, but it is worth noting for advanced BOF development scenarios.[11]

COFFLoader vs. Cobalt Strike: Implementation Differences

COFFLoader's compatibility layer aims to let BOFs written for Cobalt Strike run without modification, but the two implementations diverge in several important ways.[3]

AspectCobalt Strike BeaconCOFFLoader Compatibility Layer
Output deliveryBuffers output and transmits it back to the team server over the C2 channel on the next callback[8]Prints to the host process stdout immediately and appends to an internal string buffer
Token functionsFull implementation: BeaconUseToken applies impersonation, BeaconRevertToken reverts, integrated with Beacon's token store[6]Stub implementations that call the underlying Win32 token APIs directly, without a token store or session tracking
Process spawningBeaconSpawnTemporaryProcess creates a sacrificial process using the configured spawnto binary, with PPID spoofing and blockdll options[12]Basic CreateProcessA wrapper without OPSEC features like PPID spoofing or blockdll policy
Format buffer allocationManaged by Beacon's internal memory allocator with lifetime tied to the BOF execution contextUses standard calloc/free from the host process CRT
Error handlingErrors in API calls are logged and reported back to the operator through the C2 channelErrors may print to stderr or silently fail depending on the function

The most significant practical difference is in the process and token functions. A BOF that calls BeaconSpawnTemporaryProcess to inject shellcode into a sacrificial process will work in Cobalt Strike with all the OPSEC features the operator has configured (spawnto path, PPID spoofing, command-line spoofing, blockdll). In COFFLoader, the same call creates a plain process with none of those protections. BOF authors testing with COFFLoader should be aware that these functions will behave correctly in a functional sense but will not provide the same operational security characteristics.[3]

Pop Quiz: BOF API Contract

Q1: Why does BeaconDataParse skip the first 4 bytes of the argument buffer?

Cobalt Strike's argument packing format includes a 4-byte little-endian total size prefix at the start of the buffer. BeaconDataParse skips this prefix so subsequent calls to BeaconDataInt/BeaconDataExtract read the actual argument data.

Q2: How does a BOF call GetCurrentProcessId from KERNEL32.dll?

BOFs declare DLL imports using the LIBRARY$Function convention with DECLSPEC_IMPORT. The compiler generates a symbol like __imp_KERNEL32$GetCurrentProcessId. At load time, the COFF loader splits the symbol on $, loads the DLL with LoadLibraryA, and resolves the function with GetProcAddress.

Q3: What byte order does BeaconFormatInt use when appending an integer?

BeaconFormatInt swaps the byte order to big-endian before appending the 4-byte integer. This matches Cobalt Strike's internal structured data format (network byte order) for consistency in data exchange between Beacon and the team server.

References

  1. Cobalt Strike, "Beacon Object Files," official documentation. Describes the BOF execution model, CRT unavailability, and the go() entry point convention.
  2. Cobalt Strike, beacon.h header file. Defines the datap and formatp structures and declares all Beacon API function prototypes.
  3. TrustedSec, "COFFLoader," GitHub repository (trustedsec/COFFLoader). Source code for the open-source COFF loader and its beacon_compatibility.c/.h compatibility layer.
  4. Cobalt Strike, "Beacon Object Files – Argument Packing," documentation. Describes the 4-byte size prefix, length-prefixed strings, and the binary argument buffer format.
  5. Cobalt Strike, beacon.h: BeaconFormatInt implementation note. The function stores integers in big-endian (network byte order) for consistency with the team server's data parsing.
  6. Cobalt Strike, "BOF Developer Reference," blog post. Covers token manipulation functions, process injection APIs, and their integration with Beacon's internal state.
  7. Cobalt Strike, "Beacon Object Files – Dynamic Function Resolution," documentation. Explains the LIBRARY$Function naming convention and how the loader resolves DLL imports at load time.
  8. Cobalt Strike, "Beacon Object Files – Output Functions," documentation. Describes CALLBACK_OUTPUT, CALLBACK_ERROR, and other output type constants and their rendering in the operator console.
  9. TrustedSec, COFFLoader source: COFFLoader.c, RunCOFF() function. Shows how the loader maps sections, resolves symbols, applies relocations, and calls the go() entry point.
  10. Raphael Mudge, "Cobalt Strike 4.1 – The Mark of Injection," Cobalt Strike blog, 2020. Introduces BOF development best practices including argument validation.
  11. TrustedSec, "Beacon Object File Loader," blog post, 2021. Discusses the design goals and limitations of running BOFs outside of Cobalt Strike.
  12. Cobalt Strike, "BOF Process Injection and Token APIs," documentation. Details BeaconSpawnTemporaryProcess, spawnto configuration, PPID spoofing, and blockdll integration.

Further Reading