
Introduction
Beacon Object Files changed how we run code in memory on Windows. An operator compiles a small C file into an object file, the C2 ships it over the wire, and a loader on the target maps it and calls it without anything touching disk. All of it runs through the Cobalt Strike Beacon API, which is the part everyone copies.
I have been adding the same to my own C2, emp3r0r. The standalone teaching version is at linux-bof-loader; the one I actually ship is core/modules/coffloader_linux/loader_linux.c. It is not linked into the agent any more: the C2 hosts it as a dependency module (coffloader_linux.<arch>.gz) and either agent maps it on demand, once per BOF run. I will show the simple loader first, then go through every place it is wrong and how the in-tree version fixes it.
Also check out Starlark-based modules, which make C2 agents scriptable.
Wait, why not memfd_create + ELF?
It is shameful that you are even asking this question.
On Linux everything is a file, so memfd_create sounds like it runs your code in memory. Read it aloud: fd means file descriptor. Your "fileless" technique is not fileless. The kernel publishes a pseudo-file at /proc/<pid>/fd/<n> that points straight at your payload, and a cat dumps the whole thing. You almost certainly have to call execve (or fexecve) on it to run it anyway, which is no better than running the binary from disk, except now you also get a new process whose executable path is memfd:name (deleted), which is somehow louder than the disk version.
memfd_create is noisy and heavily monitored. MAP_ANONYMOUS, on the other hand, leaves no file descriptor and no pseudo-file behind: it is just a normal anonymous mapping, which is something every process on the box already has plenty of. If you are worried specifically about anonymous executable memory as a detection heuristic, then stomp a legitimate loaded library instead (module stomping) rather than going back to memfd.
And before anyone points at anonymous R-X memory as the giveaway: yes, it is an IOC, and no, a BOF does not try to pretend otherwise. What a BOF has going for it is time. It is not a resident implant that parks encrypted payloads in memory for hours; it is a function you call for one job and then unmap. Its code, its argument buffer, and its output exist for milliseconds. A scanner has to be looking at exactly the right process at exactly the right moment, and an EDR that samples on a timer or walks /proc/*/maps after an alert fires will usually arrive to find the mapping already gone. That transient lifetime is the biggest advantage of the whole model, but it is not the whole story, and it is worth being honest about what it does and does not buy you.
The part it does not buy you is invisibility at the syscall boundary. mmap(PROT_EXEC) and mprotect(PROT_EXEC) are syscalls, and on a monitored Linux host they are exactly the kind of thing a serious EDR watches. An eBPF program can attach to the raw syscall tracepoints or kprobe __x64_sys_mmap/__x64_sys_mprotect and read the flags, the caller, and a user stack trace; the LSM hooks around mmap/mprotect sit even closer to the kernel, and SELinux/AppArmor can deny anonymous executable mappings outright (execmem, execheap, execstack) so the BOF never runs at all. auditd can log both calls with a single rule. Note that indirect syscalls do not help here: the syscall instruction still fires the tracepoint no matter which gadget executes it, and routing through the vDSO only defeats userland hooks. Module stomping is not a free pass either, because making an existing R-X mapping writable and then executable again means you emit mprotect(RW) and mprotect(RX) anyway.
What saves the model in practice is noise. JITs, ld.so, libffi trampolines, thread stacks, dlopen, and all sorts of ordinary software create executable mappings constantly, so a naive "anonymous R-X mapping" alert is worthless and a useful rule needs process context, the caller stack, and ideally the payload. Most deployments do not have that correlation tuned, and the short lifetime means nobody can recover the content afterwards. So the accurate claim is narrower than "EDRs cannot see it": running for milliseconds defeats content scanning and post-hoc forensics, not a syscall-level sensor that is actually paying attention. Weigh that against your target's environment before you choose the technique.
Understanding BOFs: why object files?
A BOF is an ELF relocatable object (ET_REL): the intermediate file gcc -c produces before a linker turns it into a program. It has your code and data, but no load address and no resolved external calls. Wherever you reference a global or call printf, the compiler leaves a hole and records a relocation describing how to fill it.
Why load .o files instead of a full ELF or a shared object?
- Stealth. A full ELF means
execve, a.someansdlopen, and both leave traces in/proc/<pid>/maps, the dynamic loader, and the process image. An object file has no loader convention at all, so mapping it into an anonymous region and fixing it up by hand leaves much less behind. It is mapped, called, and unmapped for one task, so it only exists for milliseconds. - Size. An executable carries headers, segments, and padding. A
.ocarries only the sections you actually wrote. Over a C2 channel, that matters. - Position independence. A relocatable object assumes nothing about where it will live. It is meant to be placed anywhere and patched by a linker, which is exactly what a custom loader does.
The challenge: relocations on Linux
Compile a BOF with gcc -fPIC -c and the compiler leaves its holes in SHT_RELA sections (.rela.text and friends). The loader walks those, resolves each symbol, and patches the hole. On x86-64 the types you actually hit are:
R_X86_64_64: an absolute 64-bit address. Used for pointers in data.R_X86_64_32andR_X86_64_32S: absolute 32-bit addresses, for symbol values the compiler is sure will fit.R_X86_64_PC32: a 32-bit displacement relative to the place being patched. This is how ordinary function calls are encoded.R_X86_64_PLT32: the same idea, but emitted for a PLT entry. With direct binding we treat it exactly likePC32.
External calls are the interesting part. A normal binary resolves printf lazily through the GOT and PLT; the first call jumps into the dynamic loader, which fills in the real address. A BOF has no GOT, no PLT, and no dynamic loader watching, so I bind eagerly: when the loader sees a relocation against an undefined symbol, it asks the host for the address right now with dlsym(RTLD_DEFAULT, name) and patches the call site. No lazy resolution, no tables.
Here is the core of the loader:
for (int i = 0; i < ehdr->e_shnum; i++) {
if (shdrs[i].sh_type != SHT_RELA)
continue; /* only RELA on x86-64 */
uint32_t target_sec = shdrs[i].sh_info;
Elf64_Rela *rels = (Elf64_Rela *)(obj_buf + shdrs[i].sh_offset);
int num_rels = shdrs[i].sh_size / sizeof(Elf64_Rela);
for (int r = 0; r < num_rels; r++) {
Elf64_Rela rel = rels[r];
uint32_t sym_idx = ELF64_R_SYM(rel.r_info);
uint32_t type = ELF64_R_TYPE(rel.r_info);
uintptr_t patch_addr =
(uintptr_t)mem_base + sec_offsets[target_sec] + rel.r_offset;
Elf64_Sym sym = symtab[sym_idx];
uintptr_t sym_addr;
if (sym.st_shndx == SHN_UNDEF) {
/* external: printf, malloc, ... */
sym_addr = (uintptr_t)dlsym(RTLD_DEFAULT, strtab + sym.st_name);
} else {
/* internal: mem_base + section offset + symbol value */
sym_addr = (uintptr_t)mem_base + sec_offsets[sym.st_shndx] + sym.st_value;
}
switch (type) {
case R_X86_64_64:
*(uint64_t *)patch_addr = sym_addr + rel.r_addend;
break;
case R_X86_64_PC32:
case R_X86_64_PLT32:
*(uint32_t *)patch_addr =
(uint32_t)(sym_addr + rel.r_addend - patch_addr);
break;
/* ... R_X86_64_32 / R_X86_64_32S ... */
}
}
}
That is enough to run a simple BOF. It is also wrong in a couple of ways that only show up later.
The argument problem
The second problem is getting data into the BOF. A BOF is not a program; it is a single function with no argc/argv. I kept the Beacon convention, because that is what existing BOF source expects: one flat buffer of typed arguments.
The wire format is a 4-byte little-endian body length, then the arguments:
[uint32 body_len][arg1][arg2]...
Integers are 4 raw bytes, shorts are 2, and strings and blobs are a 4-byte length followed by the bytes. In the in-tree loader the strings also carry a terminator inside that length (narrow z is UTF-8 plus a NUL, wide Z is UTF-16LE plus a NUL), because BOFs routinely pass the extracted pointer to BeaconPrintf("%s", ...) and expect it to stop there. The standalone loader's CLI packer turns int:1337 str:"Hello World" short:25 into that buffer:
Buffer *pack_args(int argc, char **argv) {
Buffer *b = ...;
buf_write_int(b, 0); /* placeholder for the body length */
for (int i = 0; i < argc; i++) {
char *arg = argv[i];
char *val = strchr(arg, ':');
if (!val) {
fprintf(stderr, "missing type prefix on '%s'\n", arg);
return NULL;
}
*val = 0; val++;
if (strcmp(arg, "int") == 0)
buf_write_int(b, atoi(val));
else if (strcmp(arg, "short") == 0)
buf_write_short(b, (short)atoi(val));
else if (strcmp(arg, "str") == 0)
buf_write_str(b, val);
else if (strcmp(arg, "bin") == 0)
buf_write_binary(b, val);
}
int total = b->size - 4;
memcpy(b->buf, &total, 4); /* fill the placeholder */
return b;
}
In emp3r0r there is no CLI packer; the console fills the same structure from the module's declared parameters, tagging each value with a one-character wire type: i (int32), s (int16), z (NUL-terminated UTF-8), Z (NUL-terminated UTF-16LE), b (length-prefixed binary). Both agents pack it, core/lib/coffloader/bof_linux.go for Go and core/c_agent/src/bof.c for C, and the format is identical to the Windows COFFLoader format, so one console drives both platforms. On the BOF side you parse it with the usual Beacon calls:
void go(char *args, int len) {
datap parser;
BeaconDataParse(&parser, args, len);
char *who = BeaconDataString(&parser); /* 'S'/'z' argument */
BeaconPrintf(0, "Hello %s!", (who && who[0]) ? who : "World");
}
One detail: BeaconDataParse must skip the 4-byte body length, because the first thing it should read is the first argument, not the length. Getting that off by four bytes is the classic "my integer argument is 1337 and my string is garbage" bug.
Dealing with symbols, and the bug in the code above
The simple loader has a real flaw. R_X86_64_PC32 and R_X86_64_PLT32 store a signed 32-bit displacement. The loader allocates the BOF with mmap(NULL, ...), and the call target is printf inside libc, somewhere else entirely. If the two are more than 2 GB apart, the patch
*(uint32_t *)patch_addr = (uint32_t)(sym_addr + rel.r_addend - patch_addr);
silently drops the high bits. The call lands in garbage and the process dies. It usually works in a tiny test program and then fails on a real target with a different mmap layout and ASLR.
The in-tree loader fixes it with a trampoline. It reserves one page next to the BOF, and for every undefined symbol it builds a stub that can reach anywhere in 64 bits, then points the 32-bit call at the stub:
/* core/modules/coffloader_linux/loader_linux.c */
uint8_t *tramp = mem_base + trampoline_offset + (next_trampoline * 16);
next_trampoline++;
tramp[0] = 0x49;
tramp[1] = 0xbb; /* movabs r11, imm64 */
*(uint64_t *)(tramp + 2) = (uint64_t)handle;
tramp[10] = 0x41;
tramp[11] = 0xff;
tramp[12] = 0xe3; /* jmp r11 */
sym_addr = (uintptr_t)tramp;
It also checks the displacement before writing it, so an out-of-range relocation becomes a clear error instead of a corrupted call:
int64_t val = (int64_t)sym_addr + rel.r_addend - (int64_t)patch_addr;
if (val > 2147483647L || val < -2147483648L) {
return set_errf(err_buf,
"relocation overflow for symbol %s (type %u): "
"distance %ld exceeds 32 bits",
sym_name, type, val);
}
The trampoline page is mprotected R-X when the relocations are done. One 4 KB page holds 256 stubs, which is far more than any sane BOF needs.
What I changed when I moved this into emp3r0r
The standalone loader is fine for a tutorial, but the in-tree version is not the same code. A few changes matter.
W^X per section. The simple loader maps the whole BOF writable and then flips everything to PROT_READ | PROT_EXEC. That makes .data and .bss read-only and executable, so a BOF with a global counter segfaults the moment it writes to it. The in-tree loader page-aligns every SHF_ALLOC section and sets permissions per section: data gets RW, code gets RX, never both.
Crash isolation. A BOF is untrusted code, and a bad pointer in it should not kill the agent. The in-tree loader wraps the call in sigsetjmp/siglongjmp and temporarily installs handlers for SIGSEGV, SIGBUS, SIGILL, and SIGFPE. A crash longjmps back out, the handlers are restored, and the agent reports "BOF Crashed" instead of disappearing. It also serializes execution with a mutex, because a signal handler plus two concurrent BOFs is a recipe for very confusing bugs.
A real Beacon API. The simple loader resolved every undefined symbol with dlsym(RTLD_DEFAULT, ...), against whatever the host process exported into the global scope. The in-tree loader checks a small mock table first (BeaconPrintf, BeaconOutput, BeaconDataParse, BeaconDataInt, BeaconDataShort, BeaconDataLength, BeaconDataExtract) and only falls back to dlsym. That is what lets hello_linux be a normal BOF with #include "beacon_helpers.h" and an extern BeaconPrintf, while the host provides the implementation.
A stable ABI. The in-tree loader is not linked into the tester; it is a shared object with exactly three exported symbols. bof_run(obj, obj_len, func_name, args, args_len) returns a malloc'd output string, bof_last_error() describes the last failure, and bof_free() releases that string. Everything else is visibility-hidden. That is what lets the Go agent map it with memmod and the C agent map it with elfload, both for a single run, and it keeps RTLD_DEFAULT resolving to the platform libraries instead of the loader's or the agent's internal symbols.
Bounded parsing. The object is untrusted input, so the in-tree loader validates before it dereferences. Range helpers keep the section header table, every section payload, the symbol and string tables, each relocation's target section, and every symbol name inside the bytes it was handed, and the relocation writer refuses to patch past the end of its target section. A truncated or malformed .o comes back as an error string instead of a segfault.
The standalone loader is at jm33-m0/linux-bof-loader; the production one is in emp3r0r. Only the code in emp3r0r is maintained.
Comments
comments powered by Disqus