A static library for loading and executing ELF Beacon Object Files (BOFs) in-memory on Linux. BOFs are position-independent .o files compiled from C that run inside the host process without touching disk.
This SDK is a standalone, agent-agnostic library. It has no dependency on any specific C2 framework or agent implementation. Any Linux C2 agent written in C can integrate it by linking the static library and including the headers.
The NaxDarks Linux Agent is one consumer of this SDK, but the SDK is developed and versioned independently.
The included BOF Loader (bof-loader/) is a standalone interactive shell that allows BOF developers to compile and test BOFs without deploying a C2 server or agent. This makes the development cycle fast: edit → compile → test → iterate, all from a terminal.
- In-memory ELF loading — mmap → relocate → execute → zero → munmap
- Multi-architecture — x86_64 and ARM64 ELF relocation support (BOF execution)
- Cross-compilation — SDK library builds for x64 and ARM64 (agent linking)
- Async execution — run BOFs in background threads (up to 8 concurrent)
- Cooperative cancellation — stop pipe for graceful
jobkill - Custom symbol registration — expose agent-internal functions to BOFs
- Thread-safe output —
__threadbuffers prevent interference between concurrent BOFs - OPSEC cleanup — zero memory and deep-copy sensitive data before freeing
- Dynamic libc resolution — BOFs can call any function from any loaded DSO via
LIBC$,RTLD$, orLIBXXX$prefixes without dlsym - Explicit library resolution — BOFs can resolve symbols from a specific shared library via
LIBXXX$prefix (e.g.,LIBZ$zlibVersion) - Automatic BOF dependency management — pre-validates and optionally loads required DSOs before BOF execution (
bof_deps.c) - Configurable dlopen policy — strict mode (no new DSO loads) or flexible mode (auto-load missing dependencies)
| Architecture | Package | Compiler |
|---|---|---|
| x64 | gcc (default) |
gcc |
| ARM64 | gcc-aarch64-linux-gnu |
aarch64-linux-gnu-gcc |
Install all cross-compilers:
sudo apt install gcc-aarch64-linux-gnuThe SDK also requires libdl at link time (-ldl) for the dependency manager when BOF_DEPS_ALLOW_DLOPEN=1 (default).
make # Build all architectures
make x64 # Build only x86_64
make arm64 # Build only ARM64
make clean # Remove build artifacts| Option | Default | Description |
|---|---|---|
BOF_DEPS_ALLOW_DLOPEN=1 |
1 | Flexible mode — missing DSOs are found and loaded via dlopen() before BOF execution |
BOF_DEPS_ALLOW_DLOPEN=0 |
— | Strict mode — BOF is rejected if any required DSO is not already loaded |
make x64 BOF_DEPS_ALLOW_DLOPEN=0 # Strict mode buildOutput:
lib/
├── libelf_bof_x64.a
├── libelf_bof_arm64.a
By default the SDK includes the full Ax* wrapper API — convenience functions like AxOpenFile, AxMalloc, AxSocket, AxStrlen, etc. that wrap common libc and syscall functions. They exist purely to make BOF development easier: instead of resolving open dynamically, a BOF developer can call AxOpenFile directly. These wrappers have no dependency on any specific C2 agent — any agent that integrates this SDK can expose them.
If you prefer BOFs that do not rely on the Ax* wrappers at all — for example to keep BOF binaries leaner or to enforce use of the dynamic resolution system — compile with -DNAX_BOF_STANDALONE to strip all Ax* declarations, implementations and symbol table entries. BOFs must then resolve functions directly using the LIBRARY$Function syntax that the ELF BOF loader resolves at runtime (e.g. LIBC$open, LIBC$malloc). The Beacon* API remains fully available in both modes.
make standalone # Both architectures, standalone
make standalone-x64 # x64 only
make standalone-arm64 # ARM64 onlyOutput:
lib/
├── libelf_bof_x64.a
├── libelf_bof_arm64.a
| Mode | Beacon* API |
Ax* wrappers |
AxGetGlobal / AxSetGlobal |
|---|---|---|---|
Default (make) |
✓ | ✓ | ✓ |
Standalone (make standalone) |
✓ | ✗ | ✓ |
If a cross-compiler is not installed, the target fails with the exact package name to install.
The in-memory ELF loader supports relocations for two architectures:
| Architecture | Relocations | BOF execution |
|---|---|---|
| x86_64 | R_X86_64_64, R_X86_64_PC32, R_X86_64_PLT32, R_X86_64_32, R_X86_64_32S |
✅ Supported |
| ARM64 | R_AARCH64_ABS64, R_AARCH64_CALL26, R_AARCH64_JUMP26, R_AARCH64_ADR_PREL_PG_HI21, R_AARCH64_ADD_ABS_LO12_NC, R_AARCH64_LDST* |
✅ Supported |
The static libraries build for both architectures.
| Library | Target | BOF execution |
|---|---|---|
libelf_bof_x64.a |
x86_64 agents | ✅ Full BOF support |
libelf_bof_arm64.a |
ARM64 agents | ✅ Full BOF support |
The loader (elf_bof.c) performs these steps for each BOF execution:
┌─ Step 1: Validate ELF header
│ Check: magic bytes, ELFCLASS64, ET_REL, EM_X86_64 or EM_AARCH64
│
├─ Step 2: Allocate arena
│ mmap(RW) a contiguous block for all loadable sections + trampolines
│
├─ Step 3: Load sections
│ Copy .text, .data, .rodata, .bss into the arena
│ Track section bases for relocation
│
├─ Step 4: Discover dependencies (bof_deps_prepare)
│ Scan .symtab for undefined symbols with LIBXXX$ prefix
│ For each library hint:
│ - Check if DSO is already in runtime cache → skip
│ - If missing and BOF_DEPS_ALLOW_DLOPEN=1 → locate on disk + dlopen()
│ - If missing and BOF_DEPS_ALLOW_DLOPEN=0 → abort with dependency error
│
├─ Step 5: Resolve symbols
│ For each symbol in .symtab:
│ - Local/defined: compute address from section base
│ - Undefined (Ax*/Beacon*/LIBC$/RTLD$/LIBXXX$): look up in bof_resolve_symbol()
│ → Layer 1: custom symbol table (nax_bof_resolve_custom)
│ → Layer 2: base API table (bof_api_table)
│ → Layer 3: runtime DSO resolution (in-memory DSO cache walk)
│ - LIBC$foo → strip prefix → resolve "foo" from loaded DSOs
│ - RTLD$foo → strip prefix → global search across all loaded DSOs
│ - LIBXXX$foo → strip prefix, match DSO "xxx" → resolve "foo" from it
│ - Unresolved: abort with "Unresolved: <name>" error
│
├─ Step 6: Apply relocations
│ x86_64: R_X86_64_64, R_X86_64_PC32, R_X86_64_PLT32, R_X86_64_32/32S
│ ARM64: R_AARCH64_ABS64, R_AARCH64_CALL26, R_AARCH64_JUMP26,
│ R_AARCH64_ADR_PREL_PG_HI21, R_AARCH64_ADD_ABS_LO12_NC,
│ R_AARCH64_LDST{8,16,32,64,128}_ABS_LO12_NC
│ Trampolines: for calls beyond ±128MB on ARM64
│
├─ Step 7: Set memory permissions
│ .text sections: mprotect(RX) — executable, not writable
│ .data/.bss: remain RW
│
├─ Step 8: Execute
│ bof_output_init() → entry("go") → bof_output_get()
│
└─ Step 9: OPSEC cleanup
Zero arena memory → munmap → free symbol values
A BOF fails at load time if it uses symbols not in the API table and not resolvable at runtime. The loader reports the exact symbol name:
BOF error: Unresolved: dlopen
Common failure causes:
| Symptom | Cause | Fix |
|---|---|---|
Unresolved: dlopen |
BOF calls dlopen() directly (as an unregistered symbol) |
Use RTLD$dlopen or LIBC$dlopen for runtime resolution |
Unresolved: printf |
BOF calls libc directly | Use BeaconPrintf() or LIBC$printf |
Unresolved: malloc |
BOF calls libc directly | Use AxMalloc() or LIBC$malloc |
Unresolved: MyFunc |
BOF calls a custom function | Register with nax_bof_register_symbol() |
Unresolved: LIBZ$zlibVersion |
libz not loaded and dlopen disabled | Set BOF_DEPS_ALLOW_DLOPEN=1 or preload libz |
relocation failed |
Unsupported relocation type | Check compiler flags: -fPIC -c required |
dependency error: libxyz |
Required DSO not found on disk | Install the library package |
The example bof_fail.c demonstrates this intentionally — it calls dlopen() directly as an undefined symbol (not via LIBC$ or RTLD$), producing a clear error message.
| Function | Description |
|---|---|
BeaconDataParse(datap *p, char *buf, int sz) |
Initialize argument parser |
BeaconDataInt(datap *p) |
Extract 4-byte int (little-endian) |
BeaconDataShort(datap *p) |
Extract 2-byte short |
BeaconDataLength(datap *p) |
Remaining unparsed bytes |
BeaconDataExtract(datap *p, int *sz) |
Extract length-prefixed string |
BeaconOutput(int type, char *data, int len) |
Emit raw output bytes |
BeaconPrintf(int type, char *fmt, ...) |
Emit formatted output |
BeaconFormatAlloc(formatp *f, int maxsz) |
Allocate format buffer |
BeaconFormatReset/Append/Printf/ToString/Free/Int |
Format buffer operations |
BeaconIsAdmin() |
Returns 1 if euid == 0 |
BeaconGetStopJobEvent() |
Returns poll fd for async stop (-1 if sync) |
Output types: CALLBACK_OUTPUT (0x00), CALLBACK_ERROR (0x0D).
| Function | Description |
|---|---|
AxSetGlobal(key, ptr) |
Store a pointer by name (max 16, key must be a string literal) |
AxGetGlobal(key) |
Retrieve a pointer by name (NULL if missing) |
Pointers persist in the host process heap across BOF executions. Used for producer/consumer patterns (e.g., keylogger).
| Wrapper | Underlying call |
|---|---|
AxOpenFile(path, flags, mode) |
open() |
AxCloseFile(fd) |
close() |
AxReadFile(fd, buf, count) |
read() |
AxWriteFile(fd, buf, count) |
write() |
AxReadFileToBuffer(path, &buf, max) |
open + read + malloc |
AxFileStat(path, &mode, &size, &uid, &gid) |
stat() |
AxStatvfs(path, &buf) |
statvfs() — filesystem space and mount info |
AxFstatvfs(fd, &buf) |
fstatvfs() — same, via open file descriptor |
AxOpenDir(path) |
open(O_DIRECTORY) |
AxReadDir(fd, buf, size) |
getdents64 |
AxFileTime(path, &atime, &mtime) |
stat() (timestamps) |
AxSetFileTime(path, atime, mtime) |
utimensat() |
AxRealpath(path, resolved) |
realpath() |
| Wrapper | Description |
|---|---|
AxMalloc(size) / AxFree(ptr) |
Heap allocation |
AxMemset / AxMemcpy / AxMemmove |
Memory operations |
AxMemcmp(a, b, n) |
Memory comparison |
AxMemfdCreate(name, flags) |
Create anonymous memory-backed file descriptor |
AxStrlen / AxStrcmp / AxStrncmp |
String comparison |
AxStrcpy / AxStrncpy / AxStrcat |
String copy/concat |
AxStrstr / AxStrchr |
String search |
AxSnprintf(buf, size, fmt, ...) |
Formatted string |
| Wrapper | Description |
|---|---|
AxGetPid() / AxGetUid() / AxGetEuid() |
PID and UID |
AxGetCwd(buf, size) |
Current working directory |
AxGetEnv(name, buf, size) |
Read from /proc/self/environ |
| Wrapper | Underlying call |
|---|---|
AxFork() |
fork() |
AxExecve(path, argv, envp) |
execve() |
AxExecveat(dirfd, path, argv, envp, flags) |
execveat() — execute relative to directory fd |
AxFexecve(fd, argv, envp) |
fexecve() — execute from open file descriptor |
AxWaitpid(pid, &status, opts) |
waitpid() |
AxKill(pid, sig) |
kill() |
AxExit(status) |
exit() — terminate current process |
AxPipe(fd[2]) |
pipe() — create anonymous pipe |
AxSocketpair(domain, type, protocol, sv[2]) |
socketpair() — create connected socket pair |
AxPtrace(req, pid, addr, data) |
ptrace() |
AxProcessVmReadv / AxProcessVmWritev |
Cross-process memory R/W |
AxPidfdOpen(pid, flags) |
pidfd_open() |
AxPidfdGetfd(pidfd, targetfd, flags) |
pidfd_getfd() |
Note:
AxClonehas been removed. The previous implementation executedSYS_cloneraw without runningfn(arg)in the child, which did not matchclone(2)semantics. UseAxFork()for standard process creation, orLIBC$clonefor fullclone()semantics.
| Wrapper | Description |
|---|---|
AxSocket / AxConnect / AxBind / AxListen / AxAccept |
Socket lifecycle |
AxSend / AxRecv |
TCP send/receive |
AxSendto / AxRecvfrom |
UDP send/receive |
AxClose(fd) |
Close socket |
AxSetsockopt / AxGetsockopt |
Socket options |
AxInetPton / AxInetNtop / AxInetNtoa |
Address conversion |
AxHtons / AxNtohs |
Byte order |
| Wrapper | Underlying call |
|---|---|
AxChdir / AxFchdir |
Change directory |
AxChmod / AxFchmod |
Change permissions |
AxChown / AxFchown |
Change ownership |
AxSymlink / AxReadlink |
Symbolic links |
AxDup / AxDup2 |
Duplicate file descriptors |
AxIoctl(fd, request, ...) |
Device control |
| Wrapper | Description |
|---|---|
AxGetErrno() |
Current errno value |
AxFcntl(fd, cmd, arg) |
File descriptor control |
AxPoll(fds, nfds, timeout_ms) |
Poll file descriptors |
AxSysinfo(&info) |
System info (uptime, RAM, load) |
AxUname(&buf) |
Kernel version and hostname |
| Wrapper | Description |
|---|---|
AxLocaltime(timer) |
Convert time_t to local struct tm |
| Wrapper | Underlying call |
|---|---|
AxIoUringSetup(entries, ¶ms) |
io_uring_setup() |
AxIoUringEnter(fd, submit, complete, flags) |
io_uring_enter() |
AxIoUringRegister(fd, opcode, arg, nr) |
io_uring_register() |
The async system (bof_async.c) manages background BOF execution:
Main Thread Worker Thread (job #N)
─────────── ──────────────────────
nax_async_start(bof, args)
├─ deep-copy bof_data + args
├─ bof_deps_prepare() (dependency check)
├─ create stop_pipe
└─ pthread_create ──────────────────→ nax_bof_execute()
└─ go(args, len)
nax_async_drain(callback) └─ BeaconPrintf(...)
└─ if completed: └─ check should_stop()
callback(output) └─ return
free + cleanup job.state = COMPLETED
nax_async_kill(job_idx)
└─ write(stop_pipe[1]) ─────────────→ BOF detects via poll(stop_pipe[0])
| Property | Value |
|---|---|
| Max concurrent jobs | 8 |
| Output isolation | __thread buffer per thread |
| Stop mechanism | Cooperative — BOF must poll BeaconGetStopJobEvent() |
| Memory safety | Deep copies of BOF data; zero before free |
| Result delivery | Via callback in nax_async_drain() |
| Thread model | pthread_create + pthread_detach |
This section covers the API surface the agent uses to drive the SDK: initialization, synchronous execution, and the full async lifecycle (start → drain → list → kill).
The single umbrella header covers everything:
#include "nax_bof_sdk.h"void nax_bof_sdk_init(void);Call once at agent startup, before any BOF execution or symbol registration. Initializes the async job system and the extended symbol table. Not thread-safe — call from the main thread before spawning worker threads.
void nax_https_main(NaxAgent *a) {
nax_bof_sdk_init();
/* ... register custom symbols, start transport loop ... */
}int nax_bof_execute(const uint8_t *elf_data, uint32_t elf_size,
const uint8_t *args, uint32_t args_size,
const char *entry_name,
char **output, uint32_t *output_len);Loads and runs a BOF in the calling thread. Blocks until go() returns.
| Parameter | Description |
|---|---|
elf_data / elf_size |
Raw .o file bytes |
args / args_size |
Packed argument buffer (CS format); pass NULL/0 if the BOF takes no arguments |
entry_name |
Entry function name — always "go" for standard BOFs |
output |
Receives a malloc'd string with all BeaconPrintf/BeaconOutput output; caller must free() |
output_len |
Receives the output length in bytes |
Returns 0 on success, -1 on error. On error, *output contains the error message (e.g., "Unresolved: dlopen").
char *output = NULL;
uint32_t output_len = 0;
int rc = nax_bof_execute(bof_data, bof_size,
args, args_size,
"go",
&output, &output_len);
if (rc == 0) {
/* send output to C2 */
send_result(output, output_len);
} else {
send_error(output, output_len);
}
free(output);int nax_async_start(uint32_t task_id,
const uint8_t *bof_data, uint32_t bof_size,
const uint8_t *args_data, uint32_t args_size);Launches the BOF in a background pthread. bof_data and args_data are deep-copied internally — the caller can free them immediately after the call returns.
| Parameter | Description |
|---|---|
task_id |
Caller-assigned identifier used to correlate results in the drain callback |
bof_data / bof_size |
Raw .o file bytes |
args_data / args_size |
Packed argument buffer; pass NULL/0 if none |
Returns the job index (0–7) on success, or -1 if all slots are occupied (MAX_ASYNC_JOBS = 8).
int job_idx = nax_async_start(task->id,
bof_data, bof_size,
args_data, args_size);
if (job_idx < 0) {
/* all 8 slots busy */
send_error_to_c2("async slots full");
}typedef void (*async_result_cb)(uint32_t task_id, uint8_t status,
const char *output, uint32_t output_len,
void *user_data);
void nax_async_drain(async_result_cb cb, void *user_data);Checks all job slots for completed BOFs and invokes cb for each one found. The slot is freed after the callback returns. Call this periodically from the agent's heartbeat loop.
| Callback parameter | Description |
|---|---|
task_id |
The task_id passed to nax_async_start |
status |
NAX_STATUS_OK (0x00) or NAX_STATUS_ERR (0x01) |
output / output_len |
BOF output; valid only for the duration of the callback |
user_data |
Opaque pointer forwarded from nax_async_drain |
static void on_bof_complete(uint32_t task_id, uint8_t status,
const char *output, uint32_t output_len,
void *user_data) {
NaxAgent *agent = (NaxAgent *)user_data;
send_result_to_c2(agent, task_id, status, output, output_len);
}
/* In the heartbeat loop: */
nax_async_drain(on_bof_complete, agent);int nax_async_list(char *buf, int cap);Writes a formatted list of currently running async BOFs to buf. Returns the number of bytes written. Pass the result to BeaconPrintf or send it directly to the C2.
char list[1024];
uint32_t list_len = (uint32_t)nax_async_list(list, sizeof(list));
send_result_to_c2(agent, task_id, NAX_STATUS_OK, list, list_len);Example output:
job 0 task=1001 state=RUNNING
job 2 task=1003 state=RUNNING
int nax_async_kill(int job_idx);Signals the BOF in slot job_idx to stop by writing to its stop pipe. The BOF must be checking BeaconGetStopJobEvent() cooperatively; if it does not poll the stop event, the signal has no immediate effect. Returns 0 on success, -1 if the slot is not active.
int rc = nax_async_kill(job_idx);
if (rc != 0) {
send_error_to_c2("invalid or inactive job index");
}Note: A killed job's output is discarded —
nax_async_drainwill not deliver a result for it. The slot is freed by the worker thread once it detects the stop signal.
#include "nax_bof_sdk.h"
/* ── Startup ── */
void agent_init(NaxAgent *a) {
nax_bof_sdk_init();
nax_bof_register_symbol("AgentSendFile", agent_send_file);
}
/* ── Heartbeat loop (called every ~1 s) ── */
void agent_heartbeat(NaxAgent *a) {
nax_async_drain(on_bof_complete, a);
}
/* ── Dispatch incoming C2 task ── */
void agent_dispatch(NaxAgent *a, NaxTask *t) {
switch (t->type) {
case TASK_BOF_SYNC: {
char *out = NULL;
uint32_t len = 0;
int rc = nax_bof_execute(t->bof_data, t->bof_size,
t->args, t->args_size,
"go", &out, &len);
send_result_to_c2(a, t->id, rc == 0 ? NAX_STATUS_OK : NAX_STATUS_ERR, out, len);
free(out);
break;
}
case TASK_BOF_ASYNC: {
int job = nax_async_start(t->id,
t->bof_data, t->bof_size,
t->args, t->args_size);
if (job < 0)
send_error_to_c2(a, t->id, "async slots full");
break;
}
case TASK_BOF_LIST: {
char buf[1024];
uint32_t len = (uint32_t)nax_async_list(buf, sizeof(buf));
send_result_to_c2(a, t->id, NAX_STATUS_OK, buf, len);
break;
}
case TASK_BOF_KILL: {
int job_idx = *(int *)t->args;
int rc = nax_async_kill(job_idx);
if (rc != 0)
send_error_to_c2(a, t->id, "invalid job index");
break;
}
}
}The SDK allows agents to expose internal functions to BOFs via nax_bof_register_symbol(). This enables BOFs to call agent-specific functionality without modifying the SDK.
A common use case is implementing a ZIP compression BOF. A BOF cannot link against -lz at compile time because it's compiled as a relocatable object (.o). However, the agent can link against zlib. The solution is to link the agent with -lz and expose the necessary zlib functions to the BOF.
exported.h
#ifndef NAX_EXPORTED_H
#define NAX_EXPORTED_H
#include <zlib.h>
uLong AgentCrc32(uLong crc, const Bytef *buf, uInt len);
int AgentDeflateInit2(z_streamp strm, int level, int method,
int windowBits, int memLevel, int strategy,
const char *version, int stream_size);
int AgentDeflate(z_streamp strm, int flush);
int AgentDeflateEnd(z_streamp strm);
uLong AgentCompressBound(uLong sourceLen);
#endifexported.c
#include "exported.h"
#include <zlib.h>
uLong AgentCrc32(uLong crc, const Bytef *buf, uInt len)
{
return crc32(crc, buf, len);
}
int AgentDeflateInit2(z_streamp strm, int level, int method,
int windowBits, int memLevel, int strategy,
const char *version, int stream_size)
{
return deflateInit2_(strm, level, method, windowBits, memLevel,
strategy, version, stream_size);
}
int AgentDeflate(z_streamp strm, int flush)
{
return deflate(strm, flush);
}
int AgentDeflateEnd(z_streamp strm)
{
return deflateEnd(strm);
}
uLong AgentCompressBound(uLong sourceLen)
{
return compressBound(sourceLen);
}https.c (or any transport)
#include "exported.h"
void nax_https_main(NaxAgent *a)
signal(SIGPIPE, SIG_IGN);
gen_session_id(a->session_id);
nax_bof_sdk_init();
nax_bof_register_symbol("AgentCrc32", AgentCrc32);
nax_bof_register_symbol("AgentDeflateInit2", AgentDeflateInit2);
nax_bof_register_symbol("AgentDeflate", AgentDeflate);
nax_bof_register_symbol("AgentDeflateEnd", AgentDeflateEnd);
nax_bof_register_symbol("AgentCompressBound", AgentCompressBound);
NaxSysInfo info;
/*...*/bof_zip.c
#include "bof_api.h"
/* Declare agent wrappers using primitive C types — no <zlib.h> needed.
* The BOF must not link against libz; it calls the agent's wrappers instead. */
extern unsigned long AgentCrc32(unsigned long crc, const unsigned char *buf, unsigned int len);
extern int AgentDeflateInit2(void *strm, int level, int method,
int windowBits, int memLevel, int strategy,
const char *version, int stream_size);
extern int AgentDeflate(void *strm, int flush);
extern int AgentDeflateEnd(void *strm);
extern unsigned long AgentCompressBound(unsigned long sourceLen);
void go(char *args, int args_len) {
/* ... uses AgentCrc32, AgentDeflate, etc. ... */
}BOF calls AgentCrc32()
└─ bof_resolve_symbol("AgentCrc32")
├─ 1. nax_bof_resolve_custom() → checks custom table → FOUND ✓
└─ 2. bof_api_table[] → checks base Ax*/Beacon* table (fallback)
| Aspect | Benefit |
|---|---|
Security |
The agent controls exactly what functions are exposed (whitelist) |
No dlopen |
No need to expose dlopen or load libraries at runtime |
No external dependencies |
The BOF doesn't depend on libz.so being present on the target |
Small BOF size |
The BOF contains only orchestration logic, not the entire compression library |
Portability |
Works on any system where the agent runs |
- Maximum 64 custom symbols. Register before spawning any BOF threads.
- The agent must be linked with -lz to provide the underlying zlib implementation (for this example)
BOFs can resolve symbols from the host process's loaded shared libraries at runtime without dlsym or -ldl dependencies. Three prefixes are supported, each with different search semantics.
BOFs can call any exported function from the host's loaded DSOs using the LIBC$ prefix. The SDK resolves these by walking the in-memory DSO cache (populated from /proc/self/maps) — no dlsym, no -ldl dependency.
BOF calls LIBC$opendir("/etc")
└─ bof_resolve_symbol("LIBC$opendir")
├─ Layer 1: custom table → miss
├─ Layer 2: Ax* table → miss
└─ Layer 3: strip "LIBC$" → "opendir"
├─ Walk runtime DSO cache (all loaded shared libraries)
│ → locate DSO exporting "opendir" (typically libc.so)
│ → resolve via .gnu_hash / SysV hash walk
└─ Return resolved address
After the first call, the DSO cache is populated. Subsequent resolutions only perform the hash walk (~nanoseconds).
Not every libc function is exported as a symbol in libc.so. Functions that the compiler implements as builtins or inline expansions — strlen, memset, memcpy, memcmp, memmove, strcmp, strcpy, strncpy, snprintf, etc. — are typically absent from the DSO's export table. Attempting to resolve them with LIBC$strlen or similar will fail at runtime with Unresolved: LIBC$strlen.
In default mode the Ax* wrappers (AxStrlen, AxMemset, AxStrcmp, etc.) cover this gap — they are registered in the SDK's own symbol table and available to every BOF without dynamic resolution.
In standalone mode (or when writing portable BOFs without Ax* wrappers), implement these functions inline in the BOF itself. This is the safest approach regardless of build mode, since it has no runtime dependency at all:
/* Portable inline implementations — no symbol resolution required */
static int bof_strlen(const char *s) {
int n = 0; while (s && s[n]) n++; return n;
}
static void bof_memset(void *s, int c, int n) {
unsigned char *p = s; while (n-- > 0) *p++ = (unsigned char)c;
}
static int bof_strncmp(const char *a, const char *b, int n) {
while (n-- > 0) {
if ((unsigned char)*a != (unsigned char)*b)
return (unsigned char)*a - (unsigned char)*b;
if (!*a) return 0;
a++; b++;
}
return 0;
}Functions that are reliably exported and resolvable via LIBC$: open, close, read, write, malloc, free, stat, access, rename, unlink, opendir, readdir, closedir, getpid, getuid, getenv, getcwd, strerror, printf, fprintf and most non-trivial libc functions.
Declare external functions with the LIBC$ prefix. For opaque types (FILE *, DIR *, etc.), see BOF type declarations below.
#include "bof_api.h"
extern int LIBC$getpid(void);
extern int LIBC$getuid(void);
extern char *LIBC$getenv(const char *);
extern char *LIBC$getcwd(char *, int);
extern int LIBC$uname(void *);
extern int LIBC$access(const char *, int);
extern void *LIBC$opendir(const char *);
extern void *LIBC$readdir(void *);
extern int LIBC$closedir(void *);
extern void *LIBC$fopen(const char *, const char *);
extern char *LIBC$fgets(char *, int, void *);
extern int LIBC$fclose(void *);
extern long LIBC$sysconf(int);
extern double LIBC$strtod(const char *, char **);
extern long LIBC$time(long *);
void go(char *args, int args_len) {
BeaconPrintf(CALLBACK_OUTPUT, "PID=%d UID=%d\n", LIBC$getpid(), LIBC$getuid());
char *home = LIBC$getenv("HOME");
BeaconPrintf(CALLBACK_OUTPUT, "HOME=%s\n", home ? home : "(null)");
void *dir = LIBC$opendir("/tmp");
if (dir) {
void *ent = LIBC$readdir(dir);
if (ent) {
char *name = (char *)ent + 19; /* d_name offset in dirent64 */
BeaconPrintf(CALLBACK_OUTPUT, "first: %s\n", name);
}
LIBC$closedir(dir);
}
}The RTLD$ prefix performs a global symbol lookup across all shared libraries already loaded in the process. Unlike LIBC$, it does not prioritize any specific library — it finds the first DSO that exports the requested symbol.
BOF calls RTLD$dlopen(...)
└─ bof_resolve_symbol("RTLD$dlopen")
└─ Layer 3: strip "RTLD$" → "dlopen"
→ global search across all loaded DSOs
→ found in libdl.so → return address
Important: RTLD$ does not trigger dynamic loading and does not participate in dependency pre-validation. If the symbol is not found in any currently-loaded DSO, the BOF is rejected before go() runs:
BOF error: Unresolved: RTLD$some_function
Use RTLD$ when the target library is reliably present in the host process. For symbols that may need to be loaded on demand, use LIBXXX$ instead.
The LIBXXX$ prefix lets a BOF resolve a symbol from a specific shared library. The general format is:
LIB<library>$<symbol>
Examples:
| Symbol | Target library |
|---|---|
LIBZ$zlibVersion |
libz.so |
LIBSQLITE3$sqlite3_libversion |
libsqlite3.so |
LIBPIXMAN$pixman_version |
libpixman-1.so |
LIBHARFBUZZ$hb_version_string |
libharfbuzz.so |
LIBPNG16$png_access_version_number |
libpng16.so |
LIBCAIRO$cairo_version |
libcairo.so |
LIBXXX$ symbols participate in dependency pre-validation via bof_deps_prepare(). Before go() runs, the dependency manager checks whether the required DSO is loaded. In flexible mode (BOF_DEPS_ALLOW_DLOPEN=1), it will locate and dlopen() missing libraries automatically. In strict mode (BOF_DEPS_ALLOW_DLOPEN=0), the BOF is rejected if any required DSO is absent.
#include "bof_api.h"
extern const char *LIBZ$zlibVersion(void);
extern const char *LIBSQLITE3$sqlite3_libversion(void);
void go(char *args, int args_len) {
BeaconPrintf(CALLBACK_OUTPUT, "[+] zlib = %s\n", LIBZ$zlibVersion());
BeaconPrintf(CALLBACK_OUTPUT, "[+] sqlite3 = %s\n", LIBSQLITE3$sqlite3_libversion());
}Since BOFs don't have access to libc headers, opaque types must be declared as void *:
| libc type | BOF declaration |
|---|---|
FILE * |
void * |
DIR * |
void * |
struct dirent * |
void * (access fields by byte offset) |
struct utsname * |
void * or char[512] buffer |
| Aspect | Ax* Wrappers | LIBC$ / RTLD$ | LIBXXX$ |
|---|---|---|---|
| Resolve method | Compile-time table lookup | Runtime DSO cache walk | Runtime DSO cache walk + dep check |
| dlsym usage | None | None | None (hash walk) |
| Underlying calls | Mostly libc (open, read, malloc…); a few use syscall() directly (getdents64, io_uring, pidfd, process_vm, memfd_create, execveat) |
The resolved function from the DSO — may be hooked | Depends on target DSO |
| String exposure | Function names in SDK's .rodata only |
Function name briefly on stack during hash comparison | Same as LIBC$/RTLD$ |
| /proc access | None | Reads /proc/self/maps once (cached) |
Same + dep check |
| dlopen triggered | Never | Never | Only if missing + flexible mode |
The Ax* wrappers resolve at compile time (via the SDK's symbol table) — the BOF itself contains no name strings for them. The LIBC$/RTLD$/LIBXXX$ prefixes require a runtime name comparison. Both mechanisms call the same underlying libc functions for the majority of operations, so neither offers a systematic advantage over the other in terms of EDR hook avoidance. Choose based on coverage: use Ax* when the needed function is in the table, use runtime prefixes for everything else.
The dependency manager (bof_deps.c / bof_deps.h) pre-validates and optionally pre-loads shared library dependencies declared by a BOF through LIBXXX$ symbols.
BOF references LIBZ$zlibVersion
└─ bof_deps_prepare() scans .symtab for LIBXXX$ patterns
└─ extracts hint: "libz"
├─ Check runtime DSO cache → found? → OK, proceed
└─ Not found:
├─ BOF_DEPS_ALLOW_DLOPEN=0 → FAIL (dependency error)
└─ BOF_DEPS_ALLOW_DLOPEN=1 → search system lib dirs
├─ /usr/lib/x86_64-linux-gnu/libz.so.1 → dlopen()
├─ Rescan /proc/self/maps → update DSO cache
└─ OK, symbol is now resolvable
| Mode | Build flag | Behavior |
|---|---|---|
| Flexible (default) | BOF_DEPS_ALLOW_DLOPEN=1 |
Missing DSOs are located in system library paths and loaded via dlopen() |
| Strict | BOF_DEPS_ALLOW_DLOPEN=0 |
BOF is rejected if any required DSO is not already loaded in the process |
The dependency manager restricts candidate library paths to standard system locations. The following paths are rejected as potential DSO sources:
| Rejected path | Reason |
|---|---|
/tmp/ |
User-writable, potential injection vector |
/var/tmp/ |
Same as above |
/dev/shm/ |
Memory-backed, user-writable |
/home/ |
User-controlled |
/root/ |
User-controlled |
Only libraries found under standard system library directories (e.g., /usr/lib, /usr/lib64, /usr/lib/x86_64-linux-gnu, /lib, /usr/local/lib) are accepted.
extern int g_bof_deps_allow_dlopen; /* 0 = strict, 1 = flexible (default) */Set once at agent startup. Not thread-safe to modify at runtime.
The bof-loader/ is a standalone development tool for testing BOFs. It requires no C2 server, no agent deployment, and no network setup. The entire development cycle happens locally:
Edit BOF → gcc -c -o mybof.o mybof.c → ./bof_loader mybof.o → see output → repeat
This eliminates the overhead of deploying an agent just to test a BOF. The loader uses the exact same SDK runtime (ELF loader, API table, async system, dependency manager) that a real agent would use, so BOFs that work in the loader will work in any agent that integrates the SDK.
cd bof-loader
make# Interactive mode
./bof_loader
# Direct execution
./bof_loader uptime.o
# With arguments
./bof_loader mybof.o str:hello int:42
# Async execution
./bof_loader --async keylog_start.o| Command | Description |
|---|---|
<bof.o> [args...] |
Execute BOF synchronously |
--async <bof.o> [args...] |
Execute BOF in background |
jobs |
List running background BOFs |
kill <id> |
Stop a background BOF |
help |
Show commands |
exit |
Quit (kills all running jobs) |
| Type | Syntax | Example |
|---|---|---|
| String | str:value |
str:hello |
| Integer (32-bit) | int:value |
int:42 |
| Short (16-bit) | short:value |
short:80 |
| Wide string | wstr:value |
wstr:hello |
# Build example BOFs
cd example && make && cd ..
cd bof-loader && make && cd ..
# Test: uptime (uses AxSysinfo)
./bof-loader/bof_loader example/uptime.o
# Test: DLR — dynamic library resolution via LIBC$
./bof-loader/bof_loader example/DLR.o
# Test: multi_probe — multi-library resolution via LIBXXX$
./bof-loader/bof_loader example/multi_probe.o
# Test: custom symbols (calls agent-registered functions)
./bof-loader/bof_loader example/bof_custom.o
# Test: intentional failure (dlopen() called directly, not via LIBC$)
./bof-loader/bof_loader example/bof_fail.oExpected outputs:
# uptime.o — SUCCESS
[OK] BOF completed
====================[Uptime]====================
[+] System Uptime: 3 days, 14 hours, 22 minutes, 8 seconds
[+] Load averages: 0.12 0.08 0.05
[+] Total RAM: 16384 MB
[+] Free RAM: 8192 MB
[+] Total swap: 2048 MB
[+] Processes: 342
# DLR.o — SUCCESS (resolves 19 LIBC$ symbols)
[OK] BOF completed
PID=12345 PPID=12300 UID=1000 EUID=1000 GID=1000
HOME=/home/user
CWD=/home/user
Kernel: Linux 6.x.y x86_64
CPUs=8
access=0
readdir OK
hostname: myhost
strtod=3.14159
time=1753123456
=== 19 LIBC$ OK ===
# multi_probe.o — SUCCESS (resolves LIBXXX$ symbols, output depends on installed libs)
[OK] BOF completed
[*] multi-probe
[+] LIBZ = 1.2.11
[+] LIBSQLITE3 = 3.39.2
[+] LIBPIXMAN = 0.40.0
[+] LIBHARFBUZZ = 5.1.0
[+] LIBPNG16 = 10640
[+] LIBCAIRO = 11512
[*] done
# bof_custom.o — SUCCESS (uses registered custom_add / custom_get_info)
[OK] BOF completed
[*] Custom BOF initiated
[+] 10 + 32 = 42
[+] System info:: PID: 12345, UID: 1000
[*] Custom BOF completed
# bof_fail.o — FAILURE (dlopen not in API table — use LIBC$dlopen or RTLD$dlopen instead)
[ERR] BOF failed
BOF error: Unresolved: dlopen
The NaxDarks Linux Agent is one consumer of this SDK. Its integration serves as a reference for how any agent can adopt the SDK:
For authorized security research, penetration testing, red team operations, and security tooling development only. Use only on systems where you have explicit authorization.