bof-development

16 — BOF Development

BLUF: A Beacon Object File (BOF) is position-independent COFF shellcode that runs inside your beacon's process. No child process, no new command line, no process creation event. It's the quietest post-exploitation execution primitive available. This guide takes you from zero to shipping working BOFs for Cobalt Strike, Sliver, and Havoc.

Note

Navigation: 7. C2 Section 5 has the quick-start. This file covers the full authoring workflow, advanced patterns, NTAPI-only BOFs, argument passing, testing harness, and packaging for Sliver armory.


What Is a BOF?

A BOF is a COFF (.o) object file compiled from C that the C2 framework loads and executes in the beacon's memory space. It is:

Why BOFs Over execute-assembly

Property BOF execute-assembly (.NET) shell / run
New process created? No Yes (CLR host) Yes
Process creation event (4688)? No Yes Yes
New command line logged? No Yes Yes
Memory footprint Tiny (~KB) Large (CLR runtime) New process
Disk artifact None None (in-memory) None
Execution speed Instant CLR startup (~500ms) Shell startup
Supports .NET libraries No Yes No

Rule of thumb: Use BOFs for reconnaissance, token ops, and API calls. Use execute-assembly for complex .NET tooling (Rubeus, SharpHound). Never use shell in a monitored environment.


Environment Setup

# Cross-compiler (Linux → Windows BOF)
sudo apt install mingw-w64 -y

# Verify
x86_64-w64-mingw32-gcc --version

# BOF headers (required — never replace with Windows SDK headers in a BOF)
git clone https://github.com/trustedsec/CS-Situational-Awareness-BOF
# beacon.h lives in: CS-Situational-Awareness-BOF/src/common/beacon.h

Project Structure

mybof/
  Makefile
  src/
    mybof.c         # BOF source
    beacon.h        # TrustedSec beacon header
  bin/
    mybof.x64.o     # compiled output
  test/
    harness.c       # local test harness (optional)

Beacon API Reference

The BOF can only communicate outward via the beacon API functions defined in beacon.h. Never use printf, WriteFile, or OutputDebugString.

Output Functions

// Print output back to the operator
BeaconPrintf(CALLBACK_OUTPUT, "Normal output: %s\n", str);
BeaconPrintf(CALLBACK_ERROR,  "Error output: %d\n", code);

// Print raw bytes (for binary blobs)
BeaconOutput(CALLBACK_OUTPUT, buffer, buffer_len);

Argument Parsing

// datap = the beacon's argument parser
datap parser;
BeaconDataParse(&parser, args, len);   // initialize parser from go() arguments

// Extract in the same order the operator passed them:
char  *str_arg  = BeaconDataExtract(&parser, NULL);   // string
int    int_arg  = BeaconDataInt(&parser);              // 32-bit int
short  short_arg= BeaconDataShort(&parser);            // 16-bit

Memory Allocation

// Beacon-managed heap (do NOT use malloc/free in a BOF)
void *buf = intAlloc(size);    // allocate and zero
intFree(buf);                  // free

// For Win32 APIs that allocate for you (VirtualAlloc, HeapAlloc):
// Use the DLLNAME$FunctionName pattern — the beacon resolves these

API Resolution Pattern

BOFs cannot use the import table. All Win32 APIs must be declared with the DLLNAME$FunctionName macro, which tells the beacon's resolver where to find them.

// Format: RETURNTYPE CALLCONV DLLNAME$FunctionName(args...)
WINBASEAPI DWORD WINAPI KERNEL32$GetCurrentProcessId(void);
WINBASEAPI HANDLE WINAPI KERNEL32$OpenProcess(DWORD, BOOL, DWORD);
WINBASEAPI BOOL WINAPI KERNEL32$CloseHandle(HANDLE);
WINBASEAPI BOOL WINAPI ADVAPI32$OpenProcessToken(HANDLE, DWORD, PHANDLE);
WINBASEAPI BOOL WINAPI ADVAPI32$GetTokenInformation(HANDLE, TOKEN_INFORMATION_CLASS, LPVOID, DWORD, PDWORD);
WINBASEAPI BOOL WINAPI ADVAPI32$LookupPrivilegeNameA(LPCSTR, PLUID, LPSTR, LPDWORD);
WINBASEAPI NTSTATUS NTAPI NTDLL$NtQueryInformationProcess(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG);

// Usage: exactly the same as the regular Win32 call
HANDLE hProc = KERNEL32$OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid);

Common DLL prefixes:


BOF Examples

Example 1 — Minimal Whoami BOF

// whoami.c — prints current user, PID, and session ID
#include <windows.h>
#include "beacon.h"

WINBASEAPI DWORD  WINAPI KERNEL32$GetCurrentProcessId(void);
WINBASEAPI BOOL   WINAPI ADVAPI32$OpenProcessToken(HANDLE, DWORD, PHANDLE);
WINBASEAPI BOOL   WINAPI ADVAPI32$GetTokenInformation(HANDLE, TOKEN_INFORMATION_CLASS, LPVOID, DWORD, PDWORD);
WINBASEAPI BOOL   WINAPI ADVAPI32$LookupAccountSidA(LPCSTR, PSID, LPSTR, LPDWORD, LPSTR, LPDWORD, PSID_NAME_USE);
WINBASEAPI HANDLE WINAPI KERNEL32$OpenProcess(DWORD, BOOL, DWORD);
WINBASEAPI BOOL   WINAPI KERNEL32$CloseHandle(HANDLE);

void go(char *args, int len) {
    DWORD pid = KERNEL32$GetCurrentProcessId();
    HANDLE hProc = KERNEL32$OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid);
    HANDLE hToken = NULL;

    if (!ADVAPI32$OpenProcessToken(hProc, TOKEN_QUERY, &hToken)) {
        BeaconPrintf(CALLBACK_ERROR, "[-] OpenProcessToken failed: %lu\n", GetLastError());
        KERNEL32$CloseHandle(hProc);
        return;
    }

    // Get token user
    DWORD needed = 0;
    ADVAPI32$GetTokenInformation(hToken, TokenUser, NULL, 0, &needed);
    TOKEN_USER *tu = (TOKEN_USER *)intAlloc(needed);
    ADVAPI32$GetTokenInformation(hToken, TokenUser, tu, needed, &needed);

    char user[256] = {0}, domain[256] = {0};
    DWORD userlen = 256, domainlen = 256;
    SID_NAME_USE use;
    ADVAPI32$LookupAccountSidA(NULL, tu->User.Sid, user, &userlen, domain, &domainlen, &use);

    BeaconPrintf(CALLBACK_OUTPUT, "[+] PID: %lu\n", pid);
    BeaconPrintf(CALLBACK_OUTPUT, "[+] User: %s\\%s\n", domain, user);

    intFree(tu);
    KERNEL32$CloseHandle(hToken);
    KERNEL32$CloseHandle(hProc);
}

Example 2 — Process Enumeration BOF

// proclist.c — list running processes + owners without spawning tasklist.exe
#include <windows.h>
#include <tlhelp32.h>
#include "beacon.h"

WINBASEAPI HANDLE WINAPI KERNEL32$CreateToolhelp32Snapshot(DWORD, DWORD);
WINBASEAPI BOOL   WINAPI KERNEL32$Process32FirstW(HANDLE, LPPROCESSENTRY32W);
WINBASEAPI BOOL   WINAPI KERNEL32$Process32NextW(HANDLE, LPPROCESSENTRY32W);
WINBASEAPI HANDLE WINAPI KERNEL32$OpenProcess(DWORD, BOOL, DWORD);
WINBASEAPI BOOL   WINAPI KERNEL32$CloseHandle(HANDLE);
WINBASEAPI BOOL   WINAPI ADVAPI32$OpenProcessToken(HANDLE, DWORD, PHANDLE);
WINBASEAPI BOOL   WINAPI ADVAPI32$GetTokenInformation(HANDLE, TOKEN_INFORMATION_CLASS, LPVOID, DWORD, PDWORD);
WINBASEAPI BOOL   WINAPI ADVAPI32$LookupAccountSidA(LPCSTR, PSID, LPSTR, LPDWORD, LPSTR, LPDWORD, PSID_NAME_USE);

void go(char *args, int len) {
    HANDLE hSnap = KERNEL32$CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (hSnap == INVALID_HANDLE_VALUE) {
        BeaconPrintf(CALLBACK_ERROR, "[-] CreateToolhelp32Snapshot failed\n");
        return;
    }

    PROCESSENTRY32W pe;
    pe.dwSize = sizeof(PROCESSENTRY32W);
    BeaconPrintf(CALLBACK_OUTPUT, "%-8s %-8s %-40s %s\n", "PID", "PPID", "Name", "Owner");
    BeaconPrintf(CALLBACK_OUTPUT, "%-8s %-8s %-40s %s\n", "---", "----", "----", "-----");

    if (KERNEL32$Process32FirstW(hSnap, &pe)) {
        do {
            char name[MAX_PATH] = {0};
            WideCharToMultiByte(CP_ACP, 0, pe.szExeFile, -1, name, MAX_PATH, NULL, NULL);

            char owner[256] = "N/A";
            HANDLE hProc = KERNEL32$OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pe.th32ProcessID);
            if (hProc) {
                HANDLE hTok = NULL;
                if (ADVAPI32$OpenProcessToken(hProc, TOKEN_QUERY, &hTok)) {
                    DWORD needed = 0;
                    ADVAPI32$GetTokenInformation(hTok, TokenUser, NULL, 0, &needed);
                    TOKEN_USER *tu = (TOKEN_USER *)intAlloc(needed);
                    if (ADVAPI32$GetTokenInformation(hTok, TokenUser, tu, needed, &needed)) {
                        char user[128] = {0}, dom[128] = {0};
                        DWORD ul = 128, dl = 128;
                        SID_NAME_USE snu;
                        if (ADVAPI32$LookupAccountSidA(NULL, tu->User.Sid, user, &ul, dom, &dl, &snu)) {
                            _snprintf_s(owner, sizeof(owner), _TRUNCATE, "%s\\%s", dom, user);
                        }
                    }
                    intFree(tu);
                    KERNEL32$CloseHandle(hTok);
                }
                KERNEL32$CloseHandle(hProc);
            }

            BeaconPrintf(CALLBACK_OUTPUT, "%-8lu %-8lu %-40s %s\n",
                pe.th32ProcessID, pe.th32ParentProcessID, name, owner);

        } while (KERNEL32$Process32NextW(hSnap, &pe));
    }
    KERNEL32$CloseHandle(hSnap);
}

Example 3 — Registry Read BOF

// regread.c -- read a registry value (accepts key path and value name as args)
#include <windows.h>
#include "beacon.h"

WINBASEAPI LONG WINAPI ADVAPI32$RegOpenKeyExA(HKEY, LPCSTR, DWORD, REGSAM, PHKEY);
WINBASEAPI LONG WINAPI ADVAPI32$RegQueryValueExA(HKEY, LPCSTR, LPDWORD, LPDWORD, LPBYTE, LPDWORD);
WINBASEAPI LONG WINAPI ADVAPI32$RegCloseKey(HKEY);

void go(char *args, int len) {
    datap parser;
    BeaconDataParse(&parser, args, len);
    char *key_path   = BeaconDataExtract(&parser, NULL);  // e.g. "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"
    char *value_name = BeaconDataExtract(&parser, NULL);  // e.g. "ProductName"

    HKEY hKey;
    LONG rc = ADVAPI32$RegOpenKeyExA(HKEY_LOCAL_MACHINE, key_path, 0, KEY_READ, &hKey);
    if (rc != ERROR_SUCCESS) {
        BeaconPrintf(CALLBACK_ERROR, "[-] RegOpenKeyExA failed: %ld\n", rc);
        return;
    }

    char data[512] = {0};
    DWORD data_size = sizeof(data);
    DWORD data_type = 0;
    rc = ADVAPI32$RegQueryValueExA(hKey, value_name, NULL, &data_type, (LPBYTE)data, &data_size);
    if (rc == ERROR_SUCCESS) {
        BeaconPrintf(CALLBACK_OUTPUT, "[+] HKLM\\%s\\%s = %s\n", key_path, value_name, data);
    } else {
        BeaconPrintf(CALLBACK_ERROR, "[-] RegQueryValueExA failed: %ld\n", rc);
    }
    ADVAPI32$RegCloseKey(hKey);
}

Example 4 — NTAPI-Only BOF (Maximum Evasion)

Use NTDLL directly when you want to avoid kernel32.dll hook monitoring. EDRs commonly hook OpenProcess, VirtualAlloc, and WriteProcessMemory in kernel32 — the NTDLL equivalents are often unhookable or require ETW patching to detect.

// ntapi_pid.c -- get PID via NtQueryInformationProcess (no kernel32 OpenProcess)
#include <windows.h>
#include "beacon.h"

// NT typedefs not in MinGW headers
typedef LONG NTSTATUS;
#define STATUS_SUCCESS 0x00000000
typedef enum _PROCESSINFOCLASS { ProcessBasicInformation = 0 } PROCESSINFOCLASS;
typedef struct _PROCESS_BASIC_INFORMATION {
    PVOID Reserved1; PVOID PebBaseAddress; PVOID Reserved2[2]; ULONG_PTR UniqueProcessId; PVOID Reserved3;
} PROCESS_BASIC_INFORMATION;

WINBASEAPI NTSTATUS NTAPI NTDLL$NtQueryInformationProcess(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG);
WINBASEAPI HANDLE WINAPI KERNEL32$GetCurrentProcess(void);

void go(char *args, int len) {
    PROCESS_BASIC_INFORMATION pbi = {0};
    ULONG returned = 0;
    NTSTATUS status = NTDLL$NtQueryInformationProcess(
        KERNEL32$GetCurrentProcess(),
        ProcessBasicInformation,
        &pbi,
        sizeof(pbi),
        &returned
    );
    if (status == STATUS_SUCCESS) {
        BeaconPrintf(CALLBACK_OUTPUT, "[+] PID via NtQueryInformationProcess: %llu\n",
            (unsigned long long)pbi.UniqueProcessId);
    } else {
        BeaconPrintf(CALLBACK_ERROR, "[-] NtQueryInformationProcess failed: 0x%lx\n", status);
    }
}

Makefile Template

# Makefile — x64 BOF cross-compilation
CC_X64 = x86_64-w64-mingw32-gcc
CC_X86 = i686-w64-mingw32-gcc
CFLAGS = -masm=intel -Wall -Wno-unused-variable -Wno-int-to-pointer-cast
INCLUDES = -I./src

SRC = src/mybof.c
OUT_X64 = bin/mybof.x64.o
OUT_X86 = bin/mybof.x86.o

all: $(OUT_X64) $(OUT_X86)

$(OUT_X64): $(SRC)
	$(CC_X64) $(CFLAGS) $(INCLUDES) -o $@ -c $<

$(OUT_X86): $(SRC)
	$(CC_X86) $(CFLAGS) $(INCLUDES) -o $@ -c $<

clean:
	rm -f bin/*.o

Testing BOFs Without a C2

Running a BOF requires a C2 framework, but you can test the logic locally with a thin harness that stubs out the beacon API.

// harness.c -- local BOF test harness (compile and run natively on Windows)
#include <stdio.h>
#include <windows.h>

// Stub out the beacon API so the BOF compiles natively
typedef struct { char *original; int size; int offset; int length; } datap;

void BeaconDataParse(datap *parser, char *buffer, int size) {
    parser->original = buffer; parser->size = size; parser->offset = 0; parser->length = size;
}
char *BeaconDataExtract(datap *parser, int *size) {
    char *s = parser->original + parser->offset;
    parser->offset += (int)strlen(s) + 1;
    return s;
}
int BeaconDataInt(datap *parser) {
    int val = *(int *)(parser->original + parser->offset);
    parser->offset += 4;
    return val;
}
void BeaconPrintf(int type, const char *fmt, ...) {
    va_list args; va_start(args, fmt); vprintf(fmt, args); va_end(args);
}
void BeaconOutput(int type, const char *data, int len) { fwrite(data, 1, len, stdout); }
void *intAlloc(int size) { return calloc(1, size); }
void intFree(void *ptr) { free(ptr); }

// BOF-style API macros (resolve normally when running natively)
#define KERNEL32$GetCurrentProcessId GetCurrentProcessId
#define KERNEL32$OpenProcess         OpenProcess
#define KERNEL32$CloseHandle         CloseHandle
#define ADVAPI32$OpenProcessToken    OpenProcessToken
#define ADVAPI32$GetTokenInformation GetTokenInformation
#define ADVAPI32$LookupAccountSidA   LookupAccountSidA

// Include your BOF source inline
#include "mybof.c"

int main() {
    // Build args buffer if needed
    char args[256] = {0};
    go(args, 0);    // call the BOF entry point
    return 0;
}
# Compile harness natively on Windows (MinGW on Windows, or cross-compile for testing)
gcc -o harness.exe harness.c -ladvapi32 -lkernel32
.\harness.exe

Packaging BOFs for Sliver Armory

Sliver's armory supports BOFs via the armory command. To package:

// armory.json (manifest for your BOF package)
{
  "name": "mybof",
  "command_name": "mybof",
  "version": "1.0.0",
  "help": "My custom BOF — does X",
  "files": [
    {
      "name": "mybof.x64.o",
      "os": "windows",
      "arch": "amd64",
      "path": "mybof.x64.o"
    },
    {
      "name": "mybof.x86.o",
      "os": "windows",
      "arch": "386",
      "path": "mybof.x86.o"
    }
  ],
  "arguments": [
    {"name": "target", "type": "string", "desc": "Target hostname"}
  ]
}
# Install local BOF into Sliver armory
sliver > armory install /path/to/mybof-armory-bundle.tar.gz

# Or reference from Sliver session directly (no packaging needed)
sliver (beacon) > execute-bof /path/to/mybof.x64.o -- str "arg1" int 445

BOF OPSEC Notes


Quick Reference: BOF vs Other Execution Primitives

Primitive Process Disk 4688 Noisy Use When
BOF In-beacon No No Low Recon, token ops, quick API calls
execute-assembly New (CLR host) No Yes Medium .NET tooling (Rubeus, SharpHound)
shinject / spawnto New No Yes Medium Injecting shellcode into a clean process
shell / run New cmd/PS No Yes High Never in monitored env
upload + run New Yes Yes Very High Last resort only

Resources

Resource What it gives you
TrustedSec CS-Situational-Awareness-BOF 30+ ready-to-use recon BOFs + beacon.h
TrustedSec CS-Remote-OPs-BOF Token, injection, process ops BOFs
Outflank C2-Tool-Collection SharpHound BOF port, CredMan, Kerberoast
ajpc5/BOFs procdump, ETW patch, screenshot, token steal
EspressoCake NTAPI-only BOFs — no kernel32 hooks
BOF-vs-Rust comparison Sliver BOF loader documentation
Cobalt Strike BOF docs Canonical CS BOF reference

Part of the Red Teaming 101 series.