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.

Lab Files: step07-beacon-compat

Replaces the stub beacon_compatibility.c with the full implementation: BeaconPrintf with output buffering, all data parsing functions, format buffer API, and endian-swapping BeaconFormatInt.

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.[1] In COFFLoader, output is collected in a global buffer that can be retrieved after the BOF finishes executing.[7]

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. The type parameter in the real Cobalt Strike Beacon selects the callback channel -- constants like CALLBACK_OUTPUT (0x0) and CALLBACK_OUTPUT_UTF8 (0x20) tell the team server how to decode the data.[1] COFFLoader ignores this parameter since all output is local. The implementation does two things: prints to the console via vprintf for immediate visibility, and appends to a dynamically growing buffer for programmatic retrieval. A key technique in the code below is calling vsnprintf(NULL, 0, fmt, args) to measure the formatted string length without writing anything -- this is well-defined behavior per the C11 standard and returns the number of characters that would have been written.[2]

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,[11] 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

The buffer + 4 skip is not arbitrary -- Cobalt Strike's bof_pack() function prepends a 4-byte little-endian total size to the argument buffer before sending it to the BOF.[12] BeaconDataParse skips past this prefix so that subsequent calls to BeaconDataInt and BeaconDataExtract read actual argument values.

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);
}

The endian swap in BeaconFormatInt converts integers from x86's native little-endian representation to big-endian, which is the standard network byte order defined by IETF convention.[3] Cobalt Strike's structured data protocol transmits integers in big-endian format, so any BOF that builds a format buffer with BeaconFormatInt and sends it via BeaconOutput expects the receiver to interpret integers in this order. COFFLoader preserves the swap for wire-format compatibility -- if you later feed COFFLoader output to a parser that expects Cobalt Strike's format, the integers will be correctly encoded.

Implementation Challenges

Building a faithful compatibility layer is harder than it first appears. Several subtle issues arise from the difference between running inside an implant and running as a standalone process.

Output Buffer Management

The global output buffer (beacon_compatibility_output) uses realloc to grow dynamically as BOFs produce output. This approach has two noteworthy concerns. First, memory fragmentation: a BOF that produces output in many small increments (e.g., enumerating hundreds of directory entries) will trigger many realloc calls, each potentially copying the entire buffer to a new location. In practice, this is acceptable for a CLI tool but would be problematic in a memory-constrained implant. Second, thread safety: the global variables beacon_compatibility_output, beacon_compatibility_size, and beacon_compatibility_offset have no synchronization. If two BOFs were executed concurrently (not a typical scenario for COFFLoader, but possible in a framework that loads multiple BOFs), their output would interleave and corrupt the buffer. A production C2 framework would need per-thread or per-BOF output buffers.

The va_list Double-Init Pattern

In BeaconPrintf, the va_list is initialized, consumed by vprintf, ended, and then re-initialized before being passed to vsnprintf. This is not redundant -- the C standard requires that a va_list be re-initialized after being consumed by a v*printf function, because the function may have advanced the internal pointer to an indeterminate state.[2] Omitting the second va_start would cause undefined behavior on most platforms, typically manifesting as garbled output or access violations.

Format Buffer Fixed Size

Unlike the global output buffer, the format buffer (formatp) has a fixed maximum size set at allocation time by BeaconFormatAlloc. If a BOF attempts to append more data than the allocated size, BeaconFormatAppend silently drops the excess rather than growing the buffer or reporting an error. This matches Cobalt Strike's behavior -- BOF authors are expected to allocate a sufficiently large format buffer upfront -- but it can be a source of subtle bugs when porting BOFs to COFFLoader if the allocation size is too small.

Token Impersonation and BeaconUseToken / BeaconRevertToken

Token management is one of the most operationally significant parts of the Beacon API. Many BOFs that perform Active Directory enumeration, access network shares, or query remote services need to run under a different user's security context. In Cobalt Strike, BeaconUseToken and BeaconRevertToken manage this transparently.

BeaconUseToken calls the Windows API function ImpersonateLoggedOnUser,[4] which takes a primary or impersonation token handle and applies it to the calling thread. At the kernel level, this modifies the thread's token field in the ETHREAD structure -- subsequent access checks by the Security Reference Monitor (SRM) evaluate the thread token instead of the process token.[6] This means any Win32 API call made by the BOF after BeaconUseToken -- opening files, connecting to named pipes, binding to LDAP, querying the registry -- will be evaluated against the impersonated user's privileges and group memberships rather than the loader process's identity.

Cvoid BeaconUseToken(HANDLE token) {
    if (!ImpersonateLoggedOnUser(token)) {
        // Log failure -- the token handle may be invalid or
        // the caller may lack SeImpersonatePrivilege
    }
}

BeaconRevertToken calls RevertToSelf,[5] which removes the thread-level impersonation token and reverts the thread to using the process token for all access checks. This is critical for BOFs that temporarily elevate or change identity -- failing to revert can leave the loader process running under an unintended security context for subsequent operations.

Cvoid BeaconRevertToken(void) {
    RevertToSelf();
}

BOOL BeaconIsAdmin(void) {
    BOOL isAdmin = FALSE;
    SID_IDENTIFIER_AUTHORITY ntAuth = SECURITY_NT_AUTHORITY;
    PSID adminGroup;

    if (AllocateAndInitializeSid(&ntAuth, 2,
            SECURITY_BUILTIN_DOMAIN_RID, DOMAIN_ALIAS_RID_ADMINS,
            0, 0, 0, 0, 0, 0, &adminGroup)) {
        CheckTokenMembership(NULL, adminGroup, &isAdmin);
        FreeSid(adminGroup);
    }
    return isAdmin;
}

The code above shows what a complete BeaconIsAdmin implementation looks like using CheckTokenMembership[9] to determine whether the current token (thread or process) includes membership in the BUILTIN\Administrators group. Passing NULL as the first argument tells the function to check the calling thread's effective token. This is the same mechanism many Windows utilities use to determine elevation status. Note that COFFLoader's actual beacon_compatibility.c uses a simplified stub that always returns FALSE, since full admin checking is not critical for a standalone testing tool. The implementation shown here is the reference pattern you would use when integrating into a production framework.

The toWideChar Utility

The toWideChar helper converts narrow strings to wide (UTF-16LE) strings using MultiByteToWideChar.[8] This is necessary because many Windows API functions have both ANSI (*A) and wide (*W) variants, and BOFs that call wide-character APIs need to convert their string arguments. The step07 source uses CP_UTF8 as the code page (treating input as UTF-8), while the step08 version uses CP_ACP (the system's current ANSI code page). The choice depends on what encoding the BOF's input strings use:

CBOOL 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

Limitations vs. Real Cobalt Strike

While COFFLoader's compatibility layer allows most BOFs to execute correctly, several Beacon API features cannot be faithfully replicated outside of the Cobalt Strike implant. Understanding these limitations is important for knowing which BOFs will work with COFFLoader and which will fail or behave differently.

No Sacrificial Process Spawning

BeaconSpawnTemporaryProcess in Cobalt Strike creates a new "sacrificial" process for shellcode injection -- typically rundll32.exe or a configurable binary from the malleable profile.[10] The Beacon creates this process in a suspended state, injects a payload, resumes it, and reads output back through a named pipe. COFFLoader cannot replicate this workflow because it has no C2 channel to receive injected code output and no malleable profile to configure spawn-to binaries. The function exists in the compatibility table but is effectively a no-op. BOFs that rely on BeaconSpawnTemporaryProcess for post-exploitation (such as execute-assembly or mimikatz BOF wrappers) will not function.

No Pipe-Based Output Capture

In Cobalt Strike, BeaconInjectProcess and BeaconInjectTemporaryProcess set up named pipes to capture output from injected payloads. The Beacon creates a pipe, passes its write handle to the spawned process, and reads output back to relay through the C2 channel. COFFLoader's standalone architecture has no equivalent mechanism -- there is no pipe infrastructure, no output relay, and no way to collect results from a child process through the Beacon API. BOFs that call these functions will find them stubbed out.

No Metadata Channel or Job Tracking

Cobalt Strike's Beacon maintains separate output channels: the standard output channel for text, a screenshot channel, a keylogger channel, and a metadata channel for structured data. Each channel has its own callback type constant.[1] COFFLoader collapses all output into a single buffer regardless of the type parameter passed to BeaconOutput or BeaconPrintf. Additionally, Cobalt Strike tracks running BOFs as "jobs" that operators can list and kill. COFFLoader has no job table -- BOF execution is synchronous and uninterruptible once started.

Implications for BOF Selection

These limitations mean that COFFLoader works best with information-gathering BOFs that call Win32 APIs directly and report results through BeaconPrintf or BeaconOutput. Examples include directory listing, process enumeration, registry queries, service enumeration, and network interface listing. BOFs that depend on process injection, sacrificial processes, or multi-channel output will need modification or will not work at all. When evaluating a BOF for COFFLoader compatibility, check whether it calls any of the process-management functions (indices 17-21 in the InternalFunctions table) -- if it does, it likely depends on Cobalt Strike infrastructure that COFFLoader cannot provide.

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.

References

  1. Cobalt Strike, "Beacon Object Files," Official Documentation. Defines the Beacon API functions, callback type constants (CALLBACK_OUTPUT, CALLBACK_OUTPUT_OEM, CALLBACK_OUTPUT_UTF8), and the BOF execution model.
  2. ISO/IEC 9899:2011 (C11 Standard), Section 7.21.6.12. Specifies that vsnprintf called with a NULL buffer and zero size returns the number of characters that would have been written, enabling safe buffer pre-sizing.
  3. IETF RFC 1700, "Assigned Numbers," J. Reynolds and J. Postel. Defines network byte order as big-endian, the convention followed by BeaconFormatInt's endian swap.
  4. Microsoft, "ImpersonateLoggedOnUser function (securitybaseapi.h)," MSDN Documentation. Describes how the function lets a calling thread impersonate a logged-on user's security context.
  5. Microsoft, "RevertToSelf function (securitybaseapi.h)," MSDN Documentation. Terminates impersonation of a client application and reverts to the process token.
  6. Microsoft, "Access Tokens," MSDN Documentation. Explains the relationship between process tokens and thread tokens, and how the Security Reference Monitor evaluates access checks.
  7. TrustedSec, "COFFLoader," GitHub Repository. The reference implementation of beacon_compatibility.c that this module's code is based on.
  8. Microsoft, "MultiByteToWideChar function (stringapiset.h)," MSDN Documentation. Maps a character string to a UTF-16 (wide character) string using the specified code page.
  9. Microsoft, "CheckTokenMembership function (securitybaseapi.h)," MSDN Documentation. Determines whether a specified SID is enabled in the specified access token.
  10. Cobalt Strike, "BeaconSpawnTemporaryProcess," Documentation. Describes the sacrificial process model: creating a suspended process, injecting code, and reading output via named pipes.
  11. Microsoft, "vsnprintf, _vsnprintf, _vsnprintf_l," MSDN Documentation. Platform-specific behavior notes for the vsnprintf function on Windows, including differences from the C standard.
  12. Cobalt Strike, "Aggressor Script - bof_pack," Documentation. Documents the argument packing format that prepends a 4-byte little-endian size prefix to BOF argument buffers.

Further Reading