Difficulty: Advanced

Module 7: The Beacon Compatibility Layer

Implementing Cobalt Strike's Beacon API from scratch so BOFs run outside of CS.

Why This Module?

BOFs are written against Cobalt Strike's Beacon API. Functions like BeaconPrintf, BeaconDataParse, and BeaconOutput are provided by the Beacon process at runtime. When running BOFs outside of Cobalt Strike (which is the entire point of COFFLoader), someone must implement these functions. This module examines COFFLoader's beacon_compatibility.c -- the standalone implementations that make BOFs work without Cobalt Strike.

Architecture of the Compatibility Layer

The compatibility layer consists of three components:

ComponentFilePurpose
Function declarationsbeacon_compatibility.hStruct typedefs, callback constants, function prototypes, InternalFunctions extern
Function implementationsbeacon_compatibility.cActual C implementations of each Beacon API function
Function tableInternalFunctions[30][2]Name-to-pointer mapping used by process_symbol() for resolution

How BOF Calls Reach the Compatibility Layer

BOF code
BeaconPrintf()
CALL [rip+offset]
indirect via functionMapping
beacon_compatibility.c
BeaconPrintf()

Output Buffering: The Global Output Buffer

In Cobalt Strike, BeaconPrintf and BeaconOutput send data back to the team server over the C2 channel. In COFFLoader, output is collected in a global buffer that can be retrieved after the BOF finishes executing:

C// Global output buffer (beacon_compatibility.c)
char*  beacon_compatibility_output = NULL;
int    beacon_compatibility_size   = 0;
int    beacon_compatibility_offset = 0;

// The output buffer grows dynamically via realloc as data is appended.
// After RunCOFF() completes, the caller retrieves output with:
char* BeaconGetOutputData(int* outsize) {
    char* output = beacon_compatibility_output;
    *outsize = beacon_compatibility_offset;

    // Reset for next BOF execution
    beacon_compatibility_output = NULL;
    beacon_compatibility_size   = 0;
    beacon_compatibility_offset = 0;

    return output;
}

Implementing BeaconPrintf

The most-used function in any BOF. COFFLoader's implementation does two things: prints to the console (for immediate visibility) and appends to the output buffer (for programmatic retrieval):

Cvoid BeaconPrintf(int type, char* fmt, ...) {
    va_list args;
    va_start(args, fmt);

    // 1. Print to console (COFFLoader runs as a CLI tool)
    vprintf(fmt, args);

    va_end(args);
    va_start(args, fmt);

    // 2. Calculate required buffer size
    int len = vsnprintf(NULL, 0, fmt, args);
    va_end(args);

    if (len <= 0) return;

    // 3. Allocate/grow the output buffer
    char* newbuf = (char*)realloc(
        beacon_compatibility_output,
        beacon_compatibility_offset + len + 1
    );
    if (newbuf == NULL) return;
    beacon_compatibility_output = newbuf;

    // 4. Format the string into the buffer
    va_start(args, fmt);
    vsnprintf(
        beacon_compatibility_output + beacon_compatibility_offset,
        len + 1,
        fmt,
        args
    );
    va_end(args);

    beacon_compatibility_offset += len;
}

No CRT in the BOF, CRT in the Loader

The compatibility layer itself (beacon_compatibility.c) is compiled as part of COFFLoader, which is a normal C program with full CRT access. It freely uses vprintf, vsnprintf, realloc, calloc, and free. The restriction on CRT usage applies only to the BOF code, not to the loader. The BOF calls Beacon API functions (which are in the loader's address space) through resolved function pointers, and those functions use the CRT internally.

Implementing BeaconOutput

Unlike BeaconPrintf, BeaconOutput takes raw bytes (not a format string). It is used for binary data or pre-formatted output:

Cvoid BeaconOutput(int type, char* data, int len) {
    // Grow the output buffer
    char* newbuf = (char*)realloc(
        beacon_compatibility_output,
        beacon_compatibility_offset + len + 1
    );
    if (newbuf == NULL) return;
    beacon_compatibility_output = newbuf;

    // Copy raw bytes
    memcpy(
        beacon_compatibility_output + beacon_compatibility_offset,
        data,
        len
    );
    beacon_compatibility_offset += len;
    beacon_compatibility_output[beacon_compatibility_offset] = '\0';
}

Implementing the Data Parsing Functions

BeaconDataParse

Cvoid BeaconDataParse(datap* parser, char* buffer, int size) {
    // Sanity check
    if (parser == NULL) return;

    parser->original = buffer;
    parser->buffer   = buffer + 4;    // skip 4-byte size prefix
    parser->length   = size - 4;      // remaining data after prefix
    parser->size     = size - 4;
}

BeaconDataInt

Cint BeaconDataInt(datap* parser) {
    if (parser == NULL || parser->length < 4) return 0;

    int32_t value;
    memcpy(&value, parser->buffer, sizeof(int32_t));

    parser->buffer += 4;
    parser->length -= 4;

    return value;
}

BeaconDataShort

Cshort BeaconDataShort(datap* parser) {
    if (parser == NULL || parser->length < 2) return 0;

    short value;
    memcpy(&value, parser->buffer, sizeof(short));

    parser->buffer += 2;
    parser->length -= 2;

    return value;
}

BeaconDataExtract

Cchar* BeaconDataExtract(datap* parser, int* size) {
    if (parser == NULL || parser->length < 4) {
        if (size) *size = 0;
        return NULL;
    }

    // Read the 4-byte length prefix
    int32_t length;
    memcpy(&length, parser->buffer, sizeof(int32_t));
    parser->buffer += 4;
    parser->length -= 4;

    // Return pointer to the data
    char* data = parser->buffer;
    if (size) *size = length;

    // Advance past the data
    parser->buffer += length;
    parser->length -= length;

    return data;
}

Implementing the Format Functions

The format functions build output buffers piece by piece. They are analogous to a string builder:

Cvoid BeaconFormatAlloc(formatp* format, int maxsz) {
    if (format == NULL) return;
    format->original = (char*)calloc(1, maxsz);
    format->buffer   = format->original;
    format->length   = 0;
    format->size     = maxsz;
}

void BeaconFormatReset(formatp* format) {
    if (format == NULL) return;
    memset(format->original, 0, format->size);
    format->buffer = format->original;
    format->length = 0;
}

void BeaconFormatFree(formatp* format) {
    if (format == NULL) return;
    free(format->original);
    format->original = NULL;
    format->buffer   = NULL;
    format->length   = 0;
    format->size     = 0;
}

void BeaconFormatAppend(formatp* format, char* text, int len) {
    if (format == NULL || format->length + len > format->size) return;
    memcpy(format->buffer, text, len);
    format->buffer += len;
    format->length += len;
}

void BeaconFormatPrintf(formatp* format, char* fmt, ...) {
    va_list args;
    va_start(args, fmt);
    int remaining = format->size - format->length;
    int len = vsnprintf(format->buffer, remaining, fmt, args);
    va_end(args);

    if (len > 0 && len < remaining) {
        format->buffer += len;
        format->length += len;
    }
}

char* BeaconFormatToString(formatp* format, int* size) {
    if (size) *size = format->length;
    return format->original;
}

BeaconFormatInt: The Endian Swap

Cvoid BeaconFormatInt(formatp* format, int value) {
    // Swap from little-endian (native) to big-endian (network byte order)
    // This matches Cobalt Strike's internal data format
    int swapped = swap_endianess(value);
    BeaconFormatAppend(format, (char*)&swapped, sizeof(int));
}

int swap_endianess(int value) {
    return ((value >> 24) & 0x000000FF) |
           ((value >>  8) & 0x0000FF00) |
           ((value <<  8) & 0x00FF0000) |
           ((value << 24) & 0xFF000000);
}

Token and Process Stubs

COFFLoader provides minimal implementations for the token/process management functions. Since COFFLoader runs as a standalone CLI tool (not an implant), some of these are stubs:

C// Token management
void BeaconUseToken(HANDLE token) {
    // In a real implant, this would call ImpersonateLoggedOnUser(token)
    // COFFLoader's implementation may use SetThreadToken or be a no-op
}

void BeaconRevertToken(void) {
    // Revert to the process token
    // In COFFLoader: RevertToSelf() or no-op
}

BOOL BeaconIsAdmin(void) {
    // Check if running elevated
    // COFFLoader implements this with CheckTokenMembership
    // or OpenProcessToken + GetTokenInformation
    return FALSE;  // simplified stub
}

// Utility: convert ANSI to wide string
BOOL toWideChar(char* src, wchar_t* dst, int max) {
    return MultiByteToWideChar(CP_ACP, 0, src, -1, dst, max);
}

Extending the Compatibility Layer

The 30-slot InternalFunctions table has room for additional entries. Custom C2 frameworks that integrate COFFLoader can add their own internal functions beyond the standard Beacon API. For example, a custom output function that sends data over a different channel, or a custom token management function that integrates with the framework's credential store. The BOF just needs to call a function with a matching name, and the loader will resolve it from the table.

The Complete InternalFunctions Mapping

Here is the full mapping of all Beacon API functions to their compatibility layer implementations:

IndexFunction NameCategory
0BeaconDataParseData Parsing
1BeaconDataIntData Parsing
2BeaconDataShortData Parsing
3BeaconDataLengthData Parsing
4BeaconDataExtractData Parsing
5BeaconFormatAllocFormatting
6BeaconFormatResetFormatting
7BeaconFormatFreeFormatting
8BeaconFormatAppendFormatting
9BeaconFormatPrintfFormatting
10BeaconFormatToStringFormatting
11BeaconFormatIntFormatting
12BeaconPrintfOutput
13BeaconOutputOutput
14BeaconUseTokenToken
15BeaconRevertTokenToken
16BeaconIsAdminToken
17BeaconGetSpawnToProcess
18BeaconSpawnTemporaryProcessProcess
19BeaconInjectProcessProcess
20BeaconInjectTemporaryProcessProcess
21BeaconCleanupProcessProcess
22toWideCharUtility
23BeaconGetOutputDataOutput
24-29(reserved)Available for extensions

Pop Quiz: Beacon Compatibility Layer

Q1: How does COFFLoader's BeaconPrintf differ from Cobalt Strike's?

In Cobalt Strike, BeaconPrintf sends formatted output over the C2 channel to the team server. In COFFLoader, the implementation uses vprintf for console output and stores the formatted text in a dynamically growing buffer (beacon_compatibility_output) that can be retrieved with BeaconGetOutputData after the BOF finishes.

Q2: Why does BeaconDataParse set parser->buffer to buffer+4?

Cobalt Strike's bof_pack() function prepends a 4-byte little-endian total size to the argument buffer. BeaconDataParse skips this prefix so subsequent calls to BeaconDataInt and BeaconDataExtract read the actual argument values starting at offset 4.

Q3: What does BeaconFormatInt do differently than simply appending 4 bytes?

BeaconFormatInt calls swap_endianess() to convert the native little-endian integer to big-endian (network byte order) before appending the 4 bytes. This matches Cobalt Strike's internal structured data format. The swap reverses the byte order: 0xAABBCCDD becomes 0xDDCCBBAA.