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.
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:
- Position-independent — no fixed base address, no imports resolved by the loader
- In-process — runs in the beacon thread; no child process creation
- Short-lived — runs to completion and returns; no persistent thread
- API-resolved by beacon — the beacon's internal API resolver handles all Win32 lookups
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:
KERNEL32$— kernel32.dll (process, memory, file, synchronization)NTDLL$— ntdll.dll (NT native API)ADVAPI32$— advapi32.dll (security, registry, services, tokens)USER32$— user32.dll (window, message, UI operations)WS2_32$— ws2_32.dll (Winsock — must load ws2_32 first)NETAPI32$— netapi32.dll (NetUser, NetShare APIs)SHLWAPI$— shlwapi.dll (shell utilities)
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
- No CRT — never
#include <stdio.h>or#include <string.h>in a production BOF. The CRT adds imports that break COFF loading and link the BOF to known library addresses. Use the Win32 API equivalents (lstrlenAinstead ofstrlen,RtlCopyMemoryinstead ofmemcpy). - No global state — BOFs don't have BSS or initialized data sections in the traditional sense. Keep everything stack-local or
intAlloc-allocated. - Short execution — BOFs run synchronously in the beacon thread. Long-running operations block the entire beacon. If a task takes > a few seconds, consider a separate injected thread or a .NET assembly instead.
- Error handling — always check return values and
CloseHandle. A leaked handle inside the beacon process is detectable by EDR memory scanners. - NTAPI preference — for process/memory operations, prefer
NTDLL$NtXxxoverKERNEL32$Xxx— EDRs hook kernel32 wrappers more reliably than NTDLL stubs. - No network calls from BOF — do not attempt socket connections from a BOF. Use the C2 channel for data exfil, or inject a loader that handles networking.
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.