banner

Introduction

Beacon Object Files (BOFs) changed how we run code in memory on Windows. It is a nice workflow: an operator compiles a small C file into an object file, the C2 ships the object over the wire, and a loader on the target maps it and calls it without anything touching disk. The whole thing runs through the Cobalt Strike Beacon API, which is the part everyone copies now.

I have been adding this to my own C2, emp3r0r, and this post is what I learned making it work on Linux. The standalone teaching implementation is at linux-bof-loader; the version I actually ship lives in core/lib/coffloader/loader_linux.c. I will show the simple one first, because it is easier to read, then explain every place it is wrong and how the in-tree one fixes it. That is the honest version of how this went.

Also check out Starlark-based modules that makes 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 contains 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 saying how to fill it.

Why load .o files instead of dropping a full ELF or a shared object?

  1. Stealth. Running a standard ELF means execve, and running a .so means dlopen, and both leave obvious traces (/proc/<pid>/maps, the dynamic loader, a new 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 far less behind, and because the BOF is mapped, called, and unmapped for a single task, that region exists only for milliseconds.
  2. Size. An executable carries headers, segments, and padding. A .o carries only the sections you actually wrote. Over a C2 channel, that difference matters.
  3. Position independence by construction. 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. An executable has a preferred load address and fight-or-flight semantics around it; an object file just cooperates.

The challenge: relocations on Linux

Windows uses COFF, Linux uses ELF. The concepts rhyme and the details do not.

When you compile a BOF with gcc -fPIC -c, the compiler leaves holes and records them in SHT_RELA sections (.rela.text and friends). The loader walks those, resolves each symbol, and patches the hole. For 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_32 and R_X86_64_32S: absolute 32-bit addresses. Common for symbol values that 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: same idea, but the compiler emitted it expecting a PLT entry. With direct binding we treat it exactly like PC32.

The interesting part is external calls. A normal Linux 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 neither a GOT nor a PLT and no dynamic loader paying attention, so I do "eager binding" instead: when the loader sees a relocation against an undefined symbol, it asks the host process for the address right now with dlsym(RTLD_DEFAULT, name) and patches the call site with that address. 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, which we will get to.

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 everyone's BOF source already expects: a flat buffer of typed arguments.

The wire format is a 4-byte little-endian length for the body, followed by 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. The standalone loader's CLI packer turns int:1337 str:"Hello World" short:25 into exactly 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 this is not a CLI packer any more; the operator console fills the same structure from the module's declared parameters. The point is that the wire format is identical to the Windows COFFLoader format, so one operator UI can drive 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");
}

An important detail hidden in there: BeaconDataParse must skip the 4-byte body length, because what it reads first is the first typed 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 BOF's call target is printf inside libc, somewhere else entirely. If the two are more than 2 GB apart, this:

*(uint32_t *)patch_addr = (uint32_t)(sym_addr + rel.r_addend - patch_addr);

silently throws away the high bits. The call lands in garbage and the process dies. It often works in a tiny test program, and then fails on a real target with a different mmap layout and ASLR.

The fix in the in-tree emp3r0r loader is a trampoline. It reserves one page next to the BOF and, for every undefined symbol, builds a small stub that can reach anywhere in 64 bits, then points the BOF's 32-bit call at the stub:

/* core/lib/coffloader/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 direct relocation produces 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)", ...);
}

The trampoline page is mprotected R-X when the relocations are done. One 4 KB page holds 256 of these 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. Three changes matter.

W^X per section. The simple loader maps the whole BOF writable, 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, and code and data share a permission. 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 a BOF 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 BOFs at once is a recipe for very confusing bugs.

A real Beacon API. The simple loader resolved every undefined BOF symbol with dlsym(RTLD_DEFAULT, ...), i.e. against whatever the host process has 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 after that. 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. The loader and the agent's C glue are also compiled visibility-hidden, so RTLD_DEFAULT resolves to the platform libraries rather than to every internal symbol the agent happens to define.

Closing thoughts

The relocatable-object trick is genuinely useful on Linux, and it is not as hard as it looks once you stop trying to reimplement the dynamic linker. The hard parts are all small and specific: the relocations, the 4-byte argument header, and the 32-bit reach of PC32 calls. Fix those and you have a portable, diskless BOF workflow that shares its wire format with your Windows tooling.

The standalone loader is at jm33-m0/linux-bof-loader; the hardened one is in emp3r0r. Start with the first to understand it, ship the second.


Comments

comments powered by Disqus